2026-09-30 00:49:53 +08:00
|
|
|
|
<?php
|
|
|
|
|
|
/**
|
2026-09-30 08:13:07 +08:00
|
|
|
|
* 建表语句与结构升级(安装向导与运行时共用同一份)。
|
2026-09-30 00:49:53 +08:00
|
|
|
|
*
|
2026-09-30 08:13:07 +08:00
|
|
|
|
* 五条约定:
|
2026-09-30 00:49:53 +08:00
|
|
|
|
* 1. 全部 IF NOT EXISTS —— 重复执行不破坏已有数据
|
|
|
|
|
|
* 2. 字符集一律 utf8mb4_unicode_ci,emoji 与人名生僻字都不会乱码
|
|
|
|
|
|
* 3. 一切业务数据挂在 family_id 下,将来要支持多个家庭也不必改模型
|
|
|
|
|
|
* 4. 手机号存两份:phone_hash 用于登录查找(确定性哈希),phone_encrypted 用于展示
|
2026-09-30 08:13:07 +08:00
|
|
|
|
* 5. 结构升级走 ensure(),不靠人手动改库 —— 已上线的库也能自己长出新列
|
2026-09-30 00:49:53 +08:00
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
|
|
namespace App;
|
|
|
|
|
|
|
|
|
|
|
|
final class Schema
|
|
|
|
|
|
{
|
2026-09-30 09:44:55 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 当前结构版本。**唯一来源** —— 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 表
|
2026-09-30 11:06:22 +08:00
|
|
|
|
* 4 → person_groups(关系分组,全家统一但可自定义)、persons.group_id、
|
|
|
|
|
|
* user_settings(「我的」维度设置:分组顺序等)
|
2026-09-30 13:24:26 +08:00
|
|
|
|
* 5 → **名册改成「每人一份」**:person_roster(谁在我的名册里 / 归哪一组 / 排第几)、
|
|
|
|
|
|
* person_groups.owner_id(0=管理员维护的公共分组,否则=某人的私自分组);
|
|
|
|
|
|
* 同时**删掉 persons.group_id**(分组从此是「我和他的关系」,挂在 roster 上)
|
2026-09-30 15:02:27 +08:00
|
|
|
|
* 6 → **成员信息对齐 vCard**:person_contacts(多个电话 / 邮箱 / 地址 / 网址,
|
|
|
|
|
|
* 对应 vCard 的 TEL / EMAIL / ADR / URL)+ persons.org / persons.title
|
|
|
|
|
|
* (工作单位 ORG / 职务职称 TITLE)。主手机号仍留在 persons(登录凭据)。
|
2026-09-30 16:12:28 +08:00
|
|
|
|
* 7 → **家谱树 ⇒ 名册自动同步**:以每个账号为中心上下三代的树里的人
|
|
|
|
|
|
* 自动进他名册(source=kin,归公共「家人」组),树外自动撤;
|
|
|
|
|
|
* 升级时按已有关系回填。起因:家谱/首页按关系推导可见,名册按分享推导
|
|
|
|
|
|
* —— 同一个人三处口径打架(配偶能在家谱看到孩子、名册里却没有)。
|
|
|
|
|
|
* 用户拍板:树上的人三处都可见,没上树的都不可见。
|
2026-09-30 20:22:46 +08:00
|
|
|
|
* 8 → **归一事件原语落地(观影先行)**:events(统一事件:标题/日期/地点/
|
|
|
|
|
|
* 来源引用 source_ref)+ movie_details(卫星表:海报/票根/评分/影评/
|
|
|
|
|
|
* 影院三段)+ event_attendees(参与人)+ event_photos(现场照片)。
|
|
|
|
|
|
* MovieLog 的 219 部观影记录整体迁入(source_ref='movielog:movie:<id>',
|
|
|
|
|
|
* 幂等重跑的锚点)。日历本/CalDAV 是后续阶段,events 先不带 calendar_id。
|
2026-10-01 00:12:04 +08:00
|
|
|
|
* 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 后删列。
|
2026-10-02 09:28:07 +08:00
|
|
|
|
* 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,不新建表。
|
2026-09-30 09:44:55 +08:00
|
|
|
|
*/
|
2026-10-03 12:46:44 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 结构版本(**人读**用:日志、设置页上显示"库结构是哪一版")。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。
|
|
|
|
|
|
* 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
public const VERSION = '13';
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 为什么不用版本号:
|
|
|
|
|
|
* 版本号靠**人记得改**。这个项目已经因此翻过一次大车(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);
|
|
|
|
|
|
}
|
2026-09-30 09:44:55 +08:00
|
|
|
|
|
2026-09-30 00:49:53 +08:00
|
|
|
|
/** @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 '姓名',
|
2026-09-30 09:44:55 +08:00
|
|
|
|
`nickname` VARCHAR(50) DEFAULT NULL COMMENT '【旧语义,已废弃】全家共用的小名;升级时搬进 nicknames 表,新代码不再读写它',
|
2026-09-30 00:49:53 +08:00
|
|
|
|
`gender` ENUM('m','f','x') DEFAULT NULL COMMENT '男 / 女 / 未填写',
|
2026-09-30 08:13:07 +08:00
|
|
|
|
`birth_rank` INT DEFAULT NULL COMMENT '家中排行(同一对父母的子女里,1=老大),用来算长子/次女',
|
2026-09-30 00:49:53 +08:00
|
|
|
|
`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 '备注',
|
2026-09-30 08:13:07 +08:00
|
|
|
|
`is_placeholder` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '虚拟人物:还没弄清真实身份的占位(如「我的父亲」)',
|
2026-09-30 00:49:53 +08:00
|
|
|
|
`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`),
|
2026-09-30 13:24:26 +08:00
|
|
|
|
KEY `idx_family_kind` (`family_id`, `kind`),
|
2026-09-30 00:49:53 +08:00
|
|
|
|
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,
|
2026-09-30 08:13:07 +08:00
|
|
|
|
`role` ENUM('admin','member','guest') NOT NULL DEFAULT 'member' COMMENT '管理员 / 家庭成员 / 访客',
|
2026-09-30 00:49:53 +08:00
|
|
|
|
`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='登录账号'
|
2026-10-03 08:18:05 +08:00
|
|
|
|
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 专用口令(与登录口令分开:客户端会把它长期存着,不能是登录口令)'
|
2026-09-30 08:13:07 +08:00
|
|
|
|
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='亲属关系:只存相邻两代之间的边,其余靠推导'
|
2026-09-30 00:49:53 +08:00
|
|
|
|
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='家庭级键值对设置'
|
2026-09-30 09:44:55 +08:00
|
|
|
|
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='称呼是视角属性:每个人各自决定怎么称呼别人,不挂在被称呼者身上'
|
2026-09-30 11:06:22 +08:00
|
|
|
|
SQL,
|
|
|
|
|
|
<<<'SQL'
|
|
|
|
|
|
CREATE TABLE IF NOT EXISTS `person_groups` (
|
|
|
|
|
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
|
|
|
|
`family_id` INT UNSIGNED NOT NULL,
|
2026-09-30 13:24:26 +08:00
|
|
|
|
`owner_id` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0=全家的公共分组(管理员在系统设置里维护);否则=这个人的私自分组,只有他自己看得到',
|
2026-09-30 11:06:22 +08:00
|
|
|
|
`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`),
|
2026-09-30 13:24:26 +08:00
|
|
|
|
UNIQUE KEY `uk_group_owner_name` (`family_id`, `owner_id`, `name`),
|
2026-09-30 11:06:22 +08:00
|
|
|
|
KEY `idx_group_family` (`family_id`, `position`),
|
|
|
|
|
|
CONSTRAINT `fk_group_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE
|
2026-09-30 13:24:26 +08:00
|
|
|
|
) 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='每人一份名册:谁在我的名册里、归到哪一组、排第几 —— 都只影响我自己'
|
2026-09-30 11:06:22 +08:00
|
|
|
|
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='「我自己的」设置:只影响我自己看到的样子'
|
2026-09-30 15:02:27 +08:00
|
|
|
|
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'
|
2026-09-30 20:22:46 +08:00
|
|
|
|
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 '事件标题(观影=片名)',
|
2026-10-01 00:12:04 +08:00
|
|
|
|
`starts_at` DATETIME DEFAULT NULL COMMENT '发生时刻(观影=开场时间,精确到秒)',
|
|
|
|
|
|
`ends_at` DATETIME DEFAULT NULL COMMENT '结束时刻(观影=散场时间,精确到秒)',
|
2026-09-30 20:22:46 +08:00
|
|
|
|
`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='统一事件:观影/旅行/任务…共同点是都有时间、有人参与、要上日历'
|
2026-10-01 00:12:04 +08:00
|
|
|
|
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 同款:地方/名称/地址/主题色)'
|
2026-09-30 20:22:46 +08:00
|
|
|
|
SQL,
|
|
|
|
|
|
<<<'SQL'
|
|
|
|
|
|
CREATE TABLE IF NOT EXISTS `movie_details` (
|
2026-10-01 00:12:04 +08:00
|
|
|
|
`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)',
|
2026-10-01 10:21:45 +08:00
|
|
|
|
`douban_meta` TEXT DEFAULT NULL COMMENT '豆瓣自动补充的元数据(JSON:年份/类型/导演/主演/评分/链接等)',
|
2026-09-30 20:22:46 +08:00
|
|
|
|
PRIMARY KEY (`event_id`),
|
2026-10-01 00:12:04 +08:00
|
|
|
|
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
|
2026-09-30 20:22:46 +08:00
|
|
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='观影事件的特有数据(一对一卫星表)'
|
2026-10-01 00:12:04 +08:00
|
|
|
|
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 同款)'
|
2026-09-30 20:22:46 +08:00
|
|
|
|
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='事件的现场照片(可多张)'
|
2026-10-02 09:28:07 +08:00
|
|
|
|
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 '这趟旅程的一段话小结',
|
2026-10-03 11:30:48 +08:00
|
|
|
|
`is_public` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否公开显示(0=不显示在列表与日历)',
|
2026-10-02 09:28:07 +08:00
|
|
|
|
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='任务评论(支持 @人)'
|
2026-09-30 00:49:53 +08:00
|
|
|
|
SQL,
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
2026-09-30 08:13:07 +08:00
|
|
|
|
|
2026-09-30 11:06:22 +08:00
|
|
|
|
/** 安装时预置的关系分组(用户可自行增删改名,这里只是初始值) */
|
|
|
|
|
|
public const DEFAULT_GROUPS = [
|
|
|
|
|
|
'家人' => 10,
|
|
|
|
|
|
'亲戚' => 20,
|
|
|
|
|
|
'同事' => 30,
|
|
|
|
|
|
'朋友' => 40,
|
|
|
|
|
|
'合作伙伴' => 50,
|
|
|
|
|
|
'认识' => 60,
|
|
|
|
|
|
'不熟' => 70,
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2026-09-30 13:24:26 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 结构升级里「数据回填」类步骤的失败记录。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 为什么要把「回填」和「改结构」分开对待:
|
|
|
|
|
|
* 改结构失败 = 代码与库对不上,必须让整站炸出来(看得见才好修);
|
|
|
|
|
|
* 回填失败 = 往往只跟**某一行老数据**有关(比如某条空日期),
|
|
|
|
|
|
* 它不该拦住后面所有结构变更 —— 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());
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 08:13:07 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 幂等结构升级:建表 + 补齐后加的列/枚举值。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ CREATE TABLE IF NOT EXISTS 对**已存在的表**什么都不做 ——
|
|
|
|
|
|
* 所以给老库加列必须显式 ALTER,而且要先查 information_schema 再改,
|
|
|
|
|
|
* 否则第二次执行会报 Duplicate column。这就是「部署即可升级」的全部秘密。
|
|
|
|
|
|
*/
|
|
|
|
|
|
public static function ensure(): void
|
|
|
|
|
|
{
|
|
|
|
|
|
$db = Database::get();
|
2026-09-30 13:24:26 +08:00
|
|
|
|
self::$stepErrors = [];
|
2026-09-30 08:13:07 +08:00
|
|
|
|
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);
|
2026-09-30 09:44:55 +08:00
|
|
|
|
|
|
|
|
|
|
// ---- 三个生日(版本 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,无需回填。
|
2026-09-30 13:24:26 +08:00
|
|
|
|
// 把老数据补出农历生日 / 身份证生日:只在还没算过时算一次(幂等)。
|
|
|
|
|
|
// 这两步**只动已有数据、不改结构**,所以容错跑:某一行老数据有问题
|
|
|
|
|
|
// 不该把下面的改结构步骤全挡住(原因见 tryStep 的注释)。
|
|
|
|
|
|
self::tryStep('回填农历生日', static fn() => self::backfillLunarBirthday($db));
|
|
|
|
|
|
self::tryStep('回填身份证生日', static fn() => self::backfillIdCardBirthday($db));
|
2026-09-30 09:44:55 +08:00
|
|
|
|
|
|
|
|
|
|
// ---- 称呼从「人的属性」改成「视角属性」----
|
|
|
|
|
|
// 老语义:persons.nickname 是全家共用的小名。
|
|
|
|
|
|
// 新语义:nicknames 表,(viewer, target) → 我叫他什么。
|
|
|
|
|
|
// ⚠️ 必须在这里搬:不然升级之后界面上再没有人读 persons.nickname,
|
|
|
|
|
|
// 用户会觉得"以前填的小名一夜之间全没了"。搬的时候给每个账号都写一份,
|
|
|
|
|
|
// 迁移前后大家看到的叫法完全一致(`Nickname::migrateLegacy` 里说明了原因)。
|
|
|
|
|
|
// 放在结构升级里、而不是"某次请求里顺手做",是为了升级完就已经搬好。
|
2026-09-30 13:24:26 +08:00
|
|
|
|
self::tryStep('迁移旧称呼到 nicknames 表', static function () use ($db) {
|
|
|
|
|
|
foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) {
|
|
|
|
|
|
\App\Nickname::migrateLegacy((int)$fam['id']);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-09-30 11:06:22 +08:00
|
|
|
|
|
2026-09-30 13:24:26 +08:00
|
|
|
|
// ---- 关系分组:从「全家共用一套」改成「公共一套 + 每人可加自己的」(版本 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');
|
|
|
|
|
|
|
2026-09-30 15:02:27 +08:00
|
|
|
|
// ---- 成员信息对齐 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 是**额外的**电话,一样加密存。
|
|
|
|
|
|
|
2026-09-30 16:12:28 +08:00
|
|
|
|
// ---- 家谱树 ⇒ 名册自动同步(版本 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']);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-01 00:12:04 +08:00
|
|
|
|
// ---- 观影对齐 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-01 10:21:45 +08:00
|
|
|
|
// ---- 豆瓣自动补充(版本 10,MovieLog fetch_movie 同源迁移)----
|
|
|
|
|
|
// 豆瓣抓到的元数据(年份/类型/导演/主演/时长/国家/别名/评分/豆瓣链接)
|
|
|
|
|
|
// 整体存一个 JSON 列,随用随解 —— 不为每个字段单开列(家庭应用不需要按
|
|
|
|
|
|
// 这些维度做查询),以后豆瓣再补字段也不用动表。
|
|
|
|
|
|
self::addColumn($db, 'movie_details', 'douban_meta',
|
|
|
|
|
|
"ADD COLUMN `douban_meta` TEXT DEFAULT NULL COMMENT '豆瓣自动补充的元数据(JSON)' AFTER `cinema_id`");
|
|
|
|
|
|
|
2026-10-02 09:28:07 +08:00
|
|
|
|
// ---- 头像主色(版本 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']);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-03 08:18:05 +08:00
|
|
|
|
// ---- 日程按 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");
|
2026-10-03 12:46:44 +08:00
|
|
|
|
// ⚠️ 并完必须把旧的 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");
|
2026-10-03 08:18:05 +08:00
|
|
|
|
});
|
|
|
|
|
|
// 其余字段按 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']);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-03 11:30:48 +08:00
|
|
|
|
// ---- 重复日程的「单个发生」例外表(版本 13,用户 2026-10-03 第 5 条)----
|
|
|
|
|
|
// 用户要的三档编辑里,「仅此一次」与「跳过这次」都要能"只动某一次"。
|
|
|
|
|
|
// 做法(与 RFC 5545 的 EXDATE 同构):把被取消/被挪走的那一次记在这里,
|
|
|
|
|
|
// 展开时跳过它;如果那次是被"挪到别的时间",就再插一条独立日程。
|
|
|
|
|
|
// ⚠️ 不复用 calendar_events:那是"日程本体",这里是"本体的某一次例外",
|
|
|
|
|
|
// 混在一张表里会让"这条日程重复吗"变成要额外判断的字段。
|
|
|
|
|
|
$db->exec(<<<'SQL'
|
|
|
|
|
|
CREATE TABLE IF NOT EXISTS `calendar_event_skips` (
|
|
|
|
|
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
|
|
|
|
`event_id` INT UNSIGNED NOT NULL COMMENT '哪条日程(calendar_events.id)',
|
|
|
|
|
|
`skip_date` DATE NOT NULL COMMENT '被跳过的哪一天(重复展开时命中这一天就不出)',
|
|
|
|
|
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
|
|
|
|
PRIMARY KEY (`id`),
|
|
|
|
|
|
UNIQUE KEY `uk_skip` (`event_id`, `skip_date`),
|
|
|
|
|
|
CONSTRAINT `fk_skip_event` FOREIGN KEY (`event_id`) REFERENCES `calendar_events` (`id`) ON DELETE CASCADE
|
|
|
|
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
|
|
|
|
COMMENT='重复日程里被单独取消/挪走的某一次(RFC 5545 的 EXDATE)'
|
|
|
|
|
|
SQL);
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 旅程的活动「是否公开」(版本 13,用户 2026-10-03 第 4 条)----
|
|
|
|
|
|
// 用户原话:「旅程的活动中,只显示 is_public=1 的活动」。
|
|
|
|
|
|
// ⚠️ 放在 **trip_details**(1:1 的卫星表)而不是 events:
|
|
|
|
|
|
// events 是观影/旅程/… 共用的骨架表,加一个只有旅程用的开关
|
|
|
|
|
|
// 会让观影那边多出一个永远为 0 的无意义字段。
|
|
|
|
|
|
// ⚠️ 默认 **1**(公开):老数据升级后必须照旧可见 ——
|
|
|
|
|
|
// 默认 0 会让人以为"旅程全丢了"(这是最危险的一种迁移默认值)。
|
|
|
|
|
|
self::addColumn($db, 'trip_details', 'is_public',
|
|
|
|
|
|
"ADD COLUMN `is_public` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否公开显示(用户 2026-10-03 第 4 条):0=不显示在旅程列表与日历' AFTER `summary`");
|
|
|
|
|
|
|
2026-10-03 08:18:05 +08:00
|
|
|
|
// ---- 纪念日(版本 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);
|
|
|
|
|
|
|
2026-09-30 13:24:26 +08:00
|
|
|
|
// 被容错跳过的数据步骤记进设置,系统设置页会把它显出来 ——
|
|
|
|
|
|
// 不然「升级没跑完」这件事只活在日志里,跟没发生一样。
|
|
|
|
|
|
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 里有
|
|
|
|
|
|
}
|
2026-09-30 11:06:22 +08:00
|
|
|
|
}
|
2026-09-30 09:44:55 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 08:18:05 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 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)"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 13:24:26 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* ⚠️ `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 加不上 → 名册分组、保存成员、头像上传全废。)
|
|
|
|
|
|
* 用「为空」作条件,天然幂等。
|
|
|
|
|
|
*/
|
2026-09-30 09:44:55 +08:00
|
|
|
|
private static function backfillLunarBirthday(\PDO $db): void
|
|
|
|
|
|
{
|
|
|
|
|
|
$st = $db->query(
|
|
|
|
|
|
"SELECT id, birthday FROM persons
|
2026-09-30 13:24:26 +08:00
|
|
|
|
WHERE birthday IS NOT NULL AND birthday_lunar IS NULL"
|
2026-09-30 09:44:55 +08:00
|
|
|
|
);
|
|
|
|
|
|
$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']]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-30 08:13:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-01 00:12:04 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* v9:把旧 movie_details 的影院三段(place/name/address)归一进 cinemas,
|
|
|
|
|
|
* 去重键 = (family_id, place, name),再回填 movie_details.cinema_id。
|
|
|
|
|
|
* 幂等:影院按唯一键查过再插;cinema_id 覆盖写。
|
|
|
|
|
|
*/
|
|
|
|
|
|
private static function backfillCinemasFromDetails(\PDO $db): void
|
|
|
|
|
|
{
|
2026-10-02 09:28:07 +08:00
|
|
|
|
// ⚠️ 逐列守卫,而不是只信任调用方那一个判据(`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;
|
|
|
|
|
|
}
|
2026-10-01 00:12:04 +08:00
|
|
|
|
$rows = $db->query(
|
2026-10-02 09:28:07 +08:00
|
|
|
|
'SELECT d.event_id, e.family_id, d.' . implode(', d.', $cols) . '
|
2026-10-01 00:12:04 +08:00
|
|
|
|
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
|
|
|
|
|
|
{
|
2026-10-02 09:28:07 +08:00
|
|
|
|
// 同 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 <> '')";
|
|
|
|
|
|
}
|
2026-10-01 00:12:04 +08:00
|
|
|
|
$rows = $db->query(
|
2026-10-02 09:28:07 +08:00
|
|
|
|
'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
|
2026-10-01 00:12:04 +08:00
|
|
|
|
)->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,
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-02 09:28:07 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 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']]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 08:13:07 +08:00
|
|
|
|
/** 列不存在才加 */
|
|
|
|
|
|
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 '管理员 / 家庭成员 / 访客'"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-30 13:24:26 +08:00
|
|
|
|
|
2026-09-30 16:12:28 +08:00
|
|
|
|
/** 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)'"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-02 09:28:07 +08:00
|
|
|
|
/** 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)'"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 13:24:26 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-01 00:12:04 +08:00
|
|
|
|
/** 老库补外键前先查它在不在(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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 13:24:26 +08:00
|
|
|
|
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}`");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-30 08:13:07 +08:00
|
|
|
|
}
|