现算(地址形态换过一次, * 历史数据自动生效、零迁移 —— 这是 FeedLite 那边学到的最省事的一条)。 */ declare(strict_types=1); namespace App; use App\Storage\Storage; final class Attachment { /** 图片扩展名 → MIME(服务端自己判,不信浏览器给的类型) */ private const IMG_MIME = [ 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'gif' => 'image/gif', 'webp' => 'image/webp', 'heic' => 'image/heic', ]; public const MAX_PER_HOST = 60; /** 某个宿主的全部附件 */ public static function list(string $type, int $targetId): array { $st = Database::get()->prepare( 'SELECT * FROM attachments WHERE target_type = ? AND target_id = ? ORDER BY position, id' ); $st->execute([$type, $targetId]); return $st->fetchAll(); } /** * 存一个上传来的文件。 * * @return array{ok:bool,error?:string,id?:int} */ public static function save(int $familyId, string $type, int $targetId, array $file, int $uploaderId = 0): array { if (!array_key_exists('error', $file)) { return ['ok' => false, 'error' => '']; } if (is_array($file['error'])) { return ['ok' => false, 'error' => '上传参数不对']; } if ($file['error'] === UPLOAD_ERR_NO_FILE) { return ['ok' => false, 'error' => '']; } if ($file['error'] !== UPLOAD_ERR_OK) { return ['ok' => false, 'error' => '上传失败(错误码 ' . (int)$file['error'] . ')']; } $stc = Database::get()->prepare('SELECT COUNT(*) FROM attachments WHERE target_type = ? AND target_id = ?'); $stc->execute([$type, $targetId]); if ((int)$stc->fetchColumn() >= self::MAX_PER_HOST) { return ['ok' => false, 'error' => '单个条目最多 ' . self::MAX_PER_HOST . ' 个附件']; } $tmp = (string)$file['tmp_name']; $size = (int)@filesize($tmp); $fino = new \finfo(FILEINFO_MIME_TYPE); $mime = (string)$fino->file($tmp); $name = self::safeName((string)($file['name'] ?? '')); $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION)); if ($ext === '' && isset(self::IMG_MIME[$mime])) { $ext = explode('/', $mime)[1] ?? ''; } // 图片走 photo 槽位(ratio='free':不裁剪,等比缩到最长边 1600); // 非图片(PDF/文档)原样存 —— 压缩反而会把它弄坏。 // ⚠️ 槽位键必须是 ImageSlot::SLOTS 里真实存在的那个:曾经这里写 'free' // (把"不裁剪"的 ratio 当成槽位名用了),于是 Upload::image 直接返回 // 「上传位置配置不正确」,所有附件上传静默失败。 $isImage = strpos($mime, 'image/') === 0; if ($isImage) { $up = Upload::image($familyId, $file, 'photo'); if (empty($up['ok'])) { return ['ok' => false, 'error' => (string)($up['error'] ?? '存图失败')]; } $key = (string)$up['file']; } else { $key = 'files/' . bin2hex(random_bytes(12)) . ($ext !== '' ? '.' . preg_replace('/[^a-z0-9]/', '', $ext) : ''); $res = Storage::putFile($familyId, $tmp, $key, $mime !== '' ? $mime : 'application/octet-stream'); if (empty($res['ok'])) { return ['ok' => false, 'error' => '保存失败:' . (string)($res['error'] ?? '')]; } } $db = Database::get(); $db->prepare( 'INSERT INTO attachments (family_id, target_type, target_id, `key`, name, mime, size, is_image, position, uploader_id) VALUES (?,?,?,?,?,?,?,?,?,?)' )->execute([ $familyId, $type, $targetId, $key, $name !== '' ? $name : null, $mime !== '' ? $mime : null, $size > 0 ? $size : null, $isImage ? 1 : 0, self::nextPosition($type, $targetId), $uploaderId > 0 ? $uploaderId : null, ]); return ['ok' => true, 'id' => (int)$db->lastInsertId()]; } /** 删一个附件(连存储对象一起) */ public static function remove(int $id): void { $st = Database::get()->prepare('SELECT family_id, `key` FROM attachments WHERE id = ?'); $st->execute([$id]); $r = $st->fetch(); if (!$r) { return; } Database::get()->prepare('DELETE FROM attachments WHERE id = ?')->execute([$id]); Storage::delete((int)$r['family_id'], (string)$r['key']); } /** 删某个宿主的全部附件(删旅程 / 删资源 / 删任务时调) */ public static function removeAll(string $type, int $targetId): void { foreach (self::list($type, $targetId) as $a) { self::remove((int)$a['id']); } } /** * 这个附件是「哪一类」—— 决定页面上画缩略图还是画图标、点开是预览还是下载。 * * 返回:image / video / pdf / doc / xls / ppt / text / file * * ⚠️ 判据是**库里的 mime**(上传时由服务端嗅探内容得来),不看文件名后缀: * 改个后缀骗不过它,而"文档其实是张图"这种也认得出来。 */ public static function kind(array $a): string { if ((int)($a['is_image'] ?? 0) === 1) { return 'image'; } $mime = strtolower((string)($a['mime'] ?? '')); $name = strtolower((string)($a['name'] ?? '')); $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION)); if ($mime === '' && $ext !== '') { $mime = self::EXT_MIME[$ext] ?? ''; } if (strpos($mime, 'video/') === 0) { return 'video'; } if (strpos($mime, 'audio/') === 0) { return 'video'; // 音频用播放器放,与视频同一条路 } if (strpos($mime, 'pdf') !== false || $ext === 'pdf') { return 'pdf'; } if (strpos($mime, 'text/') === 0 || in_array($ext, ['txt', 'md', 'csv', 'log', 'json'], true)) { return 'text'; } // Office 的 MIME 一长串且各家写法不一(含 -msword / officedocument / opendocument), // 扩展名反而更稳,所以这里两条路都认。 if (self::inAny($mime . ' ' . $ext, ['word', 'doc', 'rtf', 'odt', 'wps'])) { return 'doc'; } if (self::inAny($mime . ' ' . $ext, ['excel', 'sheet', 'xls', 'xlsx', 'csv', 'ods', 'et'])) { return 'xls'; } if (self::inAny($mime . ' ' . $ext, ['powerpoint', 'presentation', 'ppt', 'pps', 'odp', 'dps'])) { return 'ppt'; } return 'file'; } /** * 能在**本页**直接预览吗。 * * 图片与视频不用说;PDF 浏览器自带阅读器,也能直接看。 * Word / Excel 之类浏览器渲染不了 —— 点了就下载(不装"点了没反应"的预览)。 */ public static function previewable(array $a): bool { return in_array(self::kind($a), ['image', 'video', 'pdf'], true); } /** 这一类在人话里叫什么(图标上的角标、预览标题都用它) */ public static function kindWord(string $kind): string { return self::KIND_WORDS[$kind] ?? '文件'; } private const KIND_WORDS = [ 'image' => '图片', 'video' => '视频', 'pdf' => 'PDF', 'doc' => 'Word', 'xls' => 'Excel', 'ppt' => 'PPT', 'text' => '文本', 'file' => '文件', ]; /** 扩展名兜底用的 MIME 表(库里 mime 为空时) */ private const EXT_MIME = [ 'pdf' => 'application/pdf', 'txt' => 'text/plain', 'md' => 'text/markdown', 'csv' => 'text/csv', 'json' => 'application/json', 'mp4' => 'video/mp4', 'm4v' => 'video/mp4', 'webm' => 'video/webm', 'mov' => 'video/quicktime', 'avi' => 'video/x-msvideo', 'mkv' => 'video/x-matroska', 'mp3' => 'audio/mpeg', 'm4a' => 'audio/mp4', 'wav' => 'audio/wav', 'doc' => 'application/msword', 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'xls' => 'application/vnd.ms-excel', 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'ppt' => 'application/vnd.ms-powerpoint', 'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'zip' => 'application/zip', 'rar' => 'application/x-rar-compressed', ]; private static function inAny(string $hay, array $needles): bool { foreach ($needles as $n) { if (strpos($hay, $n) !== false) { return true; } } return false; } /** 人话的大小 */ public static function sizeWord($bytes): string { $b = (int)$bytes; if ($b <= 0) { return ''; } if ($b < 1024) { return $b . ' B'; } if ($b < 1024 * 1024) { return round($b / 1024, 1) . ' KB'; } return round($b / 1024 / 1024, 1) . ' MB'; } 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'); } private static function nextPosition(string $type, int $targetId): int { $st = Database::get()->prepare( 'SELECT COALESCE(MAX(position),0)+10 FROM attachments WHERE target_type = ? AND target_id = ?' ); $st->execute([$type, $targetId]); return (int)$st->fetchColumn(); } }