Files
FamilyBook/app/Schema.php
T
laoyang a5e559b2b3 v25 第 25 批(9 条):名册分组不刷页 / 纪念日多人 / 首页等高 / 移出不回流 /
名册点击看详情拖动才排序 / 任务复选框与状态 / 财务标签多选 / 微信读书同步与统计 /
导航线上数据与图标

用户本轮 9 条需求(原话见 templates/ 与 app/ 内的逐条注释):

1. 名册:列表里给某人分组改为 AJAX 就地挪卡片(不再整页刷新)。
2. 纪念日:关联人改**多选**(复用 _person_picker,字段 person_ids[]);
   自建纪念日的按钮与其它模块统一(改=蓝 .btn、删=黄 .btn-warn);
   列表新增「关联人」列并给人名前加头像。
3. 首页:日程/任务两块**完全等高**(align-items:stretch + 固定总高 + flex 子项
   min-height:0);修「0 件待办」——数字与右侧列表同源(原来各算各的)。
4. 🔴 名册里被移出的人过一阵又回来:根因是 PersonRoster::seedFromPersons()
   **每次 Schema::ensure() 都无条件重灌**(线上实测每跑一次 +1 行)。
   修:给它加自守卫(只有在 persons 还留着 group_id/sort_order 的**老库**才跑)。
5. 名册:点击=看详情、**只有拖 ⠿ 手柄**才是排序(原来整行既能点又能拖,语义混乱)。
6. 任务:所有任务前加复选框,任何位置点勾即完成;
   状态语义照业界整理为 To Do / Doing / Done 并写出说明(用户要求参考别人的做法)。
7. 财务:类别标签改**多选非互斥**(再点一次取消该类别),上下两图 + 图例 + 表格
   全部按同一个集合联动;高亮条刻意不给「合计」(多选下各类金额不能相加)。
8. 微信读书:新增**懒同步**(访问时顺手做,6h TTL,只补空字段不覆盖用户填的)、
   封面图**拉下来存本站存储**(原来只存外链 → 看不到)、接官方 /readdata/detail 统计。
