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

194 lines
6.9 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
/**
* 存储门面:全站**唯一**的附件读写出口。
*
* 三条纪律(照 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);
}
}