交通字段简化 + 照片多图/裁剪/双存储后端 + 界面重构
交通字段 - 飞机只记航班号、火车只记车次,不再收座位号 / 线路 / 承运人 / 登机口 (多人同乘一趟时座位号本来就无意义) - 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:
@@ -3,118 +3,163 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Access;
|
||||
use App\Attachments;
|
||||
use App\Auth;
|
||||
use App\Database;
|
||||
use App\Mime;
|
||||
use App\Settings;
|
||||
use App\Storage;
|
||||
|
||||
class AttachmentController
|
||||
{
|
||||
private const ALLOWED_MIMES = [
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/gif' => 'gif',
|
||||
'image/webp' => 'webp',
|
||||
'image/heic' => 'heic',
|
||||
'application/pdf' => 'pdf',
|
||||
'application/msword' => 'doc',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
|
||||
'application/vnd.ms-excel' => 'xls',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
|
||||
'application/zip' => 'zip',
|
||||
'video/mp4' => 'mp4',
|
||||
'video/quicktime' => 'mov',
|
||||
];
|
||||
|
||||
private const MAX_SIZE = 20 * 1024 * 1024;
|
||||
/** 单次请求最多处理的文件数,防止一次拖几百个把请求拖死 */
|
||||
private const MAX_FILES = 30;
|
||||
|
||||
public function node(): void
|
||||
{
|
||||
$this->requireLogin();
|
||||
$nodeId = (int)($_GET['id'] ?? 0);
|
||||
$node = $this->fetchNodeWithAccess($nodeId);
|
||||
|
||||
$db = Database::get();
|
||||
$stmt = $db->prepare('SELECT a.*, u.name AS uploader_name
|
||||
FROM attachments a
|
||||
LEFT JOIN users u ON u.id = a.uploader_id
|
||||
WHERE a.target_type = "trip_node" AND a.target_id = ?
|
||||
ORDER BY a.created_at DESC');
|
||||
$stmt->execute([$nodeId]);
|
||||
$attachments = $stmt->fetchAll();
|
||||
$node = Access::node($nodeId); // 含 family_id / activity_id / activity_title
|
||||
|
||||
view('nodes/attachments', [
|
||||
'node' => $node,
|
||||
'attachments' => $attachments,
|
||||
'pageTitle' => '节点附件:' . ($node['title'] ?: '无标题'),
|
||||
'node' => $node,
|
||||
'attachments' => Attachments::forTarget(Attachments::TARGET_NODE, $nodeId),
|
||||
'maxMb' => (int)Settings::get('photo_max_mb', '12'),
|
||||
'driverLabel' => Storage::label(),
|
||||
'pageTitle' => '节点照片:' . ($node['title'] ?: '无标题'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传附件。
|
||||
*
|
||||
* 一个请求里同时收两类东西:
|
||||
* files[] —— 普通 multipart 文件(照片原图、PDF、视频……)
|
||||
* cropped[] —— 前端裁好的图片,JSON 字符串 {"name":..,"data":"data:image/...;base64,.."}
|
||||
* 分开传是为了省流量:没裁的照片原图直传,裁过的才走 base64。
|
||||
*/
|
||||
public function upload(): void
|
||||
{
|
||||
$this->requireLogin();
|
||||
csrf_check();
|
||||
|
||||
$nodeId = (int)($_POST['node_id'] ?? 0);
|
||||
$this->fetchNodeWithAccess($nodeId);
|
||||
$node = Access::node($nodeId);
|
||||
|
||||
if (empty($_FILES['files']) || !is_array($_FILES['files']['name'])) {
|
||||
flash('error', '未选择文件');
|
||||
$maxBytes = 20 * 1024 * 1024;
|
||||
$photoMax = (int)Settings::get('photo_max_mb', '12') * 1024 * 1024;
|
||||
|
||||
$uploaded = 0;
|
||||
$errors = [];
|
||||
$count = 0;
|
||||
|
||||
// ── 1) 普通文件 ──
|
||||
if (!empty($_FILES['files']) && is_array($_FILES['files']['name'])) {
|
||||
foreach ($this->restructureFiles($_FILES['files']) as $file) {
|
||||
if ($count >= self::MAX_FILES) {
|
||||
$errors[] = '一次最多上传 ' . self::MAX_FILES . ' 个文件,多余的已忽略';
|
||||
break;
|
||||
}
|
||||
$count++;
|
||||
|
||||
if ($file['error'] !== UPLOAD_ERR_OK) {
|
||||
$errors[] = $file['name'] . ':上传错误(码 ' . $file['error'] . ')';
|
||||
continue;
|
||||
}
|
||||
$limit = $photoMax > 0 ? max($photoMax, $maxBytes) : $maxBytes;
|
||||
if ($file['size'] > $limit) {
|
||||
$errors[] = $file['name'] . ':超过大小上限';
|
||||
continue;
|
||||
}
|
||||
|
||||
$info = Mime::detectFile($file['tmp_name'], $file['name']);
|
||||
$mime = $info['mime'];
|
||||
if ($mime === null || !isset(Mime::ALLOWED[$mime])) {
|
||||
$errors[] = $file['name'] . ':不支持的文件类型';
|
||||
continue;
|
||||
}
|
||||
if ($info['is_image'] && $photoMax > 0 && $file['size'] > $photoMax) {
|
||||
$errors[] = $file['name'] . ':照片超过 ' . (int)Settings::get('photo_max_mb') . 'MB';
|
||||
continue;
|
||||
}
|
||||
|
||||
$raw = @file_get_contents($file['tmp_name']);
|
||||
if ($raw === false) {
|
||||
$errors[] = $file['name'] . ':读取临时文件失败';
|
||||
continue;
|
||||
}
|
||||
|
||||
$err = $this->store($nodeId, $raw, $mime, $file['name'], $info);
|
||||
if ($err === null) {
|
||||
$uploaded++;
|
||||
} else {
|
||||
$errors[] = $file['name'] . ':' . $err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2) 前端裁剪后的图片 ──
|
||||
$cropped = $_POST['cropped'] ?? '';
|
||||
if (is_string($cropped) && $cropped !== '') {
|
||||
$items = json_decode($cropped, true);
|
||||
if (!is_array($items)) {
|
||||
$errors[] = '裁剪数据解析失败';
|
||||
$items = [];
|
||||
}
|
||||
foreach ($items as $item) {
|
||||
if ($count >= self::MAX_FILES) {
|
||||
$errors[] = '一次最多上传 ' . self::MAX_FILES . ' 个文件,多余的已忽略';
|
||||
break;
|
||||
}
|
||||
$count++;
|
||||
|
||||
$name = (string)($item['name'] ?? 'photo.png');
|
||||
$data = (string)($item['data'] ?? '');
|
||||
if (!preg_match('#^data:(image/[a-z0-9.+-]+);base64,(.+)$#is', $data, $m)) {
|
||||
$errors[] = $name . ':裁剪结果格式不正确';
|
||||
continue;
|
||||
}
|
||||
$raw = base64_decode($m[2], true);
|
||||
if ($raw === false || $raw === '') {
|
||||
$errors[] = $name . ':图片解码失败';
|
||||
continue;
|
||||
}
|
||||
if (strlen($raw) > $maxBytes) {
|
||||
$errors[] = $name . ':超过 20MB';
|
||||
continue;
|
||||
}
|
||||
|
||||
$info = Mime::detectBuffer($raw);
|
||||
$mime = $info['mime'];
|
||||
if ($mime === null || !Mime::isImageMime($mime) || !isset(Mime::ALLOWED[$mime])) {
|
||||
$errors[] = $name . ':裁剪结果不是受支持的图片';
|
||||
continue;
|
||||
}
|
||||
|
||||
$err = $this->store($nodeId, $raw, $mime, $this->asImageName($name, $mime), $info);
|
||||
if ($err === null) {
|
||||
$uploaded++;
|
||||
} else {
|
||||
$errors[] = $name . ':' . $err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($count === 0 && $uploaded === 0) {
|
||||
flash('error', '没有选择任何文件');
|
||||
redirect('attachments/node', ['id' => $nodeId]);
|
||||
}
|
||||
|
||||
$files = $this->restructureFiles($_FILES['files']);
|
||||
$uploaded = 0;
|
||||
$errors = [];
|
||||
|
||||
$db = Database::get();
|
||||
$uploadDir = dirname(__DIR__, 2) . '/uploads/attachments';
|
||||
if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true);
|
||||
|
||||
foreach ($files as $file) {
|
||||
if ($file['error'] !== UPLOAD_ERR_OK) {
|
||||
$errors[] = $file['name'] . ': 上传错误';
|
||||
continue;
|
||||
}
|
||||
if ($file['size'] > self::MAX_SIZE) {
|
||||
$errors[] = $file['name'] . ': 超过 20MB';
|
||||
continue;
|
||||
}
|
||||
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime = finfo_file($finfo, $file['tmp_name']);
|
||||
finfo_close($finfo);
|
||||
|
||||
if (!isset(self::ALLOWED_MIMES[$mime])) {
|
||||
$errors[] = $file['name'] . ': 不支持的类型';
|
||||
continue;
|
||||
}
|
||||
|
||||
$ext = self::ALLOWED_MIMES[$mime];
|
||||
$filename = date('Ymd') . '_' . bin2hex(random_bytes(8)) . '.' . $ext;
|
||||
$dest = $uploadDir . '/' . $filename;
|
||||
|
||||
if (!move_uploaded_file($file['tmp_name'], $dest)) {
|
||||
$errors[] = $file['name'] . ': 保存失败';
|
||||
continue;
|
||||
}
|
||||
|
||||
$stmt = $db->prepare('INSERT INTO attachments
|
||||
(target_type, target_id, uploader_id, file_url, file_name)
|
||||
VALUES ("trip_node", ?, ?, ?, ?)');
|
||||
$stmt->execute([
|
||||
$nodeId,
|
||||
Auth::id(),
|
||||
'/uploads/attachments/' . $filename,
|
||||
$file['name'],
|
||||
]);
|
||||
$uploaded++;
|
||||
}
|
||||
|
||||
$msg = "成功上传 {$uploaded} 个文件";
|
||||
$msg = "成功上传 {$uploaded} 个文件(存到" . Storage::label() . ')';
|
||||
if (!empty($errors)) {
|
||||
$msg .= ';失败:' . implode(',', $errors);
|
||||
$msg .= ';失败 ' . count($errors) . ' 个:' . implode(';', array_slice($errors, 0, 5));
|
||||
}
|
||||
if (Storage::ossMisconfigured()) {
|
||||
$msg .= '|注意:已选择 OSS 但配置不完整,本次实际存到了本站服务器';
|
||||
}
|
||||
|
||||
flash('error', $msg);
|
||||
redirect('attachments/node', ['id' => $nodeId]);
|
||||
redirect('attachments/node', ['id' => $nodeId], 'photos');
|
||||
}
|
||||
|
||||
public function destroy(): void
|
||||
@@ -122,36 +167,67 @@ class AttachmentController
|
||||
$this->requireLogin();
|
||||
csrf_check();
|
||||
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$db = Database::get();
|
||||
|
||||
$stmt = $db->prepare('SELECT * FROM attachments WHERE id = ? AND target_type = "trip_node"');
|
||||
$stmt->execute([$id]);
|
||||
$att = $stmt->fetch();
|
||||
if (!$att) {
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$att = Attachments::find($id);
|
||||
if (!$att || $att['target_type'] !== Attachments::TARGET_NODE) {
|
||||
flash('error', '附件不存在');
|
||||
redirect('activities/index');
|
||||
}
|
||||
|
||||
$this->fetchNodeWithAccess((int)$att['target_id']);
|
||||
$node = Access::node((int)$att['target_id']); // 顺带做归属校验
|
||||
Attachments::delete($id);
|
||||
|
||||
$url = $att['file_url'];
|
||||
if (strpos($url, '/uploads/attachments/') === 0) {
|
||||
$file = dirname(__DIR__, 2) . $url;
|
||||
if (is_file($file)) @unlink($file);
|
||||
flash('error', '照片已删除');
|
||||
redirect('attachments/node', ['id' => (int)$node['id']], 'photos');
|
||||
}
|
||||
|
||||
/** 真正落库 + 落存储;成功返回 null,失败返回错误文案 */
|
||||
private function store(int $nodeId, string $raw, string $mime, string $fileName, array $info): ?string
|
||||
{
|
||||
$ext = Mime::extFor($mime);
|
||||
if ($ext === null) {
|
||||
return '无法确定扩展名';
|
||||
}
|
||||
|
||||
$stmt = $db->prepare('DELETE FROM attachments WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
try {
|
||||
$key = Storage::keyFor($ext, 'attachments');
|
||||
$stored = Storage::put($key, $raw, $mime);
|
||||
} catch (\Throwable $e) {
|
||||
return $e->getMessage();
|
||||
}
|
||||
|
||||
flash('error', '附件已删除');
|
||||
redirect('attachments/node', ['id' => (int)$att['target_id']]);
|
||||
try {
|
||||
Attachments::create(
|
||||
Attachments::TARGET_NODE,
|
||||
$nodeId,
|
||||
(int)Auth::id(),
|
||||
$stored,
|
||||
$fileName,
|
||||
$mime,
|
||||
(bool)$info['is_image'],
|
||||
$info['width'],
|
||||
$info['height']
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
// 入库失败就把刚写的对象删掉,别留孤儿文件
|
||||
Storage::delete($stored['storage'], $stored['key']);
|
||||
return '入库失败:' . $e->getMessage();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 裁剪结果统一成 .jpg/.png 这类名字,避免扩展名与真实格式不符 */
|
||||
private function asImageName(string $name, string $mime): string
|
||||
{
|
||||
$base = pathinfo($name, PATHINFO_FILENAME) ?: 'photo';
|
||||
return $base . '.' . (Mime::extFor($mime) ?? 'jpg');
|
||||
}
|
||||
|
||||
private function restructureFiles(array $files): array
|
||||
{
|
||||
$result = [];
|
||||
$count = count($files['name']);
|
||||
$count = count($files['name']);
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
if (empty($files['name'][$i])) continue;
|
||||
$result[] = [
|
||||
@@ -165,33 +241,10 @@ class AttachmentController
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function fetchNodeWithAccess(int $nodeId): array
|
||||
{
|
||||
$db = Database::get();
|
||||
$stmt = $db->prepare('
|
||||
SELECT tn.*, a.family_id, a.id AS activity_id, a.title AS activity_title
|
||||
FROM trip_nodes tn
|
||||
JOIN activities a ON a.id = tn.activity_id
|
||||
WHERE tn.id = ?
|
||||
');
|
||||
$stmt->execute([$nodeId]);
|
||||
$node = $stmt->fetch();
|
||||
|
||||
if (!$node) {
|
||||
http_response_code(404);
|
||||
exit('节点不存在');
|
||||
}
|
||||
if ((int)$node['family_id'] !== Auth::familyId()) {
|
||||
http_response_code(403);
|
||||
exit('无权访问此节点');
|
||||
}
|
||||
return $node;
|
||||
}
|
||||
|
||||
private function requireLogin(): void
|
||||
{
|
||||
if (!Auth::check()) {
|
||||
redirect('auth/login');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user