330 lines
10 KiB
PHP
330 lines
10 KiB
PHP
<?php
|
|
|
|
namespace app\Service;
|
|
|
|
use app\Model\UploadedFiles;
|
|
use Webman\Http\Request;
|
|
|
|
/**
|
|
* 文件上传服务
|
|
* 使用阿里云 OSS 存储文件
|
|
*/
|
|
class UploadService
|
|
{
|
|
/**
|
|
* 上传分区类型
|
|
*/
|
|
public const PARTITION_IMAGE = 'image';
|
|
public const PARTITION_AVATAR = 'avatar';
|
|
public const PARTITION_DOCUMENT = 'document';
|
|
public const PARTITION_USEDCAR = 'usedcar';
|
|
public const PARTITION_OTHER = 'other';
|
|
|
|
private OssService $ossService;
|
|
private string $tmpPath;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->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;
|
|
}
|
|
}
|