Files
FamilyBook/app/Schema.php
T

2863 lines
189 KiB
PHP
Raw Normal View History

<?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 会报错把整段升级打断。
* 27 → **个人笔记(用户 2026-10-07)**:`notes`(卡片)+ `note_blocks`(卡内的块)
* + `note_tags`(单层标签)。参考 flomo / Obsidian / 思源,但刻意**只取轻的**:
* 块只做 text/image/list 三种、不做块引用;标签只存字符串、不建标签表;
* 权限默认私有、单条可一键共享给全家。详见 App\Note 头部。
* 28 → **名册「分组」改「标签」(用户 2026-10-07 第 5 条)**:新增 `person_tags`
* (每人一份、一人多标签、标签词存字符串)。动机是用户的原话:
* 「一个人可能并不是只在一个分组,例如他既是我的小学同学,又是我的初中同学,
* 就没法分组,改成标签,似乎更合理。」
* ⚠️ `person_roster.group_id` **保留**(vCard CATEGORIES 与老代码的读点),
* 启动时把已有分组**逐一迁成同名标签**(幂等:按 (owner,person,tag) 唯一键)。
* 名册页从「按分栏展示」改成「平铺 + 顶部标签筛选」,拖动仍可排序。
* 29 → **微信读书数据落盘(用户 2026-10-08 第 4 条)**:新增 `weread_snapshots`
* (按 (人, 数据集) 存 JSON 快照)。动机是用户的原话:
* 「读书还是慢。是不是可以做一个同步,然后落盘,这样就不用每次都读取
* 微信读书的数据了。」
* ⇒ `notebooks()` / `readStats()` 在**页面渲染路径**上不再打网关,
* 只读快照;打接口只发生在同步时(手动 / 6 小时到期)。
* ⚠️ 快照有 `updated_at`,过期(默认 24 小时)仍会触发一次后台同步,
* 但**渲染永远不等它** —— 拿旧快照先渲染,同步结果下次访问可见。
* 30 → **笔记共享改成"逐人 + 权限"(用户 2026-10-08 第 9 条)**:新增
* `note_shares`(与 `resource_shares` 同构)。动机是用户的原话:
* 「笔记的共享,和其他的模块一样,选择共享人,选择是只读还是可以读写。和资源类似。」
* ⇒ 取消 v27 的"一个 `is_shared` 布尔(共享给全家)",
* 老数据升级时**展开成逐人记录**(给家里每个人一条 read)。
* ⚠️ 用一次性哨兵(`note_shares_migrated_v29`),理由同 v28 那次:
* 迁移是"一次性数据转换",不是"持续同步",
* 否则用户取消共享后下次启动又被灌回来(本项目已栽过 4 次)。
* 31 → **通知消息(用户 2026-10-08 第 5 条)**:新增 `notifications`。动机是用户的话:
* 「引入通知模块,这个模块,不在导航显示,在个人头像点击那里显示,
* 放在个人设置前面,名字:通知消息。就是当有人在观影、旅程等地方加我的时候,
* 发一个通知,通知出现在首页中,往年今天的前面。」
* ⇒ 通知**不进 Module::LIST**(进就会被顶栏渲染、被"模块显示与排序"当成开关),
* 它是一个独立页面(`?r=notifications`),入口只在头像下拉里。
* ⚠️ 唯一键 (person_id, target_type, target_id) 是整套设计的基石:
* 参与人是"整组替换",没有它,用户改三次标题就收到三条"加了你"。
* 32 → **移动端 API v1 全量**(v44 那批):`api_tokens` 等表配合 `api.php` /
* `app/ApiHandler.php` 一次铺齐(me / calendar / events / tasks / movies /
* trips / ledger / resources / books / people / notifications / tokens)。
* ⚠️ 这一版当时**只改了 VERSION 常量、忘了往这里写版本史**(2026-10-12 补记),
* 说明"VERSION 靠人记得改"这件事本身就不可靠 —— 也正是后来改成
* `fingerprint()` 判据的原因。
* 33 → **同步基础字段(用户 2026-10-12 定「本地优先」架构)**:给 53 张业务表
* 补 `updated_at`(`DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`,
* 由 MySQL 自动维护、业务代码一行不用改)与 `deleted_at`(软删除墓碑),
* 并按有无 `family_id` 加 `idx_<表>_sync` 索引。
* 动机:增量同步必须先能回答「哪些行变了」(updated_at)与
* 「哪些行被删了」(没有 deleted_at 就只能硬删除 ⇒ 客户端永远看不到墓碑
* ⇒ 一直显示幽灵数据)。⛔ 凭据/字典/本地偏好等 8 张表**故意不加** ——
* 同步白名单应当显式而简短。
* 34 → **CalDAV 的重复日程例外(2026-10-15 用户:「既然发现了就直接全部修了」)**:
* ① `calendar_events` 加 `extra` —— **未建模的 VEVENT 属性原样保留**
* (`X-*` / `RDATE` / `CATEGORIES`…)。原来 parser 只认固定 9 个属性,
* 其余一律丢,客户端写上去的东西**读不回来**(实测 PUT 带 `X-` 属性,
* 回来一个都不剩)。
* ② 新增 `calendar_event_overrides` —— 重复系列里**被改到别的时间的某一次**。
* 动机(实测出来的真 bug):CalDAV 客户端把"仅这一次改到周五"写成
* **同一个 .ics 里的第二个 VEVENT**(同 UID + `RECURRENCE-ID`),
* 而原来的 `dav.php` PUT 只取 `$list[0]` ⇒ 覆盖行直接进垃圾桶,
* 于是"改某一次"永远同步不过去,下轮还会把被排除那次又拉回来(幽灵日程)。
* ⚠️ 为什么**不**把覆盖行塞进 `calendar_events`(加一个 `recurrence_id` 列):
* 那一列会被所有既有查询看到 —— `exportRows()` 的
* `SELECT * FROM calendar_events WHERE calendar_id = ?`、日历视图的展开、
* 提醒扫描…**每一处都得多判一次"这行是不是主事件"**,漏一处就是
* "同一次日程出现两条"。而"被改掉的某一次"本来就不是"日程本体",
* 与 `calendar_event_skips` 是同一类东西 ⇒ 并列放一张卫星表,
* 既有查询**一行都不用改**。
* ⚠️ 与 `calendar_event_skips`(= `EXDATE`,只取消)的分工:
* 被**取消**的那次 → skips;被**挪到别的时间**的那次 → overrides
* (后者自带新时间,语义上是"替换那一次",RFC 5545 §3.8.4.4)。
* 35 → **设备会话(v57,用户 2026-10-17)**:`api_tokens` 从"一把令牌"升级成
* "一台设备一个会话"。加 `device_id`(App 自生成 UUID,**不是**硬件号 ——
* HarmonyOS 三方应用拿不到 UDID)/ `device_name` / `platform` /
* `app_version` / `last_ip`,并加 `idx_api_device (person_id, device_id)`。
* 动机(用户原话):「在服务器端的个人设置那里面,就有一个这个设备管理,
* 就是说我都有哪些设备已经在登录这个东西」。
* ⚠️ 为什么必须挂 `device_id` 而不是只挂 `label`:`label` 是**人填的文本**,
* 两台设备可以填同一个名字;"踢掉丢了的那台"要求**精确到机器**。
* ⚠️ 历史令牌没有设备号 → 回填 `legacy-<令牌 id>`。不回填的话,升级后
* 旧令牌在设备列表里根本看不见 —— 等于一个**踢不掉的幽灵会话**。
* ⚠️ **故意不加** `revoked_at`:踢设备是**硬删整行**(`DELETE FROM api_tokens`),
* 不留"已吊销但还在"的死行 —— 那类行要每个查询都多判一次"还算不算数",
* 漏一处就等于"被踢的设备还能用"。审计需求在本项目不存在(家人内部使用)。
* ⚠️ **复用**既有的 `last_used_at` 当"最近活跃",不另立 `last_seen_at` ——
* 两个语义相同的列并存,迟早会出现"一个更新了另一个没更新"的假活跃。
*/
/**
* 结构版本(**人读**用:日志、设置页上显示"库结构是哪一版")。
*
* ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。
* 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。
*/
public const VERSION = '35';
/**
* 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。
*
* 为什么不用版本号:
* 版本号靠**人记得改**。这个项目已经因此翻过一次大车(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 `api_tokens` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`person_id` INT UNSIGNED NOT NULL,
`token_hash` CHAR(64) NOT NULL COMMENT 'API 令牌的 sha256;令牌本身不落库',
`label` VARCHAR(60) DEFAULT NULL COMMENT '设备名(用户自己填,如「我的手机」)',
`device_id` VARCHAR(64) DEFAULT NULL COMMENT '设备标识(App 首次启动自生成的 UUID;不是硬件号)',
`device_name` VARCHAR(60) DEFAULT NULL COMMENT '设备名(机型 + 用户可改,如「HUAWEI Mate 40」)',
`platform` VARCHAR(30) DEFAULT NULL COMMENT '平台与系统版本(如 harmonyos 5.0.1)',
`app_version` VARCHAR(30) DEFAULT NULL COMMENT 'App 版本(如 1.0.0)',
`last_ip` VARCHAR(45) DEFAULT NULL COMMENT '最近一次请求的 IP(IPv6 最长 45)',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`last_used_at` DATETIME DEFAULT NULL,
`expires_at` DATETIME DEFAULT NULL COMMENT '到期时间;NULL = 不过期(设备会话一律 24h 滑动)',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_api_token` (`token_hash`),
KEY `idx_api_person` (`person_id`),
KEY `idx_api_device` (`person_id`, `device_id`),
CONSTRAINT `fk_api_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='移动端 / 第三方调用的 API 令牌(与 DAV 口令、登录口令分开)'
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 '分组名,如「家人」「同事」',
`color` VARCHAR(7) DEFAULT NULL COMMENT '标签颜色 #rrggbb(v63;NULL=还没选过,界面按顺序取默认色)',
`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','note') 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,
// ---- 个人笔记(v27,用户 2026-10-07)----
// 三张表都很薄。为什么这样切(详见 App\Note 头部):
// · notes —— 卡片(标题 + 可见性 + 归属人)
// · note_blocks—— 卡内的块(text/image/list + 排序)「分块」的落点
// · note_tags —— 标签(单层,存字符串,**不建标签表**)
// ⚠️ 外键一律 `int(10) unsigned` 与 persons.id / families.id 严格同型 ——
// 类型不一致(比如写成 INT 而非 INT UNSIGNED)会 errno 150 建表失败,
// 而报错信息只说"外键约束不对",很难一眼看出是类型问题(本仓踩过)。
<<<'SQL'
CREATE TABLE IF NOT EXISTS `notes` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`person_id` INT UNSIGNED NOT NULL COMMENT '作者(笔记属于人,不属于家)',
`title` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '标题(可空,纯正文笔记也允许)',
`is_shared` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '共享给全家?0=私有(默认) 1=全家可见',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '改过就刷新(列表按它倒序)',
PRIMARY KEY (`id`),
KEY `idx_note_owner` (`family_id`, `person_id`, `updated_at`),
KEY `idx_note_shared` (`family_id`, `is_shared`, `updated_at`),
CONSTRAINT `fk_note_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_note_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='个人笔记:一条 = 一张卡片(v27 用户 2026-10-07)'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `note_blocks` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`note_id` INT UNSIGNED NOT NULL,
`kind` VARCHAR(16) NOT NULL DEFAULT 'text'
COMMENT '块类型:text=段落 / image=图片 / list=清单',
`content` TEXT NOT NULL COMMENT 'text/list=正文;image=附件 key(走 media.php)',
`position` INT NOT NULL DEFAULT 10 COMMENT '卡内顺序(步长 10,留插入空间)',
PRIMARY KEY (`id`),
KEY `idx_nb_note` (`note_id`, `position`),
CONSTRAINT `fk_nb_note` FOREIGN KEY (`note_id`) REFERENCES `notes` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='笔记的块:卡片内的一段/一张图/一个清单(v27「分块」)'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `note_tags` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`note_id` INT UNSIGNED NOT NULL,
`tag` VARCHAR(20) NOT NULL COMMENT '单层标签词(不含 #;同一笔记内不重复)',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_nt` (`note_id`, `tag`),
KEY `idx_nt_tag` (`tag`),
CONSTRAINT `fk_nt_note` FOREIGN KEY (`note_id`) REFERENCES `notes` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='笔记标签(单层、多对多;标签词本身不建表,见 App\\Note)'
SQL,
// ---- 名册标签(v28 用户第 5 条)----
// 用户原话:「名册的分组,用下来不太好,我感觉应该改成打标签,就是说,
// 一个人,他可能并不是只在一个分组,例如,他既是我的小学同学,
// 又是我的初中同学,就没法分组,改成标签,似乎更合理。」
//
// ⇒ 分组(一人一组)→ 标签(一人多标签)。**表结构为什么这样切**:
// · `person_tags` 是**每人一份**的(owner_id = 谁的名册),
// 与 person_roster 同口径:标签是"我和他的关系",不是这个人的属性
// (我给他打"小学同学"不该让全家的名册都变)。
// · `tag` 直接存字符串,**不建标签表** —— 与笔记模块同一条轻量取舍:
// 标签是"随手打的词",不需要改名/合并/配色这些重型能力。
// · ⚠️ `person_roster.group_id` **保留不删**:它是老库升级与 CardDAV
// 导出的读点(vCard 的 CATEGORIES 由它派生),删掉会让"没跟上升级"
// 的地方静默少一个字段。改成"以新表为准、老列只做兼容回填"。
<<<'SQL'
CREATE TABLE IF NOT EXISTS `person_tags` (
`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)',
`tag` VARCHAR(24) NOT NULL COMMENT '标签词(不含 #;同一人同一标签不重复)',
`position` INT NOT NULL DEFAULT 10 COMMENT '同一人多个标签时的顺序',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_pt` (`owner_id`, `person_id`, `tag`),
KEY `idx_pt_owner_tag` (`owner_id`, `tag`),
KEY `idx_pt_person` (`person_id`),
CONSTRAINT `fk_pt_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_pt_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='名册标签(v28:一个人可挂多个标签,取代"一人一组")'
SQL,
<<<'SQL'
/* 🔴 v29 用户第 4 条:**微信读书的数据落盘,页面只读本地**。
用户原话:「读书还是慢。是不是可以做一个同步,然后落盘,这样就不用每次都
读取微信读书的数据了。」
⇒ 症结确实在这:`notebooks()` 与 `readStats()` 每次渲染都**实时打网关**,
一次读书页要打好几次外部请求(且官方接口本身就慢)。
实测口径:以前每个请求都要等外部接口返回,`SYNC_BUDGET` 只能限制"同步"那一段,
限不住"每次渲染都去读统计"那一段。
⇒ 做法:**一份按 (人, 数据集) 存的 JSON 快照**。
· 页面渲染只读这张表(本地查询,毫秒级);
· 真正打接口只发生在**同步**时(手动「立即同步」或 6 小时到期的自动同步);
· 同步成功后把相关快照一起刷新。
⚠️ 为什么用"一个 JSON 列"而不是把统计拆成多张表:
这些数据是**只读的远端结果**、字段完全由官方决定(说改就改),
本站从不按其中任何字段做查询。拆表=把别人的字段设计背到自己身上,
官方一变就要迁移。存 JSON = 官方怎么变都能原样兜住。
⚠️ 为什么不像封面那样塞进 `book`/`attachment`:它不是"我们的实体",
是一次外部调用的缓存,有明确的**时效性**(`updated_at`),
过期就该重新拉 —— 与"实体数据"的生命周期完全不同,混在一起会后患无穷。 */
CREATE TABLE IF NOT EXISTS `weread_snapshots` (
`person_id` INT UNSIGNED NOT NULL,
`k` VARCHAR(40) NOT NULL COMMENT '数据集名:notebooks / stats_overall / stats_weekly …',
`v` LONGTEXT DEFAULT NULL COMMENT '原样存下来的 JSON(官方字段,本站不解析、不索引)',
`updated_at` DATETIME NOT NULL COMMENT '这次快照是什么时候拉的(判过期用)',
PRIMARY KEY (`person_id`, `k`),
KEY `idx_ws_updated` (`updated_at`),
CONSTRAINT `fk_ws_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='微信读书的远端数据快照(v29:页面只读本地,不再每次打网关)'
SQL,
<<<'SQL'
/* 🔴 v29 用户第 9 条:「笔记的共享,和其他的模块一样,选择共享人,
选择是只读还是可以读写。和资源类似。」
⇒ 把 v27 那个"一个 is_shared 布尔(共享给全家)"升级成**逐人共享 + 权限**。
⚠️ 为什么不是"保留 is_shared,再加一张人表":
那样会出现两种共享语义并存("全家可见" + "给这几个人"),
判定优先级、列表怎么算、谁能看到 —— 全是边界问题。
统一成一种(逐人 + 权限)之后,判定只有一处:
`owner 本人 or 在某条 share 记录里`。
⚠️ `notes.is_shared` 列**保留但不再作为判据**(升级时把老的"1"展开成
"给全家每个人一条 read 记录",迁移完就没它的事了)。
保留列是为了老库升级时能读到它(见 migrateSharedNotes)。
⚠️ 与资源共享表**同构**(列名/枚举都一样):两处的权限语义要一致,
表结构一样,读代码的人一眼就知道"跟资源那套是一回事"。 */
CREATE TABLE IF NOT EXISTS `note_shares` (
`note_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 (`note_id`, `person_id`),
KEY `idx_ns_person` (`person_id`),
CONSTRAINT `fk_ns_note` FOREIGN KEY (`note_id`) REFERENCES `notes` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_ns_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='笔记共享给别人:可只读、可读写(v29:取代"共享给全家"那个布尔)'
SQL,
// ---- 通知消息(v30 第 5 条)----
//
// 用户原话:「引入通知模块,这个模块,不在导航显示,在个人头像点击那里显示,
// 放在个人设置前面,名字:通知消息。就是当有人在观影、旅程等地方
// 加我的时候,发一个通知,通知出现在首页中,往年今天的前面。」
//
// ⚠️ `target_type` + `target_id` **不做外键**(它指向 events 等多张表,
// 外键表达不了这个形状)—— 与 `attachments` 完全同一套做法与理由。
// 宿主删除时由 Notification::removeForTarget() 手动清,
// 否则通知点进去 404。
// ⚠️ 唯一键 `uk_ntf`(person + target_type + target_id)是**整套设计的基石**:
// 观影/旅程保存参与人是"整组替换"(先删再插),
// 没有它,用户改三次标题就会收到三条"有人把你加进了《…》"。
// Notification::syncForTarget() 靠 INSERT IGNORE 落在这条键上天然去重。
<<<'SQL'
CREATE TABLE IF NOT EXISTS `notifications` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`person_id` INT UNSIGNED NOT NULL COMMENT '收件人(**不含**动作发出者 —— 不给自己发)',
`actor_id` INT UNSIGNED DEFAULT NULL COMMENT '谁触发的(把别人加进去的人)',
`kind` VARCHAR(24) NOT NULL DEFAULT '' COMMENT 'movie / trip …(决定图标)',
`title` VARCHAR(255) NOT NULL COMMENT '一句话正文(已含宿主标题)',
`href` VARCHAR(64) DEFAULT NULL COMMENT '跳转路由(url() 的 r 值)',
`params` VARCHAR(255) DEFAULT NULL COMMENT '跳转参数(json,如 {"id":12})',
`target_type` VARCHAR(24) NOT NULL DEFAULT '' COMMENT '宿主类型(去重维度之一)',
`target_id` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '宿主 id(去重维度之一)',
`is_read` TINYINT(1) NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_ntf` (`person_id`, `target_type`, `target_id`),
KEY `idx_ntf_unread` (`person_id`, `is_read`, `id`),
CONSTRAINT `fk_ntf_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_ntf_actor` FOREIGN KEY (`actor_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='通知消息(v30:别人在观影/旅程等地方把我加进去)'
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`");
/* 🔴 #48(2026-11-05):**真实开始 / 结束时间**。
用户:「我计划今天 8:00 到 10:00 开车去公园,但我可能出门晚了,
8:10 或 8:30 才点『开始』按钮。这时候就要记录住 8:30 这个时间,
因为 8:30 会成为实际行程的开始时间。也就是说,
**原来计划的时间和现在真正开始的时间都要记录**。」
⚠️ 不能复用 `starts_at` / `ends_at`(那是**计划**时间),
改掉等于把计划丢了。两者并存才能"计划 8:00–10:00 / 实际 8:30–10:30"。
⚠️ 列名带 `actual_` 前缀,与 `starts_at` 形成清晰对照。 */
self::addColumn($db, 'trip_nodes', 'actual_started_at',
"ADD COLUMN `actual_started_at` DATETIME DEFAULT NULL
COMMENT '真实开始时间(点「开始」那一刻;NULL=还没开始)' AFTER `ends_at`");
self::addColumn($db, 'trip_nodes', 'actual_ended_at',
"ADD COLUMN `actual_ended_at` DATETIME DEFAULT NULL
COMMENT '真实结束时间(点「完成」那一刻;NULL=还没完成)' AFTER `actual_started_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);
// v27:attachments.target_type 要能挂笔记的图片('note')。
// ⚠️ 与上面 widenEventKindEnum 同一类坑:建表语句里的 ENUM 对**已存在的表**
// 不会更新 —— 老库必须显式 MODIFY,否则上传笔记图片时插入
// target_type='note' 会被严格模式整条拒掉(表现为"图传不上去")。
self::widenAttachTargetEnum($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`");
/* 🔴 v24 第 26 批 用户第 3 条:看板可以选颜色。
*
* 用户原话:「任务在设置看板的时候,可以选择颜色,这样在首页列表中显示的时候,
* 就可以更加直观的看到他属于哪个看板。」
*
* ⚠️ 默认值**留空**而不是给个具体色:空 = "没选过颜色",
* 由渲染层按看板名**稳定地**算一个色(同名永远同色,见 Task::boardColor())。
* 若这里给死 `#2F6BF7`,所有没设过的看板会全变成同一个蓝 ——
* 那反而看不出"属于哪个看板"(正是这条需求要解决的问题)。
* ⚠️ `addColumn` 自带"列不存在才加",所以老库/新装都走这一条,不必写两遍。
* 列长 7 = `#RRGGBB`;不用枚举(用户可选任意色),合法性在入库时校验。 */
self::addColumn($db, 'task_boards', 'color',
"ADD COLUMN `color` CHAR(7) DEFAULT NULL COMMENT '看板色 #RRGGBB;NULL=按名字派生' AFTER `position`");
// 每个家庭至少有一个看板,否则"看板"这个概念在界面上就是空的
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);
// ---- CalDAV 的重复日程例外(版本 34,2026-10-15)----
/* ① 「被改到别的时间的某一次」= 覆盖行(RFC 5545 §3.8.4.4 / RFC 4791 §4.1)。
CalDAV 客户端表达"把第 3 次改到周五"用的是**同一个 .ics 里的第二个 VEVENT**
(同 UID + `RECURRENCE-ID: <原发生时刻>` + 自己的 `DTSTART` = 新时间)。
原来 dav.php 的 PUT 只取 `$list[0]` ⇒ 覆盖行被丢掉 ⇒ 手机上改的那一次
**永远同步不过去**,而且下一轮拉取还会把被排除的那次又画回来。
⚠️ 为什么不加在 `calendar_events` 里(加 `recurrence_id` 列):
那样所有既有查询都会看到这些行,**每一处都得补一句"排除覆盖行"**
(`exportRows` 的 `SELECT *`、日历视图展开、提醒扫描、API…),
漏一处就是"同一次日程显示两条"。放卫星表则既有查询**一行都不用改** ——
与 `calendar_event_skips` 同一个道理。
⚠️ `UNIQUE KEY (event_id, recurrence_id)` 是幂等的基石:客户端每轮都把这
整个系列推一遍,没有它就每同步一次多一条覆盖行。
⚠️ 与 skips 的分工:**取消**这一次 → skips;**挪走**这一次 → 本表。
两者都可能同时存在(一个系列里既有取消的、又有挪走的)。 */
$db->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS `calendar_event_overrides` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`event_id` INT UNSIGNED NOT NULL COMMENT '哪条日程的主事件(calendar_events.id)',
`recurrence_id` VARCHAR(64) NOT NULL COMMENT 'RFC 5545 RECURRENCE-ID:被替换的那次**原发生时刻**(本地浮动 ICS 形态,如 20261021T090000 / 20261021)',
`title` VARCHAR(200) NOT NULL,
`location` VARCHAR(255) DEFAULT NULL,
`starts_at` DATETIME NOT NULL COMMENT '改到的新开始时刻',
`ends_at` DATETIME DEFAULT NULL,
`all_day` TINYINT(1) NOT NULL DEFAULT 0,
`note` TEXT DEFAULT NULL,
`status` ENUM('confirmed','tentative','cancelled') NOT NULL DEFAULT 'confirmed',
`sequence` INT NOT NULL DEFAULT 0,
`extra` TEXT DEFAULT NULL COMMENT '该 VEVENT 里未被建模的属性原文(X-* 等),往返带过',
`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_ceo` (`event_id`, `recurrence_id`),
CONSTRAINT `fk_ceo_event` FOREIGN KEY (`event_id`) REFERENCES `calendar_events` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='重复日程里被改到别的时间的某一次(RFC 5545 的 RECURRENCE-ID 覆盖行)'
SQL);
/* ② `calendar_events.extra`:未被建模的 VEVENT 属性原样保留。
parser 原来只认固定 9 个属性,其余一律丢 —— 实测 PUT 一份带 `X-` 自定义
属性的 ICS,读回来一个都不剩。虽然我们不需要理解它们,但**丢掉就是丢数据**:
`RDATE`(额外发生日)丢 = 系列少几次;别人的 `X-APPLE-STRUCTURED-LOCATION`
丢 = 位置没了;第三方客户端发现"我写的没了"还可能改判为冲突。
⇒ 原样存、原样吐回,成本只有一列。 */
self::addColumn($db, 'calendar_events', 'extra',
"ADD COLUMN `extra` TEXT DEFAULT NULL COMMENT '未被建模的 VEVENT 属性原文(X-* 等,按行存),CalDAV 导出时原样带回' AFTER `sequence`");
// ---- 旅程的活动「是否公开」(版本 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=只此一次',
`year` SMALLINT UNSIGNED DEFAULT NULL
COMMENT '起始年份(v53:算"第几个纪念日"用;NULL=用户没填,只按月日过)',
`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);
// ---- 🔴 v53(用户 2026-10-16 第 1 条):纪念日要有**年份** ----
// 「纪念日需要记录年份,这样才能知道**是第几个纪念日**。」
// ⇒ 加 `anniversaries.year`。新库由上面那条 CREATE TABLE 带上;
// **老库**(表已在、但没有这一列)走这里补。
// ⚠️ 调用点必须**紧跟在这张表的 CREATE TABLE 之后**:
// `addColumn` 是 `ALTER TABLE`,表还不存在就会
// `1146 Table ... doesn't exist` —— 整段 Schema::ensure 直接抛异常,
// install 变成"安装失败"(本轮实测踩到过:放在 movie_details 那段里,
// 而 anniversaries 的建表在那之后 ⇒ 全新安装直接挂)。
// ⚠️ 可空、不给默认值:**老数据就是没有年份**,猜一个(比如按创建年)反而会
// 让页面上算出一个错的"第 N 个"。NULL = 只按月日过、不显示序号 —— 诚实。
self::addColumn($db, 'anniversaries', 'year',
"ADD COLUMN `year` SMALLINT UNSIGNED DEFAULT NULL COMMENT '起始年份(算第几个纪念日)' AFTER `repeat_type`");
// ---- 财务 / 账本(版本 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)"
);
});
// v28:把「分组」逐一迁成**同名标签**(用户第 5 条:一人一组 → 一人多标签)。
/* 🔴🔴 必须用**一次性哨兵**,不能"每次 ensure 都尝试搬一遍"。
*
* 我第一版写的是「该人还没有任何标签时才搬」(NOT EXISTS 判 owner+person),
* 想当然地以为那就能防住"删除被撤销"。**实测当场推翻了这个假设**:
* · 用户在界面上把某人的标签**清空** → 那人身上一条标签都没有
* → `NOT EXISTS` 成立 → 下一次 ensure **把他旧的分组名又灌回来**。
* 症状:删掉的标签自己回来(与"移出的人又回来""纪念日关联人被老列覆盖"
* 是同一类坑,本项目已经撞过三次)。
*
* ⇒ 正确做法是"这件事**只做一次**":用一个设置键当哨兵,
* 做完了就记下,之后无论库里变成什么样都不再碰。
* 这也符合迁移的本质 —— 它是**一次性的数据转换**,不是持续同步。
* ⚠️ 哨兵键名带年份/版本(`person_tags_migrated_v28`),
* 将来真有第二次迁移要另起一个键,不要复用。
*/
self::tryStep('名册分组搬到标签(一人一组 → 一人多标签)', static function () use ($db) {
$flag = 'person_tags_migrated_v28';
$has = $db->query("SELECT COUNT(*) FROM `settings` WHERE `k` = '" . $flag . "'")->fetchColumn();
if ((int)$has > 0) {
return; // 已经搬过了,永不再搬(见上面的说明)
}
if (self::hasTable($db, 'person_tags') && self::hasTable($db, 'person_groups')) {
$db->exec(
"INSERT IGNORE INTO `person_tags` (family_id, owner_id, person_id, tag, position)
SELECT r.family_id, r.owner_id, r.person_id, g.name, 10
FROM `person_roster` r
JOIN `person_groups` g ON g.id = r.group_id
WHERE r.group_id IS NOT NULL
AND g.name IS NOT NULL AND g.name <> ''"
);
}
// 记哨兵(settings 是**家庭级**的,用最小 family_id 当归属,与
// schema_upgrade_warning 同一做法)
$fid = (int)$db->query('SELECT MIN(id) FROM families')->fetchColumn();
if ($fid > 0) {
$db->prepare("INSERT IGNORE INTO `settings` (family_id, `k`, `v`) VALUES (?,?,?)")
->execute([$fid, $flag, date('Y-m-d H:i:s')]);
}
});
/* 🔴 v29 用户第 9 条:「笔记的共享,和其他的模块一样,选择共享人,
选择是只读还是可以读写。和资源类似。」
⇒ 把 v27 的"`is_shared = 1`(共享给全家)"展开成**逐人记录**。
怎么做:对每一条 `is_shared = 1` 的笔记,给**家里其他每个人**写一条
`perm = 'read'`(读得到,但不能改 —— 原来的语义就是"全家可见",
并没有给过他们改的权限,迁移必须**保持原语义**,不能顺手扩大权限)。
⚠️ 排除作者自己:作者本来就全能,给自己写一条共享记录会污染"共享给谁"的列表。
⚠️ 一次性哨兵(`note_shares_migrated_v29`)—— 理由与 v28 那次完全一样:
迁移是"一次性数据转换",不是"持续同步"。否则用户把某人从共享里去掉后,
下次启动又被灌回来(本项目已栽过 4 次,见 v28 的详细记录)。
⚠️ 这个迁移**必须在 note_shares 建好之后**跑(外键指向 notes/persons,
两张表都在 statements() 里先建出来了)。 */
self::tryStep('笔记共享展开成逐人记录(全家可见 → 逐人只读)', static function () use ($db) {
$flag = 'note_shares_migrated_v29';
$has = $db->query("SELECT COUNT(*) FROM `settings` WHERE `k` = '" . $flag . "'")->fetchColumn();
if ((int)$has > 0) {
return;
}
if (self::hasTable($db, 'note_shares')) {
// 只处理"曾经标过共享"的笔记;逐人展开,权限一律 read(保持原语义)
$db->exec(
"INSERT IGNORE INTO `note_shares` (note_id, person_id, perm)
SELECT n.id, p.id, 'read'
FROM `notes` n
JOIN `persons` p ON p.family_id = n.family_id
WHERE n.is_shared = 1
AND p.id <> n.person_id
AND p.kind = 'member'"
);
}
$fid = (int)$db->query('SELECT MIN(id) FROM families')->fetchColumn();
if ($fid > 0) {
$db->prepare("INSERT IGNORE INTO `settings` (family_id, `k`, `v`) VALUES (?,?,?)")
->execute([$fid, $flag, date('Y-m-d H:i:s')]);
}
});
// ============================================================
// 同步基础字段(版本 33,2026-10-12)
//
// 背景:客户端要改「本地优先(local-first)——本地库为主、后台同步」,
// 而增量同步必须先能回答两个问题:
// ① **哪些行变了?** → 靠 `updated_at`
// ② **哪些行被删了?** → 靠软删除 `deleted_at`
// (硬删除在客户端**永远不可见**:那条记录直接消失,没有墓碑,
// 同步时无法区分"服务端删了"和"本地还没拉下来",会一直显示幽灵数据。)
//
// 实测现状(本次迁移前):50 张表里
// · 只有 21 张有 `updated_at`;
// · **一张有 `deleted_at` 的都没有**。
//
// 🔴 关键手法:`updated_at` 一律用
// `DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`
// —— **由 MySQL 自己维护**,任何 UPDATE 都会自动刷新。
// 好处:**不需要改任何一处业务代码**(各处写库的 SQL 一行都不用动),
// 也就不存在"某处忘了更新 updated_at"的漏网之鱼
// (手工 `SET updated_at = NOW()` 这种做法,迟早漏,本项目已撞过同类坑)。
//
// ⚠️ `deleted_at` 只加列、**不加任何逻辑**:本次迁移只准备"墓碑"的容器,
// 删除改成软删除是**后续的事**(要逐个改 delete 语句,是独立一步)。
// 先加列的好处是:等那步开始时不用再动表结构。
//
// ⚠️ 只处理**业务表**(要同步的)。凭据/字典/本地偏好这几类不加:
// `api_tokens` `dav_credentials`(凭据,客户端存 token 本身)
// `settings` `user_settings`(偏好,各端可以不同)
// `regions` `nav_engines` `nav_settings`(字典 / 每人一份的本地配置)
// `weread_snapshots`(第三方原始快照,同步的是解析后的结果)
// 给它们加字段不是错,但会让"哪些表参与同步"这件事变得含糊 ——
// 同步白名单应当显式而简短。
self::tryStep('补齐同步基础字段(updated_at / deleted_at)', static function () use ($db) {
// 要同步的业务表(53 张)。**显式列全**,不用"遍历所有表",
// 因为那样会把上述 8 张不该同步的表也卷进来。
//
// 🔴 为什么清单里有两组表:建表语句**分散在两个地方** ——
// `statements()`(新库初次安装时执行)与前缀为 `ensure()` 的
// **版本迁移**段(老库升级时执行)。像 `anniversaries` / `ledgers` /
// `task_boards` 这些是后来版本才加的表,建在 ensure() 里。
// ⚠️ 第一版清单**只扫了 statements()**,于是漏掉 11 张
// (anniversaries / ledgers / ledger_entries / task_boards …)
// —— 线上实测才发现 `anniversaries` 没有 updated_at。
// 教训:**找"全部表"必须扫整个文件,不能只扫建表函数**。
$bizTables = [
// ---- statements() 里建的 ----
'families', 'persons', 'accounts', 'relations', 'nicknames',
'person_groups', 'person_roster', 'person_contacts',
'events', 'cinemas', 'movie_details', 'movie_reviews', 'movie_review_comments',
'event_attendees', 'event_photos', 'attachments',
'trip_details', 'trip_nodes', 'trip_node_members',
'calendars', 'calendar_shares', 'calendar_events',
'resources', 'resource_shares',
'tasks', 'task_shares', 'task_comments',
'books', 'book_chapters', 'book_notes',
'vehicles', 'node_types', 'trip_expenses',
'nav_groups', 'nav_sites',
'notes', 'note_blocks', 'note_tags',
'person_tags', 'note_shares', 'notifications',
// ---- ensure() 版本迁移段里建的 ----
'trip_node_revisions', 'task_boards', 'task_categories',
'calendar_event_skips', 'calendar_event_overrides', 'anniversaries', 'anniversary_people',
'ledgers', 'ledger_entries', 'ledger_shares',
'ledger_entry_snapshots', 'person_jobs', 'person_notes',
];
foreach ($bizTables as $tb) {
// 表可能还没建(分步升级时存在中间态)→ 跳过,下次 ensure 再补
if (!self::hasTable($db, $tb)) {
continue;
}
// ① updated_at:DEFAULT + ON UPDATE 都由数据库维护(见上方说明)
self::addColumn($db, $tb, 'updated_at',
"ADD COLUMN `updated_at` DATETIME NOT NULL "
. "DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP "
. "COMMENT '最后修改时间:由 MySQL 自动维护,客户端增量同步据此判断哪些行变了'");
// ② deleted_at:软删除墓碑(NULL = 未删除)
// ⚠️ 索引:同步查询是 `WHERE family_id = ? AND updated_at > ?`,
// 软删除过滤是 `deleted_at IS NULL`。这里给 deleted_at 加个
// 普通索引,避免拉取时全表扫。
self::addColumn($db, $tb, 'deleted_at',
"ADD COLUMN `deleted_at` DATETIME DEFAULT NULL "
. "COMMENT '软删除时间:NULL=未删;客户端靠它知道这一行被删了'");
// ③ 索引:增量拉取的典型条件是 (family_id, updated_at),
// 但不是每张表都有 family_id(如 calendar_events 走 calendar_id)
// ⇒ 只给**有 family_id 的表**加复合索引,其余加单列 updated_at 索引。
$idxName = 'idx_' . $tb . '_sync';
$hasIdx = (int)$db->query(
"SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{$tb}'
AND INDEX_NAME = '{$idxName}'"
)->fetchColumn();
if ($hasIdx === 0) {
try {
if (self::hasColumn($db, $tb, 'family_id')) {
$db->exec("ALTER TABLE `{$tb}` ADD INDEX `{$idxName}` (`family_id`, `updated_at`)");
} else {
$db->exec("ALTER TABLE `{$tb}` ADD INDEX `{$idxName}` (`updated_at`)");
}
} catch (\Throwable $e) {
// 索引是优化项,加不上不该挡住建列这件事本身
}
}
}
});
/* ---- v35(v57,用户 2026-10-17):API 令牌挂到「设备」上 ----
用户原话:「在服务器端的个人设置那里面,就有一个设备管理,
就是说我都有哪些设备已经在登录这个东西。」
⇒ 一把令牌就是**一次设备会话**,得能按机器踢、能显示机器是谁。
⚠️ 必须排在 statements() 之后(那里才是 `api_tokens` 的 CREATE TABLE)——
记忆里的红线:ALTER TABLE 排在同表 CREATE TABLE 之前,全新安装会 1146。
⚠️ `label` 保留不动:网页端「手动签发令牌」那条老路径还在用它,
而且是用户自己填的文本(设备管理里显示 `device_name`,两者不是一回事)。 */
self::addColumn($db, 'api_tokens', 'device_id',
"ADD COLUMN `device_id` VARCHAR(64) DEFAULT NULL COMMENT '设备标识(App 首次启动自生成 UUID,不是硬件号)' AFTER `label`");
self::addColumn($db, 'api_tokens', 'device_name',
"ADD COLUMN `device_name` VARCHAR(60) DEFAULT NULL COMMENT '设备名(机型 + 用户可改)' AFTER `device_id`");
self::addColumn($db, 'api_tokens', 'platform',
"ADD COLUMN `platform` VARCHAR(30) DEFAULT NULL COMMENT '平台与系统版本(如 harmonyos 5.0.1)' AFTER `device_name`");
self::addColumn($db, 'api_tokens', 'app_version',
"ADD COLUMN `app_version` VARCHAR(30) DEFAULT NULL COMMENT 'App 版本' AFTER `platform`");
self::addColumn($db, 'api_tokens', 'last_ip',
"ADD COLUMN `last_ip` VARCHAR(45) DEFAULT NULL COMMENT '最近一次请求的 IP(IPv6 最长 45)' AFTER `app_version`");
// 设备索引:设备列表与"按设备踢"都是 `WHERE person_id = ? AND device_id = ?`
try {
$hasDeviceIdx = (int)$db->query(
"SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'api_tokens'
AND INDEX_NAME = 'idx_api_device'"
)->fetchColumn();
if ($hasDeviceIdx === 0) {
$db->exec('ALTER TABLE `api_tokens` ADD INDEX `idx_api_device` (`person_id`, `device_id`)');
}
} catch (\Throwable $e) {
// 索引是优化项,加不上不该挡住"能按设备踢"这件事本身
}
/* 历史令牌回填设备号。
⚠️ 为什么必须回填:v35 之前的令牌只有 `label`,没有 `device_id`。
不回填的话它们在设备列表里**根本查不出来** —— 用户在网页端看不到、
也就踢不掉,等于一个"踢不掉的幽灵会话"。回填成 `legacy-<id>` 后
它就是一个正常的、可踢的设备条目。
⚠️ 幂等:条件只挑 `device_id` 为空的行,跑完自然不再命中。 */
self::tryStep('历史令牌回填设备号', static function () use ($db) {
$db->exec(
"UPDATE `api_tokens`
SET `device_id` = CONCAT('legacy-', `id`),
`device_name` = COALESCE(NULLIF(`device_name`, ''), NULLIF(`label`, ''), '旧设备')
WHERE `device_id` IS NULL OR `device_id` = ''"
);
});
/* 🔴 v51(用户 2026-10-15 第 5 条):把**已入库**的带分隔符手机号归一成纯数字。
用户原话:「名册的导进来的手机号,有些它是一共 11 个数字,前 3 个数字和后 4 个数字,
中间有两个空格……看起来还有不少这样的数据呢,都提示我数据不对。」
⇒ 光放宽校验(`Person::validate`)只能让**以后**编辑不再报错;
库里**已经存进去**的那些仍带着空格,名单上显示出来还是"138 0000 0000"。
用户明确要"统一处理一下" ⇒ 这里做一次性数据归一。
⚠️ 手机号是**加密存**的(`phone_encrypted`),SQL 里没法直接 LIKE/REPLACE ——
只能把整表读出来、解密、归一、再写回(见下面)。
⚠️ 用**哨兵键**保证"只做一次"(与 `person_tags_migrated_v28` 同一套路):
这是**一次性数据转换**,不是持续同步;否则用户哪天存回一个带空格的号,
下次 ensure 又给他抹掉,会出现"我改的号码自己变了"。
超长/超大库也不怕:条件只挑"解密后含分隔符"的行,
绝大多数行一条 SQL 的代价都没有(先全量解密是可接受的量级:
名册千人级,一次 ensure 跑完就再也不跑)。
⚠️ 幂等:写完 `phone_hash` 与原来**同一个值**(`Crypto::hash` 本来就归一),
所以唯一约束不会因为这次改写而冲突。 */
self::tryStep('手机号归一(去掉导入数据里的空格/短横)', static function () use ($db) {
$flag = 'phone_normalized_v51';
$has = $db->query("SELECT COUNT(*) FROM `settings` WHERE `k` = '" . $flag . "'")->fetchColumn();
if ((int)$has > 0) {
return; // 已经归过一次,永不再做(见上面的说明)
}
if (!self::hasColumn($db, 'persons', 'phone_encrypted')) {
return;
}
$rows = $db->query(
"SELECT id, phone_encrypted FROM persons
WHERE phone_encrypted IS NOT NULL AND phone_encrypted <> ''"
)->fetchAll();
$upd = $db->prepare('UPDATE persons SET phone_encrypted = ? WHERE id = ?');
foreach ($rows as $r) {
$plain = \App\Crypto::decrypt((string)$r['phone_encrypted']);
if ($plain === '') {
continue;
}
// 只有"含有分隔符"的才需要改写(其余原样,避免无谓的写库)
$norm = (string)preg_replace('/[\s\-()]+/u', '', str_replace(
["\xE3\x80\x80", "\xC2\xA0"], ' ', $plain));
if ($norm === $plain || $norm === '') {
continue;
}
$upd->execute([\App\Crypto::encrypt($norm), (int)$r['id']]);
}
// 记哨兵(settings 是家庭级的,用最小 family_id 归属,与其它迁移一致)
$fid = (int)$db->query('SELECT MIN(id) FROM families')->fetchColumn();
if ($fid > 0) {
$db->prepare("INSERT IGNORE INTO `settings` (family_id, `k`, `v`) VALUES (?,?,?)")
->execute([$fid, $flag, date('Y-m-d H:i:s')]);
}
});
/* 🔴 v63(用户 2026-10-19 第 5 条):「公共标签这个板块…每个标签增加一个颜色。」
⇒ `person_groups` 加 `color`。
⚠️ 老库没有这一列,**必须**用 addColumn 显式补(CREATE TABLE IF NOT EXISTS
对已存在的表什么都不做 —— 这是本文件开头那段的全部意义)。
⚠️ 允许 NULL(不是 NOT NULL DEFAULT):NULL = "从来没选过颜色",
界面按标签顺序从预设色板里挑一个(`PersonGroup::swatch()`)。
给了 DEFAULT 就分不清"用户故意选了那个色"和"系统默认给的"。
⚠️ 位置:`ADD COLUMN ... AFTER \`name\`` —— 列顺序影响不了程序,
但影响"人看 information_schema 时的心智",放在名字后面最自然。 */
self::addColumn($db, 'person_groups', 'color',
"ADD COLUMN `color` VARCHAR(7) DEFAULT NULL COMMENT '标签颜色 #rrggbb(v63;NULL=还没选过,界面按顺序取默认色)' AFTER `name`");
// 被容错跳过的数据步骤记进设置,系统设置页会把它显出来 ——
// 不然「升级没跑完」这件事只活在日志里,跟没发生一样。
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)'"
);
}
}
/**
* attachments.target_type 加 'note'(v27 个人笔记的图片块)。
* ⚠️ 判据用 strpos 找 'note' 要小心:ENUM 串里 'trip_node'、'task_comment' 都
* 不含 note,所以直接找 `'note'`(带引号)最稳,不必担心误判。
*/
private static function widenAttachTargetEnum(\PDO $db): void
{
$st = $db->prepare(
"SELECT COLUMN_TYPE FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'attachments' AND COLUMN_NAME = 'target_type'"
);
$st->execute();
$type = (string)$st->fetchColumn();
if ($type !== '' && strpos($type, "'note'") === false) {
$db->exec(
"ALTER TABLE `attachments` MODIFY COLUMN `target_type`
ENUM('trip','trip_node','resource','task','task_comment','note') NOT NULL
COMMENT '挂在谁身上'"
);
}
}
private static function hasIndex(\PDO $db, string $table, string $index): bool
{
$st = $db->prepare(
'SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?'
);
$st->execute([$table, $index]);
return (int)$st->fetchColumn() > 0;
}
private static function dropIndexIfExists(\PDO $db, string $table, string $index): void
{
if (self::hasIndex($db, $table, $index)) {
$db->exec("ALTER TABLE `{$table}` DROP INDEX `{$index}`");
}
}
/** 表在不在。(改名 / 迁移的前置判断用 —— 直接 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}`");
}
}
}