Files
FamilyBook/app/Schema.php
T
laoyang 7138314dc7 v24 第 15 批:活动 / 旅程两级语义(术语 · 派生时间城市 · 表单三项 · 题图可点)
用户原话四条,逐条落地:

1. 「旅程模块,按钮是记一次活动,而不是记一趟旅程。旅程是活动里面的概念。」
   → 顶层一律改称**活动**:按钮「记一次活动」、页标题「活动」、`N 次` / `N 段旅程`;
     活动详情三组改叫「计划的旅程 / 走过的旅程 / 没成行的」;
     节点表单「这段旅程叫什么」「阶段」;flash 文案 17 处同步。
     (导航条仍叫「旅程」——用户就是这么称呼这个模块,列表页加副标题说清两级关系。)

2. 「活动不需要开始和结束时间,是根据活动中的旅程,最早的旅程开始时间,和最晚的
     旅程结束时间,计算出来的。目的地也不需要…去的城市,也是根据旅程,得到的。」
   → 活动**不再存**这三样,全部**读时算出来**:
     · Trip::derived()      最早开始 / 最晚结束 / 城市去重
     · Trip::cities()       按最早出现时间排(先后顺序本身是信息,不是字母序)
     · Trip::attachDerived() 列表页批量补(避免 N+1)
     · Trip::activitySpan()  没有旅程时说「还没有旅程」,不用「日期待补」
     create/update 不再写 events 的起止与地点(老值保留不动,页面不读)。
     列表排序/搜索、首页「最近去了」改走子查询。

3. 「题图那里,没有办法上传图片,点击哪里都没反应。」——**真 bug**
   「点图 = 开文件框」那句 JS 只写在 people/form.php 的内页脚本里(头像专用);
   第 14 批加的旅程题图与图书封面照抄了同一套 DOM 却没有对应的 JS,
   于是图块有 cursor:pointer 与 hover 高亮(看着能点),点下去毫无反应。
   → 做成声明式通用绑定:图上写 data-pick-input="#fileInputId",
     app.js 统一接管(一个页面多个也各绑各的),并删掉旧的那份专用实现。
     三处(头像 / 题图 / 图书封面)全挂上。

4. 「添加活动的页面,顺序是这样的:添加标题,上传并裁剪题图,然后一段简介,就可以了。」
   → 主区就这三项,顺序照原话;时间/城市不做输入项(只留一句说明它们从哪来,
     不说清用户会满页找"怎么没地方填时间")。
     备注 / 公开 / 同行的人收进「更多设置」折叠区——功能一个没丢。

顺带修掉两处会炸的实现问题:
· ORDER BY 引用聚合别名(first_at)在 MariaDB/MySQL 报 1247,且只在运行时炸、
  php -l 看不出 → 改成 ORDER BY MIN(starts_at),否则整个旅程模块 Fatal。
· e2e 里"详情页显示了派生时间"原本写成整页查找,下面每段旅程自己也渲染日期,
  于是把派生值塞成 null 也照样绿(借光假绿)→ 判据收进页头那一段。

