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(把每个修复改回旧写法,对应断言都会变红)。
486 lines
18 KiB
PHP
486 lines
18 KiB
PHP
<?php
|
||
/**
|
||
* 亲属关系:**只存相邻两代之间的边,其余全部推导**。
|
||
*
|
||
* 设计(这是整个家谱功能的地基,改动前请先读懂):
|
||
* - 库里只有一张 relations 表,两种边:
|
||
* parent : from 是 to 的父或母(from_role 记 father/mother)
|
||
* spouse : from 与 to 是配偶(对称,只存一条)
|
||
* - 不做「爷爷」「岳母」这种一次性称呼的存储。要知道奶奶是谁,
|
||
* 就是「我的父亲」再「我父亲的母亲」——两级边串起来。这样:
|
||
* ① 关系只有两个方向要维护,不会出现「我记了他是我爸,他那边没记我」
|
||
* ② 同一条边在不同人眼里自动呈现不同称呼
|
||
* (我的「父亲」= 我孩子的「爷爷」= 我爱人的「公公」)
|
||
* ③ 虚拟人物(is_placeholder)可以参与关系网,等弄清真实身份再改名即可
|
||
* - 兄弟姐妹**不存边**:他们共享同一对父母,由「父母的子女」反查得出。
|
||
* 排行由 persons.birth_rank 决定,用来显示长子/次女/三弟。
|
||
*/
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App;
|
||
|
||
final class Kinship
|
||
{
|
||
/** 一次把全家的边读进内存 —— 几十人的家庭,比反复查库快得多,也让推导逻辑干净 */
|
||
public static function graph(int $familyId): array
|
||
{
|
||
$parents = []; // childId => [parentId => 'father'|'mother']
|
||
$children = []; // parentId => [childId, ...]
|
||
$spouses = []; // id => [spouseId, ...]
|
||
|
||
$st = Database::get()->prepare(
|
||
'SELECT from_id, to_id, kind, from_role FROM relations WHERE family_id = ?'
|
||
);
|
||
$st->execute([$familyId]);
|
||
foreach ($st->fetchAll() as $r) {
|
||
$f = (int)$r['from_id'];
|
||
$t = (int)$r['to_id'];
|
||
if ($r['kind'] === 'parent') {
|
||
$role = $r['from_role'] === 'mother' ? 'mother' : 'father';
|
||
$parents[$t][$f] = $role;
|
||
$children[$f][] = $t;
|
||
} else {
|
||
$spouses[$f][] = $t;
|
||
$spouses[$t][] = $f;
|
||
}
|
||
}
|
||
foreach ($children as $k => $v) {
|
||
$children[$k] = array_values(array_unique($v));
|
||
}
|
||
|
||
return ['parents' => $parents, 'children' => $children, 'spouses' => $spouses];
|
||
}
|
||
|
||
/** 某人的父母(father / mother 两个键,可能缺) */
|
||
public static function parents(array $g, int $id): array
|
||
{
|
||
$out = ['father' => null, 'mother' => null];
|
||
foreach ($g['parents'][$id] ?? [] as $pid => $role) {
|
||
$out[$role] = (int)$pid;
|
||
}
|
||
return $out;
|
||
}
|
||
|
||
/** 父母的 id 列表(不含 null),判断「跟我有关系」时用 */
|
||
public static function parentIds(array $g, int $id): array
|
||
{
|
||
$out = [];
|
||
foreach (self::parents($g, $id) as $pid) {
|
||
if ($pid !== null) {
|
||
$out[] = $pid;
|
||
}
|
||
}
|
||
return $out;
|
||
}
|
||
|
||
public static function spouseIds(array $g, int $id): array
|
||
{
|
||
return $g['spouses'][$id] ?? [];
|
||
}
|
||
|
||
/** 兄弟姐妹:与「我」共享至少一位父母的人(不含自己) */
|
||
public static function siblingIds(array $g, int $id): array
|
||
{
|
||
$sib = [];
|
||
foreach ($g['parents'][$id] ?? [] as $pid => $_) {
|
||
foreach ($g['children'][$pid] ?? [] as $c) {
|
||
if ((int)$c !== $id) {
|
||
$sib[(int)$c] = true;
|
||
}
|
||
}
|
||
}
|
||
return array_keys($sib);
|
||
}
|
||
|
||
/** 子女 */
|
||
public static function childIds(array $g, int $id): array
|
||
{
|
||
return array_map('intval', $g['children'][$id] ?? []);
|
||
}
|
||
|
||
/**
|
||
* 祖父母那一级:往上走一步再取父母。
|
||
* 只认「我的父母」,因为只有相邻两代有边 —— 这正是「只存两代」的兑现处。
|
||
*/
|
||
public static function grandparents(array $g, int $id): array
|
||
{
|
||
$out = ['paternal' => ['father' => null, 'mother' => null],
|
||
'maternal' => ['father' => null, 'mother' => null]];
|
||
$p = self::parents($g, $id);
|
||
if ($p['father']) {
|
||
$out['paternal'] = self::parents($g, $p['father']);
|
||
}
|
||
if ($p['mother']) {
|
||
$out['maternal'] = self::parents($g, $p['mother']);
|
||
}
|
||
return $out;
|
||
}
|
||
|
||
/**
|
||
* 我该怎么称呼对方 —— 整个系统的「以我为中心」就落在这个函数上。
|
||
*
|
||
* 推导顺序:自己 → 配偶 → 父母/子女 → 兄弟姐妹 → 父母的兄弟姐妹 →
|
||
* 配偶的父母 → 祖父母/孙辈 → 姻亲 → 兜底「亲属」
|
||
*/
|
||
public static function label(array $g, int $me, int $other, array $people): string
|
||
{
|
||
if ($me === $other) {
|
||
return '我';
|
||
}
|
||
|
||
$gender = strtolower((string)($people[$other]['gender'] ?? 'x'));
|
||
$isMale = $gender === 'm';
|
||
$isKnown = $gender === 'm' || $gender === 'f';
|
||
|
||
// ---- 配偶 ----
|
||
if (in_array($other, self::spouseIds($g, $me), true)) {
|
||
return self::spouseLabel($g, $me, $isMale);
|
||
}
|
||
|
||
// ---- 直系:父母 / 子女 ----
|
||
$myParents = self::parents($g, $me);
|
||
foreach (['father', 'mother'] as $role) {
|
||
if ($myParents[$role] === $other) {
|
||
return $role === 'father' ? '父亲' : '母亲';
|
||
}
|
||
}
|
||
if (in_array($other, self::childIds($g, $me), true)) {
|
||
return self::rankLabel($g, $people, $me, $other, $isMale, $isKnown);
|
||
}
|
||
|
||
// ---- 兄弟姐妹(含排行)----
|
||
foreach (self::siblingIds($g, $me) as $sib) {
|
||
if ($sib === $other) {
|
||
// compareRank(我, 他) < 0 表示他的排行键更小 —— 也就是他比我年长。
|
||
// 方向很容易写反:这里比的是「他 vs 我」,不是「我 vs 他」。
|
||
$heIsOlder = self::compareRank($people, $other, $me) < 0;
|
||
if ($isKnown) {
|
||
if ($isMale) {
|
||
return $heIsOlder ? '哥哥' : '弟弟';
|
||
}
|
||
return $heIsOlder ? '姐姐' : '妹妹';
|
||
}
|
||
return $heIsOlder ? '哥哥' : '弟弟';
|
||
}
|
||
}
|
||
|
||
// ---- 父母的兄弟姐妹:伯/叔/姑/舅/姨 ----
|
||
$uncle = self::uncleLabel($g, $people, $me, $other);
|
||
if ($uncle !== null) {
|
||
return $uncle;
|
||
}
|
||
|
||
// ---- 配偶的父母:公公婆婆 / 岳父岳母 ----
|
||
foreach (self::spouseIds($g, $me) as $sp) {
|
||
$spParents = self::parents($g, $sp);
|
||
foreach (['father', 'mother'] as $role) {
|
||
if ($spParents[$role] === $other) {
|
||
$iAmMale = strtolower((string)($people[$me]['gender'] ?? 'x')) === 'm';
|
||
if ($role === 'father') {
|
||
return $iAmMale ? '岳父' : '公公';
|
||
}
|
||
return $iAmMale ? '岳母' : '婆婆';
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- 祖父母 / 外祖父母 ----
|
||
$gp = self::grandparents($g, $me);
|
||
foreach (['paternal' => '爷爷', 'maternal' => '外公'] as $side => $maleWord) {
|
||
$femaleWord = $side === 'paternal' ? '奶奶' : '外婆';
|
||
if (($gp[$side]['father'] ?? null) === $other) {
|
||
return $maleWord;
|
||
}
|
||
if (($gp[$side]['mother'] ?? null) === $other) {
|
||
return $femaleWord;
|
||
}
|
||
}
|
||
|
||
// ---- 孙辈 / 曾孙辈 ----
|
||
// 只往下走两级:孙辈(子女的子女)与曾孙辈(子女的孙辈)。
|
||
// 再往下就不必细分了,统一「后代」。
|
||
foreach (self::childIds($g, $me) as $child) {
|
||
foreach (self::childIds($g, $child) as $gc) {
|
||
if ($gc === $other) {
|
||
if (!$isKnown) {
|
||
return '孙辈';
|
||
}
|
||
return $isMale ? '孙子' : '孙女';
|
||
}
|
||
if (in_array($other, self::childIds($g, $gc), true)) {
|
||
if (!$isKnown) {
|
||
return '后代';
|
||
}
|
||
return $isMale ? '曾孙' : '曾孙女';
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- 姻亲:配偶的兄弟姐妹 / 兄弟姐妹的配偶 ----
|
||
foreach (self::spouseIds($g, $me) as $sp) {
|
||
if (in_array($other, self::siblingIds($g, $sp), true)) {
|
||
return $isKnown ? ($isMale ? '大舅子/小舅子' : '大姨子/小姨子') : '姻亲';
|
||
}
|
||
}
|
||
foreach (self::siblingIds($g, $me) as $sib) {
|
||
if (in_array($other, self::spouseIds($g, $sib), true)) {
|
||
return $isKnown ? ($isMale ? '姐夫/妹夫' : '嫂子/弟媳') : '姻亲';
|
||
}
|
||
}
|
||
|
||
// ---- 表亲堂亲:父母的兄弟姐妹的子女 ----
|
||
foreach (self::parentSiblings($g, $me) as $ps) {
|
||
if (in_array($other, self::childIds($g, $ps), true)) {
|
||
return '表亲';
|
||
}
|
||
}
|
||
|
||
return '亲属';
|
||
}
|
||
|
||
/** 配偶的称呼:按「我的性别」定,因为中文里丈夫/妻子是从自己视角说的 */
|
||
private static function spouseLabel(array $g, int $me, bool $otherIsMale): string
|
||
{
|
||
if ($otherIsMale) {
|
||
return '丈夫';
|
||
}
|
||
return '妻子';
|
||
}
|
||
|
||
/**
|
||
* 子女的排行称呼:长子/次子/三子…,长女/次女/三女…
|
||
* 优先用 persons.birth_rank;没填就按生日或 id 推。
|
||
*
|
||
* ⚠️ $isMale 必须由调用方按 gender 传入 —— 早期这里写死成「子」,
|
||
* 结果女儿一律显示成「次子」,是很容易漏的一类 bug。
|
||
* 性别未知时退化成中性的「孩子」,不要瞎猜成儿子。
|
||
*/
|
||
private static function rankLabel(array $g, array $people, int $parent, int $child, bool $isMale, bool $known): string
|
||
{
|
||
$base = $known ? ($isMale ? '子' : '女') : '孩子';
|
||
$sibs = self::childIds($g, $parent);
|
||
if (count($sibs) <= 1) {
|
||
return $known ? ($isMale ? '儿子' : '女儿') : '孩子';
|
||
}
|
||
$rank = self::rankOf($people, $sibs, $child);
|
||
$prefix = ['', '长', '次', '三', '四', '五', '六', '七', '八', '九'][$rank] ?? ($rank . '');
|
||
return $prefix === '' ? $base : $prefix . $base;
|
||
}
|
||
|
||
/** 我在兄弟姐妹里排第几(1 起) */
|
||
private static function rankOf(array $people, array $sibs, int $id): int
|
||
{
|
||
$sorted = $sibs;
|
||
usort($sorted, function ($a, $b) use ($people) {
|
||
$ra = self::rankValue($people, $a);
|
||
$rb = self::rankValue($people, $b);
|
||
return $ra <=> $rb;
|
||
});
|
||
$pos = array_search($id, $sorted, true);
|
||
return $pos === false ? 1 : $pos + 1;
|
||
}
|
||
|
||
/** 排序用的键:显式 birth_rank 优先,否则用生日,再否则用 id */
|
||
private static function rankValue(array $people, int $id): string
|
||
{
|
||
$p = $people[$id] ?? [];
|
||
if (!empty($p['birth_rank'])) {
|
||
return sprintf('%06d', (int)$p['birth_rank']);
|
||
}
|
||
if (!empty($p['birthday'])) {
|
||
return '5' . (string)$p['birthday'];
|
||
}
|
||
return '9' . sprintf('%09d', $id);
|
||
}
|
||
|
||
/** 谁是哥哥谁是弟弟:比较排行键 */
|
||
private static function compareRank(array $people, int $a, int $b): int
|
||
{
|
||
return strcmp(self::rankValue($people, $a), self::rankValue($people, $b));
|
||
}
|
||
|
||
/** 父母的兄弟姐妹(伯父/叔叔/姑姑/舅舅/姨妈) */
|
||
private static function uncleLabel(array $g, array $people, int $me, int $other): ?string
|
||
{
|
||
$sibs = [];
|
||
$myParents = self::parents($g, $me);
|
||
$paternal = $myParents['father'] !== null;
|
||
foreach (['father' => true, 'mother' => false] as $role => $isPat) {
|
||
$pid = $myParents[$role];
|
||
if ($pid === null) {
|
||
continue;
|
||
}
|
||
foreach (self::siblingIds($g, $pid) as $sib) {
|
||
$sibs[$sib] = ['side' => $isPat ? 'paternal' : 'maternal', 'parent' => $pid];
|
||
}
|
||
}
|
||
if (!isset($sibs[$other])) {
|
||
return null;
|
||
}
|
||
$side = $sibs[$other]['side'];
|
||
$parent = $sibs[$other]['parent'];
|
||
$gender = strtolower((string)($people[$other]['gender'] ?? 'x'));
|
||
$isMale = $gender === 'm';
|
||
$older = self::compareRank($people, $other, $parent) < 0; // 比我的父母年长
|
||
|
||
if ($side === 'paternal') {
|
||
if ($isMale) {
|
||
return $older ? '伯父' : '叔叔';
|
||
}
|
||
return $gender === 'f' ? '姑姑' : '姑姑';
|
||
}
|
||
if ($isMale) {
|
||
return '舅舅';
|
||
}
|
||
return $gender === 'f' ? '姨妈' : '姨妈';
|
||
}
|
||
|
||
/** 父母的兄弟姐妹(不含配偶那边的) */
|
||
private static function parentSiblings(array $g, int $me): array
|
||
{
|
||
$out = [];
|
||
foreach (self::parents($g, $me) as $pid) {
|
||
if ($pid === null) {
|
||
continue;
|
||
}
|
||
foreach (self::siblingIds($g, $pid) as $sib) {
|
||
$out[] = $sib;
|
||
}
|
||
}
|
||
return $out;
|
||
}
|
||
|
||
// ================= 家谱渲染所需的结构化数据 =================
|
||
|
||
/**
|
||
* 以 $me 为中心,向上向下各展开若干代,产出可渲染的家谱树。
|
||
*
|
||
* 返回结构:
|
||
* [
|
||
* 'generations' => [ -2 => [节点...], -1 => [...], 0 => [...], 1 => [...] ],
|
||
* 'nodes' => [ id => ['id','name','label','rel'][...] ],
|
||
* ]
|
||
* 其中 label 是「我该怎么称呼他」,rel 是「他是我的谁」(用于画连接线)。
|
||
*/
|
||
public static function tree(array $g, int $me, array $people, int $up = 3, int $down = 3): array
|
||
{
|
||
$gen = []; // id => 相对我的代数(我 = 0,父母 = -1,子女 = +1)
|
||
$seen = [];
|
||
$queue = [[$me, 0]];
|
||
$seen[$me] = true;
|
||
|
||
while ($queue) {
|
||
[$id, $level] = array_shift($queue);
|
||
|
||
// 配偶永远同代
|
||
foreach (self::spouseIds($g, $id) as $sp) {
|
||
if (!isset($seen[$sp])) {
|
||
$seen[$sp] = true;
|
||
$gen[$sp] = $level;
|
||
$queue[] = [$sp, $level];
|
||
}
|
||
}
|
||
// 往上
|
||
if ($level > -$up) {
|
||
foreach (self::parents($g, $id) as $pid) {
|
||
if ($pid !== null && !isset($seen[$pid])) {
|
||
$seen[$pid] = true;
|
||
$gen[$pid] = $level - 1;
|
||
$queue[] = [$pid, $level - 1];
|
||
}
|
||
}
|
||
}
|
||
// 往下
|
||
if ($level < $down) {
|
||
foreach (self::childIds($g, $id) as $cid) {
|
||
if (!isset($seen[$cid])) {
|
||
$seen[$cid] = true;
|
||
$gen[$cid] = $level + 1;
|
||
$queue[] = [$cid, $level + 1];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
$gen[$me] = 0;
|
||
|
||
$generations = [];
|
||
$nodes = [];
|
||
foreach ($gen as $id => $level) {
|
||
if (!isset($people[$id])) {
|
||
continue;
|
||
}
|
||
$label = self::label($g, $me, $id, $people);
|
||
$nodes[$id] = [
|
||
'id' => $id,
|
||
'name' => (string)$people[$id]['name'],
|
||
'label' => $label,
|
||
'rel' => self::relationWord($g, $me, $id),
|
||
'avatar'=> (string)($people[$id]['avatar'] ?? ''),
|
||
'gender'=> (string)($people[$id]['gender'] ?? 'x'),
|
||
'ph' => (int)($people[$id]['is_placeholder'] ?? 0) === 1,
|
||
'person'=> $people[$id],
|
||
];
|
||
$generations[$level][] = $id;
|
||
}
|
||
ksort($generations);
|
||
foreach ($generations as &$ids) {
|
||
// 同代内排序:先配偶,再按排行
|
||
usort($ids, function ($a, $b) use ($people) {
|
||
return strcmp(self::rankValue($people, $a), self::rankValue($people, $b));
|
||
});
|
||
}
|
||
unset($ids);
|
||
|
||
return ['generations' => $generations, 'nodes' => $nodes, 'gen' => $gen];
|
||
}
|
||
|
||
/** 关系类型的「英文键」——给前端画线用,不受中文称呼影响 */
|
||
public static function relationWord(array $g, int $me, int $other): string
|
||
{
|
||
if ($me === $other) {
|
||
return 'self';
|
||
}
|
||
if (in_array($other, self::spouseIds($g, $me), true)) {
|
||
return 'spouse';
|
||
}
|
||
if (self::parents($g, $me)['father'] === $other || self::parents($g, $me)['mother'] === $other) {
|
||
return 'parent';
|
||
}
|
||
if (in_array($other, self::childIds($g, $me), true)) {
|
||
return 'child';
|
||
}
|
||
if (in_array($other, self::siblingIds($g, $me), true)) {
|
||
return 'sibling';
|
||
}
|
||
foreach (self::childIds($g, $me) as $c) {
|
||
foreach (self::childIds($g, $c) as $gc) {
|
||
if ($gc === $other || in_array($other, self::childIds($g, $gc), true)) {
|
||
return 'grandchild';
|
||
}
|
||
}
|
||
}
|
||
foreach (self::grandparents($g, $me) as $side) {
|
||
if ($side['father'] === $other || $side['mother'] === $other) {
|
||
return 'grandparent';
|
||
}
|
||
}
|
||
return 'kin';
|
||
}
|
||
|
||
/** 把 id 列表变成 名字 => 称谓 的列表,供页面直接渲染 */
|
||
public static function describe(array $g, int $me, array $ids, array $people): array
|
||
{
|
||
$out = [];
|
||
foreach ($ids as $id) {
|
||
if (isset($people[$id])) {
|
||
$out[] = [
|
||
'id' => (int)$id,
|
||
'name' => (string)$people[$id]['name'],
|
||
'label' => self::label($g, $me, (int)$id, $people),
|
||
];
|
||
}
|
||
}
|
||
return $out;
|
||
}
|
||
} |