Files
TripLog/src/Uploads.php
T
laoyang cbfdcb9d16 附件:支持视频 / 文档上传,可预览的浏览器直接看,预览不了的给下载
之前节点附件只能传图片和 PDF(accept 过滤 + 20MB 上限),视频传不上来。
本批放开:MP4 / MOV / PDF / Word / Excel / ZIP 都能作为附件上传,
仍走同一份 Uploads::handleFiles(MIME 白名单三层识别不变),存进已配置的 OSS。

大小分级:照片仍走 photo_max_mb(管理员可调),视频与文档放宽到 40MB
(Uploads::MAX_FILE_BYTES,给线上 post_max_size=50M 留余量),先 detect 再按
is_image 选上限。前端 form.php / attachments.php 同步拦截并按类型细分暂存区图标。

预览走灯箱四类型视图:图片(img)/ 视频(<video>,配合 media/show 新增的
HTTP Range 单区间 206/416,进度条可拖、Safari 也能播)/ PDF(iframe 内嵌
阅读器)/ 其它(Word / ZIP 等浏览器预览不了 → 灯箱里给下载按钮)。
dl=1 下载不接 Range;多区间回退 200 全量。

已验证:本地全链 11 步全绿(渲染检查 308 项、表单交互 73 项);
远端 e2e 0 失败(新增 mp4 上传落库 video/mp4、Range 206/416/后缀区间/
多区间回退/dl 不接 Range 专项断言);部署后双 PHP 渲染逐字节一致。
2026-09-23 16:22:01 +08:00

