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

交通字段
- 飞机只记航班号、火车只记车次,不再收座位号 / 线路 / 承运人 / 登机口
  (多人同乘一趟时座位号本来就无意义)
- 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
+213
View File
@@ -0,0 +1,213 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Access;
use App\Database;
use App\Migrate;
use App\Oss;
use App\Settings;
use App\Storage;
/**
* 存储设置(仅管理员)。
*
* 支持两种后端:本站服务器(默认)/ 阿里云 OSS;
* OSS 又支持两种鉴权:ECS 实例 RAM 角色换 STS 临时凭证(推荐,无需落库长期密钥)、
* 或直接填 AccessKey。
*/
class SettingsController
{
private const DRIVERS = ['local', 'oss'];
private const AUTHS = ['ecs_role', 'access_key'];
public function index(): void
{
Access::requireAdmin();
$s = Settings::all();
// 只探测一次:元数据服务不可达时每次调用都要等超时,别在同一个请求里问两遍
$ecsRoles = Oss::listEcsRoles();
view('settings/index', [
's' => $s,
'tableExists' => Settings::tableExists(),
'schemaNow' => Migrate::currentVersion(Database::get()),
'schemaTarget' => Migrate::VERSION,
'driver' => Storage::driver(),
'misconfigured' => Storage::ossMisconfigured(),
'ecsRoles' => $ecsRoles,
'isEcs' => $ecsRoles !== [],
'pageTitle' => '存储设置',
]);
}
public function save(): void
{
Access::requireAdmin();
csrf_check();
$driver = (string)($_POST['storage_driver'] ?? 'local');
if (!in_array($driver, self::DRIVERS, true)) {
$driver = 'local';
}
$auth = (string)($_POST['oss_auth'] ?? 'ecs_role');
if (!in_array($auth, self::AUTHS, true)) {
$auth = 'ecs_role';
}
$pairs = [
'storage_driver' => $driver,
'oss_endpoint' => $this->cleanEndpoint((string)($_POST['oss_endpoint'] ?? '')),
'oss_bucket' => $this->cleanToken((string)($_POST['oss_bucket'] ?? ''), 63),
'oss_prefix' => $this->cleanPrefix((string)($_POST['oss_prefix'] ?? '')),
'oss_auth' => $auth,
'oss_role_name' => $this->cleanToken((string)($_POST['oss_role_name'] ?? ''), 64),
'oss_ak_id' => $this->cleanToken((string)($_POST['oss_ak_id'] ?? ''), 128),
'oss_internal' => empty($_POST['oss_internal']) ? '0' : '1',
'photo_max_mb' => (string)max(1, min(50, (int)($_POST['photo_max_mb'] ?? 12))),
];
// 密钥留空 = 不修改(避免管理员每次保存都要重新粘贴一遍)
$newSecret = trim((string)($_POST['oss_ak_secret'] ?? ''));
if ($newSecret !== '') {
$pairs['oss_ak_secret'] = $newSecret;
} elseif (!Settings::tableExists() || Settings::get('oss_ak_secret') === '') {
$pairs['oss_ak_secret'] = '';
}
if ($driver === 'oss') {
$err = $this->validateOss($pairs, $auth);
if ($err !== null) {
flash('error', $err);
redirect('settings/index');
}
}
try {
Settings::put($pairs);
flash('error', '设置已保存。当前生效的存储方式:' . Storage::label(Storage::driver()));
} catch (\Throwable $e) {
flash('error', '保存失败:' . $e->getMessage()
. '(若提示表不存在,请先在服务器上执行 php tools/migrate.php)');
}
redirect('settings/index');
}
/**
* 测试连接。**用表单里当前填的值测**,所以可以先测通再保存。
* 返回 JSON,供前端按钮就地显示结果。
*/
public function test(): void
{
Access::requireAdmin();
header('Content-Type: application/json; charset=utf-8');
$token = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!hash_equals($_SESSION['_csrf'] ?? '', $token)) {
http_response_code(419);
echo json_encode(['ok' => false, 'message' => 'CSRF 校验失败'], JSON_UNESCAPED_UNICODE);
exit;
}
$auth = (string)($_POST['oss_auth'] ?? 'ecs_role');
$secret = trim((string)($_POST['oss_ak_secret'] ?? ''));
if ($secret === '') {
// 前端没填新密钥 → 用已保存的那把
$secret = (string)Settings::get('oss_ak_secret');
}
$cfg = [
'endpoint' => $this->cleanEndpoint((string)($_POST['oss_endpoint'] ?? '')),
'bucket' => $this->cleanToken((string)($_POST['oss_bucket'] ?? ''), 63),
'prefix' => $this->cleanPrefix((string)($_POST['oss_prefix'] ?? '')),
'auth' => in_array($auth, self::AUTHS, true) ? $auth : 'ecs_role',
'role_name' => $this->cleanToken((string)($_POST['oss_role_name'] ?? ''), 64),
'ak_id' => $this->cleanToken((string)($_POST['oss_ak_id'] ?? ''), 128),
'ak_secret' => $secret,
'internal' => !empty($_POST['oss_internal']),
];
if ($cfg['endpoint'] === '' || $cfg['bucket'] === '') {
echo json_encode(['ok' => false, 'message' => '请先填写 Endpoint 和 Bucket'], JSON_UNESCAPED_UNICODE);
exit;
}
try {
$result = (new Oss($cfg))->testRoundTrip();
} catch (\Throwable $e) {
$result = ['ok' => false, 'message' => $e->getMessage(), 'steps' => []];
}
$result['endpointResolved'] = (new Oss($cfg))->endpoint();
echo json_encode($result, JSON_UNESCAPED_UNICODE);
exit;
}
/** 列出该 ECS 实例可用的 RAM 角色,供前端一键填入 */
public function roles(): void
{
Access::requireAdmin();
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['roles' => Oss::listEcsRoles()], JSON_UNESCAPED_UNICODE);
exit;
}
// ────────────────────────────────────────────────── 清洗与校验
private function validateOss(array $pairs, string $auth): ?string
{
if ($pairs['oss_endpoint'] === '') {
return '请填写 OSS Endpoint(例如 oss-cn-qingdao.aliyuncs.com)';
}
if ($pairs['oss_bucket'] === '') {
return '请填写 Bucket 名称';
}
if (strpos($pairs['oss_endpoint'], 'aliyuncs.com') === false
&& strpos($pairs['oss_endpoint'], '.') === false) {
return 'Endpoint 看起来不像域名,请检查';
}
if ($auth === 'ecs_role' && $pairs['oss_role_name'] === '') {
return '选择 ECS 角色鉴权时,必须填写角色名(可点「检测可用角色」自动获取)';
}
if ($auth === 'access_key') {
$hasId = $pairs['oss_ak_id'] !== '';
$hasSecret = ($pairs['oss_ak_secret'] ?? '') !== '' || (string)Settings::get('oss_ak_secret') !== '';
if (!$hasId || !$hasSecret) {
return '选择 AccessKey 鉴权时,必须填写 AccessKeyId 与 AccessKeySecret';
}
}
return null;
}
/** endpoint 统一成纯域名(容忍用户粘贴带 https:// 的完整地址) */
private function cleanEndpoint(string $v): string
{
$v = strtolower(trim($v));
$v = preg_replace('#^https?://#', '', $v) ?? $v;
$v = rtrim($v, '/');
$v = preg_replace('/[^a-z0-9.\-]/', '', $v) ?? $v;
return $v;
}
private function cleanToken(string $v, int $max): string
{
$v = trim($v);
$v = preg_replace('/[^A-Za-z0-9._\-]/', '', $v) ?? $v;
return mb_substr($v, 0, $max);
}
private function cleanPrefix(string $v): string
{
$v = trim(str_replace('\\', '/', $v));
$v = ltrim($v, '/');
$v = preg_replace('#[^A-Za-z0-9._\-/]#', '', $v) ?? $v;
$v = str_replace('..', '', $v) ?? $v;
if ($v !== '' && substr($v, -1) !== '/') {
$v .= '/';
}
return mb_substr($v, 0, 200);
}
}