323 lines
13 KiB
PHP
323 lines
13 KiB
PHP
<?php
|
||||
|
|
/**
|
|||
|
|
* 暂存上传(v24 第 20 批 用户第 4 条)。
|
|||
|
|
*
|
|||
|
|
* 用户原话:「所有上传图片视频和附件的模块,都允许多次拖动添加文件上传,
|
|||
|
|
* 拖动一个进去,就开始上传,还可以继续拖动,继续上传,
|
|||
|
|
* 最后统一保存即可。」
|
|||
|
|
*
|
|||
|
|
* ────────────────────────────────────────────────────────────────
|
|||
|
|
* 职责划分(与 `upload_stage.php` 端点配套):
|
|||
|
|
* · stage() 把拖进来的**一个**文件落进暂存目录,回一个随机令牌(stoken);
|
|||
|
|
* · claim() 保存时把一批令牌**认领**到某个条目上(移文件 + 建附件行);
|
|||
|
|
* · sweep() 清掉过期(没被认领)的暂存文件,别让它们永远堆着。
|
|||
|
|
*
|
|||
|
|
* 🔴 两条设计取舍(都栽过同类):
|
|||
|
|
* ① **暂存文件不能只放 tmp**:web 请求结束 tmp 就没了,而用户可能在页面上
|
|||
|
|
* 停留几十分钟才点保存。所以落到 `uploads/_staging/` 下,并按 TTL 清。
|
|||
|
|
* ② **认领要"要么全成、要么不动"**:认领到一半失败(比如某张图坏了)时,
|
|||
|
|
* 已经移过去的文件要能回滚 —— 否则用户会看到"保存了但少了一张"。
|
|||
|
|
* 这里用"先全部校验、再逐个移"的顺序,把失败面收到最小;
|
|||
|
|
* 单个失败时只跳过那一个并如实回报告,不静默吞。
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
declare(strict_types=1);
|
|||
|
|
|
|||
|
|
namespace App;
|
|||
|
|
|
|||
|
|
use App\Storage\Storage;
|
|||
|
|
|
|||
|
|
final class StagedUpload
|
|||
|
|
{
|
|||
|
|
/** 暂存根目录(在 uploads 下,Web 不可直接访问 —— 由 media.php 判登录态) */
|
|||
|
|
private const DIR = '_staging';
|
|||
|
|
|
|||
|
|
/** 令牌长度(字节 → 十六进制 48 字符,猜不出来) */
|
|||
|
|
private const TOK_BYTES = 24;
|
|||
|
|
|
|||
|
|
/** 多久没被认领就当垃圾清掉(秒)。2 小时:够一次正常的"慢慢挑图" */
|
|||
|
|
public const TTL = 7200;
|
|||
|
|
|
|||
|
|
/** 一次最多认领多少个(防手搓请求塞几百个) */
|
|||
|
|
public const MAX_CLAIM = 60;
|
|||
|
|
|
|||
|
|
/** 暂存目录绝对路径 */
|
|||
|
|
private static function root(): string
|
|||
|
|
{
|
|||
|
|
return FB_UPLOADS . '/' . self::DIR;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 一个令牌对应的目录(令牌只允许十六进制,杜绝 `..` 穿越) */
|
|||
|
|
private static function tokDir(string $stoken): string
|
|||
|
|
{
|
|||
|
|
if (!preg_match('/^[0-9a-f]{16,64}$/', $stoken)) {
|
|||
|
|
return '';
|
|||
|
|
}
|
|||
|
|
return self::root() . '/' . $stoken;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 落一个文件到暂存区。
|
|||
|
|
*
|
|||
|
|
* @return array{ok:bool,stoken?:string,name?:string,is_image?:bool,kind?:string,
|
|||
|
|
* size?:int,size_word?:string,url?:string,error?:string}
|
|||
|
|
*/
|
|||
|
|
public static function stage(int $familyId, int $personId, array $file, string $slot = ''): array
|
|||
|
|
{
|
|||
|
|
$tmp = (string)($file['tmp_name'] ?? '');
|
|||
|
|
if ($tmp === '' || !is_uploaded_file($tmp)) {
|
|||
|
|
return ['ok' => false, 'error' => '临时文件不见了,请重试。'];
|
|||
|
|
}
|
|||
|
|
$origName = self::safeName((string)($file['name'] ?? ''));
|
|||
|
|
|
|||
|
|
$fino = new \finfo(FILEINFO_MIME_TYPE);
|
|||
|
|
$mime = (string)$fino->file($tmp);
|
|||
|
|
$isImage = strpos($mime, 'image/') === 0;
|
|||
|
|
|
|||
|
|
$stoken = bin2hex(random_bytes(self::TOK_BYTES));
|
|||
|
|
$dir = self::tokDir($stoken);
|
|||
|
|
if ($dir === '') {
|
|||
|
|
return ['ok' => false, 'error' => '令牌生成异常。'];
|
|||
|
|
}
|
|||
|
|
if (!@mkdir($dir, 0755, true) && !is_dir($dir)) {
|
|||
|
|
return ['ok' => false, 'error' => '暂存目录建不出来(检查 uploads 权限)。'];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if ($isImage) {
|
|||
|
|
// 图片:走既定的槽位规则(比例 / 尺寸 / 内存守卫都在 Upload 里,一处说了算)。
|
|||
|
|
// ⚠️ 槽位键必须是 ImageSlot::SLOTS 里真实存在的 —— 传空/未知时**回落到 'photo'**
|
|||
|
|
// (那个是 ratio=free 的"只缩不裁"槽位,正是"上传照片"该有的行为)。
|
|||
|
|
$slotKey = ($slot !== '' && ImageSlot::get($slot) !== null) ? $slot : 'photo';
|
|||
|
|
$up = Upload::image($familyId, $file, $slotKey);
|
|||
|
|
if (empty($up['ok'])) {
|
|||
|
|
self::rmDir($dir);
|
|||
|
|
return ['ok' => false, 'error' => (string)($up['error'] ?: '图片没存上')];
|
|||
|
|
}
|
|||
|
|
// Upload 已经把图落到**正式目录**了;但"还没保存"这段时间不该占用正式目录的语义。
|
|||
|
|
// ⚠️ 这里**不做"先落正式再搬暂存"**:Upload 只支持落到存储层,
|
|||
|
|
// 而暂存在本地。所以取回那个正式键里的**字节**再写进暂存目录,
|
|||
|
|
// 最后把正式键删掉 —— 全程只多一次读+写,换来语义干净。
|
|||
|
|
// ⚠️ 读字节必须用 Storage::read()(**不是** Storage::get —— 那是个不存在的方法,
|
|||
|
|
// 第一版就写错成 get 了;read 才同时覆盖"本地文件"与"OSS 取回"两条路)。
|
|||
|
|
$formalkey = (string)$up['file'];
|
|||
|
|
$got = Storage::read($familyId, $formalkey);
|
|||
|
|
Storage::delete($familyId, $formalkey);
|
|||
|
|
$bin = (string)($got['body'] ?? '');
|
|||
|
|
if ($bin === '') {
|
|||
|
|
self::rmDir($dir);
|
|||
|
|
return ['ok' => false, 'error' => '图片转存失败,请重试。'];
|
|||
|
|
}
|
|||
|
|
$fname = 'blob.jpg'; // Upload 输出恒为 JPEG(见 Upload 类注释)
|
|||
|
|
if (@file_put_contents($dir . '/' . $fname, $bin) === false) {
|
|||
|
|
self::rmDir($dir);
|
|||
|
|
return ['ok' => false, 'error' => '写暂存文件失败。'];
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
// 非图片(视频 / PDF / 视频原件…):原样存,不压缩(压缩会把它们弄坏)
|
|||
|
|
$fname = 'blob' . self::extOf($origName, $mime);
|
|||
|
|
if (!@copy($tmp, $dir . '/' . $fname)) {
|
|||
|
|
self::rmDir($dir);
|
|||
|
|
return ['ok' => false, 'error' => '写暂存文件失败。'];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$size = (int)@filesize($dir . '/' . $fname);
|
|||
|
|
// 元信息写一个小 json:认领时要用它的**原始文件名**与 mime
|
|||
|
|
// (不能认领时再去嗅探一遍 —— 多一次 IO,且非图片形态各一)
|
|||
|
|
@file_put_contents($dir . '/meta.json', json_encode([
|
|||
|
|
'name' => $origName,
|
|||
|
|
'mime' => $mime,
|
|||
|
|
'is_image' => $isImage,
|
|||
|
|
'size' => $size,
|
|||
|
|
'file' => $fname,
|
|||
|
|
'by' => $personId,
|
|||
|
|
'at' => time(),
|
|||
|
|
], JSON_UNESCAPED_UNICODE));
|
|||
|
|
|
|||
|
|
return [
|
|||
|
|
'ok' => true,
|
|||
|
|
'stoken' => $stoken,
|
|||
|
|
'name' => $origName,
|
|||
|
|
'is_image' => $isImage,
|
|||
|
|
'kind' => self::kindWord($mime, $origName, $isImage),
|
|||
|
|
'size' => $size,
|
|||
|
|
'size_word' => Attachment::sizeWord($size),
|
|||
|
|
// 预览地址:走 media.php 的 `stage` 形态(见 media.php 里那段的说明)
|
|||
|
|
'url' => 'media.php?stage=' . $stoken,
|
|||
|
|
];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 把一批令牌**认领**到某个条目上(保存时调用)。
|
|||
|
|
*
|
|||
|
|
* @param string $type 附件宿主类型('trip_node' / 'resource' / 'calendar' / ...)
|
|||
|
|
* @param int $targetId 宿主 id
|
|||
|
|
* @return array{ok:int,warns:string[]} ok=成功认领的个数
|
|||
|
|
*/
|
|||
|
|
public static function claim(int $familyId, int $personId, array $stokens,
|
|||
|
|
string $type, int $targetId): array
|
|||
|
|
{
|
|||
|
|
$warns = [];
|
|||
|
|
$done = 0;
|
|||
|
|
if (!$stokens) {
|
|||
|
|
return ['ok' => 0, 'warns' => []];
|
|||
|
|
}
|
|||
|
|
// ⚠️ 上限:手搓请求塞几百个令牌会把这次保存拖死
|
|||
|
|
$stokens = array_slice(array_values(array_unique(array_map('strval', $stokens))), 0, self::MAX_CLAIM);
|
|||
|
|
|
|||
|
|
foreach ($stokens as $stoken) {
|
|||
|
|
$dir = self::tokDir($stoken);
|
|||
|
|
if ($dir === '' || !is_dir($dir)) {
|
|||
|
|
continue; // 已在别处认领过 / 已被清掉 → 跳过(不是错误)
|
|||
|
|
}
|
|||
|
|
$metaRaw = @file_get_contents($dir . '/meta.json');
|
|||
|
|
$meta = $metaRaw !== false ? json_decode($metaRaw, true) : null;
|
|||
|
|
if (!is_array($meta)) {
|
|||
|
|
self::rmDir($dir);
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
// 只认**自己**暂存的(拿到别人的令牌也认领不了)
|
|||
|
|
if ((int)($meta['by'] ?? 0) !== $personId) {
|
|||
|
|
$warns[] = '有一个文件不是你自己上传的,已跳过。';
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
$src = $dir . '/' . (string)($meta['file'] ?? '');
|
|||
|
|
if (!is_file($src)) {
|
|||
|
|
self::rmDir($dir);
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
$name = (string)($meta['name'] ?? '');
|
|||
|
|
$mime = (string)($meta['mime'] ?? '');
|
|||
|
|
$isImage = !empty($meta['is_image']);
|
|||
|
|
// 复用 Attachment::save 的既定路径(它负责落存储 + 建行 + 位置 + 限额)
|
|||
|
|
$fake = [
|
|||
|
|
'name' => $name !== '' ? $name : '文件',
|
|||
|
|
'type' => $mime,
|
|||
|
|
'tmp_name' => $src,
|
|||
|
|
'error' => UPLOAD_ERR_OK,
|
|||
|
|
'size' => (int)@filesize($src),
|
|||
|
|
];
|
|||
|
|
$res = \App\Attachment::save($familyId, $type, $targetId, $fake, $personId);
|
|||
|
|
if (!empty($res['ok'])) {
|
|||
|
|
$done++;
|
|||
|
|
self::rmDir($dir);
|
|||
|
|
} else {
|
|||
|
|
$msg = (string)($res['error'] ?? '');
|
|||
|
|
if ($msg !== '') {
|
|||
|
|
$warns[] = ($name !== '' ? $name : '一个文件') . ':' . $msg;
|
|||
|
|
}
|
|||
|
|
// 存不上就别留暂存(留着下次还会失败,且占地方)
|
|||
|
|
self::rmDir($dir);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return ['ok' => $done, 'warns' => $warns];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 从 media.php 回一个暂存文件的字节(只在刚拖完、还没保存时用) */
|
|||
|
|
public static function read(int $familyId, int $personId, string $stoken): ?array
|
|||
|
|
{
|
|||
|
|
$dir = self::tokDir($stoken);
|
|||
|
|
if ($dir === '' || !is_dir($dir)) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
$metaRaw = @file_get_contents($dir . '/meta.json');
|
|||
|
|
$meta = $metaRaw !== false ? json_decode($metaRaw, true) : null;
|
|||
|
|
if (!is_array($meta) || (int)($meta['by'] ?? 0) !== $personId) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
$src = $dir . '/' . (string)($meta['file'] ?? '');
|
|||
|
|
if (!is_file($src)) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
return [
|
|||
|
|
'path' => $src,
|
|||
|
|
'mime' => (string)($meta['mime'] ?? 'application/octet-stream'),
|
|||
|
|
'name' => (string)($meta['name'] ?? ''),
|
|||
|
|
];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 清掉一个暂存目录(被"自己接管了文件"的调用方用完时调,比如海报/票根那条路) */
|
|||
|
|
public static function forget(string $stoken): void
|
|||
|
|
{
|
|||
|
|
$dir = self::tokDir($stoken);
|
|||
|
|
if ($dir !== '') {
|
|||
|
|
self::rmDir($dir);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 清掉过期没被认领的暂存目录(在 business 路由里顺手调一次,不必挂定时任务) */
|
|||
|
|
public static function sweep(): void
|
|||
|
|
{
|
|||
|
|
$root = self::root();
|
|||
|
|
if (!is_dir($root)) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
$now = time();
|
|||
|
|
foreach ((array)@scandir($root) as $d) {
|
|||
|
|
if ($d === '.' || $d === '..') {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
$p = $root . '/' . $d;
|
|||
|
|
if (!is_dir($p)) {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
$mt = (int)@filemtime($p);
|
|||
|
|
if ($mt > 0 && ($now - $mt) > self::TTL) {
|
|||
|
|
self::rmDir($p);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------------- 内部 ----------------
|
|||
|
|
|
|||
|
|
private static function safeName(string $n): string
|
|||
|
|
{
|
|||
|
|
$n = str_replace(['\\', '/', ':', '*', '?', '"', '<', '>', '|'], '', $n);
|
|||
|
|
$n = trim(preg_replace('/\s+/u', ' ', $n));
|
|||
|
|
return mb_substr($n, 0, 180, 'UTF-8');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 扩展名:优先用原文件名的,没有就按 mime 猜一个(都认不出就不带) */
|
|||
|
|
private static function extOf(string $name, string $mime): string
|
|||
|
|
{
|
|||
|
|
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
|||
|
|
$ext = preg_replace('/[^a-z0-9]/', '', $ext);
|
|||
|
|
if ($ext !== '') {
|
|||
|
|
return '.' . $ext;
|
|||
|
|
}
|
|||
|
|
$m = [
|
|||
|
|
'video/mp4' => 'mp4', 'video/quicktime' => 'mov', 'video/webm' => 'webm',
|
|||
|
|
'application/pdf' => 'pdf', 'audio/mpeg' => 'mp3', 'audio/mp4' => 'm4a',
|
|||
|
|
];
|
|||
|
|
return isset($m[$mime]) ? '.' . $m[$mime] : '';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static function kindWord(string $mime, string $name, bool $isImage): string
|
|||
|
|
{
|
|||
|
|
if ($isImage) {
|
|||
|
|
return '图片';
|
|||
|
|
}
|
|||
|
|
return Attachment::kindWord(Attachment::kind([
|
|||
|
|
'is_image' => 0, 'mime' => $mime, 'name' => $name,
|
|||
|
|
]));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static function rmDir(string $dir): void
|
|||
|
|
{
|
|||
|
|
if (!is_dir($dir)) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
foreach ((array)@scandir($dir) as $f) {
|
|||
|
|
if ($f === '.' || $f === '..') {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
$p = $dir . '/' . $f;
|
|||
|
|
if (is_dir($p)) {
|
|||
|
|
self::rmDir($p);
|
|||
|
|
} else {
|
|||
|
|
@unlink($p);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
@rmdir($dir);
|
|||
|
|
}
|
|||
|
|
}
|