10, '亲戚' => 20, '同事' => 30, '朋友' => 40, '合作伙伴' => 50, '认识' => 60, '不熟' => 70, ]; /** * 幂等结构升级:建表 + 补齐后加的列/枚举值。 * * ⚠️ CREATE TABLE IF NOT EXISTS 对**已存在的表**什么都不做 —— * 所以给老库加列必须显式 ALTER,而且要先查 information_schema 再改, * 否则第二次执行会报 Duplicate column。这就是「部署即可升级」的全部秘密。 */ public static function ensure(): void { $db = Database::get(); 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,无需回填。 // 把老数据补出农历生日:只在还没算过时算一次(幂等)。 self::backfillLunarBirthday($db); self::backfillIdCardBirthday($db); // ---- 称呼从「人的属性」改成「视角属性」---- // 老语义:persons.nickname 是全家共用的小名。 // 新语义:nicknames 表,(viewer, target) → 我叫他什么。 // ⚠️ 必须在这里搬:不然升级之后界面上再没有人读 persons.nickname, // 用户会觉得"以前填的小名一夜之间全没了"。搬的时候给每个账号都写一份, // 迁移前后大家看到的叫法完全一致(`Nickname::migrateLegacy` 里说明了原因)。 // 放在结构升级里、而不是"某次请求里顺手做",是为了升级完就已经搬好。 foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) { \App\Nickname::migrateLegacy((int)$fam['id']); } // ---- 关系分组(版本 4)---- // persons 新增 group_id。这里**不加外键**:老库只能 ADD COLUMN,加不上约束, // 新装的加了就会两边结构不一致;统一由 PersonGroup 保证不留悬空 id。 self::addColumn($db, 'persons', 'group_id', "ADD COLUMN `group_id` INT UNSIGNED DEFAULT NULL COMMENT '关系分组(人属于哪一组:家人/同事/朋友…),全家人共用一套分类' AFTER `is_placeholder`"); // 每个家庭都要有一套预置分组(新装的由 install.php 建,老的在这里补) foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) { \App\PersonGroup::seedDefaults((int)$fam['id']); } // 老库的人还没有分组 → 按名册类型补:家庭成员=家人、外部联系人=认识。 // 只补空的,用户自己改过的一律不动。 \App\PersonGroup::backfillMissing($db); } /** 老库已有阳历生日但没有农历生日 → 补算一次。用「为空」作条件,天然幂等。 */ 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 OR birthday_lunar = '')" ); $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 '管理员 / 家庭成员 / 访客'" ); } } }