ossService = new OssService(); $this->tmpPath = sys_get_temp_dir(); } /** * 上传文件到 OSS * * @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(); $mimeType = $file->getUploadMimeType(); $extension = strtolower($file->getUploadExtension()); // 验证文件大小 if ($fileSize > $config['max_size']) { $maxSizeMB = round($config['max_size'] / 1024 / 1024, 2); throw new \Exception("文件大小超过限制,最大允许 {$maxSizeMB}MB"); } // 验证文件类型 if (!empty($config['allowed_types']) && !in_array($mimeType, $config['allowed_types'])) { throw new \Exception('不支持的文件类型: ' . $mimeType); } // 验证文件扩展名 if (!empty($config['extensions']) && !in_array($extension, $config['extensions'])) { throw new \Exception('不支持的文件扩展名: .' . $extension); } // 移动到临时文件 $tmpFileName = 'upload_' . uniqid() . '_' . bin2hex(random_bytes(8)) . '.' . $extension; $tmpFilePath = $this->tmpPath . '/' . $tmpFileName; if (!$file->move($tmpFilePath)) { throw new \Exception('文件保存到临时目录失败'); } try { // 图片处理(压缩) if (str_starts_with($mimeType, 'image/')) { $processedPath = $this->processImage($tmpFilePath); if ($processedPath !== $tmpFilePath && file_exists($processedPath)) { @unlink($tmpFilePath); $tmpFilePath = $processedPath; // 更新扩展名(如果转为 webp) $extension = strtolower(pathinfo($tmpFilePath, PATHINFO_EXTENSION)); } } // 生成 OSS 对象键 $datePath = date('Ymd'); $fileName = $this->generateFileName($extension); $objectKey = $this->ossService->getObjectKey("{$config['path']}/{$datePath}/{$fileName}"); // 上传到 OSS $ossUrl = $this->ossService->uploadFile($tmpFilePath, $objectKey); // 保存相对路径用于记录 $relativePath = str_replace($this->ossService->getMainDir() . '/', '', $objectKey); $fileUrl = '/' . $relativePath; // 记录到数据库 $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' => $ossUrl, 'oss_url' => $ossUrl, 'file_size' => $fileSize, 'file_type' => $mimeType, 'original_name' => $originalName, 'partition' => $partition, 'category' => $category, ]; } finally { // 清理临时文件 if (file_exists($tmpFilePath)) { @unlink($tmpFilePath); } } } /** * 上传图片(专用接口) * * @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 uploadUsedCarImage(Request $request, ?int $userId = null): array { return $this->upload($request, self::PARTITION_USEDCAR, 'usedcar', $userId); } /** * 删除文件(OSS + 数据库) * * @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('无权删除此文件'); } // 从 file_path 构建完整的 OSS 对象键 // file_path 格式: /images/20250305/file.jpg // 需要转换为: x_server_mobile/images/20250305/file.jpg $relativePath = ltrim($file->file_path, '/'); $objectKey = $this->ossService->getObjectKey($relativePath); try { // 删除 OSS 文件 $this->ossService->deleteFile($objectKey); } catch (\Exception $e) { // OSS 删除失败,记录日志但继续删除数据库记录 error_log('删除 OSS 文件失败: ' . $e->getMessage()); } // 删除数据库记录 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('文件不存在'); } // 生成完整的 OSS URL $relativePath = ltrim($file->file_path, '/'); $objectKey = $this->ossService->getObjectKey($relativePath); $ossUrl = $this->ossService->getPublicUrl($objectKey); return [ 'id' => $file->id, 'file_name' => $file->file_name, 'file_path' => $file->file_path, 'full_url' => $ossUrl, 'oss_url' => $ossUrl, '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(string $extension): string { $timestamp = time(); $random = bin2hex(random_bytes(4)); return $timestamp . '_' . $random . '.' . $extension; } /** * 处理图片(压缩) * 返回处理后的文件路径 */ private function processImage(string $filePath): string { // 检查是否支持 imagick 扩展 if (!extension_loaded('imagick')) { return $filePath; } try { $image = new \Imagick($filePath); // 限制最大尺寸 $maxWidth = 2000; $maxHeight = 2000; $image->thumbnailImage($maxWidth, $maxHeight, true); // 自动方向修正 if (method_exists($image, 'autoOrient')) { $image->autoOrient(); } // 转换为 WebP 并压缩 $image->setImageFormat('webp'); $image->setImageCompressionQuality(80); $webpPath = preg_replace('/\.(jpe?g|png)$/i', '.webp', $filePath); $image->writeImage($webpPath); $image->clear(); $image->destroy(); // 如果转换成功且文件名改变,返回新路径 if ($webpPath !== $filePath && file_exists($webpPath)) { return $webpPath; } } catch (\Exception $e) { // 处理失败,保留原图 error_log('图片处理失败: ' . $e->getMessage()); } return $filePath; } /** * 格式化文件大小 */ 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; } }