Files
FamilyBook/app/StagedUpload.php
T

323 lines
13 KiB
PHP
Raw Normal View History

<?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);
}
}