用户 2026-10-15 七条。
① 旅程行程经 CalDAV 同步后「结束时间 = 开始时间」
**真根因**(app/Calendar.php 的 exportRows()):对**派生条目**(观影/旅程)写死
'ends_at' => null,而 Ics::vevent() 遇到空结束会退化成「DTEND = DTSTART」
⇒ 手机上每条行程的结束时间都等于开始。
**附带第二个 bug**:分组键含日期(kind-ref_id-date),而 month() 把**跨天条目逐天**
铺出来 ⇒ 跨天节点被拆成 N 条(UID 相同),resources() 按 UID 去重后**只留第一天**,
而第一天那条没有结束时刻 ⇒ 又回到 DTEND=DTSTART。
修法:键改成 `kind-ref_id`,先收集同节点的各天,再用**首末天**拼一条真实跨度
(末天那条才带 end_time)。实测单日 DTEND=12:30、跨天 DTEND=第3天11:00,
每节点恰好 1 条 VEVENT。
⚠️ 副作用(已处理):v31 DAV 去重的反向验证原来靠「跨天节点逐天出、UID 全同」造重复,
本修把那条素材**修没了** ⇒ 给夹具换成「同一份 .ics 导两次」(两条 UID 相同的
自定义日程),守卫仍能被证伪(见 _fb_dav_diag.py 与 pitfalls 的同族条目)。
② 笔记详情页标题是"看笔记"而不是笔记名
阅读态 h1 改用笔记自己的 $noteTitle;**编辑态仍用「改笔记/写笔记」**
(编辑时标题就在下面第一个输入框,两处重名看着像重复)。浏览器 <title> 同步。
③ 加段落/加图片要在**输入框下面**
note_editor.js 的 makeBlock() 里每块下方加一排 .note-block-add(+段落/+图片),
点了插在**这一块后面**;顶部那组**保留**(用户明确"上面和下面都保留")。
⚠️ 挂在 body 里而不是 wrap 上 —— .note-block 是 display:flex 横排,
挂 wrap 会变成"输入框右边的第三列",不是"下面"。
⚠️ 用 data-add-below(不是 data-add):顶部是"追加到末尾"、块内是"插在本块后"。
④ 笔记详情页「逐段就地编辑」(分块多时不用跳编辑页找那一块)
模板给每块包 .note-read-item[data-block][data-kind][data-raw] + 右上角「编辑」按钮;
新增 assets/js/note_inline.js(按需加载):划过出现 → 点编辑就地变 textarea →
保存 POST notes/block → 只换这一块。
服务端 Note::updateBlock():**按块主键改一行**(不是整组替换)——
⚠️ 整组替换会换掉每个块的 id(前端手里的 data-block 当场失效)+ 并发互相覆盖;
⚠️ SQL 必须带 `AND note_id = ?`(块 id 全局自增,少了它就能改别人笔记里的块)。
权限与整条编辑同一把尺(find() + canWrite())。
⑤ 删掉「从已有家里人中挑选」,改成「新增/导入时自动匹配」
新增 Person::matchExisting():手机号(有 phone_hash,一条 SQL 精确命中)→
身份证(无哈希,整表解密比)→ 姓名(**先记下不立刻返回**,等精确命中看完再退到它)。
people/create:校验通过后先 matchExisting,命中就在**同一张表单里**出确认条
(#personConfirm)。⚠️ **不做成单独确认页**:单独页要把整张表(含工作经历/补充联系
方式这些**数组字段**)原样回传,漏一类就静默丢数据;同表单里字段天然全在。
⚠️ 判"是不是第一次提交"用 array_key_exists('confirm_existing')(0=不是、1=是、无=首次)。
导入那条路的缺口(本轮补):ContactImporter 命中已有的人时只补字段、**不挂进导入者的
名册** ⇒ "文件里有他、系统里也有他,但导完我的名册里看不到他"。已在命中分支加
PersonRoster::ensure(..., $meId, ...)。
顺手加 PersonRoster::has()(避免调用方又踩"visibleIds 是值列表不是集合"那个坑)。
⑥ 名册页只剩「添加成员 + 设置」;分组搬到名册设置
templates/people/_settings_menu.php(新):☰ 下拉两行 = 导入联系人 / 名册分组。
templates/people/settings.php(新,路由 people/settings):require 从个人设置**整块搬出来**
的 templates/_person_groups.php。
⚠️ 是**真搬**不是复制(两份一模一样的分组界面,改一处忘一处是必然的);
⚠️ 片段变量一律加 pg 前缀 —— 模板片段与宿主 require 是**共享作用域**,
用 $groups 这种通名会覆盖宿主同名变量(本轮真踩到,已改)。
⚠️ 表单 action 仍指 settings/groups/*(一个字段没改)。
⑦ 个人设置:模块设置整合成一张表
原来是**三张卡三个表单**(首页模块 / 导航模块 / 主题色),同一串模块名出现两遍。
现在一张表五列:顺序 | 模块 | 导航 | 首页 | 颜色,一次保存。
新增 settings/modules_save 路由:**底层存储一个没改**(仍是 nav_modules /
home_modules / module_colors),只是把三个 save 依次调一遍 ⇒ 老数据天然兼容。
⚠️ 权限**没有**因为"合并"被放宽:颜色仍是家庭一份、只有管理员能改
(非管理员那列是只读色块;提交时服务端静默忽略 c[])。
窄屏:表格 min-width:560px + 外层 overflow-x:auto(375px 实测横向溢出 0)。
验证(新增 5 套件 + 更新若干旧断言):
· _fb_v52_ics.py(3)/ _fb_v52_note.py(19)/ _fb_v52_roster.py(12)/ _fb_v52_mods.py(13)
· _b52_falsify.py(**12 注入 / 31 断言全抓**)
· _fb_import_test.py 补 ⑯(命中的人也要回到导入者名册);_fb_dav_diag.py 换去重素材
· e2e 1516/0;_fb_batch_e.py / _fb_e2e.py / familybook_deploy.sh 里随 #6/#7 搬家或改形的
旧断言(分组在个人设置、模块行 li→tr、show_present、RRULE 前缀)意图一字不改
· 全流水线 **PUSH_RC=0**、部署 0 FAIL(含"第52批形态");线上 16 文件 md5 逐字节一致
⚠️ 工程纪律(已写进 pitfalls.md / 工作日志):
· 探针自起的 PHP 服务**一律随机空闲端口**(写死 + "连上就复用" 会静默测到别的目录/库);
· **同一工具链目录 + 同一库名不支持两个会话并发** —— 本轮最后一个多小时的"诡异全红"
全是**另一个会话**在同时跑 _fb_e2e.py、共享 fb_test 与 _fb_site 造成的;
· 并发下**连 git stash 都不能用**(会把别人的半成品一起藏起来),基线对照用 git worktree;
· 修好业务可能让"守着它的反向验证"空转(素材被修没了)⇒ 换一处等价素材,别删守卫;
· 断言盯**形态**而非具体数值(分栏那次写死 50% 在别的批次数据上假红)。
482 lines
23 KiB
PHP
482 lines
23 KiB
PHP
<?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 **列表**(`[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));
|
||
}
|
||
|
||
/**
|
||
* 这个人在不在**我的**名册里(v52 新增)。
|
||
*
|
||
* ⚠️ 为什么另起一个方法而不是让调用方写 `in_array($pid, visibleIds(...), true)`:
|
||
* 本项目在 v50 栽过一次「同名方法不同返回形状」——
|
||
* `visibleIds()` 返回的是**值列表**(id 列表),不是 id=>值 的集合,
|
||
* 调用方很容易顺手写成 `isset($ids[$pid])`(那会静默判错,见 pitfalls.md)。
|
||
* `has()` 的返回值只有 true/false 一种形状,名字也不与别处撞 ——
|
||
* "问在不在"就用它,别再去碰 `visibleIds` 的形状。
|
||
*/
|
||
public static function has(int $familyId, int $ownerId, int $personId): bool
|
||
{
|
||
if ($personId <= 0) {
|
||
return false;
|
||
}
|
||
return in_array($personId, self::visibleIds($familyId, $ownerId), true);
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|