Files
FamilyBook/app/PersonRoster.php
T
laoyang a5e559b2b3 v25 第 25 批(9 条):名册分组不刷页 / 纪念日多人 / 首页等高 / 移出不回流 /
名册点击看详情拖动才排序 / 任务复选框与状态 / 财务标签多选 / 微信读书同步与统计 /
导航线上数据与图标

用户本轮 9 条需求(原话见 templates/ 与 app/ 内的逐条注释):

1. 名册:列表里给某人分组改为 AJAX 就地挪卡片(不再整页刷新)。
2. 纪念日:关联人改**多选**(复用 _person_picker,字段 person_ids[]);
   自建纪念日的按钮与其它模块统一(改=蓝 .btn、删=黄 .btn-warn);
   列表新增「关联人」列并给人名前加头像。
3. 首页:日程/任务两块**完全等高**(align-items:stretch + 固定总高 + flex 子项
   min-height:0);修「0 件待办」——数字与右侧列表同源(原来各算各的)。
4. 🔴 名册里被移出的人过一阵又回来:根因是 PersonRoster::seedFromPersons()
   **每次 Schema::ensure() 都无条件重灌**(线上实测每跑一次 +1 行)。
   修:给它加自守卫(只有在 persons 还留着 group_id/sort_order 的**老库**才跑)。
5. 名册:点击=看详情、**只有拖 ⠿ 手柄**才是排序(原来整行既能点又能拖,语义混乱)。
6. 任务:所有任务前加复选框,任何位置点勾即完成;
   状态语义照业界整理为 To Do / Doing / Done 并写出说明(用户要求参考别人的做法)。
7. 财务:类别标签改**多选非互斥**(再点一次取消该类别),上下两图 + 图例 + 表格
   全部按同一个集合联动;高亮条刻意不给「合计」(多选下各类金额不能相加)。
8. 微信读书:新增**懒同步**(访问时顺手做,6h TTL,只补空字段不覆盖用户填的)、
   封面图**拉下来存本站存储**(原来只存外链 → 看不到)、接官方 /readdata/detail 统计。
9. 导航:线上数据只搬了一半(5 板块 29 站 → 7 板块 67 站 + 67 图标)、
   展示页头像不显示 / 搜索按钮错位 / 点登录 404。三个真根因:
   · 相对地址在 /nav/ 下全 404 → 新增 Nav::abs();
   · 搜索容器丢了 id="search"(CSS 是 #search button.submit 绝对定位);
   · Nav::iconKey() 与 SimpleNav 的 icon_key() **算法不一致**(少一次 md5 归一化)
     → 搬来的图标文件名对不上;且 uploads/ 整目录 Require all denied。
   新增两个**免登录公开出口** nav_icon.php / nav_avatar.php。

顺带修掉一个「数据损坏级」的真 bug(做第 4 条线上反证时发现的,之前一直没人注意):
app/Region.php 三处用中文标点当 trim/ltrim 的 charlist,而 PHP 的 charlist 是
**按字节**匹配的 —— 「。」= E3 80 **82** → 字节 82 进了可剥集合 →
「层」= E5 B1 **82** 被切掉尾字节 → 非法 UTF-8 → backfillAddress 的 UPDATE 报
`1366 Incorrect string value` → **地址拆分升级从上线起一直在失败被跳过**(每请求报一次)。
改成字符级 preg_replace('/…/u');线上实测 ensure() 现在无报错、待拆行数真的在下降。

验证战绩:
- e2e 1289/0(新增 Region 字节级用例等)
- 真机探针:选人 19/0 · 首页/导航 24/0 · 导航模块 34/0 · b18 暂存+确定 10/0 ·
  第23批 52/0
- 桌面几何 428/0 · 移动端几何 680/0 · 交互 92/0 · 窄屏 24/0
- 老库升级 153/0(含 anniversary_people 迁移 + 「再跑 ensure() 名册行数不变」)
- 部署静态断言 180 OK / 0 FAIL · 反向验证 3/3 + Region 三变体各被两条断言同时抓住
- 线上 26 个文件与本地**逐字节一致**;展示页 200、图标 66/66 200、登录 200、
  越权/不存在均 404、签发短语后 ensure() 不再报错。
2026-10-07 11:35:31 +08:00

423 lines
20 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 也一并搬——分类语义从"人的属性"变成"我和他的关系",
* 但**落在每个人名册里的值不变**,所以看起来完全一样。
*
* 🔴🔴 **自守卫:只在"还没迁移过"的库上跑**(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;
}
}