authUser(); $file = $this->request->file('file'); if (!$file) { return $this->error('请选择文件'); } try { $ext = strtolower($file->extension() ?: pathinfo($file->getOriginalName(), PATHINFO_EXTENSION)); $ext = $ext ?: 'bin'; $mime = $this->resolveMime($file, $ext); $fileType = $this->detectFileType($mime, $ext); if (!empty($user['is_guest']) && $fileType === 'image') { return $this->error('游客模式不支持发送图片,请登录后再上传', 403); } if ($fileType === 'document' && !in_array($mime, config('upload.allowed_documents'), true)) { $mime = self::DOCUMENT_MIME_BY_EXT[$ext] ?? $mime; } if (!PermissionService::canUpload($user, $fileType)) { return $this->error('您没有权限上传此类型文件或功能未开启', 403); } $maxSize = PermissionService::getMaxUploadSizeMb($user) * 1024 * 1024; if ($file->getSize() > $maxSize) { return $this->error('文件大小超出限制'); } if (!$this->isAllowedMime($mime, $fileType, $ext)) { return $this->error('不支持的文件类型: ' . $mime); } $originalName = $file->getOriginalName(); $fileSize = $file->getSize(); $uploadPath = rtrim(config('upload.path'), '/\\'); $this->ensureUploadDir($uploadPath); $subdir = date('Y/m/d'); $storedBase = uniqid('', true) . '.' . $ext; $storedName = $subdir . '/' . $storedBase; $fullDir = $uploadPath . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $subdir); if (!is_dir($fullDir) && !mkdir($fullDir, 0755, true) && !is_dir($fullDir)) { return $this->error('无法创建上传目录,请检查 uploads 权限', 500); } if (!is_writable($fullDir)) { return $this->error('上传目录不可写,请执行: chmod -R 775 uploads && chown -R www-data:www-data uploads', 500); } $moved = $file->move($fullDir, $storedBase); if (!$moved) { return $this->error('文件保存失败: ' . ($file->getError() ?: '未知错误'), 500); } $record = UploadFile::create([ 'user_id' => $user['id'], 'original_name' => $originalName, 'stored_name' => $storedBase, 'file_path' => $storedName, 'mime_type' => $mime, 'file_size' => $fileSize, 'file_type' => $fileType, ]); return $this->success([ 'id' => $record->id, 'url' => '/api/uploads/' . rawurlencode($storedBase), 'name' => $originalName, 'type' => $fileType, 'mime' => $mime, 'size' => $fileSize, ]); } catch (\think\exception\FileException $e) { return $this->error('文件保存失败: ' . $e->getMessage(), 500); } catch (\Throwable $e) { return $this->error('上传失败: ' . $e->getMessage(), 500); } } public function serve($filename) { $filename = basename($filename); $upload = UploadFile::where('stored_name', $filename) ->whereOr('file_path', 'like', '%/' . $filename) ->find(); if (!$upload) { throw new HttpResponseException(json([ 'code' => 1, 'message' => '文件不存在', 'data' => null, ], 404)); } $path = rtrim(config('upload.path'), '/\\') . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $upload->file_path); if (!is_file($path)) { throw new HttpResponseException(json([ 'code' => 1, 'message' => '文件不存在', 'data' => null, ], 404)); } $mime = $upload->mime_type ?: (@mime_content_type($path) ?: 'application/octet-stream'); return response(file_get_contents($path), 200, [ 'Content-Type' => $mime, 'Content-Length' => (string) filesize($path), 'Cache-Control' => 'public, max-age=604800', ]); } /** * 文档扩展名 -> 常见但不同系统上可能检测出不一致的 MIME 兜底表。 * .doc/.docx 等 Office 文档在不同服务器 fileinfo 版本下识别出的 MIME 差异很大, * 仅靠 MIME 白名单很容易误判为“不支持的文件类型”,因此这里用扩展名兜底放行。 */ private const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp']; private const VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov']; private const DOCUMENT_EXTENSIONS = ['pdf', 'doc', 'docx', 'txt', 'md']; private const IMAGE_MIME_BY_EXT = [ 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'gif' => 'image/gif', 'webp' => 'image/webp', 'bmp' => 'image/bmp', ]; private const DOCUMENT_MIME_BY_EXT = [ 'pdf' => 'application/pdf', 'doc' => 'application/msword', 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'txt' => 'text/plain', 'md' => 'text/markdown', ]; private function resolveMime($file, string $ext): string { $mime = $file->getMime(); if (!$mime && is_file($file->getPathname())) { $mime = @mime_content_type($file->getPathname()) ?: ''; } $mime = strtolower(trim((string) $mime)); if ($mime === 'image/jpg') { $mime = 'image/jpeg'; } if ($mime === '' || $mime === 'application/octet-stream') { $mime = self::IMAGE_MIME_BY_EXT[$ext] ?? self::DOCUMENT_MIME_BY_EXT[$ext] ?? $mime; } return $mime ?: 'application/octet-stream'; } private function ensureUploadDir(string $uploadPath): void { if (is_dir($uploadPath)) { return; } if (!mkdir($uploadPath, 0755, true) && !is_dir($uploadPath)) { throw new \RuntimeException('无法创建 uploads 目录: ' . $uploadPath); } } private function detectFileType(string $mime, string $ext = ''): string { $ext = strtolower($ext); if (in_array($ext, self::IMAGE_EXTENSIONS, true)) { return 'image'; } if (in_array($ext, self::VIDEO_EXTENSIONS, true)) { return 'video'; } if (str_starts_with($mime, 'image/')) { return 'image'; } if (str_starts_with($mime, 'video/')) { return 'video'; } if (str_starts_with($mime, 'audio/')) { return 'audio'; } if (in_array($mime, config('upload.allowed_documents'), true)) { return 'document'; } if (in_array(strtolower($ext), self::DOCUMENT_EXTENSIONS, true)) { return 'document'; } return 'other'; } private function isAllowedMime(string $mime, string $fileType, string $ext = ''): bool { $config = config('upload'); return match ($fileType) { 'image' => in_array($mime, $config['allowed_images'], true) || in_array(strtolower($ext), self::IMAGE_EXTENSIONS, true), 'video' => in_array($mime, $config['allowed_videos'], true) || in_array(strtolower($ext), self::VIDEO_EXTENSIONS, true), 'audio' => in_array($mime, $config['allowed_audios'], true), 'document' => in_array($mime, $config['allowed_documents'], true) || in_array(strtolower($ext), self::DOCUMENT_EXTENSIONS, true), default => false, }; } }