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 是**额外的**电话,一样加密存。 // 被容错跳过的数据步骤记进设置,系统设置页会把它显出来 —— // 不然「升级没跑完」这件事只活在日志里,跟没发生一样。 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']]); } } } /** 列不存在才加 */ 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 '管理员 / 家庭成员 / 访客'" ); } } 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; } 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}`"); } } }