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