Files
FamilyBook/app/Schema.php
T
laoyang 7e9b4520c0 登录居中 · 个人 slogan · 三种生日 · 称呼视角化 · 附件统一走代理
按九点反馈一次性改造:

1. 登录页「图标 + 用手机号登录你的家书」包进 .auth-head 并真正居中
   (以前只加了个 class 名,没有样式,看着像做了其实没做)。

2. slogan 改成两段式:系统级 slogan 作兜底,每个人可在自己的资料里写一个,
   填了就显示自己的、留空就回落到系统那句。settings 里「家庭名称」字段去掉
   (只在安装时归档,不参与展示)。

3. 三个生日:身份证生日(从证件号截取,永不改动)/真实阳历/农历。
   同一人填了其一即自动推出另一个(靠 birthday_source 指明填的是哪一侧,
   不依赖 JS)。三者落在同一天时合并成一条提醒,否则分别提醒。
   农历表不是照抄流传的那份 lunarInfo(它在 5 个年份上是错的)——
   用五个独立实现按 7.3 万天多数表决重新推导,再要求 100% 重现。

4. 称呼改成「视角属性」:nicknames(family_id, viewer_id, target_id, nickname)。
   「小明」不是某个人的属性——同一个人被不同人叫法不同,所以是「谁看」的属性。
   旧的 persons.nickname 由 Nickname::migrateLegacy() 搬到新表,只搬一次。

5. 点自己的头像/名字直接进「我的资料」,不必再绕
   「名册 → 找到自己 → 编辑资料」三步。

6. 附件(头像与其它上传)一律走 media.php 代理,不再直连 uploads/ 或云端地址:
   可读性由登录态决定而不是「知道文件名」,Content-Type/缓存/下载头由服务端统一给。
   uploads/ 整个目录 deny(含历史遗留备份文件),并有真实探针验证。

7. OSS 设置页加「连通性测试」(本地 / 云端两个按钮),逐步给出结论与耗时。

8. 生日输入改用「原生日期框 + 24 小时制时:分:秒 三个下拉」:
   datetime-local 的格式由浏览器 UI 语言决定(中文环境渲染成「下午 03:30」,
   没有秒、还容易把上下午填反)。点输入框任意一处都能弹出选择器(showPicker)。

9. 结构版本号收敛成 Schema::VERSION 一处,安装时即盖章,避免首次请求白跑升级。

验证(全部为「先注入 bug 证伪、再跑通过」):
  php -l 42 个文件;e2e 149 条;老库升级专测 37 条;反向验证 23 个注入全被抓;
  桌面几何 108 条(768/1280/1920/2560);移动端真机几何 212 条(360/375/390/414);
  交互检查 49 条(生日整框可点、登录居中)。
2026-09-30 09:44:55 +08:00

263 lines
14 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 表
*/
public const VERSION = '3';
/** @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 '虚拟人物:还没弄清真实身份的占位(如「我的父亲」)',
`sort_order` INT NOT NULL DEFAULT 1000 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`, `sort_order`),
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 `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,
];
}
/**
* 幂等结构升级:建表 + 补齐后加的列/枚举值。
*
* ⚠️ CREATE TABLE IF NOT EXISTS 对**已存在的表**什么都不做 ——
* 所以给老库加列必须显式 ALTER,而且要先查 information_schema 再改,
* 否则第二次执行会报 Duplicate column。这就是「部署即可升级」的全部秘密。
*/
public static function ensure(): void
{
$db = Database::get();
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,无需回填。
// 把老数据补出农历生日:只在还没算过时算一次(幂等)。
self::backfillLunarBirthday($db);
self::backfillIdCardBirthday($db);
// ---- 称呼从「人的属性」改成「视角属性」----
// 老语义:persons.nickname 是全家共用的小名。
// 新语义:nicknames 表,(viewer, target) → 我叫他什么。
// ⚠️ 必须在这里搬:不然升级之后界面上再没有人读 persons.nickname,
// 用户会觉得"以前填的小名一夜之间全没了"。搬的时候给每个账号都写一份,
// 迁移前后大家看到的叫法完全一致(`Nickname::migrateLegacy` 里说明了原因)。
// 放在结构升级里、而不是"某次请求里顺手做",是为了升级完就已经搬好。
foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) {
\App\Nickname::migrateLegacy((int)$fam['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 OR birthday_lunar = '')"
);
$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']]);
}
}
}
/** 列不存在才加 */
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 '管理员 / 家庭成员 / 访客'"
);
}
}
}