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

交通字段
- 飞机只记航班号、火车只记车次,不再收座位号 / 线路 / 承运人 / 登机口
  (多人同乘一趟时座位号本来就无意义)
- 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
+20 -43
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Controllers;
use App\Access;
use App\Attachments;
use App\Auth;
use App\Comments;
use App\Database;
@@ -59,18 +60,20 @@ class NodeController
}
$data = $input['data'];
// 交通信息只留 trip_number(车次/航班号)—— 航班号本身就隐含航空公司,
// 座位号/登机口对「一趟车多人同行」没有意义,所以这些字段不再收集。
$sql = 'INSERT INTO trip_nodes
(activity_id, node_type_id, node_category, original_plan_node_id,
start_time, end_time, duration_minutes,
start_location, end_location,
title, description, travel_mode_id, vehicle_id,
line_name, carrier, trip_number, seat_info, gate, status, cancel_reason)
trip_number, status, cancel_reason)
VALUES
(:activity_id, :node_type_id, :node_category, :original_plan_node_id,
:start_time, :end_time, :duration_minutes,
:start_location, :end_location,
:title, :description, :travel_mode_id, :vehicle_id,
:line_name, :carrier, :trip_number, :seat_info, :gate, :status, :cancel_reason)';
:trip_number, :status, :cancel_reason)';
$stmt = $db->prepare($sql);
$stmt->execute($data);
@@ -106,8 +109,7 @@ class NodeController
start_location = :start_location, end_location = :end_location,
title = :title, description = :description,
travel_mode_id = :travel_mode_id, vehicle_id = :vehicle_id,
line_name = :line_name, carrier = :carrier, trip_number = :trip_number,
seat_info = :seat_info, gate = :gate, status = :status, cancel_reason = :cancel_reason
trip_number = :trip_number, status = :status, cancel_reason = :cancel_reason
WHERE id = :id';
$stmt = $db->prepare($sql);
$stmt->execute($data);
@@ -174,8 +176,8 @@ class NodeController
start_time, end_time, duration_minutes,
start_location, end_location,
title, description, travel_mode_id, vehicle_id,
line_name, carrier, trip_number, seat_info, gate, status)
VALUES (?, ?, "reality", ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, "completed")');
trip_number, status)
VALUES (?, ?, "reality", ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, "completed")');
$stmt->execute([
(int)$plan['activity_id'],
(int)$plan['node_type_id'],
@@ -189,11 +191,7 @@ class NodeController
$plan['description'],
$plan['travel_mode_id'],
$plan['vehicle_id'],
$plan['line_name'],
$plan['carrier'],
$plan['trip_number'],
$plan['seat_info'],
$plan['gate'],
]);
flash('error', '已根据计划节点生成执行记录,可继续编辑补充实际情况');
@@ -207,14 +205,8 @@ class NodeController
$node = $this->fetchOwnNode((int)($_POST['id'] ?? 0));
// 软关联的附件先查出来,删完数据再删文件
$stmt = $db->prepare('SELECT file_url FROM attachments
WHERE target_type = "trip_node" AND target_id = ?');
$stmt->execute([(int)$node['id']]);
$files = $stmt->fetchAll(\PDO::FETCH_COLUMN);
$stmt = $db->prepare('DELETE FROM attachments WHERE target_type = "trip_node" AND target_id = ?');
$stmt->execute([(int)$node['id']]);
// 附件是软关联:交给 Attachments 统一清(同时会删本地文件或 OSS 对象)
Attachments::purgeTarget(Attachments::TARGET_NODE, (int)$node['id']);
$stmt = $db->prepare('DELETE FROM comments WHERE target_type = "trip_node" AND target_id = ?');
$stmt->execute([(int)$node['id']]);
@@ -223,10 +215,6 @@ class NodeController
$stmt = $db->prepare('DELETE FROM trip_nodes WHERE id = ?');
$stmt->execute([(int)$node['id']]);
foreach ($files as $url) {
$this->deleteAttachmentFile((string)$url);
}
flash('error', '节点已删除');
redirect('activities/show', ['id' => (int)$node['activity_id']]);
}
@@ -249,6 +237,7 @@ class NodeController
// 出行方式(连同车辆信息拼成一行展示文本)
$travelModeLabel = '';
$travelModeType = '';
if (!empty($node['travel_mode_id'])) {
$stmt = $db->prepare('
SELECT tm.mode_type, v.plate_number, v.model_name
@@ -264,18 +253,13 @@ class NodeController
} elseif (!empty($tm['model_name'])) {
$label .= ' · ' . $tm['model_name'];
}
// 线路名属于节点自身(trip_nodes.line_name),不是出行方式表的列
if (!empty($node['line_name'])) {
$label .= ' · ' . $node['line_name'];
}
$travelModeLabel = $label;
$travelModeType = (string)$tm['mode_type'];
}
}
$stmt = $db->prepare('SELECT COUNT(*) FROM attachments
WHERE target_type = "trip_node" AND target_id = ?');
$stmt->execute([$nodeId]);
$attachmentCount = (int)$stmt->fetchColumn();
$nodeAttachments = Attachments::forTarget(Attachments::TARGET_NODE, $nodeId);
$attachmentCount = count($nodeAttachments);
$familyId = (int)$node['family_id'];
@@ -284,8 +268,10 @@ class NodeController
'node_type_name' => (string)$type['name'],
'node_type_icon' => (string)$type['icon'],
'travel_mode_label' => $travelModeLabel,
'travel_mode_type' => $travelModeType,
]),
'attachmentCount' => $attachmentCount,
'attachments' => $nodeAttachments,
'members' => Members::forNode($nodeId),
'familyUsers' => Members::familyUsers($familyId),
'roleTags' => Members::roleTagMap($familyId),
@@ -414,11 +400,11 @@ class NodeController
'description' => trim($_POST['description'] ?? ''),
'travel_mode_id' => $travelModeId,
'vehicle_id' => $vehicleId,
'line_name' => $this->strOrNull('line_name'),
'carrier' => $this->strOrNull('carrier'),
'line_name' => null,
'carrier' => null,
'trip_number' => $this->strOrNull('trip_number'),
'seat_info' => $this->strOrNull('seat_info'),
'gate' => $this->strOrNull('gate'),
'seat_info' => null,
'gate' => null,
'status' => $status,
'cancel_reason' => $status === 'cancelled' ? $this->strOrNull('cancel_reason') : null,
];
@@ -473,15 +459,6 @@ class NodeController
return $node;
}
private function deleteAttachmentFile(string $url): void
{
if (strpos($url, '/uploads/attachments/') !== 0 || strpos($url, '..') !== false) {
return;
}
$file = dirname(__DIR__, 2) . $url;
if (is_file($file)) @unlink($file);
}
private function calcDuration(string $start, string $end): int
{
$s = strtotime($start);