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

交通字段
- 飞机只记航班号、火车只记车次,不再收座位号 / 线路 / 承运人 / 登机口
  (多人同乘一趟时座位号本来就无意义)
- 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
+396
View File
@@ -0,0 +1,396 @@
<?php
declare(strict_types=1);
namespace App;
/**
* 极简阿里云 OSS 客户端(不依赖 composer,只有 curl + hash)。
*
* 为什么手写:项目没有 composer / autoload,官方 SDK 拉不进来。
* OSS 的 V1 签名本身很简单,够用且可审计:
*
* StringToSign = VERB \n Content-MD5 \n Content-Type \n Date \n
* CanonicalizedOSSHeaders + CanonicalizedResource
* Authorization: OSS <AccessKeyId>:<base64(hmac-sha1(AccessKeySecret, StringToSign))>
*
* 鉴权两种:
* 1) ecs_role —— 从 ECS 实例元数据取 RAM 角色的 STS 临时凭证(含 SecurityToken,
* 需要作为 x-oss-security-token 头,并计入 CanonicalizedOSSHeaders);
* 2) access_key —— 直接用长期 AK/SK。
*
* 桶是**私有**的:本类从不生成对外直链,取文件一律经 PHP 代理(见 MediaController)。
*/
final class Oss
{
private const META_BASE = 'http://100.100.100.200/latest/meta-data/ram/security-credentials/';
private string $endpoint; // 已归一化(去掉 scheme、去尾斜杠)
private string $bucket;
private string $prefix;
private string $auth; // ecs_role | access_key
private string $roleName;
private string $akId;
private string $akSecret;
/** @var array{id:string,secret:string,token:string,expire:int} 运行时凭证 */
private array $cred = ['id' => '', 'secret' => '', 'token' => '', 'expire' => 0];
public function __construct(array $cfg)
{
$this->bucket = trim((string)($cfg['bucket'] ?? ''));
$this->prefix = self::normalizePrefix((string)($cfg['prefix'] ?? ''));
$this->auth = (string)($cfg['auth'] ?? 'ecs_role');
$this->roleName = (string)($cfg['role_name'] ?? '');
$this->akId = (string)($cfg['ak_id'] ?? '');
$this->akSecret = (string)($cfg['ak_secret'] ?? '');
$endpoint = trim((string)($cfg['endpoint'] ?? ''));
$endpoint = preg_replace('#^https?://#i', '', $endpoint) ?? $endpoint;
$endpoint = rtrim($endpoint, '/');
// 「内网」不是单独的参数,而是在 endpoint 里插 -internal:
// oss-cn-qingdao.aliyuncs.com -> oss-cn-qingdao-internal.aliyuncs.com
if (!empty($cfg['internal']) && strpos($endpoint, '-internal.') === false) {
$endpoint = preg_replace('/^([a-z0-9-]+?)(\.[a-z0-9-]+\.[a-z]+)$/i', '$1-internal$2', $endpoint, 1) ?? $endpoint;
}
$this->endpoint = $endpoint;
}
public function bucket(): string { return $this->bucket; }
public function prefix(): string { return $this->prefix; }
public function endpoint(): string { return $this->endpoint; }
/** 元数据/设置里的相对键 → 带前缀的完整对象键 */
public function objectKey(string $key): string
{
return $this->prefix . ltrim($key, '/');
}
// ────────────────────────────────────────────────────────────── 基本操作
/** 上传(覆盖)。$key 为不含前缀的相对键 */
public function put(string $key, string $body, string $mime = 'application/octet-stream'): void
{
$res = $this->request('PUT', $this->objectKey($key), [
'Content-Type' => $mime,
'Content-MD5' => base64_encode(md5($body, true)),
], $body);
if ($res['status'] < 200 || $res['status'] >= 300) {
throw new \RuntimeException('OSS 上传失败(HTTP ' . $res['status'] . '):' . self::short($res['body']));
}
}
/** 下载对象内容 */
public function get(string $key): string
{
$res = $this->request('GET', $this->objectKey($key));
if ($res['status'] === 404) {
throw new \RuntimeException('对象不存在:' . $key);
}
if ($res['status'] < 200 || $res['status'] >= 300) {
throw new \RuntimeException('OSS 读取失败(HTTP ' . $res['status'] . '):' . self::short($res['body']));
}
return $res['body'];
}
/** 读取对象响应头(用于代理时取 Content-Type / Content-Length 而不下载正文) */
public function head(string $key): ?array
{
$res = $this->request('HEAD', $this->objectKey($key));
if ($res['status'] < 200 || $res['status'] >= 300) {
return null;
}
return self::parseHeaders($res['headers']);
}
public function delete(string $key): bool
{
$res = $this->request('DELETE', $this->objectKey($key));
// 404 视为已不存在,同样算成功(幂等)
return ($res['status'] >= 200 && $res['status'] < 300) || $res['status'] === 404;
}
/**
* 走一遍「上传 → 读取 → 删除」,验证凭证对目标桶真的有读写权限。
* 比只做 HEAD 桶更贴近真实用途(很多 RAM 策略不给 HeadBucket)。
*
* @return array{ok:bool,message:string,steps:string[]}
*/
public function testRoundTrip(): array
{
$key = '.healthcheck/' . date('Ymd_His') . '_' . bin2hex(random_bytes(4)) . '.txt';
$body = 'triplog healthcheck ' . date('c');
$steps = [];
try {
$this->put($key, $body, 'text/plain');
$steps[] = '上传 OK';
} catch (\Throwable $e) {
return ['ok' => false, 'message' => $e->getMessage(), 'steps' => $steps];
}
try {
$got = $this->get($key);
if ($got !== $body) {
return ['ok' => false, 'message' => '读取内容与上传不一致', 'steps' => array_merge($steps, ['读取 内容不符'])];
}
$steps[] = '读取 OK';
} catch (\Throwable $e) {
$steps[] = '读取 失败';
$this->safeDelete($key);
return ['ok' => false, 'message' => $e->getMessage(), 'steps' => $steps];
}
$ok = $this->safeDelete($key);
$steps[] = $ok ? '删除 OK' : '删除 失败';
return [
'ok' => $ok,
'message' => $ok ? '连接正常,读写与删除都可用' : '上传读取正常,但删除失败(检查 RAM 策略是否含 DeleteObject)',
'steps' => $steps,
];
}
private function safeDelete(string $key): bool
{
try {
return $this->delete($key);
} catch (\Throwable $e) {
return false;
}
}
// ────────────────────────────────────────────────────────────── ECS 元数据
/**
* 列出该 ECS 实例可用的 RAM 角色名。
* 结果在单次请求内缓存 —— 不在 ECS 上时这里要等一次超时,别重复问。
*
* @return string[]
*/
public static function listEcsRoles(): array
{
static $cache = null;
if ($cache !== null) {
return $cache;
}
$res = self::httpGet(self::META_BASE, 2);
if ($res === null) {
return $cache = [];
}
$roles = array_filter(array_map('trim', explode("\n", $res)));
return $cache = array_values(array_unique(array_filter($roles, static function (string $r): bool {
return $r !== '' && strpos($r, '404') === false;
})));
}
// ────────────────────────────────────────────────────────────── 内部实现
private function request(string $verb, string $resourceKey, array $headers = [], string $body = ''): array
{
$cred = $this->credential();
$date = gmdate('D, d M Y H:i:s \G\M\T');
// x-oss-* 头(含 STS 的 security-token)必须参与签名
$ossHeaders = [];
if ($cred['token'] !== '') {
$ossHeaders['x-oss-security-token'] = $cred['token'];
}
$canonicalizedOssHeaders = '';
ksort($ossHeaders);
foreach ($ossHeaders as $k => $v) {
$canonicalizedOssHeaders .= strtolower($k) . ':' . $v . "\n";
}
$canonicalizedResource = '/' . $this->bucket . '/' . $resourceKey;
$stringToSign = $verb . "\n"
. ($headers['Content-MD5'] ?? '') . "\n"
. ($headers['Content-Type'] ?? '') . "\n"
. $date . "\n"
. $canonicalizedOssHeaders
. $canonicalizedResource;
$signature = base64_encode(hash_hmac('sha1', $stringToSign, $cred['secret'], true));
$reqHeaders = array_merge($headers, $ossHeaders, [
'Date' => $date,
'Authorization' => 'OSS ' . $cred['id'] . ':' . $signature,
]);
$url = 'https://' . $this->bucket . '.' . $this->endpoint . '/' . self::encodeKey($resourceKey);
return self::httpRequest($verb, $url, $reqHeaders, $body, 30);
}
/**
* 取到可用凭证:ECS 角色走元数据(带文件缓存,避免每个请求都去问元数据服务),
* AK/SK 直接返回;两者统一成 {id, secret, token, expire}。
*/
private function credential(): array
{
if ($this->cred['id'] !== '' && $this->cred['expire'] > time()) {
return $this->cred;
}
if ($this->auth === 'access_key') {
if ($this->akId === '' || $this->akSecret === '') {
throw new \RuntimeException('未填写 AccessKeyId / AccessKeySecret');
}
return $this->cred = ['id' => $this->akId, 'secret' => $this->akSecret, 'token' => '', 'expire' => time() + 3600];
}
if ($this->roleName === '') {
throw new \RuntimeException('未选择 ECS 角色名');
}
// 先看缓存
$cached = $this->readCredCache();
if ($cached !== null) {
return $this->cred = $cached;
}
$url = self::META_BASE . rawurlencode($this->roleName);
$json = self::httpGet($url, 3);
if ($json === null) {
throw new \RuntimeException('读取 ECS 元数据失败(该机器可能不是 ECS,或元数据服务不可达)');
}
$data = json_decode($json, true);
if (!is_array($data) || empty($data['AccessKeyId']) || empty($data['AccessKeySecret'])) {
throw new \RuntimeException('ECS 元数据未返回有效凭证:' . self::short($json));
}
if (($data['Code'] ?? 'Success') !== 'Success') {
throw new \RuntimeException('ECS 角色凭证获取失败:' . (string)($data['Code'] ?? ''));
}
$expire = isset($data['Expiration']) ? (int)strtotime((string)$data['Expiration']) : (time() + 900);
$cred = [
'id' => (string)$data['AccessKeyId'],
'secret' => (string)$data['AccessKeySecret'],
'token' => (string)($data['SecurityToken'] ?? ''),
// 提前 5 分钟作废,避免签名时刚好过期
'expire' => max(time() + 60, $expire - 300),
];
$this->writeCredCache($cred);
return $this->cred = $cred;
}
/**
* 凭证缓存落在 /tmp(**不能在 web 目录下**:STS 临时凭证是密钥,
* 放 uploads/ 会被直接下载走)。open_basedir 允许 /tmp。
*/
private function cachePath(): string
{
$id = substr(hash('sha256', $this->endpoint . '|' . $this->bucket . '|' . $this->auth . '|' . $this->roleName), 0, 16);
return rtrim(sys_get_temp_dir(), '/\\') . '/triplog_oss_' . $id . '.json';
}
private function readCredCache(): ?array
{
$f = $this->cachePath();
if (!is_file($f)) return null;
$raw = @file_get_contents($f);
if ($raw === false || $raw === '') return null;
$d = json_decode($raw, true);
if (!is_array($d) || empty($d['id']) || empty($d['secret']) || (int)($d['expire'] ?? 0) <= time()) {
@unlink($f);
return null;
}
return ['id' => (string)$d['id'], 'secret' => (string)$d['secret'],
'token' => (string)($d['token'] ?? ''), 'expire' => (int)$d['expire']];
}
private function writeCredCache(array $cred): void
{
$f = $this->cachePath();
@file_put_contents($f, json_encode($cred));
@chmod($f, 0600);
}
// ────────────────────────────────────────────────────────────── 工具
/** 对象键编码:逐段 rawurlencode,保留 '/' */
private static function encodeKey(string $key): string
{
return implode('/', array_map('rawurlencode', explode('/', $key)));
}
private static function normalizePrefix(string $prefix): string
{
$prefix = trim($prefix);
if ($prefix === '') return '';
return rtrim($prefix, '/') . '/';
}
private static function short(string $s): string
{
$s = trim(preg_replace('/\s+/', ' ', $s) ?? '');
return mb_strlen($s) > 200 ? mb_substr($s, 0, 200) . '…' : $s;
}
/** @return array<string,string> */
private static function parseHeaders(array $rawHeaders): array
{
$out = [];
foreach ($rawHeaders as $line) {
$pos = strpos($line, ':');
if ($pos === false) continue;
$out[strtolower(trim(substr($line, 0, $pos)))] = trim(substr($line, $pos + 1));
}
return $out;
}
private static function httpGet(string $url, int $timeout): ?string
{
$res = self::httpRequest('GET', $url, ['Date' => gmdate('D, d M Y H:i:s \G\M\T')], '', $timeout);
return ($res['status'] >= 200 && $res['status'] < 300) ? $res['body'] : null;
}
/**
* @return array{status:int, body:string, headers:string[]}
*/
private static function httpRequest(string $verb, string $url, array $headers, string $body, int $timeout): array
{
if (!function_exists('curl_init')) {
throw new \RuntimeException('缺少 curl 扩展,无法访问对象存储');
}
$ch = curl_init($url);
$headerLines = [];
foreach ($headers as $k => $v) {
$headerLines[] = $k . ': ' . $v;
}
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $verb,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true,
CURLOPT_HTTPHEADER => $headerLines,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_CONNECTTIMEOUT => min(5, $timeout),
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
]);
if ($body !== '' || in_array($verb, ['PUT', 'POST'], true)) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$raw = curl_exec($ch);
$status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$hsize = (int)curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$err = curl_error($ch);
curl_close($ch);
if ($raw === false) {
throw new \RuntimeException('网络请求失败:' . $err);
}
return [
'status' => $status,
'headers' => explode("\r\n", substr((string)$raw, 0, $hsize)),
'body' => substr((string)$raw, $hsize),
];
}
}