Files
FamilyBook/app/Relations.php
T
laoyang 1669ab48d0 五项家庭化改造:以个人为中心 · 家谱 · 三种角色 · 附件存储可配置 · 独立页样式
1. 独立页(登录/安装/错误)套上 HTML 骨架 —— 修「首页为什么没有样式」:
   新增 templates/layout-bare.php,View::render 对独立页也走完整 <head> + CSS。
2. 以个人为中心:Kinship::label() 现算称呼、Auth::homeTitle() 出「X的家书」;
   同一批人在不同登录者眼里自动变成公公婆婆 / 岳父岳母(不存一次性称谓)。
3. 附件存储可配置:Storage 门面(local / 阿里云 OSS),库里只存相对键,
   云端失败自动回退本地;设置页可选驱动并填 OSS 参数(照 MovieLog 的方案)。
4. 三种角色:admin / member / guest,访客只读、写操作一律 403;
   accounts.role 枚举扩容,老库靠 Schema::ensure() 自动升级。
5. 家谱:只存相邻两代的边(parent / spouse),其余称呼全部推导 ——
   兄弟姐妹不存边(共同父母 + birth_rank 排行推长子/次女、哥哥/妹妹),
   认不清的人用虚拟人物 is_placeholder 占位,关系天然双向。

顺带修掉本地 e2e 挖出的 8 个线上级 bug(性别写死、长幼方向反、关系方向静默写反、
birth_rank 不从表单取回、隐式可空参数致响应头失效、无文件字段被误判为上传失败、
layout 变量撞车覆盖 $me、世代 0 显示成「下 0 代」)。

验证:kinship 单测 37/0 · HTTP e2e 81/0 · 老库升级专测 14/0 ·
反向验证 11/11(把每个修复改回旧写法,对应断言都会变红)。
2026-09-30 08:13:07 +08:00

131 lines
4.8 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
/**
* 亲属关系表的读写。
*
* 只维护两种边(详见 Kinship 的头注释):
* parent : from 是 to 的父/母
* spouse : from 与 to 是配偶(对称,只存一条)
*
* ⚠️ 血缘关系必须**防环**:如果把 A 设为 B 的父亲、又顺手把 B 设成 A 的父亲,
* 家谱展开会变成死循环。addParent() 在写库前先检查。
*/
declare(strict_types=1);
namespace App;
final class Relations
{
/** 给某人指定一位父/母 */
public static function addParent(int $familyId, int $childId, int $parentId, string $role): array
{
$role = $role === 'mother' ? 'mother' : 'father';
if ($childId === $parentId) {
return ['ok' => false, 'error' => '不能把自己设成自己的父母。'];
}
if (self::wouldCycle($familyId, $parentId, $childId)) {
return ['ok' => false, 'error' => '这样会让两个人互为祖辈,关系说不通,已拒绝。'];
}
// 同一位父母只能有一个「父亲」和一个「母亲」位
$db = Database::get();
$st = $db->prepare(
'DELETE FROM relations WHERE family_id = ? AND to_id = ? AND kind = "parent" AND from_role = ?'
);
$st->execute([$familyId, $childId, $role]);
$st = $db->prepare(
'INSERT INTO relations (family_id, from_id, to_id, kind, from_role) VALUES (?,?,?,"parent",?)'
);
$st->execute([$familyId, $parentId, $childId, $role]);
return ['ok' => true, 'error' => ''];
}
/** 互为配偶(对称,只存一条;若已存在反向的记录则视为重复) */
public static function addSpouse(int $familyId, int $a, int $b): array
{
if ($a === $b) {
return ['ok' => false, 'error' => '不能把自己设成自己的配偶。'];
}
$db = Database::get();
$st = $db->prepare(
'SELECT id FROM relations
WHERE family_id = ? AND kind = "spouse"
AND ((from_id = ? AND to_id = ?) OR (from_id = ? AND to_id = ?))'
);
$st->execute([$familyId, $a, $b, $b, $a]);
if ($st->fetchColumn()) {
return ['ok' => true, 'error' => ''];
}
$st = $db->prepare(
'INSERT INTO relations (family_id, from_id, to_id, kind) VALUES (?,?,?,"spouse")'
);
$st->execute([$familyId, $a, $b]);
return ['ok' => true, 'error' => ''];
}
/** 解除某条关系(按 id) */
public static function remove(int $familyId, int $id): void
{
Database::get()->prepare('DELETE FROM relations WHERE id = ? AND family_id = ?')
->execute([$id, $familyId]);
}
/** 解除两人之间的某种关系 */
public static function detach(int $familyId, int $personId, string $kind, string $role = ''): void
{
$sql = 'DELETE FROM relations WHERE family_id = ? AND to_id = ? AND kind = ? AND from_role = ?';
Database::get()->prepare($sql)->execute([$familyId, $personId, $kind, $role]);
}
/** 某人身上挂着的全部关系(详情页编辑用) */
public static function forPerson(int $familyId, int $personId): array
{
$st = Database::get()->prepare(
'SELECT r.*,
pf.name AS from_name, pt.name AS to_name
FROM relations r
JOIN persons pf ON pf.id = r.from_id
JOIN persons pt ON pt.id = r.to_id
WHERE r.family_id = ? AND (r.from_id = ? OR r.to_id = ?)
ORDER BY r.kind, r.id'
);
$st->execute([$familyId, $personId, $personId]);
return $st->fetchAll();
}
/**
* 防环:若把 $maybeAncestor 挂到 $node 之上,会不会绕回自己?
* 做法是顺着 parent 边从 $maybeAncestor 往上走,看能否走到 $node。
*/
private static function wouldCycle(int $familyId, int $maybeAncestor, int $node): bool
{
$st = Database::get()->prepare(
'SELECT from_id FROM relations WHERE family_id = ? AND to_id = ? AND kind = "parent"'
);
$stack = [$maybeAncestor];
$seen = [];
while ($stack) {
$cur = array_pop($stack);
if ($cur === $node) {
return true;
}
if (isset($seen[$cur])) {
continue;
}
$seen[$cur] = true;
$st->execute([$familyId, $cur]);
foreach ($st->fetchAll(\PDO::FETCH_COLUMN) as $up) {
$stack[] = (int)$up;
}
}
return false;
}
/** 全家的关系条数(设置页/统计用) */
public static function count(int $familyId): int
{
$st = Database::get()->prepare('SELECT COUNT(*) FROM relations WHERE family_id = ?');
$st->execute([$familyId]);
return (int)$st->fetchColumn();
}
}