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

交通字段
- 飞机只记航班号、火车只记车次,不再收座位号 / 线路 / 承运人 / 登机口
  (多人同乘一趟时座位号本来就无意义)
- 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
+310
View File
@@ -0,0 +1,310 @@
<?php
/**
* 存储设置(仅管理员)
*
* 两件事在这里定:照片存哪(本站 / 阿里云 OSS)、OSS 怎么鉴权(ECS 角色 / AccessKey)。
* 无论存哪里,页面里的照片地址都是本站的 media/show 代理 ——
* OSS 桶保持私有,前端拿不到、也不该拿到直链。
*/
$s = $s ?? [];
$isOss = ($s['storage_driver'] ?? 'local') === 'oss';
$auth = $s['oss_auth'] ?? 'ecs_role';
$hasSecret = (string)($s['oss_ak_secret'] ?? '') !== '';
$needMigrate = (int)$schemaNow < (int)$schemaTarget;
$locked = !$tableExists; // app_settings 还没建,保存必然失败
?>
<div class="page-header">
<div>
<h1>存储设置</h1>
<p class="muted">照片存哪里、怎么鉴权 —— 影响所有活动的附件</p>
</div>
<div class="actions">
<a href="<?= url('activities/index') ?>" class="btn">返回活动</a>
</div>
</div>
<div class="settings-status">
<div class="settings-chip <?= $driver === 'oss' ? 'ok' : '' ?>">
<div>当前生效</div>
<strong><?= e(\App\Storage::label($driver)) ?></strong>
<?php if ($misconfigured): ?>
<div class="text-danger">已选 OSS 但配置不全,实际回退到本站服务器</div>
<?php endif; ?>
</div>
<div class="settings-chip <?= $needMigrate ? 'warn' : 'ok' ?>">
<div>数据库结构</div>
<strong>v<?= (int)$schemaNow ?> / v<?= (int)$schemaTarget ?></strong>
<div><?= $needMigrate ? '需要执行迁移' : '已是最新' ?></div>
</div>
<div class="settings-chip <?= $isEcs ? 'ok' : 'warn' ?>">
<div>运行环境</div>
<strong><?= $isEcs ? '阿里云 ECS' : '非 ECS / 探测不到' ?></strong>
<div><?= $isEcs ? '角色:' . e(implode('、', $ecsRoles)) : 'ECS 角色鉴权需部署在阿里云 ECS 上' ?></div>
</div>
</div>
<?php if ($needMigrate): ?>
<div class="alert alert-warn">
数据库结构还是 v<?= (int)$schemaNow ?>,低于程序要求的 v<?= (int)$schemaTarget ?>。
请在服务器上以站点用户执行 <code>php tools/migrate.php</code>(CLI 专用,HTTP 访问不到)。
<?php if (!$tableExists): ?>
<br>当前 <code>app_settings</code> 表还不存在,本页「保存」会失败。
<?php endif; ?>
</div>
<?php endif; ?>
<form method="post" action="<?= url('settings/save') ?>" class="form" id="settings-form">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
<div class="panel">
<h2 class="section-title">照片存放在哪里</h2>
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="storage_driver" value="local" <?= $isOss ? '' : 'checked' ?>>
本站服务器
</label>
<label class="radio-label">
<input type="radio" name="storage_driver" value="oss" <?= $isOss ? 'checked' : '' ?>>
阿里云 OSS
</label>
</div>
<p class="settings-hint">
本站服务器:存到站点 <code>uploads/attachments/</code>,零配置。<br>
阿里云 OSS:桶保持 <b>私有</b>,读取由 PHP 代理转发。<br>
两种方式下页面里的照片地址都是 <code><?= e(url('media/show')) ?>?id=…</code>,看不出存储后端。
</p>
<div id="oss-fields" class="oss-fields<?= $isOss ? ' show' : '' ?>">
<div class="form-row">
<label for="oss_endpoint">Endpoint <span class="req">*</span></label>
<input type="text" id="oss_endpoint" name="oss_endpoint"
value="<?= e($s['oss_endpoint'] ?? '') ?>"
placeholder="oss-cn-qingdao.aliyuncs.com" autocomplete="off">
<p class="settings-hint">只填域名,别带 https://,地域要与桶一致。</p>
</div>
<div class="form-row">
<label for="oss_bucket">Bucket 名称 <span class="req">*</span></label>
<input type="text" id="oss_bucket" name="oss_bucket"
value="<?= e($s['oss_bucket'] ?? '') ?>"
placeholder="my-bucket" autocomplete="off">
<p class="settings-hint">桶的读写权限请保持「私有」。</p>
</div>
<div class="form-row">
<label for="oss_prefix">对象前缀</label>
<input type="text" id="oss_prefix" name="oss_prefix"
value="<?= e($s['oss_prefix'] ?? '') ?>"
placeholder="triplog/" autocomplete="off">
<p class="settings-hint">相当于桶里的目录,末尾会自动补 <code>/</code>。</p>
</div>
<div class="form-row">
<label for="photo_max_mb">单张照片上限(MB)</label>
<input type="number" id="photo_max_mb" name="photo_max_mb" min="1" max="50"
value="<?= (int)($s['photo_max_mb'] ?? 12) ?>">
<p class="settings-hint">只限制图片;PDF 等其它附件仍按 20MB 处理。</p>
</div>
<div class="form-row">
<label class="checkbox-label">
<input type="checkbox" name="oss_internal" value="1"
<?= (($s['oss_internal'] ?? '1') === '1') ? 'checked' : '' ?>>
<span>使用内网 Endpoint(服务器与桶同地域时勾选,省流量费)</span>
</label>
<p class="settings-hint">勾上后会自动把 <code>-internal</code> 插进域名。</p>
</div>
<h3 class="section-title">鉴权方式</h3>
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="oss_auth" value="ecs_role" <?= $auth === 'ecs_role' ? 'checked' : '' ?>>
ECS 实例角色(推荐)
</label>
<label class="radio-label">
<input type="radio" name="oss_auth" value="access_key" <?= $auth === 'access_key' ? 'checked' : '' ?>>
AccessKey
</label>
</div>
<div id="ecs-fields" class="oss-fields<?= $auth === 'ecs_role' ? ' show' : '' ?>">
<div class="form-row">
<label for="oss_role_name">实例 RAM 角色名 <span class="req">*</span></label>
<div class="inline-row">
<input type="text" id="oss_role_name" name="oss_role_name"
value="<?= e($s['oss_role_name'] ?? '') ?>"
placeholder="例如 SyncCalendarOssRole" autocomplete="off">
<button type="button" class="btn" id="btn-detect-roles">检测可用角色</button>
</div>
<?php if ($isEcs): ?>
<div class="role-chips" id="roles-result">
<?php foreach ($ecsRoles as $role): ?>
<button type="button" class="role-chip" data-role="<?= e($role) ?>"><?= e($role) ?></button>
<?php endforeach; ?>
</div>
<?php else: ?>
<div id="roles-result" class="settings-hint"></div>
<?php endif; ?>
<p class="settings-hint">
该角色需要对目标桶有读写权限。STS 临时凭证按分钟缓存在系统的 <code>/tmp</code>,
不会写进站点目录,也不会落到数据库。
</p>
</div>
</div>
<div id="ak-fields" class="oss-fields<?= $auth === 'access_key' ? ' show' : '' ?>">
<div class="form-row">
<label for="oss_ak_id">AccessKeyId</label>
<input type="text" id="oss_ak_id" name="oss_ak_id"
value="<?= e($s['oss_ak_id'] ?? '') ?>"
placeholder="LTAI…" autocomplete="off">
</div>
<div class="form-row">
<label for="oss_ak_secret">AccessKeySecret</label>
<input type="password" id="oss_ak_secret" name="oss_ak_secret"
value="" autocomplete="new-password"
placeholder="<?= $hasSecret ? '(已保存,留空表示不修改)' : '填写 Secret' ?>">
<p class="settings-hint">
<?= $hasSecret ? '已保存一把密钥,留空即不修改。' : '尚未保存密钥。' ?>
能用 ECS 角色就别用这个 —— AccessKey 是以明文存进数据库的。
</p>
</div>
</div>
<div class="inline-row">
<button type="button" class="btn" id="btn-test">测试连接</button>
<span class="settings-hint">用表单里当前填的值实测「上传 → 读取 → 删除」,不必先保存。</span>
</div>
<div class="test-result" id="test-result"></div>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary" <?= $locked ? 'disabled' : '' ?>>保存设置</button>
<?php if ($locked): ?>
<span class="settings-hint">跑完迁移后才能保存。</span>
<?php endif; ?>
</div>
</form>
<script>
(function () {
'use strict';
var form = document.getElementById('settings-form');
var ossBox = document.getElementById('oss-fields');
var ecsBox = document.getElementById('ecs-fields');
var akBox = document.getElementById('ak-fields');
var testBox = document.getElementById('test-result');
var btnTest = document.getElementById('btn-test');
var rolesBox = document.getElementById('roles-result');
var csrf = <?= json_encode(csrf_token()) ?>;
function radioValue(name, fallback) {
var el = form.querySelector('input[name="' + name + '"]:checked');
return el ? el.value : fallback;
}
function sync() {
ossBox.classList.toggle('show', radioValue('storage_driver', 'local') === 'oss');
var auth = radioValue('oss_auth', 'ecs_role');
ecsBox.classList.toggle('show', auth === 'ecs_role');
akBox.classList.toggle('show', auth === 'access_key');
}
form.addEventListener('change', sync);
sync();
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function showResult(ok, message, steps, extra) {
testBox.className = 'test-result show ' + (ok ? 'ok' : 'bad');
var lines = [(ok ? '✓ ' : '✕ ') + (message || '')];
if (extra) lines.push(extra);
(steps || []).forEach(function (step) {
var good = step.ok !== false;
lines.push((good ? ' ✓ ' : ' ✕ ') + (step.name || step.step || '')
+ (step.message ? ' — ' + step.message : ''));
});
testBox.textContent = lines.join('\n');
}
btnTest.addEventListener('click', function () {
btnTest.disabled = true;
testBox.className = 'test-result show';
testBox.textContent = '正在测试…';
var fd = new FormData(form);
fd.set('storage_driver', 'oss'); // 测试永远针对 OSS
fd.delete('_csrf'); // token 走请求头
var xhr = new XMLHttpRequest();
xhr.open('POST', <?= json_encode(url('settings/test')) ?>, true);
xhr.setRequestHeader('X-CSRF-Token', csrf);
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.onload = function () {
btnTest.disabled = false;
var data = null;
try { data = JSON.parse(xhr.responseText); } catch (e) {}
if (!data) {
var text = String(xhr.responseText || '').replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ').trim().slice(0, 200);
showResult(false, '接口没返回 JSON(HTTP ' + xhr.status + '):' + text);
return;
}
showResult(!!data.ok, data.message, data.steps,
data.endpointResolved ? '实际请求地址:' + data.endpointResolved : '');
};
xhr.onerror = function () {
btnTest.disabled = false;
showResult(false, '网络错误,请求没能发出去');
};
xhr.send(fd);
});
document.getElementById('btn-detect-roles').addEventListener('click', function () {
var btn = this;
btn.disabled = true;
fetch(<?= json_encode(url('settings/roles')) ?>, { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (data) {
btn.disabled = false;
var roles = (data && data.roles) || [];
if (!roles.length) {
rolesBox.innerHTML = '<span class="text-danger">没探测到角色。</span> '
+ '本机可能不是阿里云 ECS,或元数据服务被挡住了 —— 这种情况请改用 AccessKey。';
rolesBox.className = 'role-chips';
return;
}
var input = document.getElementById('oss_role_name');
if (!input.value) input.value = roles[0];
rolesBox.className = 'role-chips';
rolesBox.innerHTML = roles.map(function (r) {
return '<button type="button" class="role-chip" data-role="' + esc(r) + '">'
+ esc(r) + '</button>';
}).join('');
})
.catch(function (e) {
btn.disabled = false;
rolesBox.className = 'role-chips';
rolesBox.innerHTML = '<span class="text-danger">探测失败:</span>' + esc(e.message);
});
});
rolesBox.addEventListener('click', function (ev) {
var chip = ev.target.closest ? ev.target.closest('button[data-role]') : null;
if (!chip) return;
document.getElementById('oss_role_name').value = chip.getAttribute('data-role');
});
})();
</script>