Files
FamilyBook/app/PersonRoster.php
T
laoyang 6852b4ee2c v28 第 28 批(12 条):名册分组改标签 / 资源合并为两按钮 / 首页定位与主题色 / 笔记富文本双模式
其中 4 条是**上批遗漏的真 bug**(1/4/8/9),根因已逐条定位:

1. 首页日程没定位到红线 —— 真根因:刻度区间**不含当前时刻** → $showNow=false
   → 红线**整个不渲染**,而定位脚本用同一个越界比例算目标,被 clamp 到底部。
   修:把当前小时并进区间端点(现在必然落在区间内)。
2. 「看整月」→「更多日程」+ 日程主题色;3. 「手头在做」→「近期任务」、
   「任务看板」→「全部任务」+ 任务主题色(注入 --hd-c,不再写死白底)。
4. 任务复选框提示还在 —— 真根因:**JS 在点击后**给状态徽标挂 title
   (`badge.setAttribute('title',…)`),上批只去了静态渲染,静态断言永远抓不到。
5. 名册「分组」→「标签」(用户:一个人可能既是小学同学又是初中同学,没法分组):
   新增 person_tags(每人一份、一人多标签),名册改**平铺 + 顶部标签筛选**;
   🔴 迁移用**一次性哨兵**(第一版用 NOT EXISTS,实测被"用户清空标签"绕过 → 旧分组名被灌回)。
6. 名册卡片按钮加语义底色。
7. 资源四按钮 → **编辑 / 删除**两颗:三块内容进一个带页签的弹框(信息/附件/共享)。
8. 财务多选数字不变 —— 真根因:前端发 `kinds=a,b`(逗号串),服务端 `(array)` 收成
   单元素 → 求交为空 → **静默回落整本账**。修:两种形状都收。
9. 微信读书偶发很慢 —— 真根因:syncNow 扫 60 本 × 每本 1~3 次网关调用(单次超时 20s),
   全同步阻塞在列表页请求里。修:总时长预算 + 单次超时 8s + 自动/手动分批。
10. 新增全局可见的联想接口库(6 个实测可用来源)+ 三种新解析类型。
11. 导航右上角「设置」:默认进"添加网址 + 全部网址",四类设置收进 ?set=1 视图。
12. 笔记:段落/清单支持 Markdown(自研渲染器,**不允许原始 HTML**),
    实时预览走服务端(前后端只一份规则);底部附件上传;清单多级;
    列表改**定尺寸卡片网格**(溢出显示摘要、图片缩放下方);按用户要求**去掉标签侧栏**。
2026-10-08 02:20:19 +08:00

446 lines
21 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 实际写入的条数
*/
/**
* 保存名册布局(拖动排序/改栏)。返回真正写了几条。
*
* 🔴 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;
}
}