275 lines
9.7 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
declare(strict_types=1);
namespace App;
/**
* 把「一个请求里带上来的文件」写成附件。
*
* 抽成独立类是因为有两个入口都要用它:
* · AttachmentController::upload —— 节点照片页的独立上传
* · NodeController::store/update —— 节点表单里跟节点一起提交的附件
* 两边各写一份的话,MIME 白名单、大小上限、孤儿文件回滚这些迟早会走偏。
*
* 两类入参(沿用原设计,别再合并):
* files[] —— 普通 multipart 文件(原图、PDF……)省流量,不预压
* cropped[] —— 前端裁过/压过的图片,JSON 字符串 [{"name":..,"data":"data:image/..;base64,.."}]
*/
final class Uploads
{
/** 单次请求最多处理的文件数,防止一次拖几百个把请求拖死 */
public const MAX_FILES = 30;
/** 单文件硬上限(兜底值;实际限制见 handleFiles 里的分级逻辑) */
public const MAX_BYTES = 20971520; // 20MB
/**
* 非图片文件(视频 / PDF / Word 等)的单文件上限。
* 视频普遍比照片大,20MB 根本不够用;但这再大也大不过服务器的
* post_max_size(超了整个请求会被 PHP 静默丢弃),所以取 40MB,
* 给 50M 的 post_max_size 留出表单字段的余量。
*/
public const MAX_FILE_BYTES = 41943040; // 40MB
/**
* 把 php.ini 里的 "8M" / "50M" 这类写法换算成字节数。
*
* 直接转发给全局的 ini_bytes()(helpers.php 里那个,模板也在用)——
* 项目里只能有一份实现,否则两处迟早对 '-1'(不限)这类边界值理解不一致。
*/
public static function iniBytes(string $value): int
{
return \ini_bytes($value);
}
/**
* 「整个请求体超过 post_max_size」时,PHP 会**静默丢掉 $_POST 和 $_FILES**,
* 表现为「点了保存但什么都没发生 / CSRF 校验失败」——最难排查的一类问题。
* 这里按 CONTENT_LENGTH 提前认出来,好给用户一句能懂的话。
*/
public static function requestTooLarge(): bool
{
$len = (int)($_SERVER['CONTENT_LENGTH'] ?? 0);
if ($len <= 0) {
return false;
}
$max = self::iniBytes((string)ini_get('post_max_size'));
return $max > 0 && $len > $max;
}
/** 站点设置的「单张照片上限」(字节) */
public static function maxPhotoBytes(): int
{
return (int)Settings::get('photo_max_mb', '12') * 1024 * 1024;
}
/**
* 处理 $_FILES 里的某个多选字段(形状来自 name="xxx[]")。
*
* @param int $countStart 已经计入上限的文件数(files 与 cropped 共用同一个上限)
* @return array{uploaded:int, errors:string[], count:int}
*/
public static function handleFiles(
array $filesField,
string $targetType,
int $targetId,
int $uploaderId,
int $countStart = 0
): array {
$uploaded = 0;
$errors = [];
$count = $countStart;
if (empty($filesField) || !is_array($filesField['name'] ?? null)) {
return ['uploaded' => 0, 'errors' => [], 'count' => $count];
}
$photoMax = self::maxPhotoBytes();
foreach (self::restructure($filesField) 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;
}
/* 先识别类型再限大小:图片走管理员设的 photo_max_mb(12MB),
视频 / PDF / Word 等非图片走更宽的 MAX_FILE_BYTES(40MB)。
detectFile 只读文件头 32 字节,先跑也不费。 */
$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']) {
$limit = $photoMax > 0 ? $photoMax : self::MAX_BYTES;
if ($file['size'] > $limit) {
$errors[] = $file['name'] . ':照片超过 ' . (int)Settings::get('photo_max_mb') . 'MB';
continue;
}
} elseif ($file['size'] > self::MAX_FILE_BYTES) {
$errors[] = $file['name'] . ':文件超过 ' . (int)(self::MAX_FILE_BYTES / 1048576) . 'MB';
continue;
}
$raw = @file_get_contents($file['tmp_name']);
if ($raw === false) {
$errors[] = $file['name'] . ':读取临时文件失败';
continue;
}
$err = self::storeOne($targetType, $targetId, $uploaderId, $raw, $mime, $file['name'], $info);
if ($err === null) {
$uploaded++;
} else {
$errors[] = $file['name'] . ':' . $err;
}
}
return ['uploaded' => $uploaded, 'errors' => $errors, 'count' => $count];
}
/**
* 处理前端裁剪/压缩后的图片(base64 data URL)。
*
* @return array{uploaded:int, errors:string[], count:int}
*/
public static function handleCropped(
string $json,
string $targetType,
int $targetId,
int $uploaderId,
int $countStart = 0
): array {
$uploaded = 0;
$errors = [];
$count = $countStart;
if (trim($json) === '') {
return ['uploaded' => 0, 'errors' => [], 'count' => $count];
}
$items = json_decode($json, true);
if (!is_array($items)) {
return ['uploaded' => 0, 'errors' => ['裁剪数据解析失败'], 'count' => $count];
}
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) > self::MAX_BYTES) {
$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 = self::storeOne($targetType, $targetId, $uploaderId, $raw, $mime,
self::imageName($name, $mime), $info);
if ($err === null) {
$uploaded++;
} else {
$errors[] = $name . ':' . $err;
}
}
return ['uploaded' => $uploaded, 'errors' => $errors, 'count' => $count];
}
/** 真正落库 + 落存储;成功返回 null,失败返回错误文案 */
public static function storeOne(
string $targetType,
int $targetId,
int $uploaderId,
string $raw,
string $mime,
string $fileName,
array $info
): ?string {
$ext = Mime::extFor($mime);
if ($ext === null) {
return '无法确定扩展名';
}
try {
$key = Storage::keyFor($ext, 'attachments');
$stored = Storage::put($key, $raw, $mime);
} catch (\Throwable $e) {
return $e->getMessage();
}
try {
Attachments::create(
$targetType,
$targetId,
$uploaderId,
$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 这类名字,避免扩展名与真实格式不符 */
public static function imageName(string $name, string $mime): string
{
$base = pathinfo($name, PATHINFO_FILENAME) ?: 'photo';
return $base . '.' . (Mime::extFor($mime) ?? 'jpg');
}
/** 把 $_FILES 的列式数组转成一列「一个文件一项」 */
public static function restructure(array $files): array
{
$result = [];
$count = count($files['name']);
for ($i = 0; $i < $count; $i++) {
if (empty($files['name'][$i])) {
continue;
}
$result[] = [
'name' => $files['name'][$i],
'type' => $files['type'][$i],
'tmp_name' => $files['tmp_name'][$i],
'error' => $files['error'][$i],
'size' => $files['size'][$i],
];
}
return $result;
}
}