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

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' => ''];
}
}
+175
View File
@@ -0,0 +1,175 @@
<?php
/**
* 对象存储·共用件:curl 客户端 + 若干编码工具。
* (从 MovieLog_Server 移植,去掉了与电影站相关的部分)
*
* ⚠️ 不依赖官方 SDK / Composer —— 全站手写签名 + curl,与 PHP 7.4+ 完全兼容。
*/
declare(strict_types=1);
namespace App\Storage;
final class Http
{
/**
* 发一个 HTTP 请求。
*
* ⚠️ 千万不要用 CURLOPT_CUSTOMREQUEST='HEAD' 去发 HEAD:
* 这样 curl 仍会等 Content-Length 声明的 body,而服务端对 HEAD 不回体,
* 于是一路等到超时、curl_exec 返回 false。(MovieLog 实测 8011ms / errno=28)
* HEAD 要用 CURLOPT_NOBODY=true。
*/
public static function send(
string $method,
string $url,
array $headers = [],
$body = null,
int $timeout = 30,
?int $connectTimeout = null
): array {
$respHeaders = [];
$ch = curl_init();
$method = strtoupper($method);
$opts = [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_CONNECTTIMEOUT => $connectTimeout === null ? min(10, max(3, $timeout)) : max(1, $connectTimeout),
CURLOPT_TIMEOUT => $timeout,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$respHeaders) {
$pos = strpos($line, ':');
if ($pos !== false) {
$name = strtolower(trim(substr($line, 0, $pos)));
$respHeaders[$name] = trim(substr($line, $pos + 1));
}
return strlen($line);
},
];
if ($method === 'HEAD') {
$opts[CURLOPT_NOBODY] = true; // ← 必须,见上面注释
} else {
$opts[CURLOPT_CUSTOMREQUEST] = $method;
}
curl_setopt_array($ch, $opts);
$ca = self::caBundle();
if ($ca !== '') {
curl_setopt($ch, CURLOPT_CAINFO, $ca);
}
if ($body !== null && $method !== 'HEAD') {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$out = curl_exec($ch);
$err = curl_error($ch);
$errno = curl_errno($ch);
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($out === false) {
return [
'ok' => false, 'status' => 0, 'headers' => [], 'body' => '',
'error' => $errno === CURLE_OPERATION_TIMEDOUT
? "请求超时({$timeout}s)"
: "网络错误({$errno}): {$err}",
];
}
return [
'ok' => $status >= 200 && $status < 300,
'status' => $status,
'headers' => $respHeaders,
'body' => $out,
'error' => $status >= 200 && $status < 300 ? '' : "HTTP {$status}",
];
}
/**
* 找系统的 CA 证书。宝塔的 open_basedir 会限制路径访问,
* 所以先判断「在不在允许范围内」再去 is_file,否则会刷 Warning 污染输出。
*/
public static function caBundle(): string
{
$candidates = [
'/etc/pki/tls/certs/ca-bundle.crt',
'/etc/ssl/certs/ca-certificates.crt',
'/etc/ssl/cert.pem',
ini_get('curl.cainfo'),
ini_get('openssl.cafile'),
];
$allowed = self::openBasedirPaths();
foreach ($candidates as $p) {
$p = (string)$p;
if ($p === '') {
continue;
}
if ($allowed !== [] && !self::pathAllowed($p, $allowed)) {
continue;
}
if (@is_file($p)) {
return $p;
}
}
return '';
}
/** @return string[] */
private static function openBasedirPaths(): array
{
$raw = (string)ini_get('open_basedir');
if ($raw === '') {
return [];
}
$out = [];
foreach (preg_split('/[:;]/', $raw) ?: [] as $p) {
if (trim($p) !== '') {
$out[] = rtrim(trim($p), '/');
}
}
return $out;
}
private static function pathAllowed(string $path, array $allowed): bool
{
foreach ($allowed as $base) {
if (strpos($path, $base) === 0 || strpos($base, $path) === 0) {
return true;
}
}
return false;
}
/** 对象键的 URL 编码:保留 / 分段,其余按 RFC3986 编码 */
public static function encodeKey(string $key): string
{
$parts = explode('/', $key);
$parts = array_map('rawurlencode', $parts);
return implode('/', $parts);
}
/** 把可能带协议的域名规整成 host[+port] */
public static function hostOf(string $domain): string
{
$domain = trim($domain);
if (strpos($domain, '//') !== false) {
$p = parse_url($domain, PHP_URL_HOST);
$domain = $p ?: $domain;
}
return trim($domain, '/');
}
/** 错误信息压缩成一行,便于塞进页面提示 */
public static function shortError(array $res, int $limit = 200): string
{
$msg = (string)($res['error'] ?? '');
$body = (string)($res['body'] ?? '');
if ($body !== '' && stripos($body, '<Code>') !== false) {
$msg .= ' ' . trim(preg_replace('/\s+/', ' ', strip_tags($body)));
}
$msg = trim($msg);
return mb_strlen($msg) > $limit ? mb_substr($msg, 0, $limit) . '…' : $msg;
}
}
+99
View File
@@ -0,0 +1,99 @@
<?php
/**
* 本地磁盘驱动 —— 也是云端失败时的**回退目标**。
* 绝不因为云配置出问题就让用户丢文件。
*/
declare(strict_types=1);
namespace App\Storage;
final class LocalDriver implements StorageDriver
{
private string $root;
public function __construct(string $root)
{
$this->root = rtrim($root, '/');
}
public function id(): string
{
return 'local';
}
public function validateConfig(): string
{
return is_dir($this->root) || @mkdir($this->root, 0755, true)
? ''
: '本地存储目录不可写';
}
private function abs(string $key): string
{
// 挡目录穿越:.. 一律拒绝
$key = ltrim(str_replace('\\', '/', $key), '/');
if (strpos($key, '..') !== false) {
return $this->root . '/__invalid__';
}
return $this->root . '/' . $key;
}
public function putFile(string $srcPath, string $key, ?string $mime = null): array
{
$dst = $this->abs($key);
$dir = dirname($dst);
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) {
return ['ok' => false, 'error' => '建不了目录:' . $dir];
}
if (!@copy($srcPath, $dst)) {
return ['ok' => false, 'error' => '复制文件失败'];
}
@chmod($dst, 0644);
return ['ok' => true, 'error' => ''];
}
public function putContents(string $binary, string $key, ?string $mime = null): array
{
$dst = $this->abs($key);
$dir = dirname($dst);
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) {
return ['ok' => false, 'error' => '建不了目录:' . $dir];
}
if (@file_put_contents($dst, $binary) === false) {
return ['ok' => false, 'error' => '写入文件失败'];
}
@chmod($dst, 0644);
return ['ok' => true, 'error' => ''];
}
public function delete(string $key): array
{
$p = $this->abs($key);
if (is_file($p) && !@unlink($p)) {
return ['ok' => false, 'error' => '删不掉:' . $key];
}
return ['ok' => true, 'error' => ''];
}
public function get(string $key, array $extra = []): array
{
$p = $this->abs($key);
if (!is_file($p)) {
return ['ok' => false, 'status' => 404, 'headers' => [], 'body' => '', 'error' => '文件不存在'];
}
$body = (string)@file_get_contents($p);
return ['ok' => true, 'status' => 200, 'headers' => [], 'body' => $body, 'error' => ''];
}
public function exists(string $key): bool
{
return is_file($this->abs($key));
}
/** 本地文件返回站点相对路径(相对站点根,前面带 /) */
public function url(string $key): string
{
return '/' . ltrim(str_replace('\\', '/', $key), '/');
}
}
+338
View File
@@ -0,0 +1,338 @@
<?php
/**
* 阿里云 OSS 驱动(手写签名 V1 / HMAC-SHA1,不依赖官方 SDK)。
* (从 MovieLog_Server 移植)
*
* ⚠️ 三件事必须记住:
* ① 写请求把 Content-Type 放进签名串,**读请求要传空串** —— 这是 OSS 签名的坑。
* ② 内网 endpoint 只用于**服务端读取**,对外 URL 永远用公网/自定义域名。
* 跨地域时内网域名能解析出 A 记录但永远连不上,会白等满一次连接超时,
* 所以先比地域(零成本),再只给 2 秒连接超时。
* ③ endpoint 填写时**不要带 https://**,且必须与桶所在地域一致,
* 否则报 "must be addressed using the specified endpoint"。
*/
declare(strict_types=1);
namespace App\Storage;
use App\Setting;
final class OssDriver implements StorageDriver
{
private array $cfg;
private int $familyId;
private ?array $cred = null;
public function __construct(int $familyId, array $cfg)
{
$this->familyId = $familyId;
$this->cfg = $cfg;
}
public function id(): string
{
return 'oss';
}
public function validateConfig(): string
{
if (trim((string)$this->cfg['bucket']) === '') {
return '没有填 Bucket 名称';
}
if (trim((string)$this->cfg['endpoint']) === '') {
return '没有填 Endpoint';
}
$auth = ($this->cfg['auth_mode'] ?? 'ak') === 'ecs_role'
|| (trim((string)($this->cfg['access_key_id'] ?? '')) !== ''
&& trim((string)($this->cfg['access_key_secret'] ?? '')) !== '');
if (!$auth) {
return '凭据不完整:要么填 AccessKey,要么选「用服务器角色」';
}
return '';
}
// ---------------- 凭据 ----------------
private function credentials(): array
{
if ($this->cred !== null) {
return $this->cred;
}
if (($this->cfg['auth_mode'] ?? 'ak') === 'ecs_role') {
$c = EcsMeta::ramCredentials($this->familyId, (string)($this->cfg['ram_role_name'] ?? ''));
if (empty($c['ok'])) {
return $this->cred = ['ok' => false, 'error' => $c['error'] ?? '取临时凭据失败'];
}
return $this->cred = $c;
}
$id = trim((string)($this->cfg['access_key_id'] ?? ''));
$secret = trim((string)($this->cfg['access_key_secret'] ?? ''));
if ($id === '' || $secret === '') {
return $this->cred = ['ok' => false, 'error' => '没有填 AccessKey ID / AccessKey Secret'];
}
return $this->cred = ['ok' => true, 'id' => $id, 'secret' => $secret, 'token' => '', 'error' => ''];
}
// ---------------- endpoint / 域名 ----------------
private function useHttps(): bool
{
return (string)($this->cfg['https'] ?? '1') !== '0';
}
private function scheme(): string
{
return $this->useHttps() ? 'https' : 'http';
}
private function endpoint(): string
{
return trim((string)$this->cfg['endpoint']);
}
private function host(): string
{
return trim((string)$this->cfg['bucket']) . '.' . $this->endpoint();
}
/** 内网 endpoint 只从标准域名推导,自定义/VPC 一律不猜 */
private function internalEndpoint(): string
{
if ((string)($this->cfg['internal_endpoint'] ?? '1') === '0') {
return '';
}
$ep = $this->endpoint();
if ($ep === '' || strpos($ep, '-internal.') !== false) {
return '';
}
if (preg_match('#^(oss-[a-z0-9-]+)\.aliyuncs\.com$#i', $ep, $m)) {
return $m[1] . '-internal.aliyuncs.com';
}
return '';
}
/** 内网是否可用:先比地域(零成本),必要时才真探一次 */
private function internalUsable(): bool
{
$internal = $this->internalEndpoint();
if ($internal === '') {
return false;
}
$cached = json_decode((string)Setting::get($this->familyId, 'cache_oss_internal', ''), true);
if (is_array($cached) && ($cached['endpoint'] ?? '') === $internal
&& !empty($cached['expires']) && $cached['expires'] > time()) {
return !empty($cached['ok']);
}
return $this->probeInternal($internal);
}
private function probeInternal(string $internal): bool
{
$ok = false;
$reason = '';
$bucketRegion = $this->bucketRegion();
$ecsRegion = EcsMeta::region($this->familyId);
if ($bucketRegion !== '' && $ecsRegion !== '' && $bucketRegion !== $ecsRegion) {
$reason = "本机在 {$ecsRegion}、桶在 {$bucketRegion},不是同一地域";
} else {
$cred = $this->credentials();
if (!empty($cred['ok'])) {
$url = $this->scheme() . '://' . trim((string)$this->cfg['bucket']) . '.' . $internal . '/';
// HEAD 探测:即使没 ListBucket 权限回 403,也证明网络可达
$res = Http::send('HEAD', $url, $this->readHeaders('/', $cred, [], 'HEAD'), null, 3, 2);
$ok = $res['status'] > 0;
$reason = $ok ? '' : ('连接失败:' . (string)($res['error'] ?? ''));
} else {
$reason = '凭据不可用';
}
}
Setting::set($this->familyId, 'cache_oss_internal', json_encode([
'endpoint' => $internal,
'ok' => $ok ? 1 : 0,
'reason' => $reason,
'expires' => time() + ($ok ? 3600 : 1800),
], JSON_UNESCAPED_UNICODE));
return $ok;
}
/** 桶所在地域(用 GetBucketLocation,失败则从 endpoint 推) */
private function bucketRegion(): string
{
if (preg_match('#^oss-([a-z0-9-]+)\.aliyuncs\.com$#i', $this->endpoint(), $m)) {
return $m[1];
}
return '';
}
// ---------------- 签名 ----------------
private function authorization(string $method, string $key, string $contentType, string $date, array $cred): string
{
$canonicalHeaders = '';
if ((string)($cred['token'] ?? '') !== '') {
$canonicalHeaders = "x-oss-security-token:{$cred['token']}\n";
}
$canonicalResource = '/' . trim((string)$this->cfg['bucket']) . '/' . ltrim($key, '/');
$stringToSign = $method . "\n"
. "\n" // Content-MD5(未使用)
. $contentType . "\n"
. $date . "\n"
. $canonicalHeaders
. $canonicalResource;
$signature = base64_encode(hash_hmac('sha1', $stringToSign, $cred['secret'], true));
return 'OSS ' . $cred['id'] . ':' . $signature;
}
private function writeHeaders(string $method, string $key, string $contentType, array $cred): array
{
$date = gmdate('D, d M Y H:i:s \G\M\T');
$h = [
'Date: ' . $date,
'Content-Type: ' . $contentType,
'Authorization: ' . $this->authorization($method, $key, $contentType, $date, $cred),
];
if ((string)($cred['token'] ?? '') !== '') {
$h[] = 'x-oss-security-token: ' . $cred['token'];
}
return $h;
}
/** 读请求刻意不发 Content-Type,签名串里传空串 */
private function readHeaders(string $key, array $cred, array $extra = [], string $method = 'GET'): array
{
$date = gmdate('D, d M Y H:i:s \G\M\T');
$h = [
'Date: ' . $date,
'Authorization: ' . $this->authorization($method, $key, '', $date, $cred),
];
if ((string)($cred['token'] ?? '') !== '') {
$h[] = 'x-oss-security-token: ' . $cred['token'];
}
foreach ($extra as $x) {
$h[] = $x;
}
return $h;
}
// ---------------- 对象操作 ----------------
private function objectKey(string $key): string
{
$prefix = trim((string)($this->cfg['prefix'] ?? ''), '/');
$key = ltrim($key, '/');
return $prefix === '' ? $key : $prefix . '/' . $key;
}
private function objectUrl(string $key): string
{
return $this->scheme() . '://' . $this->host() . '/' . Http::encodeKey($key);
}
public function putFile(string $srcPath, string $key, ?string $mime = null): array
{
if (!is_file($srcPath)) {
return ['ok' => false, 'error' => '本地文件不存在'];
}
$binary = @file_get_contents($srcPath);
if ($binary === false) {
return ['ok' => false, 'error' => '读不出本地文件'];
}
return $this->putContents($binary, $key, $mime);
}
public function putContents(string $binary, string $key, ?string $mime = null): array
{
if ($err = $this->validateConfig()) {
return ['ok' => false, 'error' => $err];
}
$cred = $this->credentials();
if (empty($cred['ok'])) {
return ['ok' => false, 'error' => $cred['error']];
}
$key = $this->objectKey($key);
$mime = $mime ?: 'application/octet-stream';
$res = Http::send('PUT', $this->objectUrl($key),
$this->writeHeaders('PUT', $key, $mime, $cred), $binary, 60);
if (!$res['ok']) {
return ['ok' => false, 'error' => '上传到 OSS 失败:' . Http::shortError($res)];
}
return ['ok' => true, 'error' => ''];
}
public function delete(string $key): array
{
$cred = $this->credentials();
if (empty($cred['ok'])) {
return ['ok' => false, 'error' => $cred['error']];
}
$key = $this->objectKey($key);
$res = Http::send('DELETE', $this->objectUrl($key),
$this->writeHeaders('DELETE', $key, '', $cred), null, 30);
if (!$res['ok'] && (int)$res['status'] !== 404) {
return ['ok' => false, 'error' => '删除 OSS 对象失败:' . Http::shortError($res)];
}
return ['ok' => true, 'error' => ''];
}
/** 服务端读取(内网优先 + 自动回退外网) */
public function get(string $key, array $extra = []): array
{
if ($err = $this->validateConfig()) {
return ['ok' => false, 'status' => 0, 'headers' => [], 'body' => '', 'error' => $err];
}
$cred = $this->credentials();
if (empty($cred['ok'])) {
return ['ok' => false, 'status' => 0, 'headers' => [], 'body' => '', 'error' => $cred['error']];
}
$key = $this->objectKey($key);
$headers = $this->readHeaders($key, $cred, $extra);
$hosts = [];
$internal = $this->internalEndpoint();
if ($internal !== '' && $this->internalUsable()) {
$hosts[] = $internal;
}
$hosts[] = $this->endpoint();
$res = null;
foreach ($hosts as $i => $host) {
$isInternal = ($internal !== '' && $host === $internal);
$url = $this->scheme() . '://' . trim((string)$this->cfg['bucket']) . '.' . $host
. '/' . Http::encodeKey($key);
$res = Http::send('GET', $url, $headers, null, 60, $isInternal ? 2 : null);
if ((int)$res['status'] === 0 && $isInternal && isset($hosts[$i + 1])) {
continue; // 内网连不上,换外网
}
break;
}
return $res ?: ['ok' => false, 'status' => 0, 'headers' => [], 'body' => '', 'error' => '连不上 OSS'];
}
public function exists(string $key): bool
{
$cred = $this->credentials();
if (empty($cred['ok'])) {
return false;
}
$key = $this->objectKey($key);
$res = Http::send('HEAD', $this->objectUrl($key), $this->readHeaders($key, $cred, [], 'HEAD'), null, 15);
return (int)$res['status'] === 200;
}
/** 对外地址:优先自定义域名/CDN */
public function url(string $key): string
{
$key = $this->objectKey($key);
$custom = trim((string)($this->cfg['custom_domain'] ?? ''));
if ($custom !== '') {
$host = Http::hostOf($custom);
return (strpos($custom, 'http') === 0 ? rtrim($custom, '/') : $this->scheme() . '://' . $host)
. '/' . Http::encodeKey($key);
}
return $this->objectUrl($key);
}
}
+194
View File
@@ -0,0 +1,194 @@
<?php
/**
* 存储门面:全站**唯一**的附件读写出口。
*
* 三条纪律(照 MovieLog 的教训定,别绕过):
* ① 数据库只存「相对键」(如 avatars/ab12.jpg),绝不存完整 URL ——
* 换域名、换桶、换前缀都不用改历史数据。
* ② 页面绝不手拼路径,一律走 url()。
* ③ 云端失败**自动回退本地**,绝不因云配置问题丢用户的文件。
*/
declare(strict_types=1);
namespace App\Storage;
use App\Setting;
final class Storage
{
public const AVATAR_DIR = 'avatars';
/** OSS 默认配置模板 */
public static function ossDefaults(): array
{
return [
'auth_mode' => 'ak', // ak | ecs_role
'access_key_id' => '',
'access_key_secret' => '',
'bucket' => '',
'endpoint' => '', // 例:oss-cn-beijing.aliyuncs.com(不带 https://)
'prefix' => '', // 桶内前缀,一个桶放多个站点时用
'ram_role_name' => '', // 留空 = 自动取实例绑定的角色
'custom_domain' => '', // 可选,CDN / 自定义域名
'internal_endpoint' => '1', // 服务端读取优先走内网
'https' => '1',
];
}
// ---------------- 配置读写 ----------------
public static function driver(int $familyId): string
{
$d = (string)Setting::get($familyId, 'storage_driver', 'local');
return in_array($d, ['local', 'oss'], true) ? $d : 'local';
}
public static function config(int $familyId): array
{
$raw = json_decode((string)Setting::get($familyId, 'storage_config_oss', ''), true);
$saved = is_array($raw) ? $raw : [];
return array_merge(self::ossDefaults(), $saved);
}
public static function saveConfig(int $familyId, string $driver, array $oss): void
{
$driver = in_array($driver, ['local', 'oss'], true) ? $driver : 'local';
Setting::set($familyId, 'storage_driver', $driver);
Setting::set($familyId, 'storage_config_oss',
json_encode(array_merge(self::ossDefaults(), $oss), JSON_UNESCAPED_UNICODE));
EcsMeta::clearCache($familyId);
}
/** 配置是否填齐 —— 没填齐就强制退回本地,避免「传到云端、地址却按本地算」的错配 */
public static function configComplete(int $familyId): bool
{
if (self::driver($familyId) !== 'oss') {
return true;
}
$c = self::config($familyId);
$auth = ($c['auth_mode'] === 'ecs_role')
|| (trim((string)$c['access_key_id']) !== '' && trim((string)$c['access_key_secret']) !== '');
return $auth && trim((string)$c['bucket']) !== '' && trim((string)$c['endpoint']) !== '';
}
/** 实际生效的驱动(配置不全时是 local) */
public static function effectiveDriver(int $familyId): string
{
$want = self::driver($familyId);
if ($want !== 'local' && !self::configComplete($familyId)) {
return 'local';
}
return $want;
}
private static ?StorageDriver $instance = null;
private static ?int $instanceFor = null;
public static function disk(int $familyId): StorageDriver
{
$id = self::effectiveDriver($familyId);
if (self::$instance === null || self::$instanceFor !== $familyId || self::$instance->id() !== $id) {
self::$instance = $id === 'oss'
? new OssDriver($familyId, self::config($familyId))
: new LocalDriver(FB_UPLOADS);
self::$instanceFor = $familyId;
}
return self::$instance;
}
// ---------------- 读写 ----------------
/** 存一个上传的文件;云端失败自动回退本地 */
public static function putFile(int $familyId, string $srcPath, string $key, ?string $mime = null): array
{
$disk = self::disk($familyId);
$r = $disk->putFile($srcPath, $key, $mime);
if (!empty($r['ok'])) {
return $r;
}
if ($disk->id() !== 'local') {
$local = new LocalDriver(FB_UPLOADS);
$l = $local->putFile($srcPath, $key, $mime);
if (!empty($l['ok'])) {
return ['ok' => true, 'error' => '', 'note' => '云端上传失败,已自动存到本地:' . $r['error']];
}
}
return $r;
}
public static function putContents(int $familyId, string $binary, string $key, ?string $mime = null): array
{
$disk = self::disk($familyId);
$r = $disk->putContents($binary, $key, $mime);
if (!empty($r['ok'])) {
return $r;
}
if ($disk->id() !== 'local') {
$local = new LocalDriver(FB_UPLOADS);
$l = $local->putContents($binary, $key, $mime);
if (!empty($l['ok'])) {
return ['ok' => true, 'error' => '', 'note' => '云端上传失败,已自动存到本地:' . $r['error']];
}
}
return $r;
}
public static function delete(int $familyId, string $key): void
{
if ($key === '') {
return;
}
// 两个地方都试一遍:历史文件可能只在本地
self::disk($familyId)->delete($key);
if (self::effectiveDriver($familyId) !== 'local') {
(new LocalDriver(FB_UPLOADS))->delete($key);
}
}
/** 读取内容(导出、代理等场景) */
public static function read(int $familyId, string $key): array
{
return self::disk($familyId)->get($key);
}
public static function exists(int $familyId, string $key): bool
{
if ($key === '') {
return false;
}
if (is_file(FB_UPLOADS . '/' . $key)) {
return true; // 本地优先(迁移期两者并存)
}
return self::disk($familyId)->exists($key);
}
/**
* **唯一出口**:把相对键变成可访问地址。
*
* 读取顺序刻意设计成「本地优先」:
* ① 已是绝对地址 → 原样返回
* ② 本地有文件 → 走本地(迁移期老文件仍可访问,随时可回滚)
* ③ 交给当前驱动
*/
public static function url(int $familyId, ?string $key): string
{
$key = trim((string)$key);
if ($key === '') {
return asset('/assets/avatar-default.svg');
}
if (preg_match('#^https?://#i', $key)) {
return $key;
}
$key = ltrim($key, '/');
$local = FB_UPLOADS . '/' . $key;
if (is_file($local)) {
return asset('/uploads/' . $key);
}
$disk = self::disk($familyId);
if ($disk->id() === 'local') {
return asset('/uploads/' . $key); // 本地也没有 → 让默认头像逻辑兜住
}
return $disk->url($key);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
/**
* 存储驱动接口。三种实现:local / oss / null(未配置)。
*/
declare(strict_types=1);
namespace App\Storage;
interface StorageDriver
{
public function id(): string;
/** 配置是否可用;返回空串表示可用 */
public function validateConfig(): string;
public function putFile(string $srcPath, string $key, ?string $mime = null): array;
public function putContents(string $binary, string $key, ?string $mime = null): array;
public function delete(string $key): array;
public function get(string $key, array $extra = []): array;
public function exists(string $key): bool;
/** 对外可访问地址 */
public function url(string $key): string;
}