按用户十点反馈逐条落地(v5,Schema::VERSION = 5): - 名册改成每人一份:新表 person_roster(owner_id, person_id, group_id, sort_order, source)。默认只看「我添加的 + 别人分享给我的」; source='seeded' 是升级时从老 persons.group_id 播进来的(先播数据再删列)。 - 排序不再手填:名册页直接拖动卡片(整版提交,一次 applyLayout 落库), 无 JS 时卡片上的「调整分组」表单照旧可用。 - 分组分两层:管理员在「系统设置」维护公共分组(owner_id=0), 用户在「个人设置」加自己的分组;显示顺序各人各定、互不影响。 - 名册可共享:把名片分享给别的用户(people/share);添加成员时若系统里 已有这个人,可直接复用(people/pick),不必重复录入。 - 「编辑成员」与「改我自己的资料」合并为同一张表(templates/people/form.php), 两个入口 href 完全相同,h1 按改谁切换文案;个人设置页只留 资料入口 + 称呼 + 我的分组三块,删除 templates/settings/_profile.php。 - 系统设置收进头像下拉,顶栏只剩 首页/家谱/名册。 - 裁剪器加缩放(滑块 1-4x、Ctrl+滚轮、还原):缩放只改视图大小, 落库的归一化矩形保持不变。 - 权限:管理员可以改任何人的权限(含自己),只拦「最后一位管理员」被降级。 - 去掉首页「我的一句话」后的链接、家谱说明段、以及「还没归到任何分组」提示。 迁移安全:数据回填(PersonRoster::seedFromPersons)先于删列执行、且不进 tryStep 容错;失败步骤记入 settings.schema_upgrade_warning 并在系统设置页提示。 线上已验证:schema_upgrade_warning 为空,person_roster/person_groups.owner_id 就位,persons.group_id / sort_order 已删。 验证:php -l 48 文件;本地 e2e 277/0、老库升级 81/0、桌面几何 108/0、 移动端几何 180/0、交互 96/0;反向验证 38 条注入 + 2 条缩放注入全部命中; 部署静态断言 53/0,站点自检 7/7(--resolve 锁本机走 443,探针真的进 PHP)。
312 lines
13 KiB
PHP
312 lines
13 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)
|
||
* 除此之外一概看不见。所以「添加成员」时若这个人系统里已经有了,
|
||
* 要引导用户走「从家里已有的人里挑」(见 pickable()),而不是又录一遍。
|
||
*
|
||
* ⚠️ 与 `persons.kind` 的关系:roster 只管"看不看得见",
|
||
* 家谱(Kinship)仍然按全家庭的 persons + relations 推导 ——
|
||
* 名册是通讯录,家谱是血缘图,两者故意的不是一回事。
|
||
*/
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App;
|
||
|
||
final class PersonRoster
|
||
{
|
||
/** 名册里一条记录的来源 */
|
||
public const SOURCES = ['self', 'shared', 'seeded'];
|
||
|
||
/**
|
||
* 把某人加进某人的名册(幂等:已经在里面就什么都不做)。
|
||
*
|
||
* @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;
|
||
}
|
||
|
||
/** 从我的名册里移掉一个人(只是我不再看到他,人还在名册里、还在家谱上) */
|
||
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;
|
||
}
|
||
}
|