名册每人一份 · 分组两层 · 拖动排序 · 表单合一 · 裁剪可缩放
按用户十点反馈逐条落地(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:
+24
-1
@@ -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
@@ -68,7 +68,7 @@ final class Auth
|
||||
|
||||
/**
|
||||
* 首页标题 —— 「以我为中心」最直接的一处落点。
|
||||
* 登录后看到的不是「家书」,而是「老杨的家书」。
|
||||
* 登录后看到的不是「家书」,而是「<我的名字>的家书」。
|
||||
*/
|
||||
public static function homeTitle(): string
|
||||
{
|
||||
|
||||
+1
-1
@@ -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
@@ -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
@@ -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'];
|
||||
|
||||
@@ -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
@@ -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