', * 幂等重跑的锚点)。日历本/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}`"); } } }