交通字段简化 + 照片多图/裁剪/双存储后端 + 界面重构

交通字段
- 飞机只记航班号、火车只记车次,不再收座位号 / 线路 / 承运人 / 登机口
  (多人同乘一趟时座位号本来就无意义)
- trip_nodes 的历史列保留但不再写入也不再渲染,编辑一次即被显式清成 NULL
- 节点表单按出行方式切换号码标签(航班号 / 车次 / 班次 / 线路 / 航次)

照片
- 一个节点可传多张照片,前端支持逐张裁剪(Cropper.js,只把裁过的走 base64)
- MIME 由自建的 src/Mime.php 按内容识别:getimagesize → 魔数嗅探 → 扩展名映射,
  刻意不依赖 fileinfo —— 线上同时装着 PHP 7.4(没装该扩展)与 8.1
- 单张体积上限、可传张数在设置页可调

双存储后端
- 本站 uploads/ 或阿里云 OSS,在「存储设置」页配 endpoint / 桶名 / 前缀 / 内网开关;
  OSS 配置不完整时静默回退本地,避免管理员填错就把上传整个弄挂
- OSS 鉴权支持 ECS 实例 RAM 角色换 STS 临时凭证(临时凭证缓存在 /tmp,不落 Web 目录),
  也支持直接填 AccessKey
- OSS V1 签名手写实现,不引 SDK(本项目无 composer / 无 autoload)

媒体代理
- 照片与活动封面一律经 media/show、media/cover 代理输出,先校验家庭归属再吐字节
- 页面里不出现 OSS 域名,也不出现本地真实路径;桶保持私有
- 响应带 nosniff,并关闭 display_errors,免得 PHP 报错混进图片正文把图弄坏

数据库
- 新增迁移机制(src/Migrate.php + tools/migrate.php,仅 CLI 可跑)
- 建 app_settings 表;attachments 增加 storage / object_key / mime_type / size_bytes /
  width / height / is_image 与 idx_storage
- MySQL 8 不支持「加列时带 IF NOT EXISTS」,迁移一律先查 information_schema

界面
- 顶栏只留品牌与「新建活动」,其余入口收进右上角下拉菜单(details/summary,零 JS 可开合)
- 新增「存储设置」页:当前生效存储、数据库结构版本、是否 ECS 运行环境三张状态卡,
  外加测试连接与角色探测
- 样式表整体重写

验证
- 模板渲染检查 234 项、SQL 列名静态检查、模板到 CSS 的类覆盖检查、内联 JS 语法检查全通过
- 已部署到 trip.yangyq.net 并完成线上验证:双 PHP(8.1 / 7.4)14 条路由渲染字节数完全一致、
  26 项内容断言全部通过、照片代理取件与磁盘原件逐字节一致(69/69 字节)