9. 导航:线上数据只搬了一半(5 板块 29 站 → 7 板块 67 站 + 67 图标)、
   展示页头像不显示 / 搜索按钮错位 / 点登录 404。三个真根因:
   · 相对地址在 /nav/ 下全 404 → 新增 Nav::abs();
   · 搜索容器丢了 id="search"(CSS 是 #search button.submit 绝对定位);
   · Nav::iconKey() 与 SimpleNav 的 icon_key() **算法不一致**(少一次 md5 归一化)
     → 搬来的图标文件名对不上;且 uploads/ 整目录 Require all denied。
   新增两个**免登录公开出口** nav_icon.php / nav_avatar.php。

顺带修掉一个「数据损坏级」的真 bug(做第 4 条线上反证时发现的,之前一直没人注意):
app/Region.php 三处用中文标点当 trim/ltrim 的 charlist,而 PHP 的 charlist 是
**按字节**匹配的 —— 「。」= E3 80 **82** → 字节 82 进了可剥集合 →
「层」= E5 B1 **82** 被切掉尾字节 → 非法 UTF-8 → backfillAddress 的 UPDATE 报
`1366 Incorrect string value` → **地址拆分升级从上线起一直在失败被跳过**(每请求报一次)。
改成字符级 preg_replace('/…/u');线上实测 ensure() 现在无报错、待拆行数真的在下降。

验证战绩:
- e2e 1289/0(新增 Region 字节级用例等)
- 真机探针:选人 19/0 · 首页/导航 24/0 · 导航模块 34/0 · b18 暂存+确定 10/0 ·
  第23批 52/0
- 桌面几何 428/0 · 移动端几何 680/0 · 交互 92/0 · 窄屏 24/0
- 老库升级 153/0(含 anniversary_people 迁移 + 「再跑 ensure() 名册行数不变」)
- 部署静态断言 180 OK / 0 FAIL · 反向验证 3/3 + Region 三变体各被两条断言同时抓住
- 线上 26 个文件与本地**逐字节一致**;展示页 200、图标 66/66 200、登录 200、
  越权/不存在均 404、签发短语后 ensure() 不再报错。
2026-10-07 11:35:31 +08:00

2164 lines
138 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'
* (按现有轨道自动迁,用户已确认)。
* 22 → **城市的聚合与地址(用户 2026-10-05 第 17 批)**:
* 用户第 1 条:「统计出来的不是目的地,也不是车站,而是城市…城市要聚合,
* 比如说有多段行程,都有青岛作为出发地或者目的地,那么就只记一次。」
* → 只靠 `trip_nodes.place` 一个自由文本字段是算不出"城市"的
* (那里填的是「青岛胶东国际机场」「溪水国家森林公园」这类**地点**)。
* 给 trip_nodes 补两列 `from_city` / `to_city`(出发地/到达地所属城市),
* 在**保存行程时**由 `App\Geo` 解析好存下来,读的时候直接去重。
* 为什么不每次读时现算:解析要调高德接口(有配额、有网络),
* 而城市是"写一次看很多次"的字段 —— 现算会让列表页变慢且可能超配额。
* 23 → **DAV 同步(用户 2026-10-06 第 22 批)**:名册加 CardDAV,
* 并把原来的 CalDAV 口令升成**一把通用 DAV 口令**(日历与通讯录共用)。
* 用户原话:「名册,增加cardav同步功能。caldav和cardav,都增加一个创建口令的
* 功能,这样,就使用用户名和口令来同步,避免在第三方客户端进行同步时
* 自己的密码泄露。」
* → `caldav_credentials` **改名为** `dav_credentials`(表结构一点没动,
* 只换名字与注释)。⚠️ 为什么是改名而不是新建一张 carddav_credentials:
* 用户的诉求是"用口令替代密码",**一把口令覆盖两种同步**最省事 ——
* 手机上填一次,日历本和通讯录一起进来;改成两把会让用户在两个页面
* 各配一遍、还容易只配一半(日历能用、通讯录一直说密码错)。
* ⚠️ 迁移用 RENAME TABLE 且**带存在性守卫**,不能只在建表语句里改名字:
* 老库里那张表已经存在,`CREATE TABLE IF NOT EXISTS dav_credentials`
* 会**另建一张空表** —— 用户已配好的 CalDAV 口令看起来就"消失"了
* (所有设备上的同步立刻 401)。而且必须先判旧表在不在:
* 全新安装时没有旧表,直接 RENAME 会报错把整段升级打断。
*/
/**
* 结构版本(**人读**用:日志、设置页上显示"库结构是哪一版")。
*
* ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。
* 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。
*/
public const VERSION = '25';
/**
* 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。
*
* 为什么不用版本号:
* 版本号靠**人记得改**。这个项目已经因此翻过一次大车(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 `dav_credentials` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`person_id` INT UNSIGNED NOT NULL,
`token_hash` CHAR(64) NOT NULL COMMENT 'DAV 专用口令的 sha256;口令本身不落库',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`last_used_at` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_dc_person` (`person_id`),
CONSTRAINT `fk_dc_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='DAV 专用口令(日历 CalDAV + 通讯录 CardDAV 共用一把;与登录口令分开)'
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,
// ---- 车辆库(版本 21,用户 2026-10-05 第 16 批第 8 条)----
// 用户原话:「增加一个设置按钮,用来设置车辆管理、出行方式、节点类型。
// 注意,我们在财务中,已经把车辆添加进去了,所以这里不用重复
// 添加那里面的车辆,直接读取就可以,但是可以添加其他的车辆,
// 比如租的车,借别人的车。」
//
// ⚠️ 车辆有**两个来源**,这里刻意**不合并**:
// ① **财务里的车子**(ledger_entries.kind='car' 的账目)—— 那是"资产",
// 记的是这台车值多少钱,不是"出行时可挑的一辆车"(没有车牌、没有车型名)。
// 这里**读它**、在选车时列出来(只读引用),但绝不复制一份到本表 ——
// 复制就变成同一台车两处维护,改了财务这边不同步。
// ② **本表** —— "出行时会用到的车",重点是车牌 + 车型名 + 是租的还是借的。
// 自家车在这里也**可以**录一份(干净、可挑),但不强制:
// 不录也能出行(只是选车时没得挑而已)。
// 所以"租的车 / 借别人的车"是本表的常客,`vehicle_type` 才需要 rental/borrowed
// 这两种;自家车与财务的对齐靠**车型名 + 车牌**人工对上,不做外键硬绑
// (账目随时可能被删/改名,硬绑会让车辆跟着消失)。
<<<'SQL'
CREATE TABLE IF NOT EXISTS `vehicles` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`plate` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '车牌号(借的车/租的车可能没记全,允许空)',
`name` VARCHAR(100) NOT NULL COMMENT '车型名,如「大众朗逸」「丰田卡罗拉」',
`vtype` ENUM('own','rental','borrowed') NOT NULL DEFAULT 'own'
COMMENT 'own=自家的 / rental=租的 / borrowed=借别人的',
`vendor` VARCHAR(100) DEFAULT NULL COMMENT '租车公司(vtype=rental 时)或车主(borrowed 时)',
`order_no` VARCHAR(50) DEFAULT NULL COMMENT '租车订单号',
`ledger_id` INT UNSIGNED DEFAULT NULL COMMENT '关联财务里那条 kind=car 的账目(软关联,可空;账目删了这里只是记不住,不影响车辆)',
`is_default` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '默认车(新建行程选车时默认选中它)',
`note` VARCHAR(500) DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_veh_family` (`family_id`, `vtype`),
CONSTRAINT `fk_veh_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='出行用的车辆(自家 / 租的 / 借的);财务里的车子是资产,不复制进来'
SQL,
// ---- 行程节点类型(版本 21,第 16 批第 8 条 + TripLog 同款)----
// 用户第 7 条要求「照 TripLog 的添加节点功能」,TripLog 的节点类型
// 就是一张**可自定义**的表(游玩/用餐/赶路/休息/突发…),不是写死的枚举。
// ⚠️ 与旧代码里那个 `Trip::NODE_TYPES` 常量并存一段:常量当**兜底**
// (老库/新装库都要有初始值),表是"用户改过的真相"。
// 读取一律 `NodeType::all()`(表空时自动回落到常量并预置进表)。
<<<'SQL'
CREATE TABLE IF NOT EXISTS `node_types` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`name` VARCHAR(30) NOT NULL COMMENT '游玩 / 用餐 / 赶路 / 住宿 / 休息 / 突发…',
`icon` VARCHAR(20) DEFAULT NULL COMMENT '一个表情符号',
`is_system` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '内置类型(预置的那几种),不允许删',
`position` INT NOT NULL DEFAULT 100,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_nt_family_name` (`family_id`, `name`),
CONSTRAINT `fk_nt_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='行程节点类型库(可自定义)'
SQL,
// ---- 行程花销(版本 21,第 16 批第 2/7 条)----
// 用户第 2 条要"一共花了多少钱",第 7 条要求照 TripLog 的节点功能,
// 而 TripLog 的花销是**挂在节点上**的一笔一笔(谁付的 / 多少 / 什么类别)。
// ⚠️ 用户明确"行程级花销记录"(本次确认),所以:
// · 一行 = 一段行程里的一笔花销;
// · 旅程的总花销 = 它下面所有行程的花销之和(读时算,不存冗余)。
// ⚠️ 不做 `expense_splits`(分摊):家庭出行记"一共花了多少"就够,
// 谁欠谁那是财务模块的事,硬塞进旅行里会让记账变成负担。
<<<'SQL'
CREATE TABLE IF NOT EXISTS `trip_expenses` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`node_id` INT UNSIGNED NOT NULL COMMENT '属于哪一段行程',
`person_id` INT UNSIGNED DEFAULT NULL COMMENT '谁付的钱(可空)',
`amount_cents` BIGINT NOT NULL DEFAULT 0 COMMENT '金额,单位**分**(与财务同一口径,避免浮点误差)',
`currency` VARCHAR(10) NOT NULL DEFAULT 'CNY',
`category` VARCHAR(30) DEFAULT NULL COMMENT '餐饮 / 门票 / 交通 / 购物 / 其他',
`title` VARCHAR(200) DEFAULT NULL COMMENT '消费说明',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_te_node` (`node_id`),
KEY `idx_te_family` (`family_id`),
CONSTRAINT `fk_te_node` FOREIGN KEY (`node_id`) REFERENCES `trip_nodes` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_te_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_te_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='行程花销(一笔一行;旅程总花销 = 各行程之和,读时算)'
SQL,
/* ============================================================
v24 第 24 批 用户第 4 条:个人导航(从 SimpleNav 完整搬进来)
------------------------------------------------------------
用户原话:「你去读一下个人导航,就是我之前有一个个人导航的项目,
把这个项目完整的搬过来放在导航条里,就是说增加一下个人导航
的这个模块就放在读书的后面,每个用户都可以添加自己的导航,
所有的功能模块就完全照搬原来的导航的那个系统里面的功能。」
⚠️ 原 SimpleNav 用**独立 SQLite 文件 + 自己的 users 表**。搬进来必须换掉这两样:
· 数据库 → 用家书自己的库(表名 `nav_` 前缀,不会撞)
· 用户 → 用**家书的 person_id**(「每个用户都可以添加自己的导航」)
所以原来所有查询都多了一个 `person_id = ?` 维度 ——
这是"照搬"里**唯一不能照抄**的部分:不加这一维,全家人会共用同一份导航。
============================================================ */
<<<'SQL'
CREATE TABLE IF NOT EXISTS `nav_groups` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`person_id` int(10) unsigned NOT NULL COMMENT '这份导航属于谁(家书 person id)',
`name` VARCHAR(60) NOT NULL,
`slug` VARCHAR(60) NOT NULL DEFAULT '',
`display` ENUM('grid','tabs') NOT NULL DEFAULT 'grid' COMMENT 'grid=普通网格 tabs=合集标签页',
`sort_order` INT NOT NULL DEFAULT 0,
`is_visible` TINYINT(1) NOT NULL DEFAULT 1,
PRIMARY KEY (`id`),
KEY `idx_navg_person` (`person_id`, `sort_order`),
CONSTRAINT `fk_navg_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 `nav_sites` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`person_id` int(10) unsigned NOT NULL COMMENT '冗余一份归属:删/校验不必再 JOIN 板块',
`group_id` int(10) unsigned NOT NULL,
`url` VARCHAR(500) NOT NULL,
`name` VARCHAR(120) NOT NULL DEFAULT '' COMMENT '自动抓取的网站名',
`short_name` VARCHAR(60) NOT NULL DEFAULT '' COMMENT '用户自定义简称',
`icon` VARCHAR(120) NOT NULL DEFAULT '' COMMENT '本地图标文件名',
`icon_custom` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1=自定义图标(「全部更新」不覆盖)',
`sort_order` INT NOT NULL DEFAULT 0,
`is_visible` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` DATETIME DEFAULT NULL,
`updated_at` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_navs_person` (`person_id`, `group_id`, `sort_order`),
CONSTRAINT `fk_navs_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_navs_group` FOREIGN KEY (`group_id`) REFERENCES `nav_groups` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='导航站点(每人一份)'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `nav_engines` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`person_id` int(10) unsigned NOT NULL,
`name` VARCHAR(40) NOT NULL,
`slug` VARCHAR(40) NOT NULL,
`search_url` VARCHAR(300) NOT NULL,
`param` VARCHAR(20) NOT NULL DEFAULT 'q',
`suggest_url` VARCHAR(300) NOT NULL DEFAULT '',
`suggest_type` VARCHAR(30) NOT NULL DEFAULT '',
`color` VARCHAR(20) NOT NULL DEFAULT '#2F6BF7',
`is_active` TINYINT(1) NOT NULL DEFAULT 0,
`sort_order` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_nave_person` (`person_id`, `sort_order`),
CONSTRAINT `fk_nave_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 `nav_settings` (
`person_id` int(10) unsigned NOT NULL,
`k` VARCHAR(60) NOT NULL,
`v` TEXT NOT NULL,
PRIMARY KEY (`person_id`, `k`),
CONSTRAINT `fk_navset_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='导航设置(每人一份)'
SQL,
];
}
/** 安装时预置的关系分组(用户可自行增删改名,这里只是初始值) */
public const DEFAULT_GROUPS = [
'家人' => 10,
'亲戚' => 20,
'同事' => 30,
'朋友' => 40,
'合作伙伴' => 50,
'认识' => 60,
'不熟' => 70,
];
/**
* 结构升级里「数据回填」类步骤的失败记录。
*
* 为什么要把「回填」和「改结构」分开对待:
* 改结构失败 = 代码与库对不上,必须让整站炸出来(看得见才好修);
* 回填失败 = 往往只跟**某一行老数据**有关(比如某条空日期),
* 它不该拦住后面所有结构变更 —— 2026-09-30 就是反过来:
* 一个回填里的 SQL 在 MySQL 严格模式下报错,把 addColumn(group_id)、
* 预置分组、称呼迁移**全部**挡在后面,而异常又被 route 层吞掉,
* 于是「线上看着是好的、其实升级从没跑完」,拖了两轮才发现。
*/
private static array $stepErrors = [];
/** @return string[] 最近一次 ensure() 里被容错吞掉的数据步骤失败 */
public static function stepErrors(): array
{
return self::$stepErrors;
}
private static function tryStep(string $label, callable $fn): void
{
try {
$fn();
} catch (\Throwable $e) {
self::$stepErrors[] = $label . ':' . $e->getMessage();
// log_errors=1 → 落到服务器 PHP 错误日志,别让它只活在内存里
error_log('[familybook] 升级步骤失败(已跳过,其余步骤继续)' . $label
. ':' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
}
}
/**
* 幂等结构升级:建表 + 补齐后加的列/枚举值。
*
* ⚠️ CREATE TABLE IF NOT EXISTS 对**已存在的表**什么都不做 ——
* 所以给老库加列必须显式 ALTER,而且要先查 information_schema 再改,
* 否则第二次执行会报 Duplicate column。这就是「部署即可升级」的全部秘密。
*/
public static function ensure(): void
{
$db = Database::get();
self::$stepErrors = [];
foreach (self::statements() as $sql) {
$db->exec($sql);
}
self::addColumn($db, 'persons', 'birth_rank',
"ADD COLUMN `birth_rank` INT DEFAULT NULL COMMENT '家中排行(同一对父母的子女里,1=老大),用来算长子/次女' AFTER `gender`");
self::addColumn($db, 'persons', 'is_placeholder',
"ADD COLUMN `is_placeholder` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '虚拟人物:还没弄清真实身份的占位(如「我的父亲」)' AFTER `note`");
self::widenRoleEnum($db);
// ---- 三个生日(版本 3)----
// birthday 原本是 DATE,现在要精确到时分秒 → 升成 DATETIME。
// MODIFY 是幂等的(已经是 DATETIME 时再 MODIFY 也不报错、不丢数据)。
$db->exec("ALTER TABLE `persons` MODIFY COLUMN `birthday`
DATETIME DEFAULT NULL COMMENT '真实阳历生日(精确到时分秒)'");
self::addColumn($db, 'persons', 'birthday_id_card',
"ADD COLUMN `birthday_id_card` DATE DEFAULT NULL COMMENT '身份证上的生日:由身份证第 7-14 位推导,只读、永不变' AFTER `birthday`");
self::addColumn($db, 'persons', 'birthday_lunar',
"ADD COLUMN `birthday_lunar` DATETIME DEFAULT NULL COMMENT '农历生日:**日期部分是农历年月日**,时间部分与阳历一致' AFTER `birthday_id_card`");
self::addColumn($db, 'persons', 'birthday_lunar_leap',
"ADD COLUMN `birthday_lunar_leap` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'birthday_lunar 的月份是否为闰月' AFTER `birthday_lunar`");
self::addColumn($db, 'persons', 'slogan',
"ADD COLUMN `slogan` VARCHAR(200) DEFAULT NULL COMMENT '个人的一句话;没填就用家庭统一的那句' AFTER `note`");
// 老数据里 birthday 若是 DATE,升成 DATETIME 后时间部分自动是 00:00:00,无需回填。
// 把老数据补出农历生日 / 身份证生日:只在还没算过时算一次(幂等)。
// 这两步**只动已有数据、不改结构**,所以容错跑:某一行老数据有问题
// 不该把下面的改结构步骤全挡住(原因见 tryStep 的注释)。
self::tryStep('回填农历生日', static fn() => self::backfillLunarBirthday($db));
self::tryStep('回填身份证生日', static fn() => self::backfillIdCardBirthday($db));
// ---- 称呼从「人的属性」改成「视角属性」----
// 老语义:persons.nickname 是全家共用的小名。
// 新语义:nicknames 表,(viewer, target) → 我叫他什么。
// ⚠️ 必须在这里搬:不然升级之后界面上再没有人读 persons.nickname,
// 用户会觉得"以前填的小名一夜之间全没了"。搬的时候给每个账号都写一份,
// 迁移前后大家看到的叫法完全一致(`Nickname::migrateLegacy` 里说明了原因)。
// 放在结构升级里、而不是"某次请求里顺手做",是为了升级完就已经搬好。
self::tryStep('迁移旧称呼到 nicknames 表', static function () use ($db) {
foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) {
\App\Nickname::migrateLegacy((int)$fam['id']);
}
});
// ---- 关系分组:从「全家共用一套」改成「公共一套 + 每人可加自己的」(版本 5)----
// person_groups 加 owner_id(0 = 公共)。新装的建表语句已是新结构,老库靠这里补。
self::addColumn($db, 'person_groups', 'owner_id',
"ADD COLUMN `owner_id` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0=全家的公共分组(管理员在系统设置里维护);否则=这个人的私自分组,只有他自己看得到' AFTER `family_id`");
// 唯一键要从 (family_id,name) 换成 (family_id,owner_id,name):
// 否则「我的家人」和公共的「家人」会被判重名,没法各存一份。
self::dropIndexIfExists($db, 'person_groups', 'uk_group_name');
if (!self::hasIndex($db, 'person_groups', 'uk_group_owner_name')) {
$db->exec('ALTER TABLE `person_groups`
ADD UNIQUE KEY `uk_group_owner_name` (`family_id`, `owner_id`, `name`)');
}
// 公共分组(owner_id = 0):新装的由 install.php 建,老的在这里补
self::tryStep('预置公共分组', static function () use ($db) {
foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) {
\App\PersonGroup::seedDefaults((int)$fam['id']);
}
});
// 名册改成「每人一份」:先把老库「当时人人可见」的现状原样搬进每个人的名册,
// 再把 persons 上那两个「全家人共用」的列删掉。
//
// ⚠️ 这一步**绝不能**放进 tryStep:它是下面删列的**唯一前提**,
// 半途失败还继续删列 = 直接丢数据。宁可让异常把整站炸出来(下次请求会重试)。
\App\PersonRoster::seedFromPersons($db);
self::dropColumnIfExists($db, 'persons', 'group_id');
self::dropColumnIfExists($db, 'persons', 'sort_order');
// ---- 成员信息对齐 vCard(版本 6)----
// 工作单位(ORG)与职务职称(TITLE):一个人的属性,单值,直接挂在 persons 上。
self::addColumn($db, 'persons', 'org',
"ADD COLUMN `org` VARCHAR(120) DEFAULT NULL COMMENT '工作单位(vCard ORG)' AFTER `address`");
self::addColumn($db, 'persons', 'title',
"ADD COLUMN `title` VARCHAR(120) DEFAULT NULL COMMENT '职务 / 职称(vCard TITLE)' AFTER `org`");
// 可多条的联系方式:person_contacts 建表在 statements() 里(IF NOT EXISTS 幂等)。
// ⚠️ 主手机号**不搬**:它是登录凭据(phone_hash 唯一约束、按它找人),
// 搬进 contacts 反而把「登录凭据」和「联系信息」搅在一起。
// contacts 里的 phone 是**额外的**电话,一样加密存。
// ---- 家谱树 ⇒ 名册自动同步(版本 7)----
// ⚠️ 先扩宽 source 枚举再回填:v6 老库的枚举只有 self/shared/seeded,
// 建表语句里的 IF NOT EXISTS **不会**更新已存在表的枚举 ——
// 不扩宽的话,回填插 source='kin' 会被严格模式整条拒掉,
// 而且 ensure() 的容错会把这条失败悄悄吞掉(线上表现=功能像没生效)。
self::widenRosterSourceEnum($db);
// 已有的关系也要补出 kin 名册条目:升级完的当下口径就对齐,
// 而不是等用户哪天再点一次「保存关系」才生效。
// syncKinForFamily 幂等(只补缺、撤多余,用户改过的分组/顺序不动),
// 重复跑不插重;某条脏数据只影响它自己,容错跑,别挡住后面的步骤。
self::tryStep('按家谱回填名册(kin 与首页同一棵树)', static function () use ($db) {
foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) {
\App\PersonRoster::syncKinForFamily((int)$fam['id']);
}
});
// ---- 观影对齐 MovieLog(版本 9)----
// starts_at DATE → DATETIME:MODIFY 是幂等的,老数据的时间部分自动 00:00:00。
$db->exec("ALTER TABLE `events` MODIFY COLUMN `starts_at`
DATETIME DEFAULT NULL COMMENT '发生时刻(观影=开场时间,精确到秒)'");
self::addColumn($db, 'events', 'ends_at',
"ADD COLUMN `ends_at` DATETIME DEFAULT NULL COMMENT '结束时刻(观影=散场时间,精确到秒)' AFTER `starts_at`");
// 旧 movie_details 的「影院三段 + rating/review/feelings」搬进 cinemas /
// movie_reviews,然后删列。⚠️ 回填+删列是同一条升级路径,**都不能进 tryStep**:
// 回填是删列的唯一前提,半途失败还继续删 = 直接丢数据;让异常炸出来、
// 下次请求重跑(回填都是幂等写法,重跑安全)。
// 判据用「旧列还在不在」:新装库(建表语句已是新结构)天然跳过。
//
// ⚠️ 老库的 movie_details **没有 cinema_id**(v8 及以前影院三段就在自己身上),
// 上面的 CREATE TABLE IF NOT EXISTS 对已存在的表是空操作、补不了列 ——
// 必须显式加列,否则下面的回填 UPDATE 直接炸 1054(2026-09-30 升级专测抓到,
// 线上老库走同一条路径也会炸在那里,升级永远跑不完)。
self::addColumn($db, 'movie_details', 'cinema_id',
"ADD COLUMN `cinema_id` INT UNSIGNED DEFAULT NULL COMMENT '看的哪家影院(cinemas.id)' AFTER `ticket_key`");
if (!self::hasIndex($db, 'movie_details', 'idx_mdet_cinema')) {
$db->exec('ALTER TABLE `movie_details` ADD KEY `idx_mdet_cinema` (`cinema_id`)');
}
// 外键给「删影院 ⇒ 观影记录的 cinema_id 置空」兜底;老库缺了补上。
// 容错跑:就算哪家老库有脏数据加不上 FK,也不能挡住下面的回填与删列。
self::tryStep('老库补 movie_details→cinemas 外键', static function () use ($db) {
if (!self::hasForeignKey($db, 'movie_details', 'fk_mdet_cinema')) {
$db->exec('ALTER TABLE `movie_details`
ADD CONSTRAINT `fk_mdet_cinema` FOREIGN KEY (`cinema_id`) REFERENCES `cinemas` (`id`) ON DELETE SET NULL');
}
});
if (self::hasColumn($db, 'movie_details', 'cinema_name')) {
self::backfillCinemasFromDetails($db);
self::backfillMovieReviewsFromDetails($db);
foreach (['rating', 'review', 'feelings', 'cinema_place', 'cinema_name', 'cinema_address'] as $col) {
self::dropColumnIfExists($db, 'movie_details', $col);
}
}
// ---- 豆瓣自动补充(版本 10,MovieLog fetch_movie 同源迁移)----
// 豆瓣抓到的元数据(年份/类型/导演/主演/时长/国家/别名/评分/豆瓣链接)
// 整体存一个 JSON 列,随用随解 —— 不为每个字段单开列(家庭应用不需要按
// 这些维度做查询),以后豆瓣再补字段也不用动表。
self::addColumn($db, 'movie_details', 'douban_meta',
"ADD COLUMN `douban_meta` TEXT DEFAULT NULL COMMENT '豆瓣自动补充的元数据(JSON)' AFTER `cinema_id`");
// ---- 头像主色(版本 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 第 16 批:行程对齐 TripLog(Schema v21)----
// 用户第 7 条:「添加和编辑行程的内容,你去找 triplog 里面的添加节点功能,跟那个一样。」
// TripLog 的节点比 FamilyBook 现有的"行程"多这些:
// 出行方式(走路/开车/高铁/飞机…)+ 关联车辆、车次/航班号、座位/车厢、
// 登机口/站台、起终点位置(带坐标)、线路名/承运公司。
// 这些一律**补列**到 trip_nodes 上(不是另建卫星表):
// ① 它们与行程是 1:1(一段行程一种出行方式),卫星表只是多一次 JOIN;
// ② 旅行模块里"这一段怎么到的"是**必看信息**,不是可有可无的附加物。
self::addColumn($db, 'trip_nodes', 'travel_mode',
"ADD COLUMN `travel_mode` VARCHAR(20) DEFAULT NULL COMMENT '出行方式:walk/drive/bus/subway/train/plane/ship/taxi/bike/other' AFTER `place`");
self::addColumn($db, 'trip_nodes', 'vehicle_id',
"ADD COLUMN `vehicle_id` INT UNSIGNED DEFAULT NULL COMMENT '关联车辆(仅自驾:自家/租的/借的)' AFTER `travel_mode`");
self::addColumn($db, 'trip_nodes', 'line_name',
"ADD COLUMN `line_name` VARCHAR(100) DEFAULT NULL COMMENT '线路名,如「京沪高铁」「地铁 3 号线」' AFTER `vehicle_id`");
self::addColumn($db, 'trip_nodes', 'carrier',
"ADD COLUMN `carrier` VARCHAR(100) DEFAULT NULL COMMENT '承运方,如「中国铁路」「滴滴」' AFTER `line_name`");
self::addColumn($db, 'trip_nodes', 'trip_no',
"ADD COLUMN `trip_no` VARCHAR(50) DEFAULT NULL COMMENT '车次 / 航班号 / 线路号' AFTER `carrier`");
self::addColumn($db, 'trip_nodes', 'seat_info',
"ADD COLUMN `seat_info` VARCHAR(100) DEFAULT NULL COMMENT '座位号 / 车厢' AFTER `trip_no`");
self::addColumn($db, 'trip_nodes', 'gate',
"ADD COLUMN `gate` VARCHAR(20) DEFAULT NULL COMMENT '登机口 / 站台' AFTER `seat_info`");
self::addColumn($db, 'trip_nodes', 'from_place',
"ADD COLUMN `from_place` VARCHAR(200) DEFAULT NULL COMMENT '出发地(赶路段的起点)' AFTER `gate`");
self::addColumn($db, 'trip_nodes', 'to_place',
"ADD COLUMN `to_place` VARCHAR(200) DEFAULT NULL COMMENT '到达地(赶路段的终点)' AFTER `from_place`");
// 车型/车次这类字段只在"赶路"时有意义,但**不做条件列**(列固定、界面按类型显隐),
// 免得"换个出行方式就把填过的车次弄丢"。
self::addColumn($db, 'trip_node_members', 'role_tag',
"ADD COLUMN `role_tag` VARCHAR(30) DEFAULT NULL COMMENT '角色标签,如「司机」「摄影担当」' AFTER `person_id`");
// 车辆库三张表在 statements() 里(IF NOT EXISTS 幂等),老库由那条统一建。
// 但**预置节点类型**要显式跑一次:老库建完表是空的,界面上会一个类型都没有
// ("类型"是新建行程的必填项 → 直接卡死,人以为功能坏了)。
self::tryStep('预置行程节点类型', static function () use ($db) {
foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) {
\App\NodeType::seedDefaults((int)$fam['id']);
}
});
// ---- v24 第 17 批:城市的聚合与地址(Schema v22)----
// 用户第 1 条要的是**城市**("经过了几个城市"),而 `place` 里填的是
// 「青岛胶东国际机场」这种**地点名**,直接拿它去重会得到一堆机场/车站名。
// 用户第 4 条又点明「图片右侧显示的是聚合的城市,而不是终点地址」——
// 所以必须在**出发地 / 到达地**上各解析出一个城市,去重后再展示。
//
// ⚠️ 解析结果**落库**而不是每次读时现算:
// · 解析要调高德逆地理接口(有配额、可能失败),
// · 而城市是"写一次、看很多次"的字段(列表页、日历、首页都要用),
// 现算会让每次列表刷新都打好几次接口 —— 既慢又可能把配额烧光。
// 高德拿不到时 `App\Geo` 会从地址文本里提「XX市」兜底,实在提不出就留空
// (留空只是这个城市不计入,不影响页面)。
self::addColumn($db, 'trip_nodes', 'from_city',
"ADD COLUMN `from_city` VARCHAR(60) DEFAULT NULL COMMENT '出发地所属城市(聚合统计用;由 App\\\\Geo 解析)' AFTER `to_place`");
self::addColumn($db, 'trip_nodes', 'to_city',
"ADD COLUMN `to_city` VARCHAR(60) DEFAULT NULL COMMENT '到达地所属城市(聚合统计用;由 App\\\\Geo 解析)' AFTER `from_city`");
// 地址的**详细地址**也一并存下来(TripLog 同款):用户第 3 条要
// 「出发地和到达地,都用高德地图的接口,记录详细地址」——
// 光有城市不够,导航要用的是详细地址(或坐标)。
self::addColumn($db, 'trip_nodes', 'from_addr',
"ADD COLUMN `from_addr` VARCHAR(300) DEFAULT NULL COMMENT '出发地详细地址(高德返回)' AFTER `to_city`");
self::addColumn($db, 'trip_nodes', 'to_addr',
"ADD COLUMN `to_addr` VARCHAR(300) DEFAULT NULL COMMENT '到达地详细地址(高德返回)' AFTER `from_addr`");
self::addColumn($db, 'trip_nodes', 'from_lng',
"ADD COLUMN `from_lng` DECIMAL(10,6) DEFAULT NULL COMMENT '出发地经度(高德返回,用于导航链接)' AFTER `to_addr`");
self::addColumn($db, 'trip_nodes', 'from_lat',
"ADD COLUMN `from_lat` DECIMAL(10,6) DEFAULT NULL COMMENT '出发地纬度' AFTER `from_lng`");
self::addColumn($db, 'trip_nodes', 'to_lng',
"ADD COLUMN `to_lng` DECIMAL(10,6) DEFAULT NULL COMMENT '到达地经度' AFTER `from_lat`");
self::addColumn($db, 'trip_nodes', 'to_lat',
"ADD COLUMN `to_lat` DECIMAL(10,6) DEFAULT NULL COMMENT '到达地纬度' AFTER `to_lng`");
// ⚠️ `seat_info` / `gate` **保留列、不再使用**(用户第 3 条:
// 「飞机、铁路,只记录车次、航班号就可以,其他的没法记录,
// 因为可能是多个人同行,座位什么的不记录了」)。
// 不删列的理由:删列不可逆,而 TripLog 迁来的老数据里这两个字段
// 是有真实内容的(本人当时逐条填过);留着它们在库里至少可查。
// 界面上彻底不再出现 —— 见 node_form.php / show.php。
// 类型扩充(用户第 2 条:「类型不够丰富,你看看 triplog 里面都有哪些,都拿过来」)。
// TripLog 线上实际有 7 种(含两个自定义的),本地只有 5 种内置,差的就是这些。
// ⚠️ 只**补**不删:已有的(用户可能改过名)一律保留。
self::tryStep('补齐 TripLog 那几种节点类型', static function () use ($db) {
foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) {
\App\NodeType::seedFromTripLog((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`");
/* ---- 提醒改**多档**(v24 第 20 批 第 8 条)----
用户原话:「日程的提醒,是可以多选的,就是说,一个日程,
我可以选择让它5分钟提醒,10分钟提醒等等。」
⚠️ 一个日程可以有**多个** VALARM(RFC 5545 完全支持),所以存
"逗号分隔的分钟数升序"(如 `5,15,60`)最省事:
· 不动语义(老值 "15" 照样读成 [15],一个字都不用迁);
· 人眼可读、LIKE 还能粗筛;
· 比新建一张 `calendar_reminders` 表少一层 join —— 家庭场景一个日程
最多挂几个提醒,不值得为它开表。
⚠️ 必须把列从 INT 改成 VARCHAR:INT 列里存 "5,15,60" 会被 MySQL
做**隐式数值转换**(变成 5 后截断,还可能是 Warning/严格模式报错)——
那样多档提醒会**静默只剩第一档**,最难查。
⚠️ `MODIFY` 是幂等的(已经是 VARCHAR 时再 MODIFY 一次也无害)。 */
self::tryStep('提醒列改成可存多档(INT → VARCHAR)', static function () use ($db) {
$st = $db->prepare(
'SELECT DATA_TYPE FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?'
);
$st->execute(['calendar_events', 'reminder_minutes']);
$type = strtolower((string)($st->fetchColumn() ?: ''));
if ($type !== '' && $type !== 'varchar') {
$db->exec(
"ALTER TABLE `calendar_events` MODIFY `reminder_minutes` VARCHAR(64) DEFAULT NULL "
. "COMMENT '提前多少分钟提醒,可多档(逗号分隔升序,如 5,15,60);NULL=不提醒'"
);
}
});
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);
// ---- DAV 口令:CalDAV 专用 → 日历/通讯录共用的通用口令(版本 23)----
// 🔴 用户 2026-10-06 第 22 批:「caldav和cardav,都增加一个创建口令的功能,
// 这样,就使用用户名和口令来同步,避免在第三方客户端进行同步时
// 自己的密码泄露。」→ 一把口令同时管日历与通讯录。
//
// ⚠️⚠️ 这一步**必须**是 RENAME 而不是"改建表语句里的名字":
// 建表用的是 `CREATE TABLE IF NOT EXISTS dav_credentials` —— 老库里那张
// 表还叫 caldav_credentials,于是它会**再建一张空的** dav_credentials,
// 而真正装着口令的旧表被留在原地没人读 → 用户所有已配好的同步设备
// 立刻全部 401(而且是"改了密码也修不好"的那种,因为新口令写进了新表、
// 老设备用旧表的哈希去比 —— 其实两边都对不上)。
// ⚠️ 守卫顺序也很要紧:
// ① 旧表在 & 新表不在 → RENAME(正常的老库升级路径);
// ② 两张都在 → 说明上一次升级跑到一半、或有人手工建过:**保留新表**,
// 把旧表里的行补过去(INSERT IGNORE,person 唯一键天然去重),再删旧表。
// 不这么做的话,那张旧表会永远留在库里当垃圾,而且下次升级又会走到这里。
// ③ 只有新表 → 什么都不做(全新安装,或已经升过的库)。
// ④ 两张都没有 → 也不做(紧随其后的 statements() 会建出新表)。
self::tryStep('DAV 口令表改名(caldav_credentials → dav_credentials)',
static function () use ($db) {
$hasOld = self::hasTable($db, 'caldav_credentials');
$hasNew = self::hasTable($db, 'dav_credentials');
if ($hasOld && !$hasNew) {
$db->exec('RENAME TABLE `caldav_credentials` TO `dav_credentials`');
return;
}
if ($hasOld && $hasNew) {
$db->exec('INSERT IGNORE INTO `dav_credentials`
(person_id, token_hash, created_at, last_used_at)
SELECT person_id, token_hash, created_at, last_used_at
FROM `caldav_credentials`');
$db->exec('DROP TABLE `caldav_credentials`');
}
});
// ---- 纪念日关联**多个人**(版本 25,用户 2026-10-07 第 2 条)----
//
// 用户原话:「纪念日模块,每个纪念日现在只能关联一个人,应该可以关联多个人。
// 和选择观影人一样,多选模式。」
//
// ⚠️ 建表放在 ensure() 里(而不是 statements() 的 return 数组):
// 它有两个外键指向 `anniversaries` 与 `persons` —— 那两张表由
// statements() 先建出来,这里紧随其后才安全;而且这一段本来就在
// "改结构"的位置上(statements() 只管最终形态的建表)。
// ⚠️ **不改 anniversaries.person_id 那一列**(保留它、继续写第一个关联人):
// 它是老库/老代码的读点,删掉会让"没跟上升级"的地方静默少一个人。
// 改成"以新表为准、老列只做兜底"—— 见 Anniversary::peopleOf()。
$db->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS `anniversary_people` (
`anniversary_id` int(10) unsigned NOT NULL,
`person_id` int(10) unsigned NOT NULL,
`sort_order` INT NOT NULL DEFAULT 10 COMMENT '列表里的先后(关联多个人时按它排)',
PRIMARY KEY (`anniversary_id`, `person_id`),
KEY `idx_annp_person` (`person_id`),
CONSTRAINT `fk_annp_ann` FOREIGN KEY (`anniversary_id`) REFERENCES `anniversaries` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_annp_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='纪念日关联的人(v24 第 25 批 用户第 2 条:一个纪念日可以关联多个人)'
SQL);
// 回填:把老库里 `person_id` 非空的那一条搬进新表。
// ⚠️ 必须**只在还没有任何新表行时**搬(用 NOT EXISTS 判),否则
// 用户在新界面上改过关联的人之后,下一次请求又会被老列覆盖回去
// (与第 4 条"移出的人又回来"同一个类别的坑:无条件回填 = 删除被撤销)。
self::tryStep('纪念日关联人搬到新表(一人 → 多人)', static function () use ($db) {
if (!self::hasTable($db, 'anniversary_people')) {
return;
}
$db->exec(
"INSERT IGNORE INTO `anniversary_people` (anniversary_id, person_id, sort_order)
SELECT a.id, a.person_id, 10
FROM `anniversaries` a
WHERE a.person_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM `anniversary_people` p
WHERE p.anniversary_id = a.id)"
);
});
// 被容错跳过的数据步骤记进设置,系统设置页会把它显出来 ——
// 不然「升级没跑完」这件事只活在日志里,跟没发生一样。
if (self::$stepErrors) {
try {
\App\Setting::set((int)$db->query('SELECT MIN(id) FROM families')->fetchColumn(),
'schema_upgrade_warning', implode(';', self::$stepErrors));
} catch (\Throwable $e) {
// 连这条都记不上就算了,至少 error_log 里有
}
}
}
/**
* 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}`");
}
}
/** 表在不在。(改名 / 迁移的前置判断用 —— 直接 RENAME 一张不存在的表会抛异常) */
private static function hasTable(\PDO $db, string $table): bool
{
$st = $db->prepare(
'SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?'
);
$st->execute([$table]);
return (int)$st->fetchColumn() > 0;
}
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}`");
}
}
}