From 50f28e45e4c948bccd3677f58806494c5670d488 Mon Sep 17 00:00:00 2001 From: "WIN-C27C5MSVTPO\\16961" <1696136552@qq.com> Date: Thu, 5 Mar 2026 17:10:29 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=8A=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/controller/BaseController.php | 2 +- backend/app/controller/UploadController.php | 189 ++++++++++ backend/app/model/UploadedFiles.php | 115 ++++++ backend/app/service/UploadService.php | 339 ++++++++++++++++++ backend/config/route.php | 12 + .../016_create_uploaded_files_table.sql | 22 ++ 6 files changed, 678 insertions(+), 1 deletion(-) create mode 100644 backend/app/controller/UploadController.php create mode 100644 backend/app/model/UploadedFiles.php create mode 100644 backend/app/service/UploadService.php create mode 100644 backend/database/migrations/016_create_uploaded_files_table.sql diff --git a/backend/app/controller/BaseController.php b/backend/app/controller/BaseController.php index 74adf8b..13a8fb0 100644 --- a/backend/app/controller/BaseController.php +++ b/backend/app/controller/BaseController.php @@ -52,7 +52,7 @@ abstract class BaseController } // 解析 token - $payload = app\util\JwtUtil::decode($token); + $payload = \app\Util\JwtUtil::decode($token); if (!$payload) { return null; } diff --git a/backend/app/controller/UploadController.php b/backend/app/controller/UploadController.php new file mode 100644 index 0000000..6eb1902 --- /dev/null +++ b/backend/app/controller/UploadController.php @@ -0,0 +1,189 @@ +uploadService = new UploadService(); + } + + /** + * 通用文件上传接口 + */ + public function upload(Request $request): Response + { + try { + $userId = $this->getUserId($request); // 可选,没有token则为null + $partition = $request->post('partition', 'other'); + $category = $request->post('category', 'general'); + + $result = $this->uploadService->upload($request, $partition, $category, $userId); + + return $this->success($result, '上传成功'); + } catch (\Exception $e) { + return $this->error($e->getMessage()); + } + } + + /** + * 图片上传接口 + */ + public function uploadImage(Request $request): Response + { + try { + $userId = $this->getUserId($request); // 可选,没有token则为null + $category = $request->post('category', 'general'); + + $result = $this->uploadService->uploadImage($request, $category, $userId); + + return $this->success($result, '上传成功'); + } catch (\Exception $e) { + return $this->error($e->getMessage()); + } + } + + /** + * 头像上传接口 + */ + public function uploadAvatar(Request $request): Response + { + try { + $userId = $this->getUserId($request); // 可选,没有token则为null + + $result = $this->uploadService->uploadAvatar($request, $userId); + + return $this->success($result, '头像上传成功'); + } catch (\Exception $e) { + return $this->error($e->getMessage()); + } + } + + /** + * 二手车图片上传接口 + */ + public function uploadUsedCarImage(Request $request): Response + { + try { + $userId = $this->getUserId($request); // 可选,没有token则为null + + $result = $this->uploadService->uploadUsedCarImage($request, $userId); + + return $this->success($result, '上传成功'); + } catch (\Exception $e) { + return $this->error($e->getMessage()); + } + } + + /** + * 批量上传图片(二手车专用) + */ + public function batchUploadImages(Request $request): Response + { + try { + $userId = $this->getUserId($request); // 可选,没有token则为null + $files = $request->files(); + + if (empty($files)) { + return $this->error('请选择要上传的文件'); + } + + $results = []; + $errors = []; + + foreach ($files['files'] ?? [] as $index => $file) { + try { + // 创建临时请求对象处理单个文件 + $tempRequest = new Request( + $request->method(), + $request->path(), + $request->headers(), + ['file' => $file] + ); + + $result = $this->uploadService->uploadUsedCarImage($tempRequest, $userId); + $results[] = $result; + } catch (\Exception $e) { + $errors[] = [ + 'index' => $index, + 'error' => $e->getMessage(), + ]; + } + } + + return $this->success([ + 'success' => $results, + 'errors' => $errors, + 'total' => count($results), + 'failed' => count($errors), + ], '批量上传完成'); + } catch (\Exception $e) { + return $this->error($e->getMessage()); + } + } + + /** + * 删除文件 + */ + public function deleteFile(Request $request): Response + { + try { + $userId = $this->getUserId($request); // 可选,用于权限验证 + $fileId = (int)$request->post('id'); + + if ($fileId <= 0) { + return $this->error('无效的文件ID'); + } + + $this->uploadService->deleteFile($fileId, $userId); + + return $this->success(null, '删除成功'); + } catch (\Exception $e) { + return $this->error($e->getMessage()); + } + } + + /** + * 获取文件信息 + */ + public function getFileInfo(Request $request): Response + { + try { + $fileId = (int)$request->get('id'); + + if ($fileId <= 0) { + return $this->error('无效的文件ID'); + } + + $info = $this->uploadService->getFileInfo($fileId); + + return $this->success($info); + } catch (\Exception $e) { + return $this->error($e->getMessage()); + } + } + + /** + * 获取分区配置 + */ + public function getPartitionConfig(Request $request): Response + { + try { + $configs = UploadService::getPartitionConfigs(); + + return $this->success($configs); + } catch (\Exception $e) { + return $this->error($e->getMessage()); + } + } +} diff --git a/backend/app/model/UploadedFiles.php b/backend/app/model/UploadedFiles.php new file mode 100644 index 0000000..0f3c231 --- /dev/null +++ b/backend/app/model/UploadedFiles.php @@ -0,0 +1,115 @@ + [ + 'path' => 'uploads/images', + 'max_size' => 10 * 1024 * 1024, // 10MB + 'allowed_types' => ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], + 'extensions' => ['jpg', 'jpeg', 'png', 'gif', 'webp'] + ], + 'avatar' => [ + 'path' => 'uploads/avatars', + 'max_size' => 5 * 1024 * 1024, // 5MB + 'allowed_types' => ['image/jpeg', 'image/png', 'image/webp'], + 'extensions' => ['jpg', 'jpeg', 'png', 'webp'] + ], + 'document' => [ + 'path' => 'uploads/documents', + 'max_size' => 20 * 1024 * 1024, // 20MB + 'allowed_types' => [ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ], + 'extensions' => ['pdf', 'doc', 'docx', 'xls', 'xlsx'] + ], + 'usedcar' => [ + 'path' => 'uploads/usedcar', + 'max_size' => 10 * 1024 * 1024, // 10MB + 'allowed_types' => ['image/jpeg', 'image/png', 'image/webp', 'image/gif'], + 'extensions' => ['jpg', 'jpeg', 'png', 'webp', 'gif'] + ], + 'other' => [ + 'path' => 'uploads/others', + 'max_size' => 5 * 1024 * 1024, // 5MB + 'allowed_types' => [], + 'extensions' => [] + ] + ]; + + /** + * 关联用户 + */ + public function user() + { + return $this->belongsTo(User::class, 'user_id', 'id'); + } + + /** + * 获取分区配置 + */ + public static function getPartitionConfig(string $partition): ?array + { + return self::$uploadPartitions[$partition] ?? self::$uploadPartitions['other']; + } + + /** + * 获取所有分区配置 + */ + public static function getAllPartitions(): array + { + return self::$uploadPartitions; + } +} diff --git a/backend/app/service/UploadService.php b/backend/app/service/UploadService.php new file mode 100644 index 0000000..a15d7a3 --- /dev/null +++ b/backend/app/service/UploadService.php @@ -0,0 +1,339 @@ +basePath = public_path(); // 存储到 public 目录 + $this->domain = $this->getDomain(); + } + + /** + * 获取访问域名 + */ + private function getDomain(): string + { + // 从环境变量或配置获取 + $domain = getenv('MAIN_DOMAIN') ?: getenv('APP_URL') ?: ''; + if (empty($domain)) { + $scheme = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http'; + $host = $_SERVER['HTTP_HOST'] ?? 'localhost'; + $domain = $scheme . '://' . $host; + } + return rtrim($domain, '/'); + } + + /** + * 上传文件 + * + * @param Request $request 请求对象 + * @param string $partition 上传分区 (image/avatar/document/usedcar/other) + * @param string $category 文件分类 (可选) + * @param int|null $userId 用户ID (可选) + * @return array 上传结果 + * @throws \Exception + */ + public function upload(Request $request, string $partition = self::PARTITION_OTHER, string $category = 'general', ?int $userId = null): array + { + $file = $request->file('file'); + + if (!$file || !$file->isValid()) { + throw new \Exception('文件上传失败或无效'); + } + + // 获取分区配置 + $config = UploadedFiles::getPartitionConfig($partition); + + // 获取文件大小(必须在移动文件前获取) + $fileSize = $file->getSize(); + $originalName = $file->getUploadName(); + + // 验证文件大小 + if ($fileSize > $config['max_size']) { + $maxSizeMB = round($config['max_size'] / 1024 / 1024, 2); + throw new \Exception("文件大小超过限制,最大允许 {$maxSizeMB}MB"); + } + + // 验证文件类型 + $mimeType = $file->getUploadMimeType(); + if (!empty($config['allowed_types']) && !in_array($mimeType, $config['allowed_types'])) { + throw new \Exception('不支持的文件类型: ' . $mimeType); + } + + // 验证文件扩展名 + $extension = strtolower($file->getUploadExtension()); + if (!empty($config['extensions']) && !in_array($extension, $config['extensions'])) { + throw new \Exception('不支持的文件扩展名: .' . $extension); + } + + // 生成存储路径 + $datePath = date('Ymd'); + $relativePath = '/' . $config['path'] . '/' . $datePath; + $fullPath = $this->basePath . $relativePath; + + // 创建目录 + if (!is_dir($fullPath)) { + if (!mkdir($fullPath, 0755, true)) { + throw new \Exception('创建存储目录失败'); + } + } + + // 生成文件名 + $fileName = $this->generateFileName($file, $extension); + $filePath = $fullPath . '/' . $fileName; + + // 移动上传文件 + if (!$file->move($filePath)) { + throw new \Exception('文件保存失败'); + } + + // 图片处理(压缩) + if (str_starts_with($mimeType, 'image/')) { + $this->processImage($filePath, $mimeType); + } + + // 记录到数据库 + $fileUrl = $relativePath . '/' . $fileName; + $uploadIp = $request->getRealIp() ?? '0.0.0.0'; + $userAgent = $request->header('User-Agent', ''); + + $uploadedFile = UploadedFiles::create([ + 'file_name' => $fileName, + 'file_path' => $fileUrl, + 'file_size' => $fileSize, + 'file_type' => $mimeType, + 'category' => $category, + 'original_name' => $originalName, + 'extension' => $extension, + 'partition' => $partition, + 'upload_ip' => $uploadIp, + 'user_agent' => $userAgent, + 'user_id' => $userId, + ]); + + return [ + 'id' => $uploadedFile->id, + 'file_name' => $fileName, + 'file_url' => $fileUrl, + 'full_url' => $this->domain . $fileUrl, + 'file_size' => $fileSize, + 'file_type' => $mimeType, + 'original_name' => $originalName, + 'partition' => $partition, + 'category' => $category, + ]; + } + + /** + * 上传图片(专用接口) + * + * @param Request $request 请求对象 + * @param string $category 文件分类 + * @param int|null $userId 用户ID + * @return array + * @throws \Exception + */ + public function uploadImage(Request $request, string $category = 'general', ?int $userId = null): array + { + return $this->upload($request, self::PARTITION_IMAGE, $category, $userId); + } + + /** + * 上传头像(专用接口) + * + * @param Request $request 请求对象 + * @param int|null $userId 用户ID + * @return array + * @throws \Exception + */ + public function uploadAvatar(Request $request, ?int $userId = null): array + { + return $this->upload($request, self::PARTITION_AVATAR, 'avatar', $userId); + } + + /** + * 上传二手车图片(专用接口) + * + * @param Request $request 请求对象 + * @param int|null $userId 用户ID + * @return array + * @throws \Exception + */ + public function uploadUsedCarImage(Request $request, ?int $userId = null): array + { + return $this->upload($request, self::PARTITION_USEDCAR, 'usedcar', $userId); + } + + /** + * 删除文件 + * + * @param int $fileId 文件ID + * @param int|null $userId 用户ID(用于权限验证) + * @return bool + * @throws \Exception + */ + public function deleteFile(int $fileId, ?int $userId = null): bool + { + $file = UploadedFiles::find($fileId); + + if (!$file) { + throw new \Exception('文件不存在'); + } + + // 权限验证:只能删除自己的文件(管理员除外) + if ($userId !== null && $file->user_id != $userId) { + throw new \Exception('无权删除此文件'); + } + + // 删除物理文件 + $fullPath = $this->basePath . $file->file_path; + if (file_exists($fullPath)) { + @unlink($fullPath); + } + + // 删除数据库记录 + return $file->delete(); + } + + /** + * 获取文件信息 + * + * @param int $fileId 文件ID + * @return array + * @throws \Exception + */ + public function getFileInfo(int $fileId): array + { + $file = UploadedFiles::find($fileId); + + if (!$file) { + throw new \Exception('文件不存在'); + } + + return [ + 'id' => $file->id, + 'file_name' => $file->file_name, + 'file_url' => $file->file_path, + 'full_url' => $this->domain . $file->file_path, + 'file_size' => $file->file_size, + 'file_type' => $file->file_type, + 'original_name' => $file->original_name, + 'partition' => $file->partition, + 'category' => $file->category, + 'created_at' => $file->created_at->format('Y-m-d H:i:s'), + ]; + } + + /** + * 生成唯一文件名 + */ + private function generateFileName($file, string $extension): string + { + $timestamp = time(); + $random = bin2hex(random_bytes(4)); + return $timestamp . '_' . $random . '.' . $extension; + } + + /** + * 处理图片(压缩) + */ + private function processImage(string $filePath, string $mimeType): void + { + // 检查是否支持 imagick 扩展 + if (!extension_loaded('imagick')) { + return; + } + + try { + $image = new \Imagick($filePath); + + // 限制最大尺寸 + $maxWidth = 2000; + $maxHeight = 2000; + $image->thumbnailImage($maxWidth, $maxHeight, true); + + // 自动方向修正 + if (method_exists($image, 'autoOrient')) { + $image->autoOrient(); + } + + // 根据类型处理 + $extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION)); + if (in_array($extension, ['png', 'jpeg', 'jpg'])) { + // 转换为 WebP 并压缩 + $image->setImageFormat('webp'); + $image->setImageCompressionQuality(80); + $webpPath = preg_replace('/\.(png|jpe?g)$/i', '.webp', $filePath); + $image->writeImage($webpPath); + + // 如果转换成功且文件名改变,删除原文件 + if ($webpPath !== $filePath && file_exists($webpPath)) { + @unlink($filePath); + } + } + + $image->clear(); + $image->destroy(); + } catch (\Exception $e) { + // 处理失败,保留原图 + error_log('图片处理失败: ' . $e->getMessage()); + } + } + + /** + * 格式化文件大小 + */ + public static function formatFileSize(int $bytes): string + { + $units = ['B', 'KB', 'MB', 'GB']; + $bytes = max($bytes, 0); + $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); + $pow = min($pow, count($units) - 1); + + $bytes /= pow(1024, $pow); + + return round($bytes, 2) . ' ' . $units[$pow]; + } + + /** + * 获取所有分区配置 + */ + public static function getPartitionConfigs(): array + { + $configs = UploadedFiles::getAllPartitions(); + foreach ($configs as &$config) { + $config['max_size_formatted'] = self::formatFileSize($config['max_size']); + $config['allowed_types_formatted'] = implode(', ', $config['extensions']); + } + return $configs; + } +} diff --git a/backend/config/route.php b/backend/config/route.php index 644ae6b..ed1ecc6 100644 --- a/backend/config/route.php +++ b/backend/config/route.php @@ -79,6 +79,18 @@ Route::group('/api/v1/usedcar', function () { Route::get('/pdf/preview', [app\controller\UsedCarController::class, 'previewPdf']); })->middleware($authMiddleware); +// 文件上传路由(不需要认证) +Route::group('/api/v1/upload', function () { + Route::post('/file', [app\controller\UploadController::class, 'upload']); + Route::post('/image', [app\controller\UploadController::class, 'uploadImage']); + Route::post('/avatar', [app\controller\UploadController::class, 'uploadAvatar']); + Route::post('/usedcar', [app\controller\UploadController::class, 'uploadUsedCarImage']); + Route::post('/batch', [app\controller\UploadController::class, 'batchUploadImages']); + Route::post('/delete', [app\controller\UploadController::class, 'deleteFile']); + Route::get('/info', [app\controller\UploadController::class, 'getFileInfo']); + Route::get('/config', [app\controller\UploadController::class, 'getPartitionConfig']); +})->middleware($apiMiddleware); + // 默认路由 Route::fallback(function () { return json(['code' => 404, 'message' => 'Not Found']); diff --git a/backend/database/migrations/016_create_uploaded_files_table.sql b/backend/database/migrations/016_create_uploaded_files_table.sql new file mode 100644 index 0000000..6aa0ae3 --- /dev/null +++ b/backend/database/migrations/016_create_uploaded_files_table.sql @@ -0,0 +1,22 @@ +-- 上传文件记录表 +CREATE TABLE IF NOT EXISTS `uploaded_files` ( + `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `file_name` varchar(255) NOT NULL COMMENT '存储文件名', + `file_path` varchar(500) NOT NULL COMMENT '文件存储路径', + `file_size` bigint(20) NOT NULL DEFAULT '0' COMMENT '文件大小(字节)', + `file_type` varchar(100) NOT NULL COMMENT '文件MIME类型', + `category` varchar(50) DEFAULT 'general' COMMENT '文件分类(general/avatar/document等)', + `original_name` varchar(255) DEFAULT NULL COMMENT '原始文件名', + `extension` varchar(20) DEFAULT NULL COMMENT '文件扩展名', + `partition` varchar(50) DEFAULT 'other' COMMENT '上传分区(image/document/audio等)', + `upload_ip` varchar(45) DEFAULT NULL COMMENT '上传IP地址', + `user_agent` text COMMENT '用户代理', + `user_id` int(11) DEFAULT NULL COMMENT '上传用户ID', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_partition` (`partition`), + KEY `idx_category` (`category`), + KEY `idx_user_id` (`user_id`), + KEY `idx_file_path` (`file_path`(255)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='上传文件记录表';