diff --git a/backend/.env.example b/backend/.env.example index 7e9f346..81cece3 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -21,3 +21,14 @@ JWT_SECRET=your-secret-key-change-in-production # 飞书配置 FEISHU_APP_ID= FEISHU_APP_SECRET= + +# 阿里云 OSS 配置 +OSS_ACCESSID=LTAI5tAGiH7bkVkmrniXnfkA +OSS_ACCESSSECRET=nqklJBft6t3S0A4ZvxF4ci9ni91kmm +OSS_BUCKET=vehicle-album +OSS_ENDPOINT=https://oss-cn-beijing.aliyuncs.com +OSS_STATIC_URL=https://vehicle-album.oss-cn-beijing.aliyuncs.com +OSS_MAIN_DIR=x_server_mobile + +# 域名配置(用于生成文件访问URL) +MAIN_DOMAIN=http://localhost:8787 diff --git a/backend/app/service/OssService.php b/backend/app/service/OssService.php new file mode 100644 index 0000000..c84d7a1 --- /dev/null +++ b/backend/app/service/OssService.php @@ -0,0 +1,228 @@ +accessKeyId = getenv('OSS_ACCESSID') ?: ''; + $this->accessKeySecret = getenv('OSS_ACCESSSECRET') ?: ''; + $this->bucket = getenv('OSS_BUCKET') ?: ''; + $this->endpoint = getenv('OSS_ENDPOINT') ?: ''; + $this->staticUrl = getenv('OSS_STATIC_URL') ?: ''; + $this->mainDir = rtrim(getenv('OSS_MAIN_DIR') ?: 'uploads', '/'); + + if (empty($this->accessKeyId) || empty($this->accessKeySecret) || empty($this->bucket) || empty($this->endpoint)) { + throw new \Exception('OSS 配置不完整,请检查 .env 文件'); + } + } finally { + error_reporting($oldLevel); + } + } + + /** + * 获取 OSS Client 实例(延迟加载 + 错误抑制) + */ + private function getClient() + { + if ($this->ossClient === null) { + $oldLevel = error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED); + + try { + $this->ossClient = new \OSS\OssClient($this->accessKeyId, $this->accessKeySecret, $this->endpoint); + } catch (\Throwable $e) { + throw new \Exception('OSS 初始化失败: ' . $e->getMessage()); + } finally { + error_reporting($oldLevel); + } + } + return $this->ossClient; + } + + /** + * 上传文件到 OSS + * + * @param string $filePath 本地文件路径 + * @param string $objectKey OSS上存储的对象名称 + * @return string 文件的公共访问 URL + * @throws \Exception + */ + public function uploadFile(string $filePath, string $objectKey): string + { + $oldLevel = error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED); + try { + $options = [ + \OSS\OssClient::OSS_HEADERS => [ + 'Content-Type' => $this->getMimeType($filePath), + ], + ]; + + $this->getClient()->uploadFile($this->bucket, $objectKey, $filePath, $options); + return $this->getPublicUrl($objectKey); + } catch (\OSS\Core\OssException $e) { + throw new \Exception('OSS 上传失败: ' . $e->getMessage()); + } catch (\Throwable $e) { + throw new \Exception('OSS 上传失败: ' . $e->getMessage()); + } finally { + error_reporting($oldLevel); + } + } + + /** + * 删除 OSS 上的文件 + * + * @param string $objectKey OSS上存储的对象名称 + * @return bool + * @throws \Exception + */ + public function deleteFile(string $objectKey): bool + { + $oldLevel = error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED); + try { + $this->getClient()->deleteObject($this->bucket, $objectKey); + return true; + } catch (\OSS\Core\OssException $e) { + throw new \Exception('OSS 删除失败: ' . $e->getMessage()); + } catch (\Throwable $e) { + throw new \Exception('OSS 删除失败: ' . $e->getMessage()); + } finally { + error_reporting($oldLevel); + } + } + + /** + * 批量删除文件 + * + * @param array $objectKeys 对象名称数组 + * @return bool + * @throws \Exception + */ + public function deleteFiles(array $objectKeys): bool + { + $oldLevel = error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED); + try { + $this->getClient()->deleteObjects($this->bucket, $objectKeys); + return true; + } catch (\OSS\Core\OssException $e) { + throw new \Exception('OSS 批量删除失败: ' . $e->getMessage()); + } catch (\Throwable $e) { + throw new \Exception('OSS 批量删除失败: ' . $e->getMessage()); + } finally { + error_reporting($oldLevel); + } + } + + /** + * 获取文件的公共访问 URL + * + * @param string $objectKey + * @return string + */ + public function getPublicUrl(string $objectKey): string + { + if (!empty($this->staticUrl)) { + return rtrim($this->staticUrl, '/') . '/' . $objectKey; + } + + return 'https://' . $this->bucket . '.' . str_replace('https://', '', $this->endpoint) . '/' . $objectKey; + } + + /** + * 生成带签名的访问 URL(用于私有bucket) + * + * @param string $objectKey + * @param int $timeout 有效期(秒) + * @return string + */ + public function getSignedUrl(string $objectKey, int $timeout = 3600): string + { + $oldLevel = error_reporting(E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED); + try { + return $this->getClient()->signUrl($this->bucket, $objectKey, $timeout); + } catch (\Throwable $e) { + return $this->getPublicUrl($objectKey); + } finally { + error_reporting($oldLevel); + } + } + + /** + * 获取完整的对象键(包含主目录) + * + * @param string $path 相对路径 + * @return string + */ + public function getObjectKey(string $path): string + { + $path = ltrim($path, '/'); + return $this->mainDir ? ($this->mainDir . '/' . $path) : $path; + } + + /** + * 检查 OSS 是否已配置 + * + * @return bool + */ + public static function isConfigured(): bool + { + return !empty(getenv('OSS_ACCESSID')) + && !empty(getenv('OSS_ACCESSSECRET')) + && !empty(getenv('OSS_BUCKET')) + && !empty(getenv('OSS_ENDPOINT')); + } + + /** + * 获取文件的 MIME 类型 + * + * @param string $filePath + * @return string + */ + private function getMimeType(string $filePath): string + { + $extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION)); + + $mimeMap = [ + 'jpg' => 'image/jpeg', + 'jpeg' => 'image/jpeg', + 'png' => 'image/png', + 'gif' => 'image/gif', + 'webp' => 'image/webp', + 'bmp' => 'image/bmp', + 'pdf' => 'application/pdf', + 'doc' => 'application/msword', + 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'xls' => 'application/vnd.ms-excel', + 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'txt' => 'text/plain', + 'zip' => 'application/zip', + ]; + + return $mimeMap[$extension] ?? 'application/octet-stream'; + } + + /** + * 获取 OSS 主目录 + * + * @return string + */ + public function getMainDir(): string + { + return $this->mainDir; + } +} diff --git a/backend/app/service/UploadService.php b/backend/app/service/UploadService.php index c79642a..19fe969 100644 --- a/backend/app/service/UploadService.php +++ b/backend/app/service/UploadService.php @@ -4,11 +4,10 @@ namespace app\Service; use app\Model\UploadedFiles; use Webman\Http\Request; -use support\Redis; /** * 文件上传服务 - * 支持本地存储,可扩展支持 OSS + * 使用阿里云 OSS 存储文件 */ class UploadService { @@ -21,39 +20,17 @@ class UploadService public const PARTITION_USEDCAR = 'usedcar'; public const PARTITION_OTHER = 'other'; - /** - * 基础存储路径 - */ - private string $basePath; - - /** - * 访问域名 - */ - private string $domain; + private OssService $ossService; + private string $tmpPath; public function __construct() { - $this->basePath = public_path(); // 存储到 public 目录 - $this->domain = $this->getDomain(); + $this->ossService = new OssService(); + $this->tmpPath = sys_get_temp_dir(); } /** - * 获取访问域名 - */ - 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, '/'); - } - - /** - * 上传文件 + * 上传文件到 OSS * * @param Request $request 请求对象 * @param string $partition 上传分区 (image/avatar/document/usedcar/other) @@ -73,9 +50,11 @@ class UploadService // 获取分区配置 $config = UploadedFiles::getPartitionConfig($partition); - // 获取文件大小(必须在移动文件前获取) + // 获取文件信息(必须在移动文件前获取) $fileSize = $file->getSize(); $originalName = $file->getUploadName(); + $mimeType = $file->getUploadMimeType(); + $extension = strtolower($file->getUploadExtension()); // 验证文件大小 if ($fileSize > $config['max_size']) { @@ -84,73 +63,83 @@ class UploadService } // 验证文件类型 - $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; + // 移动到临时文件 + $tmpFileName = 'upload_' . uniqid() . '_' . bin2hex(random_bytes(8)) . '.' . $extension; + $tmpFilePath = $this->tmpPath . '/' . $tmpFileName; - // 创建目录 - if (!is_dir($fullPath)) { - if (!mkdir($fullPath, 0755, true)) { - throw new \Exception('创建存储目录失败'); + 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); } } - - // 生成文件名 - $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, - ]; } /** @@ -181,7 +170,7 @@ class UploadService } /** - * 删除文件 + * 删除文件(OSS + 数据库) * * @param int $fileId 文件ID * @param int|null $userId 用户ID(用于权限验证) @@ -201,10 +190,18 @@ class UploadService throw new \Exception('无权删除此文件'); } - // 删除物理文件 - $fullPath = $this->basePath . $file->file_path; - if (file_exists($fullPath)) { - @unlink($fullPath); + // 从 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()); } // 删除数据库记录 @@ -226,11 +223,17 @@ class UploadService 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_url' => $file->file_path, - 'full_url' => $this->domain . $file->file_path, + '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, @@ -243,7 +246,7 @@ class UploadService /** * 生成唯一文件名 */ - private function generateFileName($file, string $extension): string + private function generateFileName(string $extension): string { $timestamp = time(); $random = bin2hex(random_bytes(4)); @@ -252,12 +255,13 @@ class UploadService /** * 处理图片(压缩) + * 返回处理后的文件路径 */ - private function processImage(string $filePath, string $mimeType): void + private function processImage(string $filePath): string { // 检查是否支持 imagick 扩展 if (!extension_loaded('imagick')) { - return; + return $filePath; } try { @@ -273,27 +277,26 @@ class UploadService $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); + // 转换为 WebP 并压缩 + $image->setImageFormat('webp'); + $image->setImageCompressionQuality(80); - // 如果转换成功且文件名改变,删除原文件 - if ($webpPath !== $filePath && file_exists($webpPath)) { - @unlink($filePath); - } - } + $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; } /** diff --git a/backend/composer.json b/backend/composer.json index 699ff7a..33d778e 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -18,7 +18,8 @@ "guzzlehttp/guzzle": "^7.8", "jaeger/querylist": "^4.4", "mpdf/mpdf": "^8.2", - "workerman/crontab": "^1.0" + "workerman/crontab": "^1.0", + "aliyuncs/oss-sdk-php": "^2.7" }, "require-dev": { "mockery/mockery": "^1.5", diff --git a/backend/composer.lock b/backend/composer.lock index b553335..f85b2c9 100644 --- a/backend/composer.lock +++ b/backend/composer.lock @@ -4,8 +4,47 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "3973dc3a38a7efaf78808007d80f4cd7", + "content-hash": "bb65d18c9e255409703a0be95ba22fa6", "packages": [ + { + "name": "aliyuncs/oss-sdk-php", + "version": "v2.7.2", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/aliyuncs/oss-sdk-php/v2.7.2/aliyuncs-oss-sdk-php-v2.7.2.zip", + "reference": "483dd0b8bff5d47f0e4ffc99f6077a295c5ccbb5", + "shasum": "" + }, + "require": { + "php": ">=5.3" + }, + "require-dev": { + "php-coveralls/php-coveralls": "*", + "phpunit/phpunit": "*" + }, + "type": "library", + "autoload": { + "psr-4": { + "OSS\\": "src/OSS" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aliyuncs", + "homepage": "http://www.aliyun.com" + } + ], + "description": "Aliyun OSS SDK for PHP", + "homepage": "http://www.aliyun.com/product/oss/", + "support": { + "issues": "https://github.com/aliyun/aliyun-oss-php-sdk/issues", + "source": "https://github.com/aliyun/aliyun-oss-php-sdk/tree/v2.7.2" + }, + "time": "2024-10-28T10:41:12+00:00" + }, { "name": "brick/math", "version": "0.12.3",