This commit is contained in:
2026-09-22 23:09:37 +08:00
parent 8e4004db85
commit f9a624ef4b
26 changed files with 3894 additions and 1328 deletions
+175
View File
@@ -0,0 +1,175 @@
<?php
declare(strict_types=1);
namespace App;
/**
* 存储门面:把「存哪里」这件事收在一处,业务代码不再关心本地还是 OSS。
*
* 键的约定:**对象键不含存储后端信息**,两种后端共用同一套命名
* 本地: uploads/{key}
* OSS : {settings.oss_prefix}{key}
* 例:key = "attachments/20260922_ab12cd34.jpg"
*
* 页面里一律不直接用这里的键拼 URL —— 取文件走 media/show 代理
* (桶是私有的,直链既取不到也不该暴露)。
*/
final class Storage
{
public const DRIVER_LOCAL = 'local';
public const DRIVER_OSS = 'oss';
/**
* 当前生效的驱动。OSS 配置不全时**静默回退本地**,
* 这样即使管理员填错了配置,上传也不会整个不可用。
*/
public static function driver(): string
{
if (Settings::get('storage_driver') === self::DRIVER_OSS && Settings::ossReady()) {
return self::DRIVER_OSS;
}
return self::DRIVER_LOCAL;
}
/** 是否处于「选了 OSS 但配置不全,已回退本地」的状态(用于在界面上给出提示) */
public static function ossMisconfigured(): bool
{
return Settings::get('storage_driver') === self::DRIVER_OSS && !Settings::ossReady();
}
public static function label(?string $driver = null): string
{
return ($driver ?? self::driver()) === self::DRIVER_OSS ? '阿里云 OSS' : '本站服务器';
}
/** 生成一个不会撞的新对象键 */
public static function keyFor(string $ext, string $dir = 'attachments'): string
{
$ext = ltrim(strtolower($ext), '.');
$ext = preg_replace('/[^a-z0-9]/', '', $ext) ?: 'bin';
return $dir . '/' . date('Ymd') . '_' . bin2hex(random_bytes(8)) . '.' . $ext;
}
/**
* 写入一份内容。
* @return array{storage:string, key:string, size:int}
*/
public static function put(string $key, string $body, string $mime): array
{
$driver = self::driver();
if ($driver === self::DRIVER_OSS) {
$oss = Settings::ossClient();
if ($oss === null) {
throw new \RuntimeException('OSS 未配置完整');
}
$oss->put($key, $body, $mime);
return ['storage' => self::DRIVER_OSS, 'key' => $key, 'size' => strlen($body)];
}
$path = self::localPath($key);
$dir = dirname($path);
if (!is_dir($dir) && !@mkdir($dir, 0755, true) && !is_dir($dir)) {
throw new \RuntimeException('无法创建上传目录:' . $dir);
}
if (@file_put_contents($path, $body) === false) {
throw new \RuntimeException('写入文件失败:' . $key);
}
return ['storage' => self::DRIVER_LOCAL, 'key' => $key, 'size' => strlen($body)];
}
/** 读取内容;找不到返回 null */
public static function get(string $storage, string $key, string $fileUrl = ''): ?string
{
if ($storage === self::DRIVER_OSS) {
try {
$oss = Settings::ossClient();
return $oss === null ? null : $oss->get($key);
} catch (\Throwable $e) {
return null;
}
}
$path = self::resolveLocal($key, $fileUrl);
if ($path === null || !is_file($path)) {
return null;
}
$data = @file_get_contents($path);
return $data === false ? null : $data;
}
public static function exists(string $storage, string $key, string $fileUrl = ''): bool
{
if ($storage === self::DRIVER_OSS) {
$oss = Settings::ossClient();
if ($oss === null) return false;
try {
return $oss->head($key) !== null;
} catch (\Throwable $e) {
return false;
}
}
$path = self::resolveLocal($key, $fileUrl);
return $path !== null && is_file($path);
}
/** 删除;返回是否确实删掉了(不存在也算成功,保持幂等) */
public static function delete(string $storage, string $key, string $fileUrl = ''): bool
{
if ($storage === self::DRIVER_OSS) {
try {
$oss = Settings::ossClient();
return $oss === null ? false : $oss->delete($key);
} catch (\Throwable $e) {
return false;
}
}
$path = self::resolveLocal($key, $fileUrl);
if ($path === null) {
return true;
}
return !is_file($path) || @unlink($path);
}
/**
* 本地文件的绝对路径。只接受 uploads/ 下的相对键,挡住 `../` 之类的路径穿越。
* $fileUrl 是历史遗留的 `/uploads/attachments/xxx.jpg` 形式,作为兼容兜底。
*/
public static function localPath(string $key): string
{
$key = ltrim(str_replace('\\', '/', $key), '/');
if (strpos($key, '..') !== false) {
throw new \RuntimeException('非法的对象键:' . $key);
}
return self::uploadsDir() . '/' . $key;
}
/**
* 解析本地文件的绝对路径(找不到/键非法返回 null)。
* public 是给「展示层算体积」这类只读场景用的,仍会挡住路径穿越。
*/
public static function resolveLocal(string $key, string $fileUrl = ''): ?string
{
$key = trim($key) !== '' ? $key : '';
if ($key !== '') {
try {
return self::localPath($key);
} catch (\Throwable $e) {
return null;
}
}
// 老数据只有 file_url(/uploads/...)
$url = trim($fileUrl);
if ($url === '' || strpos($url, '/uploads/') !== 0 || strpos($url, '..') !== false) {
return null;
}
return dirname(__DIR__) . $url;
}
public static function uploadsDir(): string
{
return dirname(__DIR__) . '/uploads';
}
}