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 **列表**(`[2,3,4,5]`),判断"看不看得见"时**必须用 `in_array`**。 * * 🔴🔴 v50(用户 2026-10-14)——**这里踩过一个真 bug**: * 本方法返回的是**值列表**(键是 0,1,2,3),而 index.php 的 people/tags 里 * 写的是 `isset(PersonRoster::visibleIds(...)[$pid])` —— * **把值列表当成了 id → 值的映射**。只有当 `$pid` 恰好等于数组下标时才为真: * · 名册里人少时(下标远小于 pid)⇒ 任何 pid 都判"不在名册"; * · 一般情况下 ⇒ 只有"第 pid+1 个"那个人碰巧对得上。 * 症状正是用户报的:「我搜索出来一个人、给他打标签,它提示这个人不在我的名册里, * 其实他是在我的名册里的。」 * ⇒ 该处已改用 `in_array($pid, …, true)`(与 index.php 另一处既有写法一致)。 * * ⚠️⚠️ 陷阱提醒:`PersonGroup::visibleIds()` 返回的是**键=id、值=true 的集合** * (设计给 `isset($set[$id])` 用)。两个类**同名不同形状** —— * 以后看到 `visibleIds` 先确认是哪个类,别把两种用法混着抄。 * (本方法**刻意不改形状**:另一处调用 `in_array` 已是正确用法, * 改形状会静默弄坏它。要改成集合就得两处一起动,收益不值这个风险。) */ 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 实际写入的条数 */ /** * 保存名册布局(拖动排序/改栏)。返回真正写了几条。 * * 🔴 v28 用户第 5 条:名册**平铺**之后不再有"改栏"这回事(一个人挂多个标签, * 分栏会让他在多栏重复出现)。前端会把整个平铺列表当成"唯一一栏"(`g=0`)报上来。 * ⚠️ 这时**绝对不能写 group_id**: * · `g=0` 按下面那条规则会被落成 NULL —— 拖一次就把所有人原有的分组清空了; * · 而 `person_roster.group_id` 正是"旧分组→同名标签"迁移的数据来源 * (见 Schema 的迁移步骤),也不能被随手抹掉; * · 它同时还是 vCard CATEGORIES 的读点(导出通讯录要用)。 * ⇒ 判据:`g` 缺省(前端没报这一栏的组号)时**只更新 sort_order**, * 完全不碰 group_id。这样"拖动只改顺序"这个新语义是结构上保证的, * 而不是靠调用方记得别传错值。 */ 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(); $upSort = $db->prepare( 'UPDATE person_roster SET sort_order = ? WHERE family_id = ? AND owner_id = ? AND person_id = ?' ); $upBoth = $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; } // 只有**显式带了组号**的栏才去动 group_id(见方法注释) $hasG = array_key_exists('g', $col); $gid = (int)($col['g'] ?? 0); if ($hasG && $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; if ($hasG) { $upBoth->execute([$gid ?: null, $sort, $familyId, $ownerId, $pid]); } else { $upSort->execute([$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 也一并搬——分类语义从"人的属性"变成"我和他的关系", * 但**落在每个人名册里的值不变**,所以看起来完全一样。 * * 🔴🔴 **自守卫:只在"还没迁移过"的库上跑**(v24 第 25 批 用户第 4 条修的真 bug)。 * * 用户原话:「名册中,有一些人,我从名册中移出去了,结果过一段时间就又回来了, * 不知道什么原因,需要查一下。」 * * 根因就在这里:本方法原先是**无条件跑**的,而它在**每一次** `Schema::ensure()` 里 * 都被调用一次 —— `ensure()` 的触发判据是「Schema.php 源码指纹变了」(见 * Schema::fingerprint 的说明),也就是说**任何一次改 Schema 的部署之后, * 所有已迁移完成的老库都会再被重灌一遍全家的成员**。 * 于是用户「从我的名册移走」的那个人,在一段时间后(下一次部署 / 下一次改结构) * **自己回来了** —— 而且因为 INSERT IGNORE 只看唯一键,回来的那条 source 还是 * 'seeded',看不出是刚被重灌的。 * * ⚠️ 线上实测证据(2026-10-07):线上运行一次本方法的**前后对比**是 * `total 1621 → 1622`(真的又插回来一条),而此前 members=811、 * 名册总行数 1621 > 811×2 —— 差值正是历次重灌留下的。 * * 修法:**判据用"persons 上那两个旧列还在不在"** —— * `group_id` / `sort_order` 是 **v5 迁移的输入**,迁移一跑完它们就被 * `dropColumnIfExists` 删掉了(见 Schema::ensure 里紧跟着的两行)。 * 所以「两列都不在」= 这个库早就迁移过了 = 本方法无事可做。 * 这样它天然只在**真正需要它的那一次**(老库首次升级)生效, * 不需要任何哨兵键,也不会因为"下次改 Schema"再跑一遍。 * * 幂等:靠 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'); // 🔴 自守卫:旧列都没了 = 已经迁移过 = 绝不能重灌(用户移走的人会回来) if (!$hasGroup && !$hasSort) { return; } $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; } }