验证:e2e 845/0(+15 条本批断言)· 升级 134/0 · 桌面几何 428/0 · 移动几何 688/0 ·
交互 92/0 · 题图真机点击 4/0 · 读书窄屏 15/0 · 家谱 34/0 + 10/0 + 反向 3/3 ·
观影按钮 60/0 + 反向 3/3 · 本批反向验证 4/4(题图绑定抽掉→红、派生值不挂→红、
城市退回读 location→红、时间字段加回表单→红)· 部署静态断言新增两组(含"活动不许
再写起止时间/目的地"与"ORDER BY 不许引用聚合别名"的反退化防线)。
部署后服务器自检 19/19,五个关键文件 md5 与线上逐一核对一致。
2026-10-05 10:58:17 +08:00

1777 lines
110 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 后删列。
* 10 → **豆瓣自动补充**:movie_details.douban_meta(JSON,元数据整体存一列)。
* 11 → **头像主色**:persons.avatar_color(CHAR(7))。上传/换头像时算一次存库,
* 观影人标签等处按每人主色画底色(用户 2026-10-01 第 2 条);
* 老头像在升级时**回读一次**补算,补不到的按姓名兜底(AvatarColor)。
* 12 → **四个新模块(用户 2026-10-01 第 6~10 条)**:
* ① 旅程:events.kind 加 trip + trip_details(封面/行程小结)+ trip_nodes
* (行程节点,计划/执行双轨)+ trip_node_members;
* ② 日程:calendars(每人可建、自定义颜色、可共享只读/可写)
* + calendar_events(自定义日程);观影/旅行是**每人默认的两本**,
* 内容从 events 派生、不重复落库;
* ③ 资源:resources(挂在某人名下)+ resource_shares(共享给谁、只读/读写);
* ④ 任务:tasks(两级:parent_id)+ task_shares + task_comments(支持 @人)
* + task_recur(灵活循环);
* ⑤ 统一附件 attachments(旅程/节点/资源/任务/评论共用一张表);
* ⑥ 模块导航与主题色走 user_settings / settings,不新建表。
* 19 → **旅程模块重构(用户 2026-10-05)**:活动/行程两层语义收敛。
* `trip_nodes` 的**双轨 category(plan/reality) 改为状态机 stage**:
* plan(计划)/ actual(实际)/ skipped(标记未成行,不真删)。
* 理由:用户明确「实际的行程和计划行程,是一条记录,不是两条」,
* 而旧的双轨是**两条独立记录**。
* 新增 `trip_node_revisions`:每次「修正」存一份快照
* (实际起止 / 参与人 / 备注),可无限次,供日后对比。
* 迁移:category='reality' → stage='actual','plan' → stage='plan'
* (按现有轨道自动迁,用户已确认)。
*/
/**
* 结构版本(**人读**用:日志、设置页上显示"库结构是哪一版")。
*
* ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。
* 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。
*/
public const VERSION = '20';
/**
* 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。
*
* 为什么不用版本号:
* 版本号靠**人记得改**。这个项目已经因此翻过一次大车(2026-10-03):
* 连续两批提交往 ensure() 里加了 8 个新列 / 2 张新表,
* 但没动 VERSION(一直是 12)→ 线上判定"已是最新"→ **整段升级被跳过**
* → Calendar::month() 查 `d.is_public` 直接 PDOException 500。
* 而"忘了改版本号"这件事**没有任何检查能拦**(php -l 看不出、
* 部署断言也只能查字符串在不在),因为它不是代码错,是流程漏。
*
* 指纹的好处:**只要 ensure() 改过(哪怕只是加一行 addColumn),指纹就变**,
* 下一次请求必然重跑一遍 —— 不需要任何人记得做什么。
*
* ⚠️ 为什么用**文件内容**而不是"所有 app/*.php 的哈希":
* 只是 scope 更聚焦 —— 只有改 Schema 才可能改库结构。
* (改别的类而动了结构,属于不该发生的事;真发生也由 addColumn 的幂等性兜住)
* ⚠️ **只用内容,不用 mtime**:部署(tar 解包)会重写 mtime,
* 带上它等于"每次部署都白跑一遍升级"(虽然幂等、但没必要)。
*/
public static function fingerprint(): string
{
$body = @file_get_contents(__FILE__);
if ($body === false) {
return 'v' . self::VERSION; // 读不到就退回版本号(至少不会更差)
}
return substr(md5($body), 0, 16);
}
/**
* 这次请求要不要跑结构升级。
*
* 判据 = 指纹不一致。**指纹一致就跳过**(平时只是一次文件哈希,可接受)。
*/
public static function needsUpgrade(int $familyId): bool
{
$have = (string)Setting::get($familyId, 'schema_fingerprint', '');
return $have !== self::fingerprint();
}
/** 升级跑完(或确认无需跑)后记下指纹 */
public static function markUpgraded(int $familyId): void
{
Setting::set($familyId, 'schema_fingerprint', self::fingerprint());
// 版本号也顺手对齐,方便人看(设置页/日志里显示的就是它)
Setting::set($familyId, 'schema_version', self::VERSION);
}
/** @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 `caldav_credentials` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`person_id` INT UNSIGNED NOT NULL,
`token_hash` CHAR(64) NOT NULL COMMENT 'CalDAV 专用口令的 sha256;口令本身不落库',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`last_used_at` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_cd_person` (`person_id`),
CONSTRAINT `fk_cd_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='CalDAV 专用口令(与登录口令分开:客户端会把它长期存着,不能是登录口令)'
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,
// ---- 统一附件(v12)----
// 旅程照片 / 资源附件 / 任务附件 / 评论附件,全都走这一张表:
// 下载、预览、权限判断只写一次;将来再加模块也不用再造一张附件表。
// ⚠️ 不做外键(target_type 决定指向哪张表,外键表达不了),
// 只在 (target_type, target_id) 上建索引。删宿主时由各模块自己清。
<<<'SQL'
CREATE TABLE IF NOT EXISTS `attachments` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`target_type` ENUM('trip','trip_node','resource','task','task_comment') NOT NULL COMMENT '挂在谁身上',
`target_id` INT UNSIGNED NOT NULL COMMENT '宿主的 id',
`key` VARCHAR(255) NOT NULL COMMENT '文件(存储相对键)',
`name` VARCHAR(200) DEFAULT NULL COMMENT '原始文件名(下载时回给它)',
`mime` VARCHAR(120) DEFAULT NULL COMMENT '真实 MIME(由服务端 finfo 判定,不信浏览器给的)',
`size` BIGINT UNSIGNED DEFAULT NULL COMMENT '字节数',
`is_image` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1=图片,浏览器可直接预览',
`position` INT NOT NULL DEFAULT 100,
`uploader_id` INT UNSIGNED DEFAULT NULL COMMENT '谁传的(persons.id)',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_att_target` (`target_type`, `target_id`, `position`),
CONSTRAINT `fk_att_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_att_uploader` FOREIGN KEY (`uploader_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统一附件:旅程/资源/任务/评论共用'
SQL,
// ---- 旅程(v12,TripLog 迁入)----
<<<'SQL'
CREATE TABLE IF NOT EXISTS `trip_details` (
`event_id` INT UNSIGNED NOT NULL COMMENT '对应 events(kind="trip")',
`cover_key` VARCHAR(255) DEFAULT NULL COMMENT '封面图(存储相对键)',
`summary` TEXT DEFAULT NULL COMMENT '这趟旅程的一段话小结',
`is_public` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否公开显示(0=不显示在列表与日历)',
PRIMARY KEY (`event_id`),
CONSTRAINT `fk_tdet_event` FOREIGN KEY (`event_id`) REFERENCES `events` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='旅程事件的特有数据(一对一卫星表)'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `trip_nodes` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`event_id` INT UNSIGNED NOT NULL COMMENT '属于哪趟旅程',
`category` ENUM('plan','reality') NOT NULL DEFAULT 'plan' COMMENT '【v19 起仅作兼容保留,业务改用 stage】plan=计划 / reality=实际走过的',
`stage` ENUM('plan','actual','skipped') NOT NULL DEFAULT 'plan' COMMENT 'v19 行程状态机:plan=计划 / actual=实际行程 / skipped=标记未成行(不真删)',
`revised_at` DATETIME DEFAULT NULL COMMENT '最后一次「修正」的时间(NULL=从未修正)',
`revision_n` INT NOT NULL DEFAULT 0 COMMENT '修正次数(可用于显示"已修正 N 次")',
`node_type` VARCHAR(20) NOT NULL DEFAULT 'other' COMMENT '游玩 / 用餐 / 赶路 / 住宿 / 其他',
`title` VARCHAR(200) NOT NULL,
`detail` TEXT DEFAULT NULL COMMENT '这一段的文字记录',
`place` VARCHAR(200) DEFAULT NULL COMMENT '地点',
`starts_at` DATETIME DEFAULT NULL,
`ends_at` DATETIME DEFAULT NULL,
`status` ENUM('planned','ongoing','done','cancelled') NOT NULL DEFAULT 'planned',
`position` INT NOT NULL DEFAULT 100 COMMENT '手动排序(拖动改的就是它)',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_tn_event` (`event_id`, `category`, `position`),
KEY `idx_tn_time` (`starts_at`),
CONSTRAINT `fk_tn_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_tn_event` FOREIGN KEY (`event_id`) REFERENCES `events` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='行程节点:一趟旅程里的每一段(计划/执行双轨,TripLog 同款)'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `trip_node_revisions` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`node_id` INT UNSIGNED NOT NULL COMMENT '被修正的行程(trip_nodes.id)',
`seq` INT NOT NULL DEFAULT 1 COMMENT '第几次修正(从 1 开始)',
`starts_at` DATETIME DEFAULT NULL COMMENT '这次修正填的**实际**开始',
`ends_at` DATETIME DEFAULT NULL COMMENT '这次修正填的**实际**结束',
`place` VARCHAR(200) DEFAULT NULL COMMENT '实际地点',
`detail` TEXT DEFAULT NULL COMMENT '这次修正的说明(为什么改了)',
`members` TEXT DEFAULT NULL COMMENT '实际参与人 id 列表(逗号分隔的快照)',
`created_by` INT UNSIGNED DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_tnr_node` (`node_id`, `seq`),
CONSTRAINT `fk_tnr_node` FOREIGN KEY (`node_id`) REFERENCES `trip_nodes` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='行程的修正历史(每次「修正」一条,可无限次,供日后对比)'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `trip_node_members` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`node_id` INT UNSIGNED NOT NULL,
`person_id` INT UNSIGNED NOT NULL COMMENT '这一段里有谁',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_tnm_node_person` (`node_id`, `person_id`),
CONSTRAINT `fk_tnm_node` FOREIGN KEY (`node_id`) REFERENCES `trip_nodes` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_tnm_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='行程节点的参与人'
SQL,
// ---- 日程(v12)----
<<<'SQL'
CREATE TABLE IF NOT EXISTS `calendars` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`owner_id` INT UNSIGNED NOT NULL COMMENT '这本日历是谁的',
`name` VARCHAR(50) NOT NULL,
`color` CHAR(7) NOT NULL DEFAULT '#4A7C82' COMMENT '日历本的颜色(日程按本子的颜色显示)',
`kind` ENUM('custom','movie','trip','lunar','holiday','traditional','solarterm') NOT NULL DEFAULT 'custom' COMMENT 'movie/trip/lunar/holiday/traditional/solarterm = 系统预置的派生只读本(lunar/holiday 为 v24 第 10 批新增;traditional/solarterm 为第 12 批新增;都不可删除)',
`position` INT NOT NULL DEFAULT 100,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_cal_owner_kind` (`owner_id`, `kind`, `name`),
KEY `idx_cal_family` (`family_id`),
CONSTRAINT `fk_cal_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_cal_owner` FOREIGN KEY (`owner_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='日历本:每人可建,可自定义颜色,可共享给别人'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `calendar_shares` (
`calendar_id` INT UNSIGNED NOT NULL,
`person_id` INT UNSIGNED NOT NULL COMMENT '共享给谁',
`perm` ENUM('read','write') NOT NULL DEFAULT 'read' COMMENT 'read=只读 / write=可写(能新增与修改日程)',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`calendar_id`, `person_id`),
CONSTRAINT `fk_cs_cal` FOREIGN KEY (`calendar_id`) REFERENCES `calendars` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_cs_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 `calendar_events` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`calendar_id` INT UNSIGNED NOT NULL,
`title` VARCHAR(200) NOT NULL,
`starts_at` DATE NOT NULL COMMENT '开始日期',
`ends_at` DATE DEFAULT NULL COMMENT '结束日期(跨天日程;NULL=就一天)',
`start_time` TIME DEFAULT NULL COMMENT '开始时刻;**NULL 与 00:00:00 都表示全天**',
`end_time` TIME DEFAULT NULL,
`all_day` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '1=全天日程(没有具体时分)',
`note` TEXT DEFAULT NULL,
`created_by` INT UNSIGNED DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_ce_cal` (`calendar_id`, `starts_at`),
CONSTRAINT `fk_ce_cal` FOREIGN KEY (`calendar_id`) REFERENCES `calendars` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_ce_author` FOREIGN KEY (`created_by`) REFERENCES `persons` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='自定义日程(观影/旅行那两本不落这里,从 events 派生)'
SQL,
// ---- 资源(v12)----
<<<'SQL'
CREATE TABLE IF NOT EXISTS `resources` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`owner_id` INT UNSIGNED NOT NULL COMMENT '这是**谁名下**的资源(如「我的身份证」)',
`title` VARCHAR(120) NOT NULL COMMENT '标题:身份证 / 学位证 / 一寸照片…',
`content` TEXT DEFAULT NULL COMMENT '正文(文字说明)',
`created_by` INT UNSIGNED DEFAULT NULL COMMENT '谁建的(本人自然是本人,别人代建时记一笔)',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_res_owner` (`owner_id`, `id`),
CONSTRAINT `fk_res_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_res_owner` FOREIGN KEY (`owner_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_res_author` FOREIGN KEY (`created_by`) REFERENCES `persons` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='挂在某人名下的资源信息(证件、照片…)+ 多附件'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `resource_shares` (
`resource_id` INT UNSIGNED NOT NULL,
`person_id` INT UNSIGNED NOT NULL COMMENT '共享给谁(**不含** owner —— 名下的人本来就能看能改)',
`perm` ENUM('read','write') NOT NULL DEFAULT 'read',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`resource_id`, `person_id`),
CONSTRAINT `fk_rs_res` FOREIGN KEY (`resource_id`) REFERENCES `resources` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_rs_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='资源共享给别人:可只读、可读写'
SQL,
// ---- 任务(v12)----
<<<'SQL'
CREATE TABLE IF NOT EXISTS `tasks` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`parent_id` INT UNSIGNED DEFAULT NULL COMMENT 'NULL=父任务;非空=某个父任务下的子任务(只做两级)',
`title` VARCHAR(200) NOT NULL,
`note` TEXT DEFAULT NULL,
`status` ENUM('todo','doing','done') NOT NULL DEFAULT 'todo' COMMENT '看板的三列',
`due_at` DATETIME DEFAULT NULL COMMENT '截止时间;**有截止时间才会出现在日历里**',
`position` INT NOT NULL DEFAULT 100 COMMENT '看板里的先后(拖动改的就是它)',
`creator_id` INT UNSIGNED DEFAULT NULL,
`assignee_id` INT UNSIGNED DEFAULT NULL COMMENT '主要负责人(可空:共享给谁谁都能完成)',
`repeat_rule` VARCHAR(40) DEFAULT NULL COMMENT '循环:day / week / month / year(空=不循环)',
`done_at` DATETIME DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_task_board` (`family_id`, `status`, `position`),
KEY `idx_task_parent` (`parent_id`),
KEY `idx_task_due` (`due_at`),
CONSTRAINT `fk_task_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_task_parent` FOREIGN KEY (`parent_id`) REFERENCES `tasks` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_task_creator` FOREIGN KEY (`creator_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL,
CONSTRAINT `fk_task_assignee` FOREIGN KEY (`assignee_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务:看板三列 + 两级子任务 + 评论 + 共享 + 循环'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `task_shares` (
`task_id` INT UNSIGNED NOT NULL,
`person_id` INT UNSIGNED NOT NULL COMMENT '共享给谁(两个人里任何一个完成都算完成)',
`perm` ENUM('read','write') NOT NULL DEFAULT 'write',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`task_id`, `person_id`),
CONSTRAINT `fk_ts_task` FOREIGN KEY (`task_id`) REFERENCES `tasks` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_ts_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 `task_comments` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`task_id` INT UNSIGNED NOT NULL,
`person_id` INT UNSIGNED NOT NULL,
`content` TEXT NOT NULL COMMENT '支持 @某人:渲染时把 @名字 高亮出来',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_tc_task` (`task_id`, `created_at`),
CONSTRAINT `fk_tc_task` FOREIGN KEY (`task_id`) REFERENCES `tasks` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_tc_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 `regions` (
/* ⚠️ VARCHAR(12) 不是 6:东莞、中山这类「市直管镇」的城市,数据集里第四级
(镇 / 街道)是 9 位代码 —— 写 6 会让插入在第 2170 行被截断裂掉,
后面一千多条全丢(实测只剩 19 个省)。 */
`code` VARCHAR(12) NOT NULL COMMENT '行政区划代码:省 2 位 / 市 4 位 / 区县 6 位(市直管镇的镇 9 位)',
`name` VARCHAR(60) NOT NULL COMMENT '标准名称,如「北京市」「青岛市」「市南区」',
`level` TINYINT NOT NULL COMMENT '1=省级 2=地级市 3=区县(含市直管镇)',
`parent_code` VARCHAR(12) DEFAULT NULL COMMENT '上级代码;省级为 NULL',
PRIMARY KEY (`code`),
KEY `idx_region_parent` (`parent_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='全国行政区划(省/市/区县三级):地址结构化与导入时对齐用'
SQL,
// ---- 读书(版本 20,用户 2026-10-05 第 14 批第 5 条)----
// 像 calibre 那样管自己的书:书架 + 上传/导入 + 在线读 + 读完 + 笔记 + 统计。
//
// 三张表,各管一件事,**不要**合并:
// · books —— 书本身(元数据 + 文件 + 读到哪 + 读完没读完)
// · book_chapters—— 解析出来的正文(一本书几十上百段,单独一张表,
// 否则列表页 SELECT * 会把整本书一起捞出来)
// · book_notes —— 笔记 / 划线 / 感想,**按人**记(同一个家里两个人读
// 同一本书,各自的笔记不能混)
<<<'SQL'
CREATE TABLE IF NOT EXISTS `books` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`owner_id` INT UNSIGNED DEFAULT NULL COMMENT '谁在读(NULL = 全家共用一本)',
`creator_id` INT UNSIGNED DEFAULT NULL COMMENT '谁把它加进书架的',
`title` VARCHAR(300) NOT NULL,
`subtitle` VARCHAR(300) DEFAULT NULL,
`author` VARCHAR(200) DEFAULT NULL,
`translator` VARCHAR(200) DEFAULT NULL,
`publisher` VARCHAR(120) DEFAULT NULL,
`published` VARCHAR(40) DEFAULT NULL COMMENT '出版时间(自由文本:2012 / 2012-05 / 2012年5月)',
`isbn` VARCHAR(40) DEFAULT NULL,
`language` VARCHAR(40) DEFAULT NULL,
`category` VARCHAR(80) DEFAULT NULL COMMENT '分类(自由文本,家庭自己约定叫法)',
`tags` VARCHAR(300) DEFAULT NULL COMMENT '标签,逗号分隔',
`summary` TEXT DEFAULT NULL COMMENT '内容简介',
`cover_key` VARCHAR(255) DEFAULT NULL COMMENT '封面(走 media.php 代理,与头像同一套)',
`file_key` VARCHAR(255) DEFAULT NULL COMMENT '原书文件(books 目录下的相对键,如 books/xxx.epub)',
`file_name` VARCHAR(255) DEFAULT NULL COMMENT '上传时的文件名(下载时用)',
`file_format` VARCHAR(20) DEFAULT NULL COMMENT 'epub / txt / pdf / mobi / azw3 …',
`file_size` INT UNSIGNED DEFAULT NULL,
`pages` INT UNSIGNED DEFAULT NULL COMMENT '页数(有就填,用于统计)',
`words` INT UNSIGNED DEFAULT NULL COMMENT '字数(解析时估算)',
`chapter_count` INT NOT NULL DEFAULT 0 COMMENT '解析出多少章(0 = 没解析过)',
`status` ENUM('wish','reading','done','paused') NOT NULL DEFAULT 'wish'
COMMENT '想读 / 在读 / 读完 / 搁置',
`rating` TINYINT DEFAULT NULL COMMENT '评分 0-10(NULL = 还没打分)',
`progress` INT NOT NULL DEFAULT 0 COMMENT '读到第几章(0 = 还没开始)',
`started_at` DATE DEFAULT NULL,
`finished_at` DATE DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_book_family` (`family_id`, `status`, `updated_at`),
KEY `idx_book_owner` (`family_id`, `owner_id`),
CONSTRAINT `fk_book_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_book_owner` FOREIGN KEY (`owner_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL,
CONSTRAINT `fk_book_creator` FOREIGN KEY (`creator_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='书架:一本书一行(元数据 + 文件 + 读到哪 + 读完没读完)'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `book_chapters` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`book_id` INT UNSIGNED NOT NULL,
`seq` INT NOT NULL DEFAULT 1 COMMENT '第几章(从 1 开始)',
`title` VARCHAR(300) DEFAULT NULL,
`content` MEDIUMTEXT DEFAULT NULL COMMENT '正文(已去标签的纯文本,段落之间换行)',
`words` INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_bc_book` (`book_id`, `seq`),
CONSTRAINT `fk_bc_book` FOREIGN KEY (`book_id`) REFERENCES `books` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='书的正文(解析出来的章节)'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `book_notes` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`book_id` INT UNSIGNED NOT NULL,
`person_id` INT UNSIGNED DEFAULT NULL COMMENT '谁的笔记(导入时可能不知道是谁 → NULL)',
`kind` ENUM('quote','note','review') NOT NULL DEFAULT 'note'
COMMENT 'quote=划线/原文摘录,note=想法/笔记,review=书评',
`chapter` VARCHAR(200) DEFAULT NULL COMMENT '在哪一章(导入时带得上的话)',
`loc` VARCHAR(80) DEFAULT NULL COMMENT '位置(页数 / 百分比 / 微信读书的定位串)',
`content` TEXT NOT NULL,
`source` VARCHAR(40) NOT NULL DEFAULT 'manual' COMMENT 'manual / weread / douban / …(哪来的)',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_bn_book` (`book_id`, `created_at`),
KEY `idx_bn_person` (`person_id`),
CONSTRAINT `fk_bn_book` FOREIGN KEY (`book_id`) REFERENCES `books` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_bn_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL
) 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`");
// ---- 头像主色(版本 11)----
// 只在「上传/换头像」那一刻算一次(见 AvatarColor 的说明):渲染时再算
// 就要为页面上的每个人回读一次存储,几十个人 = 几十次外网往返。
self::addColumn($db, 'persons', 'avatar_color',
"ADD COLUMN `avatar_color` CHAR(7) DEFAULT NULL COMMENT '头像主色(上传时算一次;页面按它派生底色/字色)' AFTER `avatar`");
// ---- 地址结构化(版本 16)----
// 用户 2026-10-04 第 4 条:地址 = 国家 / 省 / 市 / 区 / 详细地址五级,
// 而且「如果没有非常详细的地址,就只记到区」—— 所以四级都可空,
// 只有 `address`(详细地址)是原来那一个自由文本列,语义不变。
// ⚠️ 存的是**标准名**不是代码:通讯录导进来的地址是自由文本,
// 按代码存就要求导入时 100% 匹配得上,匹配不上的整条地址就丢了;
// 存标准名则"对得上就规范化、对不上就照抄",永远不会更差。
self::addColumn($db, 'persons', 'addr_country',
"ADD COLUMN `addr_country` VARCHAR(60) DEFAULT NULL COMMENT '国家' AFTER `address`");
self::addColumn($db, 'persons', 'addr_province',
"ADD COLUMN `addr_province` VARCHAR(60) DEFAULT NULL COMMENT '省 / 自治区 / 直辖市 / 特别行政区(标准名,对应 regions.level=1)' AFTER `addr_country`");
self::addColumn($db, 'persons', 'addr_city',
"ADD COLUMN `addr_city` VARCHAR(60) DEFAULT NULL COMMENT '地级市(标准名,对应 regions.level=2)' AFTER `addr_province`");
self::addColumn($db, 'persons', 'addr_district',
"ADD COLUMN `addr_district` VARCHAR(60) DEFAULT NULL COMMENT '区 / 县(标准名,对应 regions.level=3)' AFTER `addr_city`");
// ⚠️ CREATE TABLE IF NOT EXISTS 不会改已存在的列宽 —— 老库(v16 之前建的
// `code VARCHAR(6)`)必须显式 MODIFY,否则东莞那种 9 位镇级代码插不进去。
$db->exec("ALTER TABLE `regions` MODIFY COLUMN `code`
VARCHAR(12) NOT NULL COMMENT '行政区划代码:省 2 位 / 市 4 位 / 区县 6 位(市直管镇的镇 9 位)'");
$db->exec("ALTER TABLE `regions` MODIFY COLUMN `parent_code`
VARCHAR(12) DEFAULT NULL COMMENT '上级代码;省级为 NULL'");
// 全国行政区划灌库(只是数据步骤 → 容错跑:种子文件丢了也不该挡住后面的升级)
self::tryStep('灌入全国行政区划(省/市/区县)', static function () use ($db) {
\App\Region::seed($db);
});
// 老地址(原来那一整串)拆成结构化:只补空字段,绝不覆盖用户已经改过的
self::tryStep('老地址拆成国家/省/市/区', static function () use ($db) {
self::backfillAddress($db);
});
// ---- v24 第 10 批 #4:日历本 kind 枚举加 lunar / holiday ----
// ⚠️ 与上面 widenEventKindEnum 同一类坑:建表语句里的 ENUM 对**已存在的表**
// 不会更新(CREATE TABLE IF NOT EXISTS 直接跳过),必须显式 MODIFY。
// 不改的后果:给老库插 kind='lunar' 会被严格模式整条拒掉 →
// "农历日历本建不出来",而新装的库一切正常(最难查的那种)。
// ---- v24 第 12 批 #4:再加 traditional(传统节日)/ solarterm(二十四节气)----
// ⚠️ 同一类坑的第二次:ENUM 必须显式 MODIFY 才能对老表生效。
$db->exec("ALTER TABLE `calendars` MODIFY COLUMN `kind`
ENUM('custom','movie','trip','lunar','holiday','traditional','solarterm')
NOT NULL DEFAULT 'custom'
COMMENT 'movie/trip/lunar/holiday/traditional/solarterm = 系统预置的派生只读本'");
// 给每个家庭里的每个人都补上这 6 本(幂等;seedDefaultsForFamily 按 kind 去重)
self::tryStep('补建每人的农历 / 节假日 / 传统节日 / 二十四节气日历本', static function () use ($db) {
foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) {
\App\Calendar::seedDefaultsForFamily((int)$fam['id']);
}
});
// ---- v24 第 13 批:旅程模块重构(Schema v19)----
// ① 给 trip_nodes 补三列(老库建表语句不会更新已存在的表,必须显式加)
self::addColumn($db, 'trip_nodes', 'stage',
"ADD COLUMN `stage` ENUM('plan','actual','skipped') NOT NULL DEFAULT 'plan' "
. "COMMENT 'v19 行程状态机:plan/actual/skipped' AFTER `category`");
self::addColumn($db, 'trip_nodes', 'revised_at',
"ADD COLUMN `revised_at` DATETIME DEFAULT NULL COMMENT '最后一次修正时间' AFTER `stage`");
self::addColumn($db, 'trip_nodes', 'revision_n',
"ADD COLUMN `revision_n` INT NOT NULL DEFAULT 0 COMMENT '修正次数' AFTER `revised_at`");
// ② 建修正历史表(老库没有)
self::tryStep('建行程修正历史表', static function () use ($db) {
$db->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS `trip_node_revisions` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`node_id` INT UNSIGNED NOT NULL COMMENT '被修正的行程(trip_nodes.id)',
`seq` INT NOT NULL DEFAULT 1 COMMENT '第几次修正(从 1 开始)',
`starts_at` DATETIME DEFAULT NULL COMMENT '这次修正填的**实际**开始',
`ends_at` DATETIME DEFAULT NULL COMMENT '这次修正填的**实际**结束',
`place` VARCHAR(200) DEFAULT NULL COMMENT '实际地点',
`detail` TEXT DEFAULT NULL COMMENT '这次修正的说明(为什么改了)',
`members` TEXT DEFAULT NULL COMMENT '实际参与人 id 列表(逗号分隔的快照)',
`created_by` INT UNSIGNED DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_tnr_node` (`node_id`, `seq`),
CONSTRAINT `fk_tnr_node` FOREIGN KEY (`node_id`) REFERENCES `trip_nodes` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='行程的修正历史(每次「修正」一条,可无限次,供日后对比)'
SQL);
});
// ③ 按现有轨道迁移 stage(用户 2026-10-05 确认:"按现有轨道自动迁移")
// category='reality' 的那条 → 已经是实际行程;'plan' → 计划行程。
// ⚠️ 只迁**还是默认值**的行(stage='plan' 且 revision_n=0),
// 避免把用户在新版里已经改过的状态又刷回去(重复跑升级是常态)。
self::tryStep('按现有轨道迁移行程状态(plan/reality → stage)', static function () use ($db) {
$db->exec("UPDATE `trip_nodes` SET `stage` = 'actual'
WHERE `category` = 'reality' AND `revision_n` = 0 AND `stage` = 'plan'");
});
// ---- v24 第 10 批 #2:负债类账目一律存正数 ----
// 用户原话:「类别,增加一个欠款,用来记录别人的钱,属于负债,是负的。」
// 也就是说用户会把欠款填成 **-500**。旧的 summary() 用 `net = 资产 − 负债`,
// 负的负债会让净资产**反向变大**(1000 − (−500) = 1500,欠钱反而更有钱)。
// 写入口已加 normalizeAmount 归一化;这里把**库里已有的**负数负债也纠正过来,
// 否则老数据的净资产会一直错下去。
self::tryStep('负债类账目金额取绝对值(纠正负数欠款)', static function () use ($db) {
$kinds = [];
foreach (\App\Ledger::KINDS as $k => $v) {
if (($v[1] ?? 'asset') === 'debt') {
$kinds[] = $k;
}
}
if (!$kinds) {
return;
}
$in = implode(',', array_fill(0, count($kinds), '?'));
// 账目表自己带 kind,直接筛
$db->prepare("UPDATE `ledger_entries` SET amount_cents = -amount_cents
WHERE amount_cents < 0 AND kind IN ($in)")->execute($kinds);
// ⚠️ 快照表**没有 kind 列**(它只有 entry_id)—— 必须回表 ledger_entries 判类别,
// 不能凭"金额为负"就取反:负的**资产**(透支、欠款记账前的误录)是合法值。
$db->prepare("UPDATE `ledger_entry_snapshots` s
JOIN `ledger_entries` e ON e.id = s.entry_id
SET s.amount_cents = -s.amount_cents
WHERE s.amount_cents < 0 AND e.kind IN ($in)")->execute($kinds);
});
// 老头像(v11 之前传的)补算一次:回读已存的图算主色。
// 纯数据步骤 → 容错跑:某张图读不回来(OSS 权限、对象丢了)只影响那一个人,
// 他会自动落到「按姓名取色」的兜底上,不该把后面的升级步骤挡住。
self::tryStep('回填头像主色', static function () use ($db) {
self::backfillAvatarColor($db);
});
// ---- 四个新模块(版本 12)----
// ⚠️ events.kind 原本只有 'movie',旅程要复用统一事件原语就必须加 'trip'。
// 建表语句的 IF NOT EXISTS 对**已存在的表**不会更新枚举 —— 老库必须显式改,
// 否则插入 kind='trip' 会被严格模式整条拒掉(v7 就踩过同一类坑)。
self::widenEventKindEnum($db);
// 每人的两本默认日历(观影 / 旅行):新装的由 Calendar::seedDefaults 在
// 建账号时建,老库在这里补(幂等:按 (owner_id, kind, name) 唯一键查过再插)。
self::tryStep('补建每人默认的观影/旅行日历本', static function () use ($db) {
foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) {
\App\Calendar::seedDefaultsForFamily((int)$fam['id']);
}
});
// ---- 日程按 CalDAV / iCalendar 对齐(版本 13)----
// 用户 2026-10-02 第 5、6 条:起止时刻**精确到秒**,且「日期和时间在一个控件里一起选」。
// 原来是 starts_at DATE + start_time TIME 两个列,天然就是"分开的"两件事 ——
// 连"跨天又跨时刻"这种最基本的日程都表达不了。现在并成一个 DATETIME。
// ⚠️ 先 MODIFY 再回填:MODIFY 之后老数据的 DATE 自动变成 'YYYY-MM-DD 00:00:00',
// 紧接着把 start_time 并进去,老日程的时刻才不会一夜之间全变成 00:00。
$db->exec("ALTER TABLE `calendar_events` MODIFY COLUMN `starts_at`
DATETIME NOT NULL COMMENT '开始时刻(精确到秒;全天日程的时间部分为 00:00:00)'");
$db->exec("ALTER TABLE `calendar_events` MODIFY COLUMN `ends_at`
DATETIME DEFAULT NULL COMMENT '结束时刻(精确到秒);NULL=与开始同一时刻'");
self::tryStep('老日程的时刻并进 starts_at/ends_at', static function () use ($db) {
$db->exec("UPDATE `calendar_events`
SET `starts_at` = CONCAT(DATE(`starts_at`), ' ', `start_time`)
WHERE `start_time` IS NOT NULL AND `all_day` = 0");
$db->exec("UPDATE `calendar_events`
SET `ends_at` = CONCAT(DATE(`ends_at`), ' ', `end_time`)
WHERE `ends_at` IS NOT NULL AND `end_time` IS NOT NULL AND `all_day` = 0");
// ⚠️ 并完必须把旧的 start_time/end_time **清空**,否则这步不是幂等的:
// 用户后来改了时刻(改的是 starts_at),下次再跑升级又把陈旧的
// start_time 盖回去,等于**悄悄撤销用户的修改**。
// 清空之后,下次重跑时 WHERE start_time IS NOT NULL 自然不命中 → 安全。
$db->exec("UPDATE `calendar_events` SET `start_time` = NULL WHERE `start_time` IS NOT NULL");
$db->exec("UPDATE `calendar_events` SET `end_time` = NULL WHERE `end_time` IS NOT NULL");
});
// 其余字段按 RFC 5545(iCalendar)/ RFC 4791(CalDAV)的 VEVENT 属性定:
// UID/DTSTAMP/SEQUENCE 是 CalDAV 同步三件套(没有它们客户端每次都当新日程);
// LOCATION / STATUS / TRANSP / RRULE 是用户第 6 条要的地点与忙闲;
// reminder_minutes 对应 VALARM 的 TRIGGER(提前多久提醒)。
self::addColumn($db, 'calendar_events', 'uid',
"ADD COLUMN `uid` VARCHAR(255) DEFAULT NULL COMMENT 'iCalendar UID(RFC 5545):一条日程全球唯一的身份证,CalDAV 与 ics 导入导出全靠它判重' AFTER `calendar_id`");
self::addColumn($db, 'calendar_events', 'location',
"ADD COLUMN `location` VARCHAR(255) DEFAULT NULL COMMENT '地点(RFC 5545 LOCATION)' AFTER `title`");
self::addColumn($db, 'calendar_events', 'reminder_minutes',
"ADD COLUMN `reminder_minutes` INT DEFAULT NULL COMMENT '提前多少分钟提醒(对应 VALARM 的 TRIGGER);NULL=不提醒' AFTER `all_day`");
self::addColumn($db, 'calendar_events', 'status',
"ADD COLUMN `status` ENUM('confirmed','tentative','cancelled') NOT NULL DEFAULT 'confirmed' COMMENT 'RFC 5545 STATUS:确定 / 待定 / 取消' AFTER `reminder_minutes`");
self::addColumn($db, 'calendar_events', 'transp',
"ADD COLUMN `transp` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'RFC 5545 TRANSP:0=忙碌(OPAQUE) / 1=空闲(TRANSPARENT)' AFTER `status`");
self::addColumn($db, 'calendar_events', 'rrule',
"ADD COLUMN `rrule` VARCHAR(255) DEFAULT NULL COMMENT 'RFC 5545 RRULE 重复规则原文(ics 导入导出原样带过,不做解释)' AFTER `transp`");
self::addColumn($db, 'calendar_events', 'sequence',
"ADD COLUMN `sequence` INT NOT NULL DEFAULT 0 COMMENT 'RFC 5545 SEQUENCE:改一次 +1,CalDAV 客户端据此判断要不要拉新版' AFTER `rrule`");
self::addColumn($db, 'calendar_events', 'created_at',
"ADD COLUMN `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'RFC 5545 CREATED' AFTER `created_by`");
self::addColumn($db, 'calendar_events', 'updated_at',
"ADD COLUMN `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'RFC 5545 LAST-MODIFIED' AFTER `created_at`");
// 老日程补 UID(CalDAV 那边没有 UID 就等于"每次都是新日程",客户端会无限重复)
self::tryStep('给老日程补 UID / SEQUENCE', static function () use ($db) {
self::backfillCalendarUid($db);
});
// 每人的 CalDAV 口令(第 7 条:把我的日历本通过 CalDAV 分享出去)。
// 存 user_settings,不新建表 —— 与 nav_modules / nav_known 同一套做法。
// ---- 资源的创建者默认共享(版本 13)----
// 历史数据里「我建的、挂在别人名下的资源」没有共享行 → 我在自己的列表里看不到它。
// 新建时已经会自动补(Resource::create),这里把老的一次补齐(幂等:只补没有的)。
self::tryStep('回填资源创建者的共享权限', static function () use ($db) {
self::backfillResourceCreatorShare($db);
});
// ---- 任务:分类 + 自定义看板(版本 13,用户第 9、10 条)----
// 看板(board)与状态(status)是两件事:
// status 是这条任务**做到哪一步**(todo / doing / done);
// board 是它**属于哪一块**(家务 / 装修 / 旅行准备 …)。
// 之前只有 status,于是"想分开看几件事"只能靠给标题加前缀,越用越乱。
$db->exec(
'CREATE TABLE IF NOT EXISTS `task_boards` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`name` VARCHAR(80) NOT NULL,
`position` INT NOT NULL DEFAULT 100,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_tb_family` (`family_id`, `position`),
CONSTRAINT `fk_tb_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
);
$db->exec(
'CREATE TABLE IF NOT EXISTS `task_categories` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`name` VARCHAR(80) NOT NULL,
`position` INT NOT NULL DEFAULT 100,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_tc_family` (`family_id`, `position`),
CONSTRAINT `fk_tc_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
);
self::addColumn($db, 'tasks', 'board_id',
"ADD COLUMN `board_id` INT UNSIGNED DEFAULT NULL COMMENT '属于哪个看板(用户自建;NULL=默认看板)' AFTER `family_id`");
self::addColumn($db, 'tasks', 'category_id',
"ADD COLUMN `category_id` INT UNSIGNED DEFAULT NULL COMMENT '属于哪个分类(用户自建;NULL=未分类)' AFTER `board_id`");
// 每个家庭至少有一个看板,否则"看板"这个概念在界面上就是空的
self::tryStep('给每个家庭补一个默认看板', static function () use ($db) {
foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) {
\App\Task::ensureDefaultBoard((int)$fam['id']);
}
});
// ---- 重复日程的「单个发生」例外表(版本 13,用户 2026-10-03 第 5 条)----
// 用户要的三档编辑里,「仅此一次」与「跳过这次」都要能"只动某一次"。
// 做法(与 RFC 5545 的 EXDATE 同构):把被取消/被挪走的那一次记在这里,
// 展开时跳过它;如果那次是被"挪到别的时间",就再插一条独立日程。
// ⚠️ 不复用 calendar_events:那是"日程本体",这里是"本体的某一次例外",
// 混在一张表里会让"这条日程重复吗"变成要额外判断的字段。
$db->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS `calendar_event_skips` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`event_id` INT UNSIGNED NOT NULL COMMENT '哪条日程(calendar_events.id)',
`skip_date` DATE NOT NULL COMMENT '被跳过的哪一天(重复展开时命中这一天就不出)',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_skip` (`event_id`, `skip_date`),
CONSTRAINT `fk_skip_event` FOREIGN KEY (`event_id`) REFERENCES `calendar_events` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='重复日程里被单独取消/挪走的某一次(RFC 5545 的 EXDATE)'
SQL);
// ---- 旅程的活动「是否公开」(版本 13,用户 2026-10-03 第 4 条)----
// 用户原话:「旅程的活动中,只显示 is_public=1 的活动」。
// ⚠️ 放在 **trip_details**(1:1 的卫星表)而不是 events:
// events 是观影/旅程/… 共用的骨架表,加一个只有旅程用的开关
// 会让观影那边多出一个永远为 0 的无意义字段。
// ⚠️ 默认 **1**(公开):老数据升级后必须照旧可见 ——
// 默认 0 会让人以为"旅程全丢了"(这是最危险的一种迁移默认值)。
self::addColumn($db, 'trip_details', 'is_public',
"ADD COLUMN `is_public` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否公开显示(用户 2026-10-03 第 4 条):0=不显示在旅程列表与日历' AFTER `summary`");
// ---- 纪念日(版本 13,用户第 13 条)----
// ⚠️ 名册里每个人的生日**不入这张表** —— 它们是实时从 persons 派生的
// (见 Anniversary::birthdays),改了生日纪念日就跟着变。
// 这里只存"用户自己加的"纪念日(结婚纪念日、忌日、相识纪念日…)。
// month/day 按**该重复方式的月日**存:repeat_type=lunar 时是农历的月日,
// 不是公历换算结果(换算只对某一年有效,存了明年就错)。
// ⚠️ 用 heredoc(<<<'SQL')而不是手写单引号字符串:这一段里有 ENUM 的
// 引号、中文 COMMENT、反引号列名,逐处 `\'` 转义极容易漏一个引号 ——
// 实测就漏过(PHP 报 "unexpected identifier VARCHAR",看着像 SQL 语法错,
// 其实全是 PHP 字符串没闭合)。heredoc 里什么都不用转义。
$db->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS `anniversaries` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`title` VARCHAR(120) NOT NULL,
`repeat_type` ENUM('solar','lunar','once') NOT NULL DEFAULT 'solar'
COMMENT '重复方式:solar=每年阳历同月同日 / lunar=每年农历同月同日 / once=只此一次',
`month` TINYINT UNSIGNED NOT NULL,
`day` TINYINT UNSIGNED NOT NULL,
`is_leap` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '农历:该月是否为闰月',
`kind` VARCHAR(40) DEFAULT NULL COMMENT '分类:生日 / 结婚纪念日 / 忌日 …',
`note` TEXT DEFAULT NULL,
`person_id` INT UNSIGNED DEFAULT NULL COMMENT '与谁相关(可空)',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_ann_family` (`family_id`, `month`, `day`),
CONSTRAINT `fk_ann_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_ann_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='纪念日:用户自建(生日不在此表,由 persons 实时派生)'
SQL);
// ---- 财务 / 账本(版本 13,用户第 15 条)----
// 两层:账本(可共享给人的一本总账)+ 账目(存款/基金/保险/房子/车子…)。
// ⚠️ 金额一律存**分**(BIGINT),不存浮点:钱是最不能容忍
// "0.1+0.2=0.30000000000000004" 的地方(见 Ledger::toCents 的注释)。
$db->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS `ledgers` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`owner_id` INT UNSIGNED DEFAULT NULL COMMENT '创建者',
`name` VARCHAR(60) NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_led_family` (`family_id`),
CONSTRAINT `fk_led_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_led_owner` FOREIGN KEY (`owner_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='账本:一本家庭总账,可共享给人'
SQL);
$db->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS `ledger_entries` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`ledger_id` INT UNSIGNED NOT NULL,
`kind` VARCHAR(20) NOT NULL DEFAULT 'other' COMMENT '存款/基金/保险/房子/车子/贷款…',
`title` VARCHAR(120) NOT NULL,
`amount_cents` BIGINT NOT NULL DEFAULT 0 COMMENT '金额(**分**):正数记绝对值,是资产还是负债由 kind 决定',
`note` TEXT DEFAULT NULL,
`person_id` INT UNSIGNED DEFAULT NULL COMMENT '挂在谁名下(可空)',
`created_by` INT UNSIGNED DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_le_ledger` (`ledger_id`),
CONSTRAINT `fk_le_ledger` FOREIGN KEY (`ledger_id`) REFERENCES `ledgers` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_le_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='账目:一笔资产或负债'
SQL);
$db->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS `ledger_shares` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`ledger_id` INT UNSIGNED NOT NULL,
`person_id` INT UNSIGNED NOT NULL,
`perm` ENUM('read','write') NOT NULL DEFAULT 'read',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_led_person` (`ledger_id`, `person_id`),
CONSTRAINT `fk_ls_ledger` FOREIGN KEY (`ledger_id`) REFERENCES `ledgers` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_ls_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='账本共享'
SQL);
// ---- 工作经历(版本 15,用户 2026-10-03 第 4 条)----
// 用户原话:「工作单位、职称、职务,都是可以填写多个的,因为一个人的工作可能会变,
// 我们需要记住他工作和职务的变化。」
// 于是一个人不再只有"一个单位 + 一个职称",而是一串经历(见 App\PersonJob)。
// ⚠️ persons.org / title **保留不动**,语义变成"当前那一段的快照":
// 名册/家谱/观影标签这些地方只要"现在在哪儿",不该为一行字去 JOIN 历史表;
// 而且老数据也不会因为这次改动而消失。
$db->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS `person_jobs` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`person_id` INT UNSIGNED NOT NULL,
`org` VARCHAR(120) DEFAULT NULL COMMENT '单位',
`title` VARCHAR(120) DEFAULT NULL COMMENT '职务(如 主任 / 副院长)',
`rank_name` VARCHAR(120) DEFAULT NULL COMMENT '职称(如 副教授 / 高级工程师)',
`start_ym` CHAR(7) DEFAULT NULL COMMENT '起始年月 YYYY-MM(可空)',
`end_ym` CHAR(7) DEFAULT NULL COMMENT '结束年月;**空 = 现在还在**(列表里标「现在」)',
`note` VARCHAR(200) DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_pj_person` (`person_id`, `end_ym`),
CONSTRAINT `fk_pj_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='工作经历:一个人可以有多段(单位/职务/职称/起止年月)'
SQL);
// 升级回填:老数据只有 persons.org / title 两个字段 → 造一段"没有起止"的当前经历。
// ⚠️ 只在**这个人还没有任何经历**时补,重复跑不会重复插(幂等)。
try {
$db->exec(
'INSERT INTO person_jobs (person_id, org, title, rank_name, start_ym, end_ym, note)
SELECT p.id, p.org, p.title, NULL, NULL, NULL, NULL
FROM persons p
WHERE (p.org IS NOT NULL AND p.org <> "" OR p.title IS NOT NULL AND p.title <> "")
AND NOT EXISTS (SELECT 1 FROM person_jobs j WHERE j.person_id = p.id)'
);
} catch (\Throwable $ex) {
self::$stepErrors[] = '工作经历回填:' . $ex->getMessage();
}
// ---- 「我对他的备注」(版本 15,用户 2026-10-03 第 5 条)----
// 用户原话:「名册中,每个人的备注,都有两个,一个是他自己填写的,就好像他自己的
// 一个格言或者自我介绍;如果其他人打开这个人的信息,编辑备注,
// 是这个人对他的独有的备注,例如记录怎么跟这个人认识的等等。」
//
// 所以两种备注分开放:
// · persons.note —— **本人写的**自我介绍(每个人自己一份,见 form 的说明);
// · person_notes —— **"我"对他写的**私人备注(每人每对象一份,别人看不到)。
// ⚠️ 不塞进 persons:那是"一个人的属性",而"我怎么认识他的"是**我和他之间**的事,
// 混在一起必然出现"我把备注写在别人身上、全家都看得见"。
$db->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS `person_notes` (
`person_id` INT UNSIGNED NOT NULL COMMENT '被写备注的那个人',
`viewer_id` INT UNSIGNED NOT NULL COMMENT '谁写的(只有他自己看得到)',
`note` TEXT DEFAULT NULL COMMENT '我对他的私人备注(怎么认识的、要注意什么…)',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`person_id`, `viewer_id`),
CONSTRAINT `fk_pn_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_pn_viewer` FOREIGN KEY (`viewer_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='「我对某个人的私人备注」:每人每对象一条,只有自己看得到'
SQL);
// ---- 账本盘点(版本 14,用户 2026-10-03 第 8 条)----
// 用户原话:「账本要增加盘点功能,因为某一个账目可能会变,要记住每一次变化,
// 用盘点来完成。我点击盘点,让我对一个账目的金额进行重新输入,
// 但是盘点之前的账目数目不能清空,要记住。」
//
// 所以需要一张**只增不改**的历史表:每次盘点落一行快照。
// ⚠️ 不复用 ledger_entries 的 amount_cents:账目表存的是"现在是多少",
// 这张表存的是"什么时候变成过多少"。一张表里塞两种语义,
// 迟早有人 UPDATE 了 entry 又奇怪"历史怎么也跟着变了"。
$db->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS `ledger_entry_snapshots` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`ledger_id` INT UNSIGNED NOT NULL COMMENT '冗余存一份:画"整本账随时间的变化"时不用回表',
`entry_id` INT UNSIGNED NOT NULL,
`amount_cents` BIGINT NOT NULL DEFAULT 0 COMMENT '这次盘点的金额(分)',
`note` VARCHAR(120) DEFAULT NULL COMMENT '盘点备注(可选)',
`by_id` INT UNSIGNED DEFAULT NULL COMMENT '谁盘的',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_snap_entry` (`entry_id`, `created_at`),
KEY `idx_snap_ledger` (`ledger_id`, `created_at`),
CONSTRAINT `fk_snap_ledger` FOREIGN KEY (`ledger_id`) REFERENCES `ledgers` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_snap_entry` FOREIGN KEY (`entry_id`) REFERENCES `ledger_entries` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_snap_person` FOREIGN KEY (`by_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='账目金额的历史快照(盘点记录,只增不改)'
SQL);
// 被容错跳过的数据步骤记进设置,系统设置页会把它显出来 ——
// 不然「升级没跑完」这件事只活在日志里,跟没发生一样。
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 里有
}
}
}
/**
* v24:给老日程补 UID(RFC 5545 要求 VEVENT 必须有 UID)。
*
* 格式沿用「域名倒写 + 随机」的通行写法:`<random>-<id>@familybook`,
* 保证在本系统内唯一即可(跨系统的唯一性靠随机段)。幂等:只补 uid IS NULL 的。
*/
private static function backfillCalendarUid(\PDO $db): void
{
$rows = $db->query('SELECT id FROM calendar_events WHERE uid IS NULL OR uid = \'\'')->fetchAll();
if (!$rows) {
return;
}
$st = $db->prepare('UPDATE calendar_events SET uid = ? WHERE id = ?');
foreach ($rows as $r) {
$st->execute([bin2hex(random_bytes(8)) . '-' . (int)$r['id'] . '@familybook', (int)$r['id']]);
}
}
/**
* v24(用户 2026-10-02 第 17 条):给「我建的、挂在别人名下的资源」补一条共享。
*
* 只补 `created_by` 有值且 ≠ owner 的、**共享表里还没有这一对的**。
* 幂等(NOT EXISTS 判过才插),重复跑不插重。
*/
private static function backfillResourceCreatorShare(\PDO $db): void
{
if (!self::hasColumn($db, 'resources', 'created_by')) {
return;
}
$db->exec(
"INSERT INTO resource_shares (resource_id, person_id, perm)
SELECT r.id, r.created_by, 'write'
FROM resources r
WHERE r.created_by IS NOT NULL
AND r.created_by <> r.owner_id
AND NOT EXISTS (
SELECT 1 FROM resource_shares s
WHERE s.resource_id = r.id AND s.person_id = r.created_by)"
);
}
/**
* ⚠️ `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
{
// ⚠️ 逐列守卫,而不是只信任调用方那一个判据(`hasColumn(cinema_name)`):
// 老库的版本千差万别,"有 cinema_name 但缺 cinema_place"是完全可能的,
// 那样下面这句 SELECT 会 1054(Unknown column)—— 而它**不在 tryStep 里**
// (回填是删列的唯一前提,炸出来才是对的),一次升级就断在这儿、
// 下次请求再断一次。按实际存在的列动态拼查询,缺列就少搬一列,不炸。
$cols = [];
foreach (['cinema_place', 'cinema_name', 'cinema_address'] as $c) {
if (self::hasColumn($db, 'movie_details', $c)) {
$cols[] = $c;
}
}
if (!$cols) {
return;
}
$rows = $db->query(
'SELECT d.event_id, e.family_id, d.' . implode(', d.', $cols) . '
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
{
// 同 backfillCinemasFromDetails:按实际存在的列动态拼,缺列就少搬一列,
// 绝不让"某个中间版本的老库正好缺一列"把整条升级路径炸断。
$cols = [];
foreach (['rating', 'review', 'feelings'] as $c) {
if (self::hasColumn($db, 'movie_details', $c)) {
$cols[] = $c;
}
}
if (!in_array('rating', $cols, true)) {
return; // 连 rating 都没有 → 没有"评价"可迁
}
$where = 'd.rating IS NOT NULL';
if (in_array('review', $cols, true)) {
$where .= " OR (d.review IS NOT NULL AND d.review <> '')";
}
$rows = $db->query(
'SELECT d.event_id, e.family_id, e.creator_id, d.' . implode(', d.', $cols) . '
FROM movie_details d JOIN events e ON e.id = d.event_id WHERE ' . $where
)->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,
]);
}
}
/**
* v11:给「有头像但没主色」的人补算主色。
*
* 幂等:条件是 `avatar_color IS NULL`,算过的人不会被重算(用户后来换的头像
* 由上传流程负责更新,升级不该覆盖它)。
* 读不回来就跳过 —— 那个人走 AvatarColor::for() 的姓名兜底,永远是同一种颜色。
*/
private static function backfillAvatarColor(\PDO $db): void
{
$rows = $db->query(
"SELECT id, family_id, avatar, name FROM persons
WHERE avatar IS NOT NULL AND avatar <> '' AND avatar_color IS NULL
ORDER BY id"
)->fetchAll();
if (!$rows) {
return;
}
$up = $db->prepare('UPDATE persons SET avatar_color = ? WHERE id = ?');
foreach ($rows as $r) {
$key = trim((string)$r['avatar']);
if ($key === '') {
continue;
}
try {
$res = \App\Storage\Storage::read((int)$r['family_id'], $key);
} catch (\Throwable $e) {
continue;
}
if (empty($res['ok']) || (string)($res['body'] ?? '') === '') {
continue;
}
$hex = \App\AvatarColor::fromBinary((string)$res['body']);
if ($hex === '') {
continue; // 灰片:留给姓名兜底,不要塞一个飘忽的灰进去
}
$up->execute([$hex, (int)$r['id']]);
}
}
/**
* 老地址(原来 `address` 里那一整串自由文本)拆成 国家/省/市/区。
*
* ⚠️ 只补**空着**的四个字段,绝不覆盖:用户如果已经手工选过省市区,
* 一次升级把他选的改成"从文本里猜的",比不猜更糟(猜错就悄悄错了)。
* ⚠️ 拆不出来的(比如只写了「老家」)就**原样留在 address 里** ——
* 详细地址那一栏本来就允许是任意文本,不拆等于没变,不会丢东西。
*/
private static function backfillAddress(\PDO $db): void
{
$rows = $db->query(
"SELECT id, address FROM persons
WHERE address IS NOT NULL AND address <> ''
AND addr_province IS NULL AND addr_city IS NULL AND addr_district IS NULL
ORDER BY id"
)->fetchAll();
if (!$rows) {
return;
}
$up = $db->prepare(
'UPDATE persons SET addr_country = ?, addr_province = ?, addr_city = ?,
addr_district = ?, address = ? WHERE id = ?'
);
foreach ($rows as $r) {
$p = \App\Region::parse((string)$r['address']);
if ($p['province'] === null && $p['city'] === null && $p['district'] === null) {
continue; // 什么都认不出来 → 整串留在详细地址里,不动它
}
$up->execute([
$p['country'], $p['province'], $p['city'],
$p['district'], $p['detail'] ?? '', (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 '管理员 / 家庭成员 / 访客'"
);
}
}
/** 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)'"
);
}
}
/** events.kind 加 'trip'(旅程复用统一事件原语;见 ensure 里的说明) */
private static function widenEventKindEnum(\PDO $db): void
{
$st = $db->prepare(
"SELECT COLUMN_TYPE FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'events' AND COLUMN_NAME = 'kind'"
);
$st->execute();
$type = (string)$st->fetchColumn();
if ($type !== '' && strpos($type, 'trip') === false) {
$db->exec(
"ALTER TABLE `events` MODIFY COLUMN `kind`
ENUM('movie','trip') NOT NULL
COMMENT '事件类型:movie=观影 / trip=旅程(任务等只在有截止时间时上日历,不占 kind)'"
);
}
}
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}`");
}
}
}