名册每人一份 · 分组两层 · 拖动排序 · 表单合一 · 裁剪可缩放

按用户十点反馈逐条落地(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:
2026-09-30 13:24:26 +08:00
parent 1c460a0e83
commit ed38a40c08
21 changed files with 1780 additions and 594 deletions
+24 -1
View File
@@ -26,7 +26,30 @@ final class Account
'INSERT INTO accounts (person_id, password_hash, role) VALUES (?, ?, ?)'
);
$st->execute([$personId, password_hash($password, PASSWORD_DEFAULT), $role]);
return (int)Database::get()->lastInsertId();
$id = (int)Database::get()->lastInsertId();
self::seedSelfRoster($personId);
return $id;
}
/**
* 刚开通账号的人,自己的名册里先放上他自己。
*
* 名册是「每人一份」(v5 起),所以新账号的名册默认是空的 ——
* 那是对的(他再加/再复用别人),但连「我」都看不见就太奇怪了:
* 首页/家谱里都有他自己,名册里却没有,看着像自己不在家里。
* 所以这里只补他自己,别人一概不补。
*
* ⚠️ 故意**不** try/catch 吞异常:这是开账号链路的一部分,
* 出了问题要立刻看得见(静默失败正是这个项目踩过的坑)。
*/
private static function seedSelfRoster(int $personId): void
{
$st = Database::get()->prepare('SELECT family_id FROM persons WHERE id = ?');
$st->execute([$personId]);
$fid = (int)$st->fetchColumn();
if ($fid > 0) {
PersonRoster::ensure($fid, $personId, $personId, null, 'self');
}
}
public static function setPassword(int $accountId, string $password): void
+1 -1
View File
@@ -68,7 +68,7 @@ final class Auth
/**
* 首页标题 —— 「以我为中心」最直接的一处落点。
* 登录后看到的不是「家书」,而是「老杨的家书」。
* 登录后看到的不是「家书」,而是「<我的名字>的家书」。
*/
public static function homeTitle(): string
{
+1 -1
View File
@@ -149,7 +149,7 @@ final class Nickname
'SELECT n.target_id, n.nickname, p.name, p.avatar, p.is_placeholder
FROM nicknames n JOIN persons p ON p.id = n.target_id
WHERE n.viewer_id = ? AND n.family_id = ?
ORDER BY p.sort_order ASC, p.id ASC'
ORDER BY p.id ASC'
);
$st->execute([$viewerId, $familyId]);
return $st->fetchAll();
+48 -26
View File
@@ -41,7 +41,44 @@ final class Person
$args[] = $like;
$args[] = $like;
}
$sql .= ' ORDER BY p.sort_order ASC, p.id ASC';
$sql .= ' ORDER BY p.id ASC';
$st = Database::get()->prepare($sql);
$st->execute($args);
return $st->fetchAll();
}
/**
* **我的名册**:只列我自己的名册里的家庭成员(带分组与顺序)。
*
* 与 all() 的分工(v5 起两者不再是一回事):
* · all() —— 全家庭的成员,不管是谁的名册(首页徽标、邀请访客等内部用途);
* · roster() —— 名册页该显示的那些:我加的人 + 别人分享给我的,顺序是我的。
*
* 分组与顺序都从 person_roster 取(列名故意与老库的 persons.group_id /
* persons.sort_order 保持一致,调用方几乎不用改)。
*/
public static function roster(int $familyId, int $viewerId, string $kind = 'member', string $q = ''): array
{
$sql = 'SELECT p.*, r.group_id, r.sort_order, r.source AS roster_source,
a.id AS account_id, a.role AS account_role, a.status AS account_status,
a.last_login_at, n.nickname AS my_nickname
FROM person_roster r
JOIN persons p ON p.id = r.person_id
LEFT JOIN accounts a ON a.person_id = p.id
LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ?';
$args = [$viewerId];
$sql .= ' WHERE r.family_id = ? AND r.owner_id = ? AND p.kind = ?';
$args[] = $familyId;
$args[] = $viewerId;
$args[] = $kind;
if ($q !== '') {
$sql .= ' AND (p.name LIKE ? OR p.email LIKE ? OR n.nickname LIKE ?)';
$like = '%' . $q . '%';
$args[] = $like;
$args[] = $like;
$args[] = $like;
}
$sql .= ' ORDER BY r.sort_order ASC, p.id ASC';
$st = Database::get()->prepare($sql);
$st->execute($args);
return $st->fetchAll();
@@ -91,8 +128,8 @@ final class Person
(family_id, kind, name, gender, birth_rank, birthday,
birthday_id_card, birthday_lunar, birthday_lunar_leap, slogan, avatar,
phone_hash, phone_encrypted, id_card_encrypted, email, address, note,
is_placeholder, group_id, sort_order)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)';
is_placeholder)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)';
$st = Database::get()->prepare($sql);
$st->execute([
$familyId,
@@ -113,8 +150,6 @@ final class Person
($d['address'] ?? '') !== '' ? $d['address'] : null,
($d['note'] ?? '') !== '' ? $d['note'] : null,
!empty($d['is_placeholder']) ? 1 : 0,
!empty($d['group_id']) ? (int)$d['group_id'] : null,
(int)($d['sort_order'] ?? 1000),
]);
return (int)Database::get()->lastInsertId();
}
@@ -127,7 +162,7 @@ final class Person
name = ?, gender = ?, birth_rank = ?, birthday = ?,
birthday_id_card = ?, birthday_lunar = ?, birthday_lunar_leap = ?, slogan = ?, avatar = ?,
phone_hash = ?, phone_encrypted = ?, id_card_encrypted = ?,
email = ?, address = ?, note = ?, is_placeholder = ?, group_id = ?, sort_order = ?
email = ?, address = ?, note = ?, is_placeholder = ?
WHERE id = ?';
Database::get()->prepare($sql)->execute([
$d['name'],
@@ -146,32 +181,19 @@ final class Person
($d['address'] ?? '') !== '' ? $d['address'] : null,
($d['note'] ?? '') !== '' ? $d['note'] : null,
!empty($d['is_placeholder']) ? 1 : 0,
// ⚠️ 这里不能用 `!empty($d['group_id'])` 之外的花样:
// "清空分组"(改成未分类)也是一个合法意图,所以 0/'' 一律落成 NULL。
!empty($d['group_id']) ? (int)$d['group_id'] : null,
(int)($d['sort_order'] ?? 1000),
$id,
]);
}
/**
* 只改「这个人属于哪一组」(名册上的快捷归类用)。
* 自助改自己的资料。
*
* 单独一个方法,是为了让名册那一小块表单**不必**把整条记录回传 ——
* 否则一次归类提交就要带上姓名/生日/身份证号,任何一处没带全就会把数据改坏。
*/
public static function setGroup(int $id, ?int $groupId): void
{
Database::get()->prepare('UPDATE persons SET group_id = ? WHERE id = ?')
->execute([$groupId ?: null, $id]);
}
/**
* 自助改自己的资料(个人信息页用)。
*
* 与上面的 update() 分开是有意的:那个是"管理员编名册",字段全、权限高;
* 与上面的 update() 分开是有意的:那个是"能写的人编名册",字段全、权限高;
* 这个只允许改**属于自己的**东西 —— 姓名、性别、三个生日、一句话、头像、联系方式。
* 排行、排序号、虚拟人物标记、以及身份证号(会牵动身份证生日)都只能由管理员改名册时动。
* 排行、虚拟人物标记、以及身份证号(会牵动身份证生日)都只能由管理员改名册时动。
*
* ⚠️ v5 起这里**不再有**分组:分组是「我和他的关系」,挂在 person_roster 上,
* 只能在名册里拖或者在那张卡片的「调整分组」里改(见 PersonRoster::assignGroup)。
*/
public static function updateSelf(int $id, array $d): void
{
@@ -230,7 +252,7 @@ final class Person
FROM persons p
LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ?
WHERE p.family_id = ? AND p.kind = "member" AND p.id <> ?
ORDER BY p.sort_order ASC, p.id ASC'
ORDER BY p.id ASC'
);
$st->execute([$viewerId, $familyId, $exceptId]);
return $st->fetchAll();
+159 -89
View File
@@ -1,17 +1,18 @@
<?php
/**
* 关系分组:一个人属于哪一组(家人 / 亲戚 / 同事 / 朋友 …),名册按它分组展示。
* 关系分组:一个人属于哪一组(家人 / 亲戚 / 同事 / 朋友 …),名册按它分栏展示。
*
* 三条刻意的设计:
* ① **分类是全家共用的,顺序是各人各定的。**
* 老杨的同事,对全家来说都是同事 —— 所以分类只存一份(persons.group_id)。
* 但「哪一组排前面」纯属个人习惯,存在 user_settings.group_order 里,
* 每个人可以不一样(爸爸把「家人」排第一,妈妈把「亲戚」排第一)。
* ② **分类可以由用户自己加。** 所以是表而不是 ENUM —— 预置的七个只是初始值,
* 觉得不够就自己加,加错了可以改名或删掉。
* ③ **不用外键约束 group_id。** 老库升级只能 ADD COLUMN,加不上外键,
* 四条刻意的设计(第一、二条是被纠正过之后定下来的):
* ① **一套是公共的,一套是我自己的。** `owner_id = 0` 是管理员在「系统设置」
* 里维护的公共分组(全家都能用);`owner_id = 我` 是我在「个人设置」里加的,
* 只有我自己看得见。所以「某人的同事」如果全家都认,管理员建公共的;
* 只有我觉得该有「球友」,我自己加一个,不影响别人。
* ② **顺序是各人各定的。** 「哪一组排前面」纯属个人习惯,存在
* user_settings.group_order 里(每人一份),改它不会动到公共的默认顺序。
* ③ **分类可以由用户自己加。** 所以是表而不是 ENUM —— 预置的七个只是初始值。
* ④ **不用外键约束 person_roster.group_id。** 老库升级只能 ADD COLUMN、加不上外键,
* 新装的又加了就会两边结构不一致;索性统一由程序保证 ——
* 删除分组时把挂靠的人置为「未分类」(NULL),不留悬空 id。
* 删除分组时把挂靠的名册条目置为「未分类」(NULL),不留悬空 id。
*/
declare(strict_types=1);
@@ -20,27 +21,46 @@ namespace App;
final class PersonGroup
{
/** 公共分组(管理员维护,全家共用)的 owner_id */
public const PUBLIC_OWNER = 0;
/** 预置分组的来源在 Schema::DEFAULT_GROUPS(安装与升级共用一份,避免两处漂移) */
/**
* 给一个家庭补齐预置分组。幂等 —— 靠 uk_group_name 唯一键,重跑不会重复插入。
* 给一个家庭补齐**公共**分组。幂等 —— 靠 uk_group_owner_name 唯一键,重跑不会重复插入。
* 也不覆盖用户改过的名字(同名才算重复,改名后重跑会**补回**那个默认名,
* 这正是想要的:删掉的默认分组下次升级会回来,用户自定义的则不受影响)。
*/
public static function seedDefaults(int $familyId): void
public static function seedDefaults(int $familyId, int $ownerId = self::PUBLIC_OWNER): void
{
if ($familyId <= 0) {
return;
}
$st = Database::get()->prepare(
'INSERT IGNORE INTO person_groups (family_id, name, position, is_builtin) VALUES (?, ?, ?, 1)'
'INSERT IGNORE INTO person_groups (family_id, owner_id, name, position, is_builtin)
VALUES (?, ?, ?, ?, 1)'
);
foreach (Schema::DEFAULT_GROUPS as $name => $pos) {
$st->execute([$familyId, $name, $pos]);
$st->execute([$familyId, max(0, $ownerId), $name, $pos]);
}
}
/** 全部分组,id => 行(按默认顺序) */
/** 某一套分组(公共传 0,个人传自己的 person_id),id => 行,按默认顺序 */
public static function forOwner(int $familyId, int $ownerId): array
{
$st = Database::get()->prepare(
'SELECT * FROM person_groups WHERE family_id = ? AND owner_id = ?
ORDER BY position ASC, id ASC'
);
$st->execute([$familyId, max(0, $ownerId)]);
$out = [];
foreach ($st->fetchAll() as $r) {
$out[(int)$r['id']] = $r;
}
return $out;
}
/** 这个家庭里的全部分组(含所有人的私自分组)—— 只用来校验"这个 id 是本家庭的" */
public static function byId(int $familyId): array
{
$st = Database::get()->prepare(
@@ -54,6 +74,34 @@ final class PersonGroup
return $out;
}
/**
* **这个人在名册里能用哪些分组** = 公共的 + 他自己的私自分组。
*
* 访客 / 还没定过个人分组的人也至少能拿到公共的那一套,
* 所以"管理员建了分组但没人用得上"不会发生。
*/
public static function visible(int $familyId, int $viewerId): array
{
$viewerId = max(0, $viewerId);
$st = Database::get()->prepare(
'SELECT * FROM person_groups
WHERE family_id = ? AND (owner_id = 0 OR owner_id = ?)
ORDER BY owner_id ASC, position ASC, id ASC'
);
$st->execute([$familyId, $viewerId]);
$out = [];
foreach ($st->fetchAll() as $r) {
$out[(int)$r['id']] = $r;
}
return $out;
}
/** visible() 的 id 集合版本,用来做「这个分组 id 合不合法」的判断 */
public static function visibleIds(int $familyId, int $viewerId): array
{
return array_fill_keys(array_keys(self::visible($familyId, $viewerId)), true);
}
/**
* 按**当前用户自定义的顺序**返回分组(用户没自定义过就按默认位置)。
*
@@ -62,7 +110,7 @@ final class PersonGroup
*/
public static function orderFor(int $familyId, int $viewerId = 0): array
{
$rest = self::byId($familyId);
$rest = self::visible($familyId, $viewerId);
if (!$rest) {
return [];
}
@@ -90,7 +138,7 @@ final class PersonGroup
/** 保存「我」的分组顺序(只影响我自己) */
public static function saveOrder(int $familyId, int $viewerId, array $ids): void
{
$valid = self::byId($familyId);
$valid = self::visible($familyId, $viewerId);
$clean = [];
foreach ($ids as $id) {
$id = (int)$id;
@@ -107,6 +155,20 @@ final class PersonGroup
UserSetting::set($viewerId, 'group_order', json_encode($clean, JSON_UNESCAPED_UNICODE));
}
/** 按给定的 id 顺序重写某一套分组的 position(公共分组的「全家默认顺序」靠它) */
public static function savePositions(int $familyId, int $ownerId, array $ids): void
{
$db = Database::get();
$up = $db->prepare(
'UPDATE person_groups SET position = ? WHERE family_id = ? AND owner_id = ? AND id = ?'
);
$pos = 0;
foreach ($ids as $id) {
$pos += 10;
$up->execute([$pos, $familyId, max(0, $ownerId), (int)$id]);
}
}
/** 分组名(找不到就返回空串,调用方自己决定回退文案) */
public static function name(int $familyId, ?int $id): string
{
@@ -120,81 +182,107 @@ final class PersonGroup
/**
* 新增一个分组。
* @param int $ownerId 0=公共(只有管理员能建,调用方把关);否则=建给自己的
* @return array{ok:bool,id?:int,error?:string}
*/
public static function add(int $familyId, string $name): array
public static function add(int $familyId, string $name, int $ownerId = self::PUBLIC_OWNER): array
{
$ownerId = max(0, $ownerId);
$name = trim($name);
$bad = self::validateName($name);
if ($bad !== '') {
return ['ok' => false, 'error' => $bad];
}
$st = Database::get()->prepare(
'SELECT COUNT(*) FROM person_groups WHERE family_id = ? AND name = ?'
$db = Database::get();
$st = $db->prepare(
'SELECT COUNT(*) FROM person_groups WHERE family_id = ? AND owner_id = ? AND name = ?'
);
$st->execute([$familyId, $name]);
$st->execute([$familyId, $ownerId, $name]);
if ((int)$st->fetchColumn() > 0) {
return ['ok' => false, 'error' => '已经有叫「' . $name . '」的分组了。'];
}
// ⚠️ 别写成 `$max = (int)$db->prepare(...)->execute([...])`:
// PDOStatement::execute() 返回 bool,强转永远是 1 —— 位置会算错。
$st2 = Database::get()->prepare('SELECT COALESCE(MAX(position), 0) FROM person_groups WHERE family_id = ?');
$st2->execute([$familyId]);
$st2 = $db->prepare(
'SELECT COALESCE(MAX(position), 0) FROM person_groups WHERE family_id = ? AND owner_id = ?'
);
$st2->execute([$familyId, $ownerId]);
$max = (int)$st2->fetchColumn();
Database::get()->prepare(
'INSERT INTO person_groups (family_id, name, position, is_builtin) VALUES (?, ?, ?, 0)'
)->execute([$familyId, $name, max($max + 10, 80)]);
return ['ok' => true, 'id' => (int)Database::get()->lastInsertId()];
$db->prepare(
'INSERT INTO person_groups (family_id, owner_id, name, position, is_builtin)
VALUES (?, ?, ?, ?, 0)'
)->execute([$familyId, $ownerId, $name, max($max + 10, 80)]);
return ['ok' => true, 'id' => (int)$db->lastInsertId()];
}
/** @return array{ok:bool,error?:string} */
public static function rename(int $familyId, int $id, string $name): array
/**
* 改名。$ownerId 用来确认"你改的是你自己那一套里的分组"——
* 公共分组只有管理员能改(调用方把关),别人的私自分组连管理员也不该动。
* @return array{ok:bool,error?:string}
*/
public static function rename(int $familyId, int $id, string $name, int $ownerId): array
{
$ownerId = max(0, $ownerId);
$name = trim($name);
$bad = self::validateName($name);
if ($bad !== '') {
return ['ok' => false, 'error' => $bad];
}
$st = Database::get()->prepare(
'SELECT COUNT(*) FROM person_groups WHERE family_id = ? AND name = ? AND id <> ?'
$db = Database::get();
$own = $db->prepare(
'SELECT COUNT(*) FROM person_groups WHERE family_id = ? AND owner_id = ? AND id = ?'
);
$st->execute([$familyId, $name, $id]);
$own->execute([$familyId, $ownerId, $id]);
if ((int)$own->fetchColumn() === 0) {
return ['ok' => false, 'error' => '找不到这个分组(可能不是你那一套里的)。'];
}
$st = $db->prepare(
'SELECT COUNT(*) FROM person_groups WHERE family_id = ? AND owner_id = ? AND name = ? AND id <> ?'
);
$st->execute([$familyId, $ownerId, $name, $id]);
if ((int)$st->fetchColumn() > 0) {
return ['ok' => false, 'error' => '已经有叫「' . $name . '」的分组了。'];
}
$st = Database::get()->prepare('UPDATE person_groups SET name = ? WHERE family_id = ? AND id = ?');
$st->execute([$name, $familyId, $id]);
if ($st->rowCount() === 0) {
$up = $db->prepare(
'UPDATE person_groups SET name = ? WHERE family_id = ? AND owner_id = ? AND id = ?'
);
$up->execute([$name, $familyId, $ownerId, $id]);
if ($up->rowCount() === 0) {
return ['ok' => false, 'error' => '找不到这个分组。'];
}
return ['ok' => true];
}
/**
* 删除一个分组。挂在这个分组下的人**不会消失**,只是变成「未分类」——
* 名册上会多出一个「未分类」组提醒去归类。
* 最后一个分组不给删:全删光就没有任何分类可用了,那是死路。
* 删除一个分组。名册里挂在这个分组下的人**不会消失**,只是变成「未分类」——
* 名册上会多出一个「未分类」栏提醒去归类。
*
* 最后一个**公共**分组不给删:全删光就没有任何分类可用了,那是死路。
* 私自分组可以删到零(公共那套还在,不会没得选)。
*
* @return array{ok:bool,error?:string,moved?:int}
*/
public static function delete(int $familyId, int $id): array
public static function delete(int $familyId, int $id, int $ownerId): array
{
$all = self::byId($familyId);
$ownerId = max(0, $ownerId);
$all = self::forOwner($familyId, $ownerId);
if (!isset($all[$id])) {
return ['ok' => false, 'error' => '找不到这个分组。'];
return ['ok' => false, 'error' => '找不到这个分组(可能不是你那一套里的)。'];
}
if (count($all) <= 1) {
return ['ok' => false, 'error' => '至少要留一个分组 —— 全删光就没法给成员归类了。'];
if ($ownerId === self::PUBLIC_OWNER && count($all) <= 1) {
return ['ok' => false, 'error' => '至少要留一个公共分组 —— 全删光就没法给成员归类了。'];
}
$db = Database::get();
$st = $db->prepare('SELECT COUNT(*) FROM persons WHERE family_id = ? AND group_id = ?');
$st->execute([$familyId, $id]);
$st = $db->prepare('SELECT COUNT(*) FROM person_roster WHERE group_id = ?');
$st->execute([$id]);
$moved = (int)$st->fetchColumn();
$db->prepare('UPDATE persons SET group_id = NULL WHERE family_id = ? AND group_id = ?')
->execute([$familyId, $id]);
$db->prepare('DELETE FROM person_groups WHERE family_id = ? AND id = ?')
->execute([$familyId, $id]);
// ⚠️ 释放范围是**所有**名册条目(不限 owner):公共分组人人都能用,
// 只清自己那几条会让别人名册里留下指向已删分组的 id(那一栏就永远空着、
// 而对应的卡片在同一次渲染里彻底消失)。
$db->prepare('UPDATE person_roster SET group_id = NULL WHERE group_id = ?')->execute([$id]);
$db->prepare('DELETE FROM person_groups WHERE family_id = ? AND owner_id = ? AND id = ?')
->execute([$familyId, $ownerId, $id]);
// 各人自定义顺序里可能留着这个 id,清掉免得越积越脏
self::pruneOrder($familyId, $id);
@@ -229,56 +317,38 @@ final class PersonGroup
continue;
}
$keep = array_values(array_filter($ids, fn($x) => (int)$x !== $removedId));
$db->prepare('UPDATE user_settings SET v = ? WHERE person_id = ? AND k = ?')
->execute([json_encode(array_map('intval', $keep)), (int)$row['person_id'], 'group_order']);
$prep = $db->prepare('UPDATE user_settings SET v = ? WHERE person_id = ? AND k = ?');
$prep->execute([json_encode(array_map('intval', $keep)), (int)$row['person_id'], 'group_order']);
}
}
/**
* 老库升级:把还没有分组的人按名册类型补一个合理默认。
* 家庭成员 → 家人;外部联系人 → 认识。
* 只补 NULL,用户自己改过的一律不动(幂等)。
*/
public static function backfillMissing(\PDO $db): void
/** 按名字找分组 id(找不到返回 0)。默认在**公共**分组里找。 */
public static function idByName(int $familyId, string $name, int $ownerId = self::PUBLIC_OWNER): int
{
foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) {
$fid = (int)$fam['id'];
// 家庭可能还没套用预置分组(ensure 的 seed 在表建好之后才跑)
$st = $db->prepare('SELECT COUNT(*) FROM person_groups WHERE family_id = ?');
$st->execute([$fid]);
if ((int)$st->fetchColumn() === 0) {
self::seedDefaults($fid);
}
foreach ([['member', '家人'], ['contact', '认识']] as [$kind, $name]) {
$gid = self::idByName($fid, $name);
if ($gid === 0) {
continue;
}
$db->prepare(
'UPDATE persons SET group_id = ?
WHERE family_id = ? AND kind = ? AND group_id IS NULL'
)->execute([$gid, $fid, $kind]);
}
}
}
/** 按名字找分组 id(找不到返回 0) */
public static function idByName(int $familyId, string $name): int
{
$st = Database::get()->prepare('SELECT id FROM person_groups WHERE family_id = ? AND name = ?');
$st->execute([$familyId, $name]);
$st = Database::get()->prepare(
'SELECT id FROM person_groups WHERE family_id = ? AND owner_id = ? AND name = ?'
);
$st->execute([$familyId, max(0, $ownerId), $name]);
return (int)($st->fetchColumn() ?: 0);
}
/** 每个分组里有多少人(没分组的人记在键 0 上) */
public static function counts(int $familyId, string $kind = 'member'): array
/**
* 某个分组下有多少人 —— **从我自己的名册里数**。
*
* 这是 v5 的关键语义变化:老版本数的是"全家名册里 group_id = 它的有几个人",
* 那种数字对"我自己该怎么归类"没有指导意义(别人把人归到哪一组关我什么事)。
* 现在数的是"我名册里归到这一组的有几个",用来给个人设置里的分组列表做提示。
*/
public static function counts(int $familyId, int $ownerId, string $kind = 'member'): array
{
$st = Database::get()->prepare(
'SELECT COALESCE(group_id, 0) AS g, COUNT(*) AS n
FROM persons WHERE family_id = ? AND kind = ?
GROUP BY COALESCE(group_id, 0)'
'SELECT COALESCE(r.group_id, 0) AS g, COUNT(*) AS n
FROM person_roster r
JOIN persons p ON p.id = r.person_id
WHERE r.family_id = ? AND r.owner_id = ? AND p.kind = ?
GROUP BY COALESCE(r.group_id, 0)'
);
$st->execute([$familyId, $kind]);
$st->execute([$familyId, $ownerId, $kind]);
$out = [];
foreach ($st->fetchAll() as $r) {
$out[(int)$r['g']] = (int)$r['n'];
+311
View File
@@ -0,0 +1,311 @@
<?php
/**
* 每人一份名册。
*
* 老模型是「一份全家共用的名册」:`persons.group_id` 决定归类、`persons.sort_order`
* 决定顺序 —— 于是妈妈想把「亲戚」排前面,就得把全家人的视图一起改掉。
*
* 新模型把「**我和他的关系**」单独拿出来存:
* · `person_roster(owner_id, person_id, group_id, sort_order, source)`
* · owner 是我的名册,person 是躺在我名册里的人,group 是我把他归到哪一组,
* sort 是在**我的名册里**的先后。
* 同一个「张伟」,在我的名册里是「同事」、在妈妈的名册里是「亲戚」,互不打扰。
*
* 谁看得到谁(这是"每人一份"的核心):
* · 我自己加的人(source=self)
* · 别人分享给我的名片(source=shared)
* · 升级时按当时"人人在自己名册里都能看到全家"的现状补的(source=seeded)
* 除此之外一概看不见。所以「添加成员」时若这个人系统里已经有了,
* 要引导用户走「从家里已有的人里挑」(见 pickable()),而不是又录一遍。
*
* ⚠️ 与 `persons.kind` 的关系:roster 只管"看不看得见",
* 家谱(Kinship)仍然按全家庭的 persons + relations 推导 ——
* 名册是通讯录,家谱是血缘图,两者故意的不是一回事。
*/
declare(strict_types=1);
namespace App;
final class PersonRoster
{
/** 名册里一条记录的来源 */
public const SOURCES = ['self', 'shared', 'seeded'];
/**
* 把某人加进某人的名册(幂等:已经在里面就什么都不做)。
*
* @param string $source self=我加的 / shared=别人分享的 / seeded=升级补的
* @return bool 真的插入了才返回 true
*/
public static function ensure(
int $familyId,
int $ownerId,
int $personId,
?int $groupId = null,
string $source = 'self'
): bool {
if ($familyId <= 0 || $ownerId <= 0 || $personId <= 0) {
return false;
}
if (!in_array($source, self::SOURCES, true)) {
$source = 'self';
}
$db = Database::get();
$st = $db->prepare('SELECT COUNT(*) FROM person_roster WHERE owner_id = ? AND person_id = ?');
$st->execute([$ownerId, $personId]);
if ((int)$st->fetchColumn() > 0) {
return false;
}
// 分组必须是**这个人自己能用**的(公共的 + 他自己的)——
// 传进来一个别人家的私自分组就落成未分类,绝不静默挂错
// (挂上去的后果是:那一栏永远显示不出来他,而他在别的栏里也不见了)。
if ($groupId !== null && $groupId > 0
&& !isset(PersonGroup::visibleIds($familyId, $ownerId)[$groupId])) {
$groupId = null;
}
// ⚠️ 新条目排在末尾:不是取 COUNT(*),而是取当前最大 sort_order + 10 ——
// 中间被拖走/删掉的人会在序号里留下空档,用 COUNT 会插到别人前面去。
$st2 = $db->prepare('SELECT COALESCE(MAX(sort_order), 0) FROM person_roster WHERE owner_id = ?');
$st2->execute([$ownerId]);
$next = (int)$st2->fetchColumn() + 10;
$db->prepare(
'INSERT INTO person_roster (family_id, owner_id, person_id, group_id, sort_order, source)
VALUES (?, ?, ?, ?, ?, ?)'
)->execute([$familyId, $ownerId, $personId, $groupId ?: null, $next, $source]);
return true;
}
/**
* 我的名册:person_id => ['group_id' => ?int, 'sort_order' => int, 'source' => string]
*/
public static function mine(int $familyId, int $ownerId): array
{
if ($ownerId <= 0) {
return [];
}
$st = Database::get()->prepare(
'SELECT person_id, group_id, sort_order, source FROM person_roster
WHERE family_id = ? AND owner_id = ?
ORDER BY sort_order ASC, person_id ASC'
);
$st->execute([$familyId, $ownerId]);
$out = [];
foreach ($st->fetchAll() as $r) {
$out[(int)$r['person_id']] = [
'group_id' => $r['group_id'] === null ? null : (int)$r['group_id'],
'sort_order' => (int)$r['sort_order'],
'source' => (string)$r['source'],
];
}
return $out;
}
/** 只取 id 列表(判断"看不看得见"时用) */
public static function visibleIds(int $familyId, int $ownerId): array
{
return array_keys(self::mine($familyId, $ownerId));
}
public static function countFor(int $familyId, int $ownerId): int
{
$st = Database::get()->prepare(
'SELECT COUNT(*) FROM person_roster WHERE family_id = ? AND owner_id = ?'
);
$st->execute([$familyId, $ownerId]);
return (int)$st->fetchColumn();
}
/**
* 把「我名册里的某个人」改到一个分组(名册卡片上的快捷归类 / 拖动改栏都用它)。
*
* $groupId 必须是**我能用的**分组(公共的 + 我自己的),否则会被拒绝 ——
* 不然可以把人挂到别人家的分组上,那一栏就永远显示不出来他。
*/
public static function assignGroup(int $familyId, int $ownerId, int $personId, ?int $groupId): bool
{
if ($groupId !== null && $groupId > 0
&& !isset(PersonGroup::visibleIds($familyId, $ownerId)[$groupId])) {
return false;
}
$st = Database::get()->prepare(
'UPDATE person_roster SET group_id = ?
WHERE family_id = ? AND owner_id = ? AND person_id = ?'
);
$st->execute([$groupId ?: null, $familyId, $ownerId, $personId]);
return $st->rowCount() > 0;
}
/**
* 一次性把「每一栏有哪些人、什么顺序」落库 —— 拖动卡片排序/换栏之后调它。
*
* 为什么整版提交而不是"移动一条算一次":
* 拖拽的结果是**一组相对顺序**,逐条提交会在中途留下一个自相矛盾的中间态
* (比如两个人暂时同号),刷新一下就变样。整版提交 = 一次事务、一个确定结果。
*
* @param array $columns [['g' => 分组id(0=未分类), 'ids' => [personId, ...]], ...]
* @return int 实际写入的条数
*/
public static function applyLayout(int $familyId, int $ownerId, array $columns): int
{
$mine = self::mine($familyId, $ownerId);
if (!$mine) {
return 0;
}
$okGroups = PersonGroup::visibleIds($familyId, $ownerId);
$db = Database::get();
$up = $db->prepare(
'UPDATE person_roster SET group_id = ?, sort_order = ?
WHERE family_id = ? AND owner_id = ? AND person_id = ?'
);
$written = 0;
$sort = 0;
foreach ($columns as $col) {
if (!is_array($col)) {
continue;
}
$gid = (int)($col['g'] ?? 0);
// 分组不合法(别人家的私自分组 / 已删掉的分组)→ 落成未分类,绝不静默挂错
if ($gid !== 0 && !isset($okGroups[$gid])) {
$gid = 0;
}
foreach ((array)($col['ids'] ?? []) as $pid) {
$pid = (int)$pid;
if ($pid <= 0 || !isset($mine[$pid])) {
continue; // 不在我名册里的人,忽略
}
$sort += 10;
$up->execute([$gid ?: null, $sort, $familyId, $ownerId, $pid]);
$written++;
}
}
return $written;
}
/** 把一条(或几条)名片分享到别人的名册里。返回真的新增了几条。 */
public static function share(int $familyId, int $toOwnerId, array $personIds, string $source = 'shared'): int
{
$n = 0;
foreach ($personIds as $pid) {
if (self::ensure($familyId, $toOwnerId, (int)$pid, null, $source)) {
$n++;
}
}
return $n;
}
/** 从我的名册里移掉一个人(只是我不再看到他,人还在名册里、还在家谱上) */
public static function remove(int $familyId, int $ownerId, int $personId): bool
{
$st = Database::get()->prepare(
'DELETE FROM person_roster WHERE family_id = ? AND owner_id = ? AND person_id = ?'
);
$st->execute([$familyId, $ownerId, $personId]);
return $st->rowCount() > 0;
}
/**
* 系统里已经有的人 —— 但不在我的名册里。
*
* 「添加成员」的第二个入口就靠它:同一个人不该被录两遍
* (录两遍会变成两条 persons,账号、亲属关系、生日全都各存一份)。
*
* @return array 每行含 id/name/gender/avatar + 已有账号信息(好让人认得出是谁)
*/
public static function pickable(int $familyId, int $ownerId, string $kind = 'member'): array
{
$st = Database::get()->prepare(
'SELECT p.id, p.name, p.gender, p.avatar, p.is_placeholder,
a.role AS account_role, a.status AS account_status,
n.nickname AS my_nickname
FROM persons p
LEFT JOIN accounts a ON a.person_id = p.id
LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ?
WHERE p.family_id = ? AND p.kind = ?
AND p.id NOT IN (SELECT person_id FROM person_roster WHERE owner_id = ? AND family_id = ?)
ORDER BY p.id ASC'
);
$st->execute([$ownerId, $familyId, $kind, $ownerId, $familyId]);
return $st->fetchAll();
}
/**
* 结构升级专用:把老库「人人在自己名册里都能看到全家」的现状原样搬过来。
*
* ⚠️ 这一步必须在**删 persons.group_id 之前**跑完,而且**不能容错**:
* 它是删列的唯一前提,半途失败还继续删 = 直接丢数据(见 Schema::ensure)。
*
* 谁的名册要补:**有账号的人**(没账号的人永远登不进来,给他补名册只是一堆死数据)。
* 补谁进去:家庭全部成员(kind='member')。这正是老代码 `Person::all(...)` 的范围,
* 所以升级前后每个人看到的名册**一模一样**,不会有人觉得"人丢了"。
* 老库里的 group_id / sort_order 也一并搬——分类语义从"人的属性"变成"我和他的关系",
* 但**落在每个人名册里的值不变**,所以看起来完全一样。
*
* 幂等:靠 uk_roster_owner_person 唯一键 + INSERT IGNORE,重复跑不会插重。
*/
public static function seedFromPersons(\PDO $db): void
{
$hasGroup = self::hasColumn($db, 'persons', 'group_id');
$hasSort = self::hasColumn($db, 'persons', 'sort_order');
$oldGroup = $hasGroup ? 'p.group_id' : 'NULL';
$oldSort = $hasSort ? 'p.sort_order' : 'p.id';
foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) {
$fid = (int)$fam['id'];
$members = $db->prepare(
"SELECT p.id, {$oldGroup} AS g, {$oldSort} AS s
FROM persons p
WHERE p.family_id = ? AND p.kind = 'member'
ORDER BY p.id ASC"
);
$members->execute([$fid]);
$rows = $members->fetchAll();
if (!$rows) {
continue;
}
// 有账号的人 = 可能登进来的人
$owners = $db->prepare(
'SELECT a.person_id FROM accounts a
JOIN persons p ON p.id = a.person_id
WHERE p.family_id = ?'
);
$owners->execute([$fid]);
// ⚠️ 必须在这里一次取完:PDOStatement::fetchAll() 取的是"剩余行",
// 放进下面的循环里调第二次就会是空数组 —— 于是第一个人之后
// 谁的名册都补不上,而且不报任何错(线上表现是"升级完只有我自己的名册有人")。
$ownerIds = array_map(static fn($o) => (int)$o['person_id'], $owners->fetchAll());
$ins = $db->prepare(
'INSERT IGNORE INTO person_roster
(family_id, owner_id, person_id, group_id, sort_order, source)
VALUES (?, ?, ?, ?, ?, ?)'
);
$seq = 0;
foreach ($rows as $r) {
$seq += 10;
$pid = (int)$r['id'];
$gid = ((int)($r['g'] ?? 0)) ?: null;
// 顺序优先沿用老库的 sort_order;重号了就靠位次兜底,保证结果稳定
$sort = (int)($r['s'] ?? 0);
if ($sort <= 0) {
$sort = $seq;
}
foreach ($ownerIds as $oid) {
$ins->execute([$fid, $oid, $pid, $gid, $sort, 'seeded']);
}
}
}
}
/** 判断某列是否存在(升级中途用,Schema 的私有方法不便跨类调用) */
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;
}
}
+149 -26
View File
@@ -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}`");
}
}
}