Files
FamilyBook/app/Schema.php
T
laoyang 8f80f3e835 v24 第 2 轮:19 条清单收尾(附件预览/CalDAV/任务分类看板/全局选人/家谱/纪念日/财务)
用户 2026-10-02 提的 19 条需求,本轮完成剩下的 8 条 + CalDAV 服务端:

- ③ 附件:图片/视频给缩略图,PDF/Word/Excel 给**同尺寸**图标;可预览的在本页
     弹层里看(不再新开标签页),渲染不了的点了直接下载;预览层右上角恒有下载按钮。
- ⑦b CalDAV:把「我的日历本」按 RFC 4791 只读子集分享出去(OPTIONS/PROPFIND/
     REPORT/GET,写方法一律 403)。用**专用口令**(库里只存 sha256),不是登录口令。
- ⑨⑩ 任务:分类与看板都由用户自建;已完成的任务从看板消失,改在「已完成」页
     按分类查看/搜索;设置入口在「新建任务」旁的 ☰。
- ⑪ 全局人员选择器:一个片段铺到观影/旅程/资源/日历共享/任务共享/财务共享;
     修掉了老组件「名字和拼音都搜不动」的 bug —— .att-item 自己声明了 display:flex,
     压过了浏览器 [hidden]{display:none},过滤根本没生效。
- ⑫ 家谱:头像改方形不再裁切;姻亲不在图上的卡片给「⇄ 姻亲」入口,
     以 TA 为中心重画 + 「返回我的家谱」。
- ⑬ 纪念日:阴历/阳历都能设且按对应历法重复;名册里每个人的生日默认可见
     (实时派生,不复制一份);全部作为全天日程进日历。
- ⑮ 财务:账本可共享,双方都能记账、都能看全部账目,资产−负债=净资产;
     金额一律以「分」存,不用浮点。

验证:端到端 724 项通过 / 0 失败;几何 368、交互 97、移动端 516、家谱 24、
升级 123 全绿;SQL 列名静态校验 0 处可疑;部署 11 条线上探针全过。
2026-10-03 08:18:05 +08:00

1321 lines
79 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 建表语句与结构升级(安装向导与运行时共用同一份)。
*
* 五条约定:
* 1. 全部 IF NOT EXISTS —— 重复执行不破坏已有数据
* 2. 字符集一律 utf8mb4_unicode_ci,emoji 与人名生僻字都不会乱码
* 3. 一切业务数据挂在 family_id 下,将来要支持多个家庭也不必改模型
* 4. 手机号存两份:phone_hash 用于登录查找(确定性哈希),phone_encrypted 用于展示
* 5. 结构升级走 ensure(),不靠人手动改库 —— 已上线的库也能自己长出新列
*/
declare(strict_types=1);
namespace App;
final class Schema
{
/**
* 当前结构版本。**唯一来源** —— index.php 的 FB_SCHEMA_VERSION 与
* install.php 的盖章都取自这里,别在两处各写一个字符串(迟早会漂移)。
*
* 版本史:
* 1 → 初始(families/persons/accounts/settings/relations)
* 2 → birth_rank、is_placeholder、accounts.role 加 guest
* 3 → 三个生日(birthday 升 DATETIME + id_card/lunar/leap)、slogan、nicknames 表
* 4 → person_groups(关系分组,全家统一但可自定义)、persons.group_id、
* user_settings(「我的」维度设置:分组顺序等)
* 5 → **名册改成「每人一份」**:person_roster(谁在我的名册里 / 归哪一组 / 排第几)、
* person_groups.owner_id(0=管理员维护的公共分组,否则=某人的私自分组);
* 同时**删掉 persons.group_id**(分组从此是「我和他的关系」,挂在 roster 上)
* 6 → **成员信息对齐 vCard**:person_contacts(多个电话 / 邮箱 / 地址 / 网址,
* 对应 vCard 的 TEL / EMAIL / ADR / URL)+ persons.org / persons.title
* (工作单位 ORG / 职务职称 TITLE)。主手机号仍留在 persons(登录凭据)。
* 7 → **家谱树 ⇒ 名册自动同步**:以每个账号为中心上下三代的树里的人
* 自动进他名册(source=kin,归公共「家人」组),树外自动撤;
* 升级时按已有关系回填。起因:家谱/首页按关系推导可见,名册按分享推导
* —— 同一个人三处口径打架(配偶能在家谱看到孩子、名册里却没有)。
* 用户拍板:树上的人三处都可见,没上树的都不可见。
* 8 → **归一事件原语落地(观影先行)**:events(统一事件:标题/日期/地点/
* 来源引用 source_ref)+ movie_details(卫星表:海报/票根/评分/影评/
* 影院三段)+ event_attendees(参与人)+ event_photos(现场照片)。
* MovieLog 的 219 部观影记录整体迁入(source_ref='movielog:movie:<id>',
* 幂等重跑的锚点)。日历本/CalDAV 是后续阶段,events 先不带 calendar_id。
* 9 → **观影对齐 MovieLog(用户五点反馈)**:① 列表改瀑布流海报墙;
* ② events.starts_at 升 DATETIME + 新增 ends_at(观影起止精确到秒,
* 复用 _datetime.php 控件);③ 新建 cinemas(所在地方/名称/地址/主题色),
* movie_details 只留 cinema_id(三段列迁移后删掉);④ 评分评论照搬
* MovieLog 模型:movie_reviews(每账号每片一条,1-10 分 + 正文 + 感受)
* + movie_review_comments(两级平铺讨论),movie_details 旧的单条
* rating/review/feelings 迁进 movie_reviews 后删列。
* 10 → **豆瓣自动补充**:movie_details.douban_meta(JSON,元数据整体存一列)。
* 11 → **头像主色**:persons.avatar_color(CHAR(7))。上传/换头像时算一次存库,
* 观影人标签等处按每人主色画底色(用户 2026-10-01 第 2 条);
* 老头像在升级时**回读一次**补算,补不到的按姓名兜底(AvatarColor)。
* 12 → **四个新模块(用户 2026-10-01 第 6~10 条)**:
* ① 旅程:events.kind 加 trip + trip_details(封面/行程小结)+ trip_nodes
* (行程节点,计划/执行双轨)+ trip_node_members;
* ② 日程:calendars(每人可建、自定义颜色、可共享只读/可写)
* + calendar_events(自定义日程);观影/旅行是**每人默认的两本**,
* 内容从 events 派生、不重复落库;
* ③ 资源:resources(挂在某人名下)+ resource_shares(共享给谁、只读/读写);
* ④ 任务:tasks(两级:parent_id)+ task_shares + task_comments(支持 @人)
* + task_recur(灵活循环);
* ⑤ 统一附件 attachments(旅程/节点/资源/任务/评论共用一张表);
* ⑥ 模块导航与主题色走 user_settings / settings,不新建表。
*/
public const VERSION = '12';
/** @return string[] */
public static function statements(): array
{
return [
<<<'SQL'
CREATE TABLE IF NOT EXISTS `families` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`name` VARCHAR(100) NOT NULL COMMENT '家庭名称',
`motto` VARCHAR(200) DEFAULT NULL COMMENT '一句话,展示在首页',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='家庭空间(一切数据的根)'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `persons` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`kind` ENUM('member','contact') NOT NULL DEFAULT 'member' COMMENT '家庭成员 / 外部联系人',
`name` VARCHAR(50) NOT NULL COMMENT '姓名',
`nickname` VARCHAR(50) DEFAULT NULL COMMENT '【旧语义,已废弃】全家共用的小名;升级时搬进 nicknames 表,新代码不再读写它',
`gender` ENUM('m','f','x') DEFAULT NULL COMMENT '男 / 女 / 未填写',
`birth_rank` INT DEFAULT NULL COMMENT '家中排行(同一对父母的子女里,1=老大),用来算长子/次女',
`birthday` DATE DEFAULT NULL COMMENT '生日(自动生成日历上的生日日程用)',
`avatar` VARCHAR(255) DEFAULT NULL COMMENT '头像,相对于 uploads/avatars',
`phone_hash` CHAR(64) DEFAULT NULL COMMENT '手机号 sha256,登录查找用;NULL 不参与唯一约束',
`phone_encrypted` VARCHAR(512) DEFAULT NULL COMMENT 'AES 加密的手机号',
`id_card_encrypted` VARCHAR(512) DEFAULT NULL COMMENT 'AES 加密的身份证号',
`email` VARCHAR(120) DEFAULT NULL,
`address` VARCHAR(200) DEFAULT NULL,
`note` TEXT DEFAULT NULL COMMENT '备注',
`is_placeholder` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '虚拟人物:还没弄清真实身份的占位(如「我的父亲」)',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_phone_hash` (`phone_hash`),
KEY `idx_family_kind` (`family_id`, `kind`),
CONSTRAINT `fk_persons_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='家庭名册'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `accounts` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`person_id` INT UNSIGNED NOT NULL COMMENT '一个人最多一个账号',
`password_hash` VARCHAR(255) NOT NULL,
`role` ENUM('admin','member','guest') NOT NULL DEFAULT 'member' COMMENT '管理员 / 家庭成员 / 访客',
`status` ENUM('active','disabled') NOT NULL DEFAULT 'active',
`last_login_at` DATETIME DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_account_person` (`person_id`),
CONSTRAINT `fk_accounts_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='登录账号'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `caldav_credentials` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`person_id` INT UNSIGNED NOT NULL,
`token_hash` CHAR(64) NOT NULL COMMENT 'CalDAV 专用口令的 sha256;口令本身不落库',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`last_used_at` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_cd_person` (`person_id`),
CONSTRAINT `fk_cd_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='CalDAV 专用口令(与登录口令分开:客户端会把它长期存着,不能是登录口令)'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `relations` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`from_id` INT UNSIGNED NOT NULL COMMENT '关系的一端',
`to_id` INT UNSIGNED NOT NULL COMMENT '关系的另一端',
`kind` ENUM('parent','spouse') NOT NULL COMMENT 'parent: from 是 to 的父/母;spouse: 配偶(对称)',
`from_role` ENUM('father','mother') DEFAULT NULL COMMENT 'kind=parent 时必填:from 相对 to 是父亲还是母亲',
`note` VARCHAR(200) DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_rel` (`from_id`,`to_id`,`kind`,`from_role`),
KEY `idx_rel_from` (`from_id`,`kind`),
KEY `idx_rel_to` (`to_id`,`kind`),
CONSTRAINT `fk_rel_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_rel_from` FOREIGN KEY (`from_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_rel_to` FOREIGN KEY (`to_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='亲属关系:只存相邻两代之间的边,其余靠推导'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `settings` (
`family_id` INT UNSIGNED NOT NULL,
`k` VARCHAR(64) NOT NULL,
`v` TEXT DEFAULT NULL,
PRIMARY KEY (`family_id`, `k`),
CONSTRAINT `fk_settings_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='家庭级键值对设置'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `nicknames` (
`family_id` INT UNSIGNED NOT NULL,
`viewer_id` INT UNSIGNED NOT NULL COMMENT '谁在称呼别人(当前登录的人)',
`target_id` INT UNSIGNED NOT NULL COMMENT '被称呼的人',
`nickname` VARCHAR(50) NOT NULL COMMENT '我叫他/她什么',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`viewer_id`, `target_id`),
KEY `idx_nick_family` (`family_id`),
KEY `idx_nick_target` (`target_id`),
CONSTRAINT `fk_nick_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_nick_viewer` FOREIGN KEY (`viewer_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_nick_target` FOREIGN KEY (`target_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='称呼是视角属性:每个人各自决定怎么称呼别人,不挂在被称呼者身上'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `person_groups` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`owner_id` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0=全家的公共分组(管理员在系统设置里维护);否则=这个人的私自分组,只有他自己看得到',
`name` VARCHAR(20) NOT NULL COMMENT '分组名,如「家人」「同事」',
`position` INT NOT NULL DEFAULT 100 COMMENT '默认顺序(用户没自定义顺序时按它排)',
`is_builtin` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1=安装时预置的分组',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_group_owner_name` (`family_id`, `owner_id`, `name`),
KEY `idx_group_family` (`family_id`, `position`),
CONSTRAINT `fk_group_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='关系分组:一套公共的(管理员维护)+每人可加只给自己看的分组'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `person_roster` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`owner_id` INT UNSIGNED NOT NULL COMMENT '这是谁的名册(persons.id)',
`person_id` INT UNSIGNED NOT NULL COMMENT '躺在他名册里的人(persons.id)',
`group_id` INT UNSIGNED DEFAULT NULL COMMENT '我把他归到哪一组(公共同我的私自分组都行);NULL=未分类',
`sort_order` INT NOT NULL DEFAULT 1000 COMMENT '**在我的名册里**的先后,拖动卡片改的就是它',
`source` ENUM('self','shared','seeded') NOT NULL DEFAULT 'self' COMMENT 'self=我自己加的 / shared=别人分享给我的 / seeded=升级时按当时的可见范围补的',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_roster_owner_person` (`owner_id`, `person_id`),
KEY `idx_roster_owner_sort` (`owner_id`, `sort_order`),
KEY `idx_roster_person` (`person_id`),
CONSTRAINT `fk_roster_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_roster_owner` FOREIGN KEY (`owner_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_roster_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='每人一份名册:谁在我的名册里、归到哪一组、排第几 —— 都只影响我自己'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `user_settings` (
`person_id` INT UNSIGNED NOT NULL COMMENT '这条设置属于谁(登录者本人)',
`k` VARCHAR(64) NOT NULL,
`v` TEXT DEFAULT NULL,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`person_id`, `k`),
CONSTRAINT `fk_uset_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='「我自己的」设置:只影响我自己看到的样子'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `person_contacts` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`person_id` INT UNSIGNED NOT NULL COMMENT '属于哪个人',
`kind` ENUM('phone','email','address','url') NOT NULL COMMENT '对应 vCard 的 TEL / EMAIL / ADR / URL',
`label` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '用途标注,如「工作」「住宅」「奶奶家」;空 = 不标注',
`value` VARCHAR(500) NOT NULL COMMENT '内容。kind=phone 时是 AES 密文(与主手机号同一把钥匙)',
`position` INT NOT NULL DEFAULT 100 COMMENT '同 kinds 里的先后',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_pc_person` (`person_id`, `kind`, `position`),
CONSTRAINT `fk_pc_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='成员的联系方式(可多条):电话 / 邮箱 / 地址 / 网址,对齐 vCard'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `events` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`creator_id` INT UNSIGNED DEFAULT NULL COMMENT '谁记的(persons.id);系统迁移可为 NULL',
`kind` ENUM('movie') NOT NULL COMMENT '事件类型:movie=观影(trip/任务等是后续阶段的卫星表)',
`title` VARCHAR(200) NOT NULL COMMENT '事件标题(观影=片名)',
`starts_at` DATETIME DEFAULT NULL COMMENT '发生时刻(观影=开场时间,精确到秒)',
`ends_at` DATETIME DEFAULT NULL COMMENT '结束时刻(观影=散场时间,精确到秒)',
`location` VARCHAR(200) DEFAULT NULL COMMENT '地点(观影=影院名)',
`note` TEXT DEFAULT NULL COMMENT '备注',
`source_ref` VARCHAR(64) DEFAULT NULL COMMENT '外部来源引用(如 movielog:movie:123)——迁移幂等重跑的锚点',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_event_source` (`family_id`, `source_ref`),
KEY `idx_event_family_kind` (`family_id`, `kind`, `starts_at`),
CONSTRAINT `fk_event_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_event_creator` FOREIGN KEY (`creator_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统一事件:观影/旅行/任务…共同点是都有时间、有人参与、要上日历'
SQL,
// ⚠️ cinemas 必须排在 movie_details **之前**:后者的 fk_mdet_cinema
// 引用 cinemas.id,MariaDB 默认 FOREIGN_KEY_CHECKS=1,
// 顺序反了全新安装会在 movie_details 上报 1215、
// 整个建表循环从那里断掉(线上老库 IF NOT EXISTS 跳过、炸不出来,
// 只有全新安装/测试库会 500 —— 2026-09-30 e2e 抓到)。
<<<'SQL'
CREATE TABLE IF NOT EXISTS `cinemas` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`place` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '所在地方(城市 / 商圈)',
`name` VARCHAR(100) NOT NULL COMMENT '影院名称',
`address` VARCHAR(200) DEFAULT NULL COMMENT '详细地址',
`color` CHAR(7) NOT NULL DEFAULT '#888888' COMMENT '展示用主题色(列表/详情里影院名着色)',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_cinema` (`family_id`, `place`, `name`),
KEY `idx_cinema_family` (`family_id`),
CONSTRAINT `fk_cinema_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='影院(独立管理,MovieLog 同款:地方/名称/地址/主题色)'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `movie_details` (
`event_id` INT UNSIGNED NOT NULL,
`info` TEXT DEFAULT NULL COMMENT '简介(豆瓣抓取的剧情简介等)',
`poster_key` VARCHAR(255) DEFAULT NULL COMMENT '海报(存储相对键,如 posters/xxx.jpg)',
`ticket_key` VARCHAR(255) DEFAULT NULL COMMENT '票根照片(存储相对键)',
`cinema_id` INT UNSIGNED DEFAULT NULL COMMENT '看的哪家影院(cinemas.id)',
`douban_meta` TEXT DEFAULT NULL COMMENT '豆瓣自动补充的元数据(JSON:年份/类型/导演/主演/评分/链接等)',
PRIMARY KEY (`event_id`),
KEY `idx_mdet_cinema` (`cinema_id`),
CONSTRAINT `fk_mdet_event` FOREIGN KEY (`event_id`) REFERENCES `events` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_mdet_cinema` FOREIGN KEY (`cinema_id`) REFERENCES `cinemas` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='观影事件的特有数据(一对一卫星表)'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `movie_reviews` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`event_id` INT UNSIGNED NOT NULL,
`person_id` INT UNSIGNED NOT NULL COMMENT '评价人(登录账号背后的人);与观影人无关,没去看的也能评',
`rating` TINYINT NOT NULL COMMENT '评分 1-10(录入校验 1-10;迁移兜底可存 0)',
`content` TEXT NOT NULL COMMENT '评价正文',
`feelings` VARCHAR(255) DEFAULT NULL COMMENT '观后感受 key 的 JSON 数组(目录见 MovieReview::CATALOG)',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_review_event_person` (`event_id`, `person_id`),
KEY `idx_review_event` (`event_id`),
KEY `idx_review_person` (`person_id`),
CONSTRAINT `fk_review_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_review_event` FOREIGN KEY (`event_id`) REFERENCES `events` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_review_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='观影评价:一部电影 × 一个账号 至多一条(MovieLog 同款)'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `movie_review_comments` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`review_id` INT UNSIGNED NOT NULL,
`person_id` INT UNSIGNED NOT NULL COMMENT '评论人(登录账号背后的人)',
`parent_id` INT UNSIGNED DEFAULT NULL COMMENT 'NULL=顶层评论;非空=所属顶层评论 id(回复的回复会被规范化到顶层,页面永远只有两层)',
`reply_to_person_id` INT UNSIGNED DEFAULT NULL COMMENT '被回复的人(显示「回复 @谁」用;存人不存评论 id,对方删评不留悬空)',
`content` TEXT NOT NULL,
`feelings` VARCHAR(255) DEFAULT NULL COMMENT '感受 key 的 JSON 数组',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_mrc_review` (`review_id`),
KEY `idx_mrc_parent` (`parent_id`),
CONSTRAINT `fk_mrc_review` FOREIGN KEY (`review_id`) REFERENCES `movie_reviews` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_mrc_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_mrc_replyto` FOREIGN KEY (`reply_to_person_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='评价下的讨论(两级平铺,MovieLog 同款)'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `event_attendees` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`event_id` INT UNSIGNED NOT NULL,
`person_id` INT UNSIGNED NOT NULL COMMENT '同行的人(persons.id)',
`position` INT NOT NULL DEFAULT 100 COMMENT '展示先后',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_att_event_person` (`event_id`, `person_id`),
KEY `idx_att_person` (`person_id`),
CONSTRAINT `fk_att_event` FOREIGN KEY (`event_id`) REFERENCES `events` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_att_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='事件的参与人(观影=同行观影的人)'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `event_photos` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`event_id` INT UNSIGNED NOT NULL,
`key` VARCHAR(255) NOT NULL COMMENT '照片(存储相对键)',
`position` INT NOT NULL DEFAULT 100 COMMENT '展示先后',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_ep_event` (`event_id`, `position`),
CONSTRAINT `fk_ep_event` FOREIGN KEY (`event_id`) REFERENCES `events` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='事件的现场照片(可多张)'
SQL,
// ---- 统一附件(v12)----
// 旅程照片 / 资源附件 / 任务附件 / 评论附件,全都走这一张表:
// 下载、预览、权限判断只写一次;将来再加模块也不用再造一张附件表。
// ⚠️ 不做外键(target_type 决定指向哪张表,外键表达不了),
// 只在 (target_type, target_id) 上建索引。删宿主时由各模块自己清。
<<<'SQL'
CREATE TABLE IF NOT EXISTS `attachments` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`target_type` ENUM('trip','trip_node','resource','task','task_comment') NOT NULL COMMENT '挂在谁身上',
`target_id` INT UNSIGNED NOT NULL COMMENT '宿主的 id',
`key` VARCHAR(255) NOT NULL COMMENT '文件(存储相对键)',
`name` VARCHAR(200) DEFAULT NULL COMMENT '原始文件名(下载时回给它)',
`mime` VARCHAR(120) DEFAULT NULL COMMENT '真实 MIME(由服务端 finfo 判定,不信浏览器给的)',
`size` BIGINT UNSIGNED DEFAULT NULL COMMENT '字节数',
`is_image` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1=图片,浏览器可直接预览',
`position` INT NOT NULL DEFAULT 100,
`uploader_id` INT UNSIGNED DEFAULT NULL COMMENT '谁传的(persons.id)',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_att_target` (`target_type`, `target_id`, `position`),
CONSTRAINT `fk_att_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_att_uploader` FOREIGN KEY (`uploader_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统一附件:旅程/资源/任务/评论共用'
SQL,
// ---- 旅程(v12,TripLog 迁入)----
<<<'SQL'
CREATE TABLE IF NOT EXISTS `trip_details` (
`event_id` INT UNSIGNED NOT NULL COMMENT '对应 events(kind="trip")',
`cover_key` VARCHAR(255) DEFAULT NULL COMMENT '封面图(存储相对键)',
`summary` TEXT DEFAULT NULL COMMENT '这趟旅程的一段话小结',
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 'plan=计划 / reality=实际走过的',
`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_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') NOT NULL DEFAULT 'custom' COMMENT 'movie/trip = 系统给每人预置的默认本,内容从 events 派生',
`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,
];
}
/** 安装时预置的关系分组(用户可自行增删改名,这里只是初始值) */
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`");
// 老头像(v11 之前传的)补算一次:回读已存的图算主色。
// 纯数据步骤 → 容错跑:某张图读不回来(OSS 权限、对象丢了)只影响那一个人,
// 他会自动落到「按姓名取色」的兜底上,不该把后面的升级步骤挡住。
self::tryStep('回填头像主色', static function () use ($db) {
self::backfillAvatarColor($db);
});
// ---- 四个新模块(版本 12)----
// ⚠️ events.kind 原本只有 'movie',旅程要复用统一事件原语就必须加 'trip'。
// 建表语句的 IF NOT EXISTS 对**已存在的表**不会更新枚举 —— 老库必须显式改,
// 否则插入 kind='trip' 会被严格模式整条拒掉(v7 就踩过同一类坑)。
self::widenEventKindEnum($db);
// 每人的两本默认日历(观影 / 旅行):新装的由 Calendar::seedDefaults 在
// 建账号时建,老库在这里补(幂等:按 (owner_id, kind, name) 唯一键查过再插)。
self::tryStep('补建每人默认的观影/旅行日历本', static function () use ($db) {
foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) {
\App\Calendar::seedDefaultsForFamily((int)$fam['id']);
}
});
// ---- 日程按 CalDAV / iCalendar 对齐(版本 13)----
// 用户 2026-10-02 第 5、6 条:起止时刻**精确到秒**,且「日期和时间在一个控件里一起选」。
// 原来是 starts_at DATE + start_time TIME 两个列,天然就是"分开的"两件事 ——
// 连"跨天又跨时刻"这种最基本的日程都表达不了。现在并成一个 DATETIME。
// ⚠️ 先 MODIFY 再回填:MODIFY 之后老数据的 DATE 自动变成 'YYYY-MM-DD 00:00:00',
// 紧接着把 start_time 并进去,老日程的时刻才不会一夜之间全变成 00:00。
$db->exec("ALTER TABLE `calendar_events` MODIFY COLUMN `starts_at`
DATETIME NOT NULL COMMENT '开始时刻(精确到秒;全天日程的时间部分为 00:00:00)'");
$db->exec("ALTER TABLE `calendar_events` MODIFY COLUMN `ends_at`
DATETIME DEFAULT NULL COMMENT '结束时刻(精确到秒);NULL=与开始同一时刻'");
self::tryStep('老日程的时刻并进 starts_at/ends_at', static function () use ($db) {
$db->exec("UPDATE `calendar_events`
SET `starts_at` = CONCAT(DATE(`starts_at`), ' ', `start_time`)
WHERE `start_time` IS NOT NULL AND `all_day` = 0");
$db->exec("UPDATE `calendar_events`
SET `ends_at` = CONCAT(DATE(`ends_at`), ' ', `end_time`)
WHERE `ends_at` IS NOT NULL AND `end_time` IS NOT NULL AND `all_day` = 0");
});
// 其余字段按 RFC 5545(iCalendar)/ RFC 4791(CalDAV)的 VEVENT 属性定:
// UID/DTSTAMP/SEQUENCE 是 CalDAV 同步三件套(没有它们客户端每次都当新日程);
// LOCATION / STATUS / TRANSP / RRULE 是用户第 6 条要的地点与忙闲;
// reminder_minutes 对应 VALARM 的 TRIGGER(提前多久提醒)。
self::addColumn($db, 'calendar_events', 'uid',
"ADD COLUMN `uid` VARCHAR(255) DEFAULT NULL COMMENT 'iCalendar UID(RFC 5545):一条日程全球唯一的身份证,CalDAV 与 ics 导入导出全靠它判重' AFTER `calendar_id`");
self::addColumn($db, 'calendar_events', 'location',
"ADD COLUMN `location` VARCHAR(255) DEFAULT NULL COMMENT '地点(RFC 5545 LOCATION)' AFTER `title`");
self::addColumn($db, 'calendar_events', 'reminder_minutes',
"ADD COLUMN `reminder_minutes` INT DEFAULT NULL COMMENT '提前多少分钟提醒(对应 VALARM 的 TRIGGER);NULL=不提醒' AFTER `all_day`");
self::addColumn($db, 'calendar_events', 'status',
"ADD COLUMN `status` ENUM('confirmed','tentative','cancelled') NOT NULL DEFAULT 'confirmed' COMMENT 'RFC 5545 STATUS:确定 / 待定 / 取消' AFTER `reminder_minutes`");
self::addColumn($db, 'calendar_events', 'transp',
"ADD COLUMN `transp` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'RFC 5545 TRANSP:0=忙碌(OPAQUE) / 1=空闲(TRANSPARENT)' AFTER `status`");
self::addColumn($db, 'calendar_events', 'rrule',
"ADD COLUMN `rrule` VARCHAR(255) DEFAULT NULL COMMENT 'RFC 5545 RRULE 重复规则原文(ics 导入导出原样带过,不做解释)' AFTER `transp`");
self::addColumn($db, 'calendar_events', 'sequence',
"ADD COLUMN `sequence` INT NOT NULL DEFAULT 0 COMMENT 'RFC 5545 SEQUENCE:改一次 +1,CalDAV 客户端据此判断要不要拉新版' AFTER `rrule`");
self::addColumn($db, 'calendar_events', 'created_at',
"ADD COLUMN `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'RFC 5545 CREATED' AFTER `created_by`");
self::addColumn($db, 'calendar_events', 'updated_at',
"ADD COLUMN `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'RFC 5545 LAST-MODIFIED' AFTER `created_at`");
// 老日程补 UID(CalDAV 那边没有 UID 就等于"每次都是新日程",客户端会无限重复)
self::tryStep('给老日程补 UID / SEQUENCE', static function () use ($db) {
self::backfillCalendarUid($db);
});
// 每人的 CalDAV 口令(第 7 条:把我的日历本通过 CalDAV 分享出去)。
// 存 user_settings,不新建表 —— 与 nav_modules / nav_known 同一套做法。
// ---- 资源的创建者默认共享(版本 13)----
// 历史数据里「我建的、挂在别人名下的资源」没有共享行 → 我在自己的列表里看不到它。
// 新建时已经会自动补(Resource::create),这里把老的一次补齐(幂等:只补没有的)。
self::tryStep('回填资源创建者的共享权限', static function () use ($db) {
self::backfillResourceCreatorShare($db);
});
// ---- 任务:分类 + 自定义看板(版本 13,用户第 9、10 条)----
// 看板(board)与状态(status)是两件事:
// status 是这条任务**做到哪一步**(todo / doing / done);
// board 是它**属于哪一块**(家务 / 装修 / 旅行准备 …)。
// 之前只有 status,于是"想分开看几件事"只能靠给标题加前缀,越用越乱。
$db->exec(
'CREATE TABLE IF NOT EXISTS `task_boards` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`name` VARCHAR(80) NOT NULL,
`position` INT NOT NULL DEFAULT 100,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_tb_family` (`family_id`, `position`),
CONSTRAINT `fk_tb_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
);
$db->exec(
'CREATE TABLE IF NOT EXISTS `task_categories` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`name` VARCHAR(80) NOT NULL,
`position` INT NOT NULL DEFAULT 100,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_tc_family` (`family_id`, `position`),
CONSTRAINT `fk_tc_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
);
self::addColumn($db, 'tasks', 'board_id',
"ADD COLUMN `board_id` INT UNSIGNED DEFAULT NULL COMMENT '属于哪个看板(用户自建;NULL=默认看板)' AFTER `family_id`");
self::addColumn($db, 'tasks', 'category_id',
"ADD COLUMN `category_id` INT UNSIGNED DEFAULT NULL COMMENT '属于哪个分类(用户自建;NULL=未分类)' AFTER `board_id`");
// 每个家庭至少有一个看板,否则"看板"这个概念在界面上就是空的
self::tryStep('给每个家庭补一个默认看板', static function () use ($db) {
foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) {
\App\Task::ensureDefaultBoard((int)$fam['id']);
}
});
// ---- 纪念日(版本 13,用户第 13 条)----
// ⚠️ 名册里每个人的生日**不入这张表** —— 它们是实时从 persons 派生的
// (见 Anniversary::birthdays),改了生日纪念日就跟着变。
// 这里只存"用户自己加的"纪念日(结婚纪念日、忌日、相识纪念日…)。
// month/day 按**该重复方式的月日**存:repeat_type=lunar 时是农历的月日,
// 不是公历换算结果(换算只对某一年有效,存了明年就错)。
// ⚠️ 用 heredoc(<<<'SQL')而不是手写单引号字符串:这一段里有 ENUM 的
// 引号、中文 COMMENT、反引号列名,逐处 `\'` 转义极容易漏一个引号 ——
// 实测就漏过(PHP 报 "unexpected identifier VARCHAR",看着像 SQL 语法错,
// 其实全是 PHP 字符串没闭合)。heredoc 里什么都不用转义。
$db->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS `anniversaries` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`title` VARCHAR(120) NOT NULL,
`repeat_type` ENUM('solar','lunar','once') NOT NULL DEFAULT 'solar'
COMMENT '重复方式:solar=每年阳历同月同日 / lunar=每年农历同月同日 / once=只此一次',
`month` TINYINT UNSIGNED NOT NULL,
`day` TINYINT UNSIGNED NOT NULL,
`is_leap` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '农历:该月是否为闰月',
`kind` VARCHAR(40) DEFAULT NULL COMMENT '分类:生日 / 结婚纪念日 / 忌日 …',
`note` TEXT DEFAULT NULL,
`person_id` INT UNSIGNED DEFAULT NULL COMMENT '与谁相关(可空)',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_ann_family` (`family_id`, `month`, `day`),
CONSTRAINT `fk_ann_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_ann_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='纪念日:用户自建(生日不在此表,由 persons 实时派生)'
SQL);
// ---- 财务 / 账本(版本 13,用户第 15 条)----
// 两层:账本(可共享给人的一本总账)+ 账目(存款/基金/保险/房子/车子…)。
// ⚠️ 金额一律存**分**(BIGINT),不存浮点:钱是最不能容忍
// "0.1+0.2=0.30000000000000004" 的地方(见 Ledger::toCents 的注释)。
$db->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS `ledgers` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`owner_id` INT UNSIGNED DEFAULT NULL COMMENT '创建者',
`name` VARCHAR(60) NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_led_family` (`family_id`),
CONSTRAINT `fk_led_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_led_owner` FOREIGN KEY (`owner_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='账本:一本家庭总账,可共享给人'
SQL);
$db->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS `ledger_entries` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`ledger_id` INT UNSIGNED NOT NULL,
`kind` VARCHAR(20) NOT NULL DEFAULT 'other' COMMENT '存款/基金/保险/房子/车子/贷款…',
`title` VARCHAR(120) NOT NULL,
`amount_cents` BIGINT NOT NULL DEFAULT 0 COMMENT '金额(**分**):正数记绝对值,是资产还是负债由 kind 决定',
`note` TEXT DEFAULT NULL,
`person_id` INT UNSIGNED DEFAULT NULL COMMENT '挂在谁名下(可空)',
`created_by` INT UNSIGNED DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_le_ledger` (`ledger_id`),
CONSTRAINT `fk_le_ledger` FOREIGN KEY (`ledger_id`) REFERENCES `ledgers` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_le_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='账目:一笔资产或负债'
SQL);
$db->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS `ledger_shares` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`ledger_id` INT UNSIGNED NOT NULL,
`person_id` INT UNSIGNED NOT NULL,
`perm` ENUM('read','write') NOT NULL DEFAULT 'read',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_led_person` (`ledger_id`, `person_id`),
CONSTRAINT `fk_ls_ledger` FOREIGN KEY (`ledger_id`) REFERENCES `ledgers` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_ls_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='账本共享'
SQL);
// 被容错跳过的数据步骤记进设置,系统设置页会把它显出来 ——
// 不然「升级没跑完」这件事只活在日志里,跟没发生一样。
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']]);
}
}
/** 列不存在才加 */
private static function addColumn(\PDO $db, string $table, string $column, string $alter): void
{
$st = $db->prepare(
'SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?'
);
$st->execute([$table, $column]);
if ((int)$st->fetchColumn() === 0) {
$db->exec("ALTER TABLE `{$table}` {$alter}");
}
}
/** accounts.role 原本只有 admin/member,升级时要把 guest 加进枚举 */
private static function widenRoleEnum(\PDO $db): void
{
$st = $db->prepare(
"SELECT COLUMN_TYPE FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'accounts' AND COLUMN_NAME = 'role'"
);
$st->execute();
$type = (string)$st->fetchColumn();
if ($type !== '' && strpos($type, 'guest') === false) {
$db->exec(
"ALTER TABLE `accounts` MODIFY COLUMN `role`
ENUM('admin','member','guest') NOT NULL DEFAULT 'member'
COMMENT '管理员 / 家庭成员 / 访客'"
);
}
}
/** person_roster.source 原本只有 self/shared/seeded,v7 起要加 kin(见 ensure 里的说明) */
private static function widenRosterSourceEnum(\PDO $db): void
{
$st = $db->prepare(
"SELECT COLUMN_TYPE FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'person_roster' AND COLUMN_NAME = 'source'"
);
$st->execute();
$type = (string)$st->fetchColumn();
if ($type !== '' && strpos($type, 'kin') === false) {
$db->exec(
"ALTER TABLE `person_roster` MODIFY COLUMN `source`
ENUM('self','shared','seeded','kin') NOT NULL DEFAULT 'self'
COMMENT 'self=我自己加的 / shared=别人分享给我的 / seeded=升级时按当时可见范围补的 / kin=家谱树自动同步(v7)'"
);
}
}
/** events.kind 加 'trip'(旅程复用统一事件原语;见 ensure 里的说明) */
private static function widenEventKindEnum(\PDO $db): void
{
$st = $db->prepare(
"SELECT COLUMN_TYPE FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'events' AND COLUMN_NAME = 'kind'"
);
$st->execute();
$type = (string)$st->fetchColumn();
if ($type !== '' && strpos($type, 'trip') === false) {
$db->exec(
"ALTER TABLE `events` MODIFY COLUMN `kind`
ENUM('movie','trip') NOT NULL
COMMENT '事件类型:movie=观影 / trip=旅程(任务等只在有截止时间时上日历,不占 kind)'"
);
}
}
private static function hasIndex(\PDO $db, string $table, string $index): bool
{
$st = $db->prepare(
'SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?'
);
$st->execute([$table, $index]);
return (int)$st->fetchColumn() > 0;
}
private static function dropIndexIfExists(\PDO $db, string $table, string $index): void
{
if (self::hasIndex($db, $table, $index)) {
$db->exec("ALTER TABLE `{$table}` DROP INDEX `{$index}`");
}
}
private static function hasColumn(\PDO $db, string $table, string $column): bool
{
$st = $db->prepare(
'SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?'
);
$st->execute([$table, $column]);
return (int)$st->fetchColumn() > 0;
}
/** 老库补外键前先查它在不在(STATISTICS 里看不到约束名,得查 TABLE_CONSTRAINTS) */
private static function hasForeignKey(\PDO $db, string $table, string $name): bool
{
$st = $db->prepare(
"SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = ? AND CONSTRAINT_NAME = ?
AND CONSTRAINT_TYPE = 'FOREIGN KEY'"
);
$st->execute([$table, $name]);
return (int)$st->fetchColumn() > 0;
}
private static function dropColumnIfExists(\PDO $db, string $table, string $column): void
{
if (self::hasColumn($db, $table, $column)) {
$db->exec("ALTER TABLE `{$table}` DROP COLUMN `{$column}`");
}
}
}