现算(地址形态换过一次, * 历史数据自动生效、零迁移 —— 这是 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']); } } /** 能在浏览器里直接预览吗(图片 / PDF) */ public static function previewable(array $a): bool { if ((int)($a['is_image'] ?? 0) === 1) { return true; } return strpos((string)($a['mime'] ?? ''), 'pdf') !== 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(); } }