名册每人一份 · 分组两层 · 拖动排序 · 表单合一 · 裁剪可缩放
按用户十点反馈逐条落地(v5,Schema::VERSION = 5): - 名册改成每人一份:新表 person_roster(owner_id, person_id, group_id, sort_order, source)。默认只看「我添加的 + 别人分享给我的」; source='seeded' 是升级时从老 persons.group_id 播进来的(先播数据再删列)。 - 排序不再手填:名册页直接拖动卡片(整版提交,一次 applyLayout 落库), 无 JS 时卡片上的「调整分组」表单照旧可用。 - 分组分两层:管理员在「系统设置」维护公共分组(owner_id=0), 用户在「个人设置」加自己的分组;显示顺序各人各定、互不影响。 - 名册可共享:把名片分享给别的用户(people/share);添加成员时若系统里 已有这个人,可直接复用(people/pick),不必重复录入。 - 「编辑成员」与「改我自己的资料」合并为同一张表(templates/people/form.php), 两个入口 href 完全相同,h1 按改谁切换文案;个人设置页只留 资料入口 + 称呼 + 我的分组三块,删除 templates/settings/_profile.php。 - 系统设置收进头像下拉,顶栏只剩 首页/家谱/名册。 - 裁剪器加缩放(滑块 1-4x、Ctrl+滚轮、还原):缩放只改视图大小, 落库的归一化矩形保持不变。 - 权限:管理员可以改任何人的权限(含自己),只拦「最后一位管理员」被降级。 - 去掉首页「我的一句话」后的链接、家谱说明段、以及「还没归到任何分组」提示。 迁移安全:数据回填(PersonRoster::seedFromPersons)先于删列执行、且不进 tryStep 容错;失败步骤记入 settings.schema_upgrade_warning 并在系统设置页提示。 线上已验证:schema_upgrade_warning 为空,person_roster/person_groups.owner_id 就位,persons.group_id / sort_order 已删。 验证:php -l 48 文件;本地 e2e 277/0、老库升级 81/0、桌面几何 108/0、 移动端几何 180/0、交互 96/0;反向验证 38 条注入 + 2 条缩放注入全部命中; 部署静态断言 53/0,站点自检 7/7(--resolve 锁本机走 443,探针真的进 PHP)。
This commit is contained in:
+149
-26
@@ -26,8 +26,11 @@ final class Schema
|
||||
* 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 上)
|
||||
*/
|
||||
public const VERSION = '4';
|
||||
public const VERSION = '5';
|
||||
|
||||
/** @return string[] */
|
||||
public static function statements(): array
|
||||
@@ -60,14 +63,11 @@ CREATE TABLE IF NOT EXISTS `persons` (
|
||||
`address` VARCHAR(200) DEFAULT NULL,
|
||||
`note` TEXT DEFAULT NULL COMMENT '备注',
|
||||
`is_placeholder` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '虚拟人物:还没弄清真实身份的占位(如「我的父亲」)',
|
||||
`group_id` INT UNSIGNED DEFAULT NULL 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`),
|
||||
KEY `idx_person_group` (`group_id`),
|
||||
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,
|
||||
@@ -132,15 +132,35 @@ 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_name` (`family_id`, `name`),
|
||||
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='关系分组:全家人共用一套(一个人只属于一个分组),但每个人可以各自决定分组的先后'
|
||||
) 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` (
|
||||
@@ -166,6 +186,37 @@ SQL,
|
||||
'不熟' => 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());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 幂等结构升级:建表 + 补齐后加的列/枚举值。
|
||||
*
|
||||
@@ -176,6 +227,7 @@ SQL,
|
||||
public static function ensure(): void
|
||||
{
|
||||
$db = Database::get();
|
||||
self::$stepErrors = [];
|
||||
foreach (self::statements() as $sql) {
|
||||
$db->exec($sql);
|
||||
}
|
||||
@@ -200,9 +252,11 @@ SQL,
|
||||
"ADD COLUMN `slogan` VARCHAR(200) DEFAULT NULL COMMENT '个人的一句话;没填就用家庭统一的那句' AFTER `note`");
|
||||
|
||||
// 老数据里 birthday 若是 DATE,升成 DATETIME 后时间部分自动是 00:00:00,无需回填。
|
||||
// 把老数据补出农历生日:只在还没算过时算一次(幂等)。
|
||||
self::backfillLunarBirthday($db);
|
||||
self::backfillIdCardBirthday($db);
|
||||
// 把老数据补出农历生日 / 身份证生日:只在还没算过时算一次(幂等)。
|
||||
// 这两步**只动已有数据、不改结构**,所以容错跑:某一行老数据有问题
|
||||
// 不该把下面的改结构步骤全挡住(原因见 tryStep 的注释)。
|
||||
self::tryStep('回填农历生日', static fn() => self::backfillLunarBirthday($db));
|
||||
self::tryStep('回填身份证生日', static fn() => self::backfillIdCardBirthday($db));
|
||||
|
||||
// ---- 称呼从「人的属性」改成「视角属性」----
|
||||
// 老语义:persons.nickname 是全家共用的小名。
|
||||
@@ -211,30 +265,65 @@ SQL,
|
||||
// 用户会觉得"以前填的小名一夜之间全没了"。搬的时候给每个账号都写一份,
|
||||
// 迁移前后大家看到的叫法完全一致(`Nickname::migrateLegacy` 里说明了原因)。
|
||||
// 放在结构升级里、而不是"某次请求里顺手做",是为了升级完就已经搬好。
|
||||
foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) {
|
||||
\App\Nickname::migrateLegacy((int)$fam['id']);
|
||||
}
|
||||
self::tryStep('迁移旧称呼到 nicknames 表', static function () use ($db) {
|
||||
foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) {
|
||||
\App\Nickname::migrateLegacy((int)$fam['id']);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 关系分组(版本 4)----
|
||||
// persons 新增 group_id。这里**不加外键**:老库只能 ADD COLUMN,加不上约束,
|
||||
// 新装的加了就会两边结构不一致;统一由 PersonGroup 保证不留悬空 id。
|
||||
self::addColumn($db, 'persons', 'group_id',
|
||||
"ADD COLUMN `group_id` INT UNSIGNED DEFAULT NULL COMMENT '关系分组(人属于哪一组:家人/同事/朋友…),全家人共用一套分类' AFTER `is_placeholder`");
|
||||
// 每个家庭都要有一套预置分组(新装的由 install.php 建,老的在这里补)
|
||||
foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) {
|
||||
\App\PersonGroup::seedDefaults((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');
|
||||
|
||||
// 被容错跳过的数据步骤记进设置,系统设置页会把它显出来 ——
|
||||
// 不然「升级没跑完」这件事只活在日志里,跟没发生一样。
|
||||
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 里有
|
||||
}
|
||||
}
|
||||
// 老库的人还没有分组 → 按名册类型补:家庭成员=家人、外部联系人=认识。
|
||||
// 只补空的,用户自己改过的一律不动。
|
||||
\App\PersonGroup::backfillMissing($db);
|
||||
}
|
||||
|
||||
/** 老库已有阳历生日但没有农历生日 → 补算一次。用「为空」作条件,天然幂等。 */
|
||||
/**
|
||||
* ⚠️ `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 OR birthday_lunar = '')"
|
||||
WHERE birthday IS NOT NULL AND birthday_lunar IS NULL"
|
||||
);
|
||||
$rows = $st->fetchAll();
|
||||
if (!$rows) {
|
||||
@@ -312,4 +401,38 @@ SQL,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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}`");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user