Files
FamilyBook/app/Storage/EcsMeta.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

125 lines
5.1 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
/**
* 阿里云 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' => ''];
}
}