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 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; } }