Files
TripLog/templates/settings/index.php
T
laoyang f9a624ef4b 交通字段简化 + 照片多图/裁剪/双存储后端 + 界面重构
交通字段
- 飞机只记航班号、火车只记车次,不再收座位号 / 线路 / 承运人 / 登机口
  (多人同乘一趟时座位号本来就无意义)
- 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 字节)
2026-09-22 23:09:37 +08:00

311 lines
14 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
/**
* 存储设置(仅管理员)
*
* 两件事在这里定:照片存哪(本站 / 阿里云 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>