Files
FamilyBook/app/Schema.php
T

771 lines
45 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 建表语句与结构升级(安装向导与运行时共用同一份)。
*
* 五条约定:
* 1. 全部 IF NOT EXISTS —— 重复执行不破坏已有数据
* 2. 字符集一律 utf8mb4_unicode_ci,emoji 与人名生僻字都不会乱码
* 3. 一切业务数据挂在 family_id 下,将来要支持多个家庭也不必改模型
* 4. 手机号存两份:phone_hash 用于登录查找(确定性哈希),phone_encrypted 用于展示
* 5. 结构升级走 ensure(),不靠人手动改库 —— 已上线的库也能自己长出新列
*/
declare(strict_types=1);
namespace App;
final class Schema
{
/**
* 当前结构版本。**唯一来源** —— index.php 的 FB_SCHEMA_VERSION 与
* install.php 的盖章都取自这里,别在两处各写一个字符串(迟早会漂移)。
*
* 版本史:
* 1 → 初始(families/persons/accounts/settings/relations)
* 2 → birth_rank、is_placeholder、accounts.role 加 guest
* 3 → 三个生日(birthday 升 DATETIME + id_card/lunar/leap)、slogan、nicknames 表
* 4 → person_groups(关系分组,全家统一但可自定义)、persons.group_id、
* user_settings(「我的」维度设置:分组顺序等)
* 5 → **名册改成「每人一份」**:person_roster(谁在我的名册里 / 归哪一组 / 排第几)、
* person_groups.owner_id(0=管理员维护的公共分组,否则=某人的私自分组);
* 同时**删掉 persons.group_id**(分组从此是「我和他的关系」,挂在 roster 上)
* 6 → **成员信息对齐 vCard**:person_contacts(多个电话 / 邮箱 / 地址 / 网址,
* 对应 vCard 的 TEL / EMAIL / ADR / URL)+ persons.org / persons.title
* (工作单位 ORG / 职务职称 TITLE)。主手机号仍留在 persons(登录凭据)。
* 7 → **家谱树 ⇒ 名册自动同步**:以每个账号为中心上下三代的树里的人
* 自动进他名册(source=kin,归公共「家人」组),树外自动撤;
* 升级时按已有关系回填。起因:家谱/首页按关系推导可见,名册按分享推导
* —— 同一个人三处口径打架(配偶能在家谱看到孩子、名册里却没有)。
* 用户拍板:树上的人三处都可见,没上树的都不可见。
* 8 → **归一事件原语落地(观影先行)**:events(统一事件:标题/日期/地点/
* 来源引用 source_ref)+ movie_details(卫星表:海报/票根/评分/影评/
* 影院三段)+ event_attendees(参与人)+ event_photos(现场照片)。
* MovieLog 的 219 部观影记录整体迁入(source_ref='movielog:movie:<id>',
* 幂等重跑的锚点)。日历本/CalDAV 是后续阶段,events 先不带 calendar_id。
* 9 → **观影对齐 MovieLog(用户五点反馈)**:① 列表改瀑布流海报墙;
* ② events.starts_at 升 DATETIME + 新增 ends_at(观影起止精确到秒,
* 复用 _datetime.php 控件);③ 新建 cinemas(所在地方/名称/地址/主题色),
* movie_details 只留 cinema_id(三段列迁移后删掉);④ 评分评论照搬
* MovieLog 模型:movie_reviews(每账号每片一条,1-10 分 + 正文 + 感受)
* + movie_review_comments(两级平铺讨论),movie_details 旧的单条
* rating/review/feelings 迁进 movie_reviews 后删列。
*/
public const VERSION = '10';
/** @return string[] */
public static function statements(): array
{
return [
<<<'SQL'
CREATE TABLE IF NOT EXISTS `families` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`name` VARCHAR(100) NOT NULL COMMENT '家庭名称',
`motto` VARCHAR(200) DEFAULT NULL COMMENT '一句话,展示在首页',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='家庭空间(一切数据的根)'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `persons` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`kind` ENUM('member','contact') NOT NULL DEFAULT 'member' COMMENT '家庭成员 / 外部联系人',
`name` VARCHAR(50) NOT NULL COMMENT '姓名',
`nickname` VARCHAR(50) DEFAULT NULL COMMENT '【旧语义,已废弃】全家共用的小名;升级时搬进 nicknames 表,新代码不再读写它',
`gender` ENUM('m','f','x') DEFAULT NULL COMMENT '男 / 女 / 未填写',
`birth_rank` INT DEFAULT NULL COMMENT '家中排行(同一对父母的子女里,1=老大),用来算长子/次女',
`birthday` DATE DEFAULT NULL COMMENT '生日(自动生成日历上的生日日程用)',
`avatar` VARCHAR(255) DEFAULT NULL COMMENT '头像,相对于 uploads/avatars',
`phone_hash` CHAR(64) DEFAULT NULL COMMENT '手机号 sha256,登录查找用;NULL 不参与唯一约束',
`phone_encrypted` VARCHAR(512) DEFAULT NULL COMMENT 'AES 加密的手机号',
`id_card_encrypted` VARCHAR(512) DEFAULT NULL COMMENT 'AES 加密的身份证号',
`email` VARCHAR(120) DEFAULT NULL,
`address` VARCHAR(200) DEFAULT NULL,
`note` TEXT DEFAULT NULL COMMENT '备注',
`is_placeholder` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '虚拟人物:还没弄清真实身份的占位(如「我的父亲」)',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_phone_hash` (`phone_hash`),
KEY `idx_family_kind` (`family_id`, `kind`),
CONSTRAINT `fk_persons_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='家庭名册'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `accounts` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`person_id` INT UNSIGNED NOT NULL COMMENT '一个人最多一个账号',
`password_hash` VARCHAR(255) NOT NULL,
`role` ENUM('admin','member','guest') NOT NULL DEFAULT 'member' COMMENT '管理员 / 家庭成员 / 访客',
`status` ENUM('active','disabled') NOT NULL DEFAULT 'active',
`last_login_at` DATETIME DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_account_person` (`person_id`),
CONSTRAINT `fk_accounts_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='登录账号'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `relations` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`from_id` INT UNSIGNED NOT NULL COMMENT '关系的一端',
`to_id` INT UNSIGNED NOT NULL COMMENT '关系的另一端',
`kind` ENUM('parent','spouse') NOT NULL COMMENT 'parent: from 是 to 的父/母;spouse: 配偶(对称)',
`from_role` ENUM('father','mother') DEFAULT NULL COMMENT 'kind=parent 时必填:from 相对 to 是父亲还是母亲',
`note` VARCHAR(200) DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_rel` (`from_id`,`to_id`,`kind`,`from_role`),
KEY `idx_rel_from` (`from_id`,`kind`),
KEY `idx_rel_to` (`to_id`,`kind`),
CONSTRAINT `fk_rel_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_rel_from` FOREIGN KEY (`from_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_rel_to` FOREIGN KEY (`to_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='亲属关系:只存相邻两代之间的边,其余靠推导'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `settings` (
`family_id` INT UNSIGNED NOT NULL,
`k` VARCHAR(64) NOT NULL,
`v` TEXT DEFAULT NULL,
PRIMARY KEY (`family_id`, `k`),
CONSTRAINT `fk_settings_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='家庭级键值对设置'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `nicknames` (
`family_id` INT UNSIGNED NOT NULL,
`viewer_id` INT UNSIGNED NOT NULL COMMENT '谁在称呼别人(当前登录的人)',
`target_id` INT UNSIGNED NOT NULL COMMENT '被称呼的人',
`nickname` VARCHAR(50) NOT NULL COMMENT '我叫他/她什么',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`viewer_id`, `target_id`),
KEY `idx_nick_family` (`family_id`),
KEY `idx_nick_target` (`target_id`),
CONSTRAINT `fk_nick_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_nick_viewer` FOREIGN KEY (`viewer_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_nick_target` FOREIGN KEY (`target_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='称呼是视角属性:每个人各自决定怎么称呼别人,不挂在被称呼者身上'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `person_groups` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`owner_id` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0=全家的公共分组(管理员在系统设置里维护);否则=这个人的私自分组,只有他自己看得到',
`name` VARCHAR(20) NOT NULL COMMENT '分组名,如「家人」「同事」',
`position` INT NOT NULL DEFAULT 100 COMMENT '默认顺序(用户没自定义顺序时按它排)',
`is_builtin` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1=安装时预置的分组',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_group_owner_name` (`family_id`, `owner_id`, `name`),
KEY `idx_group_family` (`family_id`, `position`),
CONSTRAINT `fk_group_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='关系分组:一套公共的(管理员维护)+每人可加只给自己看的分组'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `person_roster` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`owner_id` INT UNSIGNED NOT NULL COMMENT '这是谁的名册(persons.id)',
`person_id` INT UNSIGNED NOT NULL COMMENT '躺在他名册里的人(persons.id)',
`group_id` INT UNSIGNED DEFAULT NULL COMMENT '我把他归到哪一组(公共同我的私自分组都行);NULL=未分类',
`sort_order` INT NOT NULL DEFAULT 1000 COMMENT '**在我的名册里**的先后,拖动卡片改的就是它',
`source` ENUM('self','shared','seeded') NOT NULL DEFAULT 'self' COMMENT 'self=我自己加的 / shared=别人分享给我的 / seeded=升级时按当时的可见范围补的',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_roster_owner_person` (`owner_id`, `person_id`),
KEY `idx_roster_owner_sort` (`owner_id`, `sort_order`),
KEY `idx_roster_person` (`person_id`),
CONSTRAINT `fk_roster_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_roster_owner` FOREIGN KEY (`owner_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_roster_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='每人一份名册:谁在我的名册里、归到哪一组、排第几 —— 都只影响我自己'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `user_settings` (
`person_id` INT UNSIGNED NOT NULL COMMENT '这条设置属于谁(登录者本人)',
`k` VARCHAR(64) NOT NULL,
`v` TEXT DEFAULT NULL,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`person_id`, `k`),
CONSTRAINT `fk_uset_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='「我自己的」设置:只影响我自己看到的样子'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `person_contacts` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`person_id` INT UNSIGNED NOT NULL COMMENT '属于哪个人',
`kind` ENUM('phone','email','address','url') NOT NULL COMMENT '对应 vCard 的 TEL / EMAIL / ADR / URL',
`label` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '用途标注,如「工作」「住宅」「奶奶家」;空 = 不标注',
`value` VARCHAR(500) NOT NULL COMMENT '内容。kind=phone 时是 AES 密文(与主手机号同一把钥匙)',
`position` INT NOT NULL DEFAULT 100 COMMENT '同 kinds 里的先后',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_pc_person` (`person_id`, `kind`, `position`),
CONSTRAINT `fk_pc_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='成员的联系方式(可多条):电话 / 邮箱 / 地址 / 网址,对齐 vCard'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `events` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`creator_id` INT UNSIGNED DEFAULT NULL COMMENT '谁记的(persons.id);系统迁移可为 NULL',
`kind` ENUM('movie') NOT NULL COMMENT '事件类型:movie=观影(trip/任务等是后续阶段的卫星表)',
`title` VARCHAR(200) NOT NULL COMMENT '事件标题(观影=片名)',
`starts_at` DATETIME DEFAULT NULL COMMENT '发生时刻(观影=开场时间,精确到秒)',
`ends_at` DATETIME DEFAULT NULL COMMENT '结束时刻(观影=散场时间,精确到秒)',
`location` VARCHAR(200) DEFAULT NULL COMMENT '地点(观影=影院名)',
`note` TEXT DEFAULT NULL COMMENT '备注',
`source_ref` VARCHAR(64) DEFAULT NULL COMMENT '外部来源引用(如 movielog:movie:123)——迁移幂等重跑的锚点',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_event_source` (`family_id`, `source_ref`),
KEY `idx_event_family_kind` (`family_id`, `kind`, `starts_at`),
CONSTRAINT `fk_event_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_event_creator` FOREIGN KEY (`creator_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统一事件:观影/旅行/任务…共同点是都有时间、有人参与、要上日历'
SQL,
// ⚠️ cinemas 必须排在 movie_details **之前**:后者的 fk_mdet_cinema
// 引用 cinemas.id,MariaDB 默认 FOREIGN_KEY_CHECKS=1,
// 顺序反了全新安装会在 movie_details 上报 1215、
// 整个建表循环从那里断掉(线上老库 IF NOT EXISTS 跳过、炸不出来,
// 只有全新安装/测试库会 500 —— 2026-09-30 e2e 抓到)。
<<<'SQL'
CREATE TABLE IF NOT EXISTS `cinemas` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`place` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '所在地方(城市 / 商圈)',
`name` VARCHAR(100) NOT NULL COMMENT '影院名称',
`address` VARCHAR(200) DEFAULT NULL COMMENT '详细地址',
`color` CHAR(7) NOT NULL DEFAULT '#888888' COMMENT '展示用主题色(列表/详情里影院名着色)',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_cinema` (`family_id`, `place`, `name`),
KEY `idx_cinema_family` (`family_id`),
CONSTRAINT `fk_cinema_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='影院(独立管理,MovieLog 同款:地方/名称/地址/主题色)'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `movie_details` (
`event_id` INT UNSIGNED NOT NULL,
`info` TEXT DEFAULT NULL COMMENT '简介(豆瓣抓取的剧情简介等)',
`poster_key` VARCHAR(255) DEFAULT NULL COMMENT '海报(存储相对键,如 posters/xxx.jpg)',
`ticket_key` VARCHAR(255) DEFAULT NULL COMMENT '票根照片(存储相对键)',
`cinema_id` INT UNSIGNED DEFAULT NULL COMMENT '看的哪家影院(cinemas.id)',
`douban_meta` TEXT DEFAULT NULL COMMENT '豆瓣自动补充的元数据(JSON:年份/类型/导演/主演/评分/链接等)',
PRIMARY KEY (`event_id`),
KEY `idx_mdet_cinema` (`cinema_id`),
CONSTRAINT `fk_mdet_event` FOREIGN KEY (`event_id`) REFERENCES `events` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_mdet_cinema` FOREIGN KEY (`cinema_id`) REFERENCES `cinemas` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='观影事件的特有数据(一对一卫星表)'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `movie_reviews` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`event_id` INT UNSIGNED NOT NULL,
`person_id` INT UNSIGNED NOT NULL COMMENT '评价人(登录账号背后的人);与观影人无关,没去看的也能评',
`rating` TINYINT NOT NULL COMMENT '评分 1-10(录入校验 1-10;迁移兜底可存 0)',
`content` TEXT NOT NULL COMMENT '评价正文',
`feelings` VARCHAR(255) DEFAULT NULL COMMENT '观后感受 key 的 JSON 数组(目录见 MovieReview::CATALOG)',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_review_event_person` (`event_id`, `person_id`),
KEY `idx_review_event` (`event_id`),
KEY `idx_review_person` (`person_id`),
CONSTRAINT `fk_review_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_review_event` FOREIGN KEY (`event_id`) REFERENCES `events` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_review_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='观影评价:一部电影 × 一个账号 至多一条(MovieLog 同款)'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `movie_review_comments` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`review_id` INT UNSIGNED NOT NULL,
`person_id` INT UNSIGNED NOT NULL COMMENT '评论人(登录账号背后的人)',
`parent_id` INT UNSIGNED DEFAULT NULL COMMENT 'NULL=顶层评论;非空=所属顶层评论 id(回复的回复会被规范化到顶层,页面永远只有两层)',
`reply_to_person_id` INT UNSIGNED DEFAULT NULL COMMENT '被回复的人(显示「回复 @谁」用;存人不存评论 id,对方删评不留悬空)',
`content` TEXT NOT NULL,
`feelings` VARCHAR(255) DEFAULT NULL COMMENT '感受 key 的 JSON 数组',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_mrc_review` (`review_id`),
KEY `idx_mrc_parent` (`parent_id`),
CONSTRAINT `fk_mrc_review` FOREIGN KEY (`review_id`) REFERENCES `movie_reviews` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_mrc_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_mrc_replyto` FOREIGN KEY (`reply_to_person_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='评价下的讨论(两级平铺,MovieLog 同款)'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `event_attendees` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`event_id` INT UNSIGNED NOT NULL,
`person_id` INT UNSIGNED NOT NULL COMMENT '同行的人(persons.id)',
`position` INT NOT NULL DEFAULT 100 COMMENT '展示先后',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_att_event_person` (`event_id`, `person_id`),
KEY `idx_att_person` (`person_id`),
CONSTRAINT `fk_att_event` FOREIGN KEY (`event_id`) REFERENCES `events` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_att_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='事件的参与人(观影=同行观影的人)'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `event_photos` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`event_id` INT UNSIGNED NOT NULL,
`key` VARCHAR(255) NOT NULL COMMENT '照片(存储相对键)',
`position` INT NOT NULL DEFAULT 100 COMMENT '展示先后',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_ep_event` (`event_id`, `position`),
CONSTRAINT `fk_ep_event` FOREIGN KEY (`event_id`) REFERENCES `events` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='事件的现场照片(可多张)'
SQL,
];
}
/** 安装时预置的关系分组(用户可自行增删改名,这里只是初始值) */
public const DEFAULT_GROUPS = [
'家人' => 10,
'亲戚' => 20,
'同事' => 30,
'朋友' => 40,
'合作伙伴' => 50,
'认识' => 60,
'不熟' => 70,
];
/**
* 结构升级里「数据回填」类步骤的失败记录。
*
* 为什么要把「回填」和「改结构」分开对待:
* 改结构失败 = 代码与库对不上,必须让整站炸出来(看得见才好修);
* 回填失败 = 往往只跟**某一行老数据**有关(比如某条空日期),
* 它不该拦住后面所有结构变更 —— 2026-09-30 就是反过来:
* 一个回填里的 SQL 在 MySQL 严格模式下报错,把 addColumn(group_id)、
* 预置分组、称呼迁移**全部**挡在后面,而异常又被 route 层吞掉,
* 于是「线上看着是好的、其实升级从没跑完」,拖了两轮才发现。
*/
private static array $stepErrors = [];
/** @return string[] 最近一次 ensure() 里被容错吞掉的数据步骤失败 */
public static function stepErrors(): array
{
return self::$stepErrors;
}
private static function tryStep(string $label, callable $fn): void
{
try {
$fn();
} catch (\Throwable $e) {
self::$stepErrors[] = $label . ':' . $e->getMessage();
// log_errors=1 → 落到服务器 PHP 错误日志,别让它只活在内存里
error_log('[familybook] 升级步骤失败(已跳过,其余步骤继续)' . $label
. ':' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
}
}
/**
* 幂等结构升级:建表 + 补齐后加的列/枚举值。
*
* ⚠️ CREATE TABLE IF NOT EXISTS 对**已存在的表**什么都不做 ——
* 所以给老库加列必须显式 ALTER,而且要先查 information_schema 再改,
* 否则第二次执行会报 Duplicate column。这就是「部署即可升级」的全部秘密。
*/
public static function ensure(): void
{
$db = Database::get();
self::$stepErrors = [];
foreach (self::statements() as $sql) {
$db->exec($sql);
}
self::addColumn($db, 'persons', 'birth_rank',
"ADD COLUMN `birth_rank` INT DEFAULT NULL COMMENT '家中排行(同一对父母的子女里,1=老大),用来算长子/次女' AFTER `gender`");
self::addColumn($db, 'persons', 'is_placeholder',
"ADD COLUMN `is_placeholder` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '虚拟人物:还没弄清真实身份的占位(如「我的父亲」)' AFTER `note`");
self::widenRoleEnum($db);
// ---- 三个生日(版本 3)----
// birthday 原本是 DATE,现在要精确到时分秒 → 升成 DATETIME。
// MODIFY 是幂等的(已经是 DATETIME 时再 MODIFY 也不报错、不丢数据)。
$db->exec("ALTER TABLE `persons` MODIFY COLUMN `birthday`
DATETIME DEFAULT NULL COMMENT '真实阳历生日(精确到时分秒)'");
self::addColumn($db, 'persons', 'birthday_id_card',
"ADD COLUMN `birthday_id_card` DATE DEFAULT NULL COMMENT '身份证上的生日:由身份证第 7-14 位推导,只读、永不变' AFTER `birthday`");
self::addColumn($db, 'persons', 'birthday_lunar',
"ADD COLUMN `birthday_lunar` DATETIME DEFAULT NULL COMMENT '农历生日:**日期部分是农历年月日**,时间部分与阳历一致' AFTER `birthday_id_card`");
self::addColumn($db, 'persons', 'birthday_lunar_leap',
"ADD COLUMN `birthday_lunar_leap` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'birthday_lunar 的月份是否为闰月' AFTER `birthday_lunar`");
self::addColumn($db, 'persons', 'slogan',
"ADD COLUMN `slogan` VARCHAR(200) DEFAULT NULL COMMENT '个人的一句话;没填就用家庭统一的那句' AFTER `note`");
// 老数据里 birthday 若是 DATE,升成 DATETIME 后时间部分自动是 00:00:00,无需回填。
// 把老数据补出农历生日 / 身份证生日:只在还没算过时算一次(幂等)。
// 这两步**只动已有数据、不改结构**,所以容错跑:某一行老数据有问题
// 不该把下面的改结构步骤全挡住(原因见 tryStep 的注释)。
self::tryStep('回填农历生日', static fn() => self::backfillLunarBirthday($db));
self::tryStep('回填身份证生日', static fn() => self::backfillIdCardBirthday($db));
// ---- 称呼从「人的属性」改成「视角属性」----
// 老语义:persons.nickname 是全家共用的小名。
// 新语义:nicknames 表,(viewer, target) → 我叫他什么。
// ⚠️ 必须在这里搬:不然升级之后界面上再没有人读 persons.nickname,
// 用户会觉得"以前填的小名一夜之间全没了"。搬的时候给每个账号都写一份,
// 迁移前后大家看到的叫法完全一致(`Nickname::migrateLegacy` 里说明了原因)。
// 放在结构升级里、而不是"某次请求里顺手做",是为了升级完就已经搬好。
self::tryStep('迁移旧称呼到 nicknames 表', static function () use ($db) {
foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) {
\App\Nickname::migrateLegacy((int)$fam['id']);
}
});
// ---- 关系分组:从「全家共用一套」改成「公共一套 + 每人可加自己的」(版本 5)----
// person_groups 加 owner_id(0 = 公共)。新装的建表语句已是新结构,老库靠这里补。
self::addColumn($db, 'person_groups', 'owner_id',
"ADD COLUMN `owner_id` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0=全家的公共分组(管理员在系统设置里维护);否则=这个人的私自分组,只有他自己看得到' AFTER `family_id`");
// 唯一键要从 (family_id,name) 换成 (family_id,owner_id,name):
// 否则「我的家人」和公共的「家人」会被判重名,没法各存一份。
self::dropIndexIfExists($db, 'person_groups', 'uk_group_name');
if (!self::hasIndex($db, 'person_groups', 'uk_group_owner_name')) {
$db->exec('ALTER TABLE `person_groups`
ADD UNIQUE KEY `uk_group_owner_name` (`family_id`, `owner_id`, `name`)');
}
// 公共分组(owner_id = 0):新装的由 install.php 建,老的在这里补
self::tryStep('预置公共分组', static function () use ($db) {
foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) {
\App\PersonGroup::seedDefaults((int)$fam['id']);
}
});
// 名册改成「每人一份」:先把老库「当时人人可见」的现状原样搬进每个人的名册,
// 再把 persons 上那两个「全家人共用」的列删掉。
//
// ⚠️ 这一步**绝不能**放进 tryStep:它是下面删列的**唯一前提**,
// 半途失败还继续删列 = 直接丢数据。宁可让异常把整站炸出来(下次请求会重试)。
\App\PersonRoster::seedFromPersons($db);
self::dropColumnIfExists($db, 'persons', 'group_id');
self::dropColumnIfExists($db, 'persons', 'sort_order');
// ---- 成员信息对齐 vCard(版本 6)----
// 工作单位(ORG)与职务职称(TITLE):一个人的属性,单值,直接挂在 persons 上。
self::addColumn($db, 'persons', 'org',
"ADD COLUMN `org` VARCHAR(120) DEFAULT NULL COMMENT '工作单位(vCard ORG)' AFTER `address`");
self::addColumn($db, 'persons', 'title',
"ADD COLUMN `title` VARCHAR(120) DEFAULT NULL COMMENT '职务 / 职称(vCard TITLE)' AFTER `org`");
// 可多条的联系方式:person_contacts 建表在 statements() 里(IF NOT EXISTS 幂等)。
// ⚠️ 主手机号**不搬**:它是登录凭据(phone_hash 唯一约束、按它找人),
// 搬进 contacts 反而把「登录凭据」和「联系信息」搅在一起。
// contacts 里的 phone 是**额外的**电话,一样加密存。
// ---- 家谱树 ⇒ 名册自动同步(版本 7)----
// ⚠️ 先扩宽 source 枚举再回填:v6 老库的枚举只有 self/shared/seeded,
// 建表语句里的 IF NOT EXISTS **不会**更新已存在表的枚举 ——
// 不扩宽的话,回填插 source='kin' 会被严格模式整条拒掉,
// 而且 ensure() 的容错会把这条失败悄悄吞掉(线上表现=功能像没生效)。
self::widenRosterSourceEnum($db);
// 已有的关系也要补出 kin 名册条目:升级完的当下口径就对齐,
// 而不是等用户哪天再点一次「保存关系」才生效。
// syncKinForFamily 幂等(只补缺、撤多余,用户改过的分组/顺序不动),
// 重复跑不插重;某条脏数据只影响它自己,容错跑,别挡住后面的步骤。
self::tryStep('按家谱回填名册(kin 与首页同一棵树)', static function () use ($db) {
foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) {
\App\PersonRoster::syncKinForFamily((int)$fam['id']);
}
});
// ---- 观影对齐 MovieLog(版本 9)----
// starts_at DATE → DATETIME:MODIFY 是幂等的,老数据的时间部分自动 00:00:00。
$db->exec("ALTER TABLE `events` MODIFY COLUMN `starts_at`
DATETIME DEFAULT NULL COMMENT '发生时刻(观影=开场时间,精确到秒)'");
self::addColumn($db, 'events', 'ends_at',
"ADD COLUMN `ends_at` DATETIME DEFAULT NULL COMMENT '结束时刻(观影=散场时间,精确到秒)' AFTER `starts_at`");
// 旧 movie_details 的「影院三段 + rating/review/feelings」搬进 cinemas /
// movie_reviews,然后删列。⚠️ 回填+删列是同一条升级路径,**都不能进 tryStep**:
// 回填是删列的唯一前提,半途失败还继续删 = 直接丢数据;让异常炸出来、
// 下次请求重跑(回填都是幂等写法,重跑安全)。
// 判据用「旧列还在不在」:新装库(建表语句已是新结构)天然跳过。
//
// ⚠️ 老库的 movie_details **没有 cinema_id**(v8 及以前影院三段就在自己身上),
// 上面的 CREATE TABLE IF NOT EXISTS 对已存在的表是空操作、补不了列 ——
// 必须显式加列,否则下面的回填 UPDATE 直接炸 1054(2026-09-30 升级专测抓到,
// 线上老库走同一条路径也会炸在那里,升级永远跑不完)。
self::addColumn($db, 'movie_details', 'cinema_id',
"ADD COLUMN `cinema_id` INT UNSIGNED DEFAULT NULL COMMENT '看的哪家影院(cinemas.id)' AFTER `ticket_key`");
if (!self::hasIndex($db, 'movie_details', 'idx_mdet_cinema')) {
$db->exec('ALTER TABLE `movie_details` ADD KEY `idx_mdet_cinema` (`cinema_id`)');
}
// 外键给「删影院 ⇒ 观影记录的 cinema_id 置空」兜底;老库缺了补上。
// 容错跑:就算哪家老库有脏数据加不上 FK,也不能挡住下面的回填与删列。
self::tryStep('老库补 movie_details→cinemas 外键', static function () use ($db) {
if (!self::hasForeignKey($db, 'movie_details', 'fk_mdet_cinema')) {
$db->exec('ALTER TABLE `movie_details`
ADD CONSTRAINT `fk_mdet_cinema` FOREIGN KEY (`cinema_id`) REFERENCES `cinemas` (`id`) ON DELETE SET NULL');
}
});
if (self::hasColumn($db, 'movie_details', 'cinema_name')) {
self::backfillCinemasFromDetails($db);
self::backfillMovieReviewsFromDetails($db);
foreach (['rating', 'review', 'feelings', 'cinema_place', 'cinema_name', 'cinema_address'] as $col) {
self::dropColumnIfExists($db, 'movie_details', $col);
}
}
// ---- 豆瓣自动补充(版本 10,MovieLog fetch_movie 同源迁移)----
// 豆瓣抓到的元数据(年份/类型/导演/主演/时长/国家/别名/评分/豆瓣链接)
// 整体存一个 JSON 列,随用随解 —— 不为每个字段单开列(家庭应用不需要按
// 这些维度做查询),以后豆瓣再补字段也不用动表。
self::addColumn($db, 'movie_details', 'douban_meta',
"ADD COLUMN `douban_meta` TEXT DEFAULT NULL COMMENT '豆瓣自动补充的元数据(JSON)' AFTER `cinema_id`");
// 被容错跳过的数据步骤记进设置,系统设置页会把它显出来 ——
// 不然「升级没跑完」这件事只活在日志里,跟没发生一样。
if (self::$stepErrors) {
try {
\App\Setting::set((int)$db->query('SELECT MIN(id) FROM families')->fetchColumn(),
'schema_upgrade_warning', implode(';', self::$stepErrors));
} catch (\Throwable $e) {
// 连这条都记不上就算了,至少 error_log 里有
}
}
}
/**
* ⚠️ `birthday_lunar = ''` 这种写法**只在本机的 MariaDB 上能跑**:
* MySQL 严格模式(线上是 8.0 + STRICT_TRANS_TABLES)会把右边的 `''`
* 按 DATETIME 解析,直接抛 `SQLSTATE[HY000] 1525 Incorrect DATETIME value: ''`。
* 而 DATETIME 列加了之后默认是 NULL、不可能是空串,所以 `IS NULL` 就够了。
* (2026-09-30 线上事故:这条比较让整个结构升级从这里抛出去,
* 被 index.php 静默吞掉 → schema_version 永远停在 2、
* persons.group_id 加不上 → 名册分组、保存成员、头像上传全废。)
* 用「为空」作条件,天然幂等。
*/
private static function backfillLunarBirthday(\PDO $db): void
{
$st = $db->query(
"SELECT id, birthday FROM persons
WHERE birthday IS NOT NULL AND birthday_lunar IS NULL"
);
$rows = $st->fetchAll();
if (!$rows) {
return;
}
$up = $db->prepare('UPDATE persons SET birthday_lunar = ?, birthday_lunar_leap = ? WHERE id = ?');
foreach ($rows as $r) {
$bd = substr((string)$r['birthday'], 0, 10);
[$y, $m, $d] = array_map('intval', explode('-', $bd));
$lu = \App\Lunar::solarToLunar($y, $m, $d);
if ($lu === null) {
continue;
}
$up->execute([
sprintf('%04d-%02d-%02d %s', $lu['year'], $lu['month'], $lu['day'], substr((string)$r['birthday'], 11, 8) ?: '00:00:00'),
$lu['is_leap'] ? 1 : 0,
(int)$r['id'],
]);
}
}
/** 老库已有身份证号但没有「身份证生日」→ 推导一次。同样以「为空」为条件。 */
private static function backfillIdCardBirthday(\PDO $db): void
{
$st = $db->query(
"SELECT id, id_card_encrypted FROM persons
WHERE id_card_encrypted IS NOT NULL AND id_card_encrypted <> ''
AND (birthday_id_card IS NULL)"
);
$rows = $st->fetchAll();
if (!$rows) {
return;
}
$up = $db->prepare('UPDATE persons SET birthday_id_card = ? WHERE id = ?');
foreach ($rows as $r) {
try {
$id = \App\Crypto::decrypt((string)$r['id_card_encrypted']);
} catch (\Throwable $ex) {
continue; // 密钥换过 / 解不开就跳过,绝不能因此让整次升级失败
}
$bd = \App\Birthday::fromIdCard($id);
if ($bd !== null) {
$up->execute([$bd, (int)$r['id']]);
}
}
}
/**
* v9:把旧 movie_details 的影院三段(place/name/address)归一进 cinemas,
* 去重键 = (family_id, place, name),再回填 movie_details.cinema_id。
* 幂等:影院按唯一键查过再插;cinema_id 覆盖写。
*/
private static function backfillCinemasFromDetails(\PDO $db): void
{
$rows = $db->query(
'SELECT d.event_id, e.family_id, d.cinema_place, d.cinema_name, d.cinema_address
FROM movie_details d JOIN events e ON e.id = d.event_id
WHERE d.cinema_name IS NOT NULL AND d.cinema_name <> \'\''
)->fetchAll();
if (!$rows) {
return;
}
$chk = $db->prepare('SELECT id FROM cinemas WHERE family_id = ? AND place = ? AND name = ?');
$ins = $db->prepare('INSERT INTO cinemas (family_id, place, name, address, color) VALUES (?, ?, ?, ?, "#888888")');
$upd = $db->prepare('UPDATE movie_details SET cinema_id = ? WHERE event_id = ?');
foreach ($rows as $r) {
$place = trim((string)($r['cinema_place'] ?? ''));
$name = trim((string)$r['cinema_name']);
$chk->execute([(int)$r['family_id'], $place, $name]);
$cid = $chk->fetchColumn();
if ($cid === false) {
$addr = trim((string)($r['cinema_address'] ?? ''));
$ins->execute([(int)$r['family_id'], $place, $name, $addr !== '' ? $addr : null]);
$cid = (int)$db->lastInsertId();
}
$upd->execute([(int)$cid, (int)$r['event_id']]);
}
}
/**
* v9:把旧 movie_details 的单条 rating/review/feelings 迁成 movie_reviews
* (评价人 = 记这条事件的人;creator 缺失时退而求其次用该家第一个账号)。
* v8 感受存的是中文标签,这里反查回 MovieLog 的英文 key;认不出的丢掉。
* 幂等:uk_review_event_person 撞了就跳过。
*/
private static function backfillMovieReviewsFromDetails(\PDO $db): void
{
$rows = $db->query(
'SELECT d.event_id, e.family_id, e.creator_id, d.rating, d.review, d.feelings
FROM movie_details d JOIN events e ON e.id = d.event_id
WHERE d.rating IS NOT NULL OR (d.review IS NOT NULL AND d.review <> \'\')'
)->fetchAll();
if (!$rows) {
return;
}
$firstPerson = $db->prepare(
'SELECT a.person_id FROM accounts a JOIN persons p ON p.id = a.person_id
WHERE p.family_id = ? AND a.status = "active" ORDER BY a.id LIMIT 1'
);
$chk = $db->prepare('SELECT COUNT(*) FROM movie_reviews WHERE event_id = ? AND person_id = ?');
$ins = $db->prepare(
'INSERT INTO movie_reviews (family_id, event_id, person_id, rating, content, feelings)
VALUES (?, ?, ?, ?, ?, ?)'
);
foreach ($rows as $r) {
$pid = $r['creator_id'] !== null ? (int)$r['creator_id'] : 0;
if ($pid <= 0) {
$firstPerson->execute([(int)$r['family_id']]);
$pid = (int)$firstPerson->fetchColumn();
}
if ($pid <= 0) {
continue; // 全家连一个账号都没有:评价挂不上人,只能不动它
}
$chk->execute([(int)$r['event_id'], $pid]);
if ((int)$chk->fetchColumn() > 0) {
continue;
}
$rating = $r['rating'] !== null ? max(0, min(10, (int)$r['rating'])) : 0;
$feel = \App\MovieReview::labelsToKeysJson((string)($r['feelings'] ?? ''));
$ins->execute([
(int)$r['family_id'], (int)$r['event_id'], $pid, $rating,
trim((string)($r['review'] ?? '')), $feel,
]);
}
}
/** 列不存在才加 */
private static function addColumn(\PDO $db, string $table, string $column, string $alter): void
{
$st = $db->prepare(
'SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?'
);
$st->execute([$table, $column]);
if ((int)$st->fetchColumn() === 0) {
$db->exec("ALTER TABLE `{$table}` {$alter}");
}
}
/** accounts.role 原本只有 admin/member,升级时要把 guest 加进枚举 */
private static function widenRoleEnum(\PDO $db): void
{
$st = $db->prepare(
"SELECT COLUMN_TYPE FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'accounts' AND COLUMN_NAME = 'role'"
);
$st->execute();
$type = (string)$st->fetchColumn();
if ($type !== '' && strpos($type, 'guest') === false) {
$db->exec(
"ALTER TABLE `accounts` MODIFY COLUMN `role`
ENUM('admin','member','guest') NOT NULL DEFAULT 'member'
COMMENT '管理员 / 家庭成员 / 访客'"
);
}
}
/** person_roster.source 原本只有 self/shared/seeded,v7 起要加 kin(见 ensure 里的说明) */
private static function widenRosterSourceEnum(\PDO $db): void
{
$st = $db->prepare(
"SELECT COLUMN_TYPE FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'person_roster' AND COLUMN_NAME = 'source'"
);
$st->execute();
$type = (string)$st->fetchColumn();
if ($type !== '' && strpos($type, 'kin') === false) {
$db->exec(
"ALTER TABLE `person_roster` MODIFY COLUMN `source`
ENUM('self','shared','seeded','kin') NOT NULL DEFAULT 'self'
COMMENT 'self=我自己加的 / shared=别人分享给我的 / seeded=升级时按当时可见范围补的 / kin=家谱树自动同步(v7)'"
);
}
}
private static function hasIndex(\PDO $db, string $table, string $index): bool
{
$st = $db->prepare(
'SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?'
);
$st->execute([$table, $index]);
return (int)$st->fetchColumn() > 0;
}
private static function dropIndexIfExists(\PDO $db, string $table, string $index): void
{
if (self::hasIndex($db, $table, $index)) {
$db->exec("ALTER TABLE `{$table}` DROP INDEX `{$index}`");
}
}
private static function hasColumn(\PDO $db, string $table, string $column): bool
{
$st = $db->prepare(
'SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?'
);
$st->execute([$table, $column]);
return (int)$st->fetchColumn() > 0;
}
/** 老库补外键前先查它在不在(STATISTICS 里看不到约束名,得查 TABLE_CONSTRAINTS) */
private static function hasForeignKey(\PDO $db, string $table, string $name): bool
{
$st = $db->prepare(
"SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = ? AND CONSTRAINT_NAME = ?
AND CONSTRAINT_TYPE = 'FOREIGN KEY'"
);
$st->execute([$table, $name]);
return (int)$st->fetchColumn() > 0;
}
private static function dropColumnIfExists(\PDO $db, string $table, string $column): void
{
if (self::hasColumn($db, $table, $column)) {
$db->exec("ALTER TABLE `{$table}` DROP COLUMN `{$column}`");
}
}
}