Files
FamilyBook/app/PersonRoster.php
T
laoyang 809443d429 v7:家谱树与名册三处口径统一(树上的人自动互相可见)
用户反馈:我把一个人设成配偶、又设了我们的孩子,配偶登录后首页能看到
孩子、家谱也能看到,名册里却看不到——没分享的人却在家谱上看得到,
口径打架。

口径由用户拍板:**以每个人为中心上下三代那棵家谱树(= 首页「我的家人」
同一棵)里的人,自动进他名册(source=kin,默认归公共「家人」组);
树外的人三处都不可见。**

- PersonRoster::syncKinForFamily():整树同步(补缺、撤多余;已存在的
  self/shared/seeded 行绝不碰,用户改过的分组/顺序保留)。幂等。
- 同步挂在事件上:建边 / 解边 / 开通账号 / 结构升级回填。
- Relations::addParent/addSpouse 建边后同步;remove/detach 解边后同步。
- 名册不许单独移走 kin 来源的人(服务端拦截 + 人话提示 + 卡片隐藏
  移走按钮改提示语):他在首页/家谱照样看得见,单藏名册只会回到口径
  打架;想不再看到他,解除关系即自动移出。
- 名册卡片给 kin 来源标「家谱」徽标(来历可辨)。
- Schema v7:widenRosterSourceEnum(⚠️ CREATE TABLE IF NOT EXISTS 不会
  更新已存在表的枚举,v6 老库不扩宽则 strict 模式下插 kin 整条被拒、
  且被容错吞掉——e2e 的 v6 旧库当场抓出)+ 按已有关系回填 kin 条目。

验证:e2e 309/0、升级专测 82/0、部署静态空跑 78/0、桌面几何 0 失败、
交互 97/0、移动几何 180/0;线上探针 ver=7、enum 含 kin、无升级告警。
2026-09-30 16:12:28 +08:00

395 lines
18 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 每人一份名册。
*
* 老模型是「一份全家共用的名册」:`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)
* · **家谱树上的人**(source=kin,2026-09-30 v7 起)——
* 以我为中心上下三代那棵树(和首页「我的家人」同一棵)里的人,自动进我名册。
* 否则会撞出这种拧巴:家谱上看得见孩子、首页也有他,名册里却找不到
* (2026-09-30 用户反馈:「我还没有分享给另外一个人,他应该都看不到才对吧」——
* 反过来说,只要关系建了,三处口径就必须一致:要都看得见)。
* 除此之外一概看不见。所以「添加成员」时若这个人系统里已经有了,
* 要引导用户走「从家里已有的人里挑」(见 pickable()),而不是又录一遍。
*
* ⚠️ 与 `persons.kind` 的关系:roster 只管"看不看得见",
* 家谱(Kinship)仍然按全家庭的 persons + relations 推导 ——
* 名册是通讯录,家谱是血缘图,两者故意的不是一回事。
* v7 之后两者的口径在「直接相邻的人」这一点上对齐了:有边 = 名册里也有。
*/
declare(strict_types=1);
namespace App;
final class PersonRoster
{
/** 名册里一条记录的来源 */
public const SOURCES = ['self', 'shared', 'seeded', 'kin'];
/**
* 把某人加进某人的名册(幂等:已经在里面就什么都不做)。
*
* @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;
}
/**
* 按家谱把全家的 kin 名册条目对齐一遍(v7)。
*
* 语义与首页「我的家人」**完全同一棵树**:以每个有账号的人为中心、上下各三代,
* 树里有的人 = 他名册里自动有(source=kin,默认归公共「家人」组);
* 树里没有但名册里还挂着 kin 条目的 = 撤掉。这样三处口径(首页/家谱/名册)
* 永远是同一个集合 —— 2026-09-30 用户反馈的问题(配偶在家谱看得到孩子、
* 名册里却没有;没分享的人却能在家谱看到)就是这么来的。
*
* 为什么是「整棵树」而不是「直接相邻的人」:只记了「我是父亲」的孩子,
* 跟配偶之间没有直接边,但配偶在家谱/首页照样看得到他 —— 只同步直接相邻
* 治不了这个不一致。树的展开规则(含配偶同代等)只写一遍,在这里复用。
*
* 幂等:已存在的行不动(用户改过的分组/顺序保留),只补缺、撤多余的。
* 调用点:建边 / 解边 / 开通账号 / 结构升级回填 —— 名册口径可能变的所有时刻。
*
* @return array ['added' => int, 'dropped' => int]
*/
public static function syncKinForFamily(int $familyId): array
{
// 有账号的人才是「owner」——没账号的永远登不进来,给他同步是死数据
$st = Database::get()->prepare(
'SELECT a.person_id FROM accounts a JOIN persons p ON p.id = a.person_id
WHERE p.family_id = ?'
);
$st->execute([$familyId]);
$owners = array_map('intval', $st->fetchAll(\PDO::FETCH_COLUMN));
if (!$owners) {
return ['added' => 0, 'dropped' => 0];
}
$people = Person::map($familyId);
if (!$people) {
return ['added' => 0, 'dropped' => 0];
}
$graph = Kinship::graph($familyId);
$gid = PersonGroup::idByName($familyId, '家人');
$use = $gid > 0 ? $gid : null; // 公共「家人」被删了就落未分类,不为它再造分组
$db = Database::get();
$added = 0;
$dropped = 0;
foreach ($owners as $oid) {
$tree = Kinship::tree($graph, $oid, $people, 3, 3);
$want = [];
foreach (array_keys($tree['nodes']) as $nid) {
$nid = (int)$nid;
if ($nid !== $oid && isset($people[$nid])) {
$want[] = $nid;
}
}
foreach ($want as $pid) {
if (self::ensure($familyId, $oid, $pid, $use, 'kin')) {
$added++;
}
}
// 撤掉「树里已经没有、名册还挂着」的 kin 条目
//(只动 source='kin':self/shared/seeded 是用户自己的名册,绝不碰)
if ($want) {
$in = implode(',', $want);
$del = $db->prepare(
"DELETE FROM person_roster
WHERE family_id = ? AND owner_id = ? AND source = 'kin'
AND person_id NOT IN ({$in})"
);
$del->execute([$familyId, $oid]);
} else {
$del = $db->prepare(
"DELETE FROM person_roster
WHERE family_id = ? AND owner_id = ? AND source = 'kin'"
);
$del->execute([$familyId, $oid]);
}
$dropped += $del->rowCount();
}
return ['added' => $added, 'dropped' => $dropped];
}
/** 从我的名册里移掉一个人(只是我不再看到他,人还在名册里、还在家谱上) */
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;
}
}