五项家庭化改造:以个人为中心 · 家谱 · 三种角色 · 附件存储可配置 · 独立页样式

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(把每个修复改回旧写法,对应断言都会变红)。
This commit is contained in:
2026-09-30 08:13:07 +08:00
parent 8eb19267bc
commit 1669ab48d0
30 changed files with 3034 additions and 161 deletions
+125
View File
@@ -0,0 +1,125 @@
<?php
/**
* 阿里云 ECS 实例元数据:RAM 角色临时凭据 + 地域。
* (从 MovieLog_Server 移植)
*
* 为什么值得支持这种方式:这样**服务器上不用落任何长期密钥**。
* 实例绑一个 RAM 角色,程序现用现取 STS 临时凭据;即使站点被拖库,
* 拿到的也只是几十分钟就过期的临时 token。
*
* ⚠️ 一台 ECS 只能绑一个 RAM 角色;该角色必须被授权访问目标 Bucket,
* 否则报 AccessDenied(NoPermissionType=ImplicitDeny)。
*/
declare(strict_types=1);
namespace App\Storage;
use App\Setting;
final class EcsMeta
{
private const BASE = 'http://100.100.100.200/latest/meta-data/';
private const TIMEOUT = 3;
/**
* 取 RAM 角色的 STS 临时凭据。
* $roleName 留空则自动取实例绑定的第一个角色。
*/
public static function ramCredentials(int $familyId, string $roleName = '', bool $force = false): array
{
static $memo = [];
$memoKey = 'aliyun:' . $roleName;
if (!$force && isset($memo[$memoKey])) {
return $memo[$memoKey];
}
// 跨请求缓存:STS 一般 >= 1 小时,到期前 5 分钟就刷新
if (!$force) {
$cached = json_decode((string)Setting::get($familyId, 'cache_sts_aliyun', ''), true);
if (is_array($cached)
&& !empty($cached['expires']) && $cached['expires'] > time() + 300
&& ($cached['role'] ?? '') === $roleName) {
return $memo[$memoKey] = $cached + ['ok' => true, 'error' => ''];
}
}
// IMDSv2:先拿一次性 token(拿不到就降级走 v1,兼容老实例)
$token = '';
$t = Http::send('PUT', self::BASE . '../api/token',
['X-aliyun-ecs-metadata-token-ttl-seconds: 21600'], null, self::TIMEOUT);
if ($t['ok'] && trim((string)$t['body']) !== '') {
$token = trim($t['body']);
}
$auth = $token !== '' ? ["X-aliyun-ecs-metadata-token: {$token}"] : [];
$credsUrl = self::BASE . 'ram/security-credentials/';
if ($roleName === '') {
$r = Http::send('GET', $credsUrl, $auth, null, self::TIMEOUT);
if (!$r['ok']) {
return $memo[$memoKey] = self::fail('无法访问 ECS 元数据服务(这台服务器可能不是阿里云 ECS)');
}
$first = trim((string)strtok((string)$r['body'], "\r\n"));
if ($first === '') {
return $memo[$memoKey] = self::fail('该实例没有绑定 RAM 角色');
}
$roleName = $first;
}
$r = Http::send('GET', $credsUrl . rawurlencode($roleName), $auth, null, self::TIMEOUT);
if (!$r['ok']) {
return $memo[$memoKey] = self::fail("取 RAM 角色 {$roleName} 的临时凭据失败");
}
$json = json_decode((string)$r['body'], true);
if (!is_array($json) || empty($json['AccessKeyId']) || empty($json['AccessKeySecret'])) {
$code = is_array($json) ? (string)($json['Code'] ?? '') : '';
return $memo[$memoKey] = self::fail('元数据返回的内容看不懂' . ($code !== '' ? "({$code})" : ''));
}
$cred = [
'ok' => true,
'id' => (string)$json['AccessKeyId'],
'secret' => (string)$json['AccessKeySecret'],
'token' => (string)($json['SecurityToken'] ?? ''),
'expires' => !empty($json['Expiration']) ? (int)strtotime((string)$json['Expiration']) : time() + 1800,
'error' => '',
'role' => $roleName,
];
Setting::set($familyId, 'cache_sts_aliyun', json_encode($cred, JSON_UNESCAPED_UNICODE));
return $memo[$memoKey] = $cred;
}
/** 本机地域,用于判断内网 endpoint 是否可用(1 小时缓存) */
public static function region(int $familyId, bool $force = false): string
{
static $memo = null;
if ($memo !== null && !$force) {
return $memo;
}
if (!$force) {
$c = json_decode((string)Setting::get($familyId, 'cache_ecs_region', ''), true);
if (is_array($c) && !empty($c['expires']) && $c['expires'] > time()) {
return $memo = (string)($c['region'] ?? '');
}
}
$r = Http::send('GET', self::BASE . 'region-id', [], null, self::TIMEOUT);
$region = $r['ok'] ? trim((string)$r['body']) : '';
Setting::set($familyId, 'cache_ecs_region',
json_encode(['region' => $region, 'expires' => time() + 3600]));
return $memo = $region;
}
/** 清掉全部元数据缓存(换角色/改配置后调用) */
public static function clearCache(int $familyId): void
{
foreach (['cache_sts_aliyun', 'cache_ecs_region', 'cache_oss_internal'] as $k) {
Setting::set($familyId, $k, '');
}
}
private static function fail(string $msg): array
{
return ['ok' => false, 'id' => '', 'secret' => '', 'token' => '',
'expires' => 0, 'error' => $msg, 'role' => ''];
}
}