[ 'base_dir' => 'uploads/appraisal-evidence', 'filename_prefix' => 'evidence', 'allowed_file_types' => ['image', 'video', 'pdf'], 'invalid_type_message' => '仅支持上传图片、视频或 PDF 文件', ], 'zhongjian_report' => [ 'base_dir' => 'uploads/zhongjian-report', 'filename_prefix' => 'zhongjian', 'allowed_file_types' => ['image', 'video', 'pdf'], 'invalid_type_message' => '仅支持上传图片、视频或 PDF 文件', ], 'warehouse_inbound_evidence' => [ 'base_dir' => 'uploads/warehouse-inbound-evidence', 'filename_prefix' => 'inbound', 'allowed_file_types' => ['image', 'video'], 'invalid_type_message' => '拆包附件仅支持上传图片或视频', ], 'warehouse_return_packing' => [ 'base_dir' => 'uploads/warehouse-return-packing', 'filename_prefix' => 'packing', 'allowed_file_types' => ['image', 'video'], 'invalid_type_message' => '打包装箱附件仅支持上传图片或视频', ], ]; public function upload(Request $request, string $scene, string $inputName = 'file'): array { $sceneConfig = $this->sceneConfig($scene); $file = $request->file($inputName); if (!$file || !$file->isValid()) { throw new \RuntimeException('上传文件无效'); } if ($file->getSize() > self::MAX_SERVER_UPLOAD_BYTES) { throw new \RuntimeException('上传文件不能超过' . self::MAX_SERVER_UPLOAD_LABEL . ',请压缩后再上传'); } $extension = strtolower($file->getUploadExtension() ?: ''); $fileType = $this->detectFileType($extension); $this->assertAllowedFileType($fileType, $sceneConfig); $relativePath = $this->buildRelativePath($sceneConfig, $extension ?: 'dat'); $this->storage()->putUploadedFile($file, $relativePath); return $this->buildAsset( $request, $relativePath, $fileType, $extension, (string)$file->getUploadName(), '' ); } public function createOssDirectUploadPolicy( Request $request, string $scene, string $originalName, int $fileSize = 0, string $mimeType = '' ): array { $config = $this->configService(); if (!$config->isOss()) { return ['enabled' => false]; } $sceneConfig = $this->sceneConfig($scene); $config->assertReady(); $maxDirectUploadBytes = $config->directUploadMaxBytes(); $maxDirectUploadLabel = $config->directUploadMaxLabel(); if ($fileSize > $maxDirectUploadBytes) { throw new \RuntimeException('上传文件不能超过' . $maxDirectUploadLabel . ',请压缩后再上传'); } $extension = $this->extensionFromNameOrMimeType($originalName, $mimeType); $fileType = $this->detectFileType($extension); $this->assertAllowedFileType($fileType, $sceneConfig); $relativePath = $this->buildRelativePath($sceneConfig, $extension); $objectKey = $config->objectKey($relativePath); $expiration = gmdate('Y-m-d\TH:i:s\Z', time() + 600); $policy = base64_encode((string)json_encode([ 'expiration' => $expiration, 'conditions' => [ ['content-length-range', 1, $maxDirectUploadBytes], ['eq', '$key', $objectKey], ['eq', '$success_action_status', '200'], ], ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); $signature = base64_encode(hash_hmac('sha1', $policy, $config->accessKeySecret(), true)); return [ 'enabled' => true, 'upload_url' => sprintf('https://%s.%s', $config->bucket(), $config->uploadEndpoint()), 'form_data' => [ 'key' => $objectKey, 'policy' => $policy, 'OSSAccessKeyId' => $config->accessKeyId(), 'signature' => $signature, 'success_action_status' => '200', ], 'asset' => $this->buildAsset($request, $relativePath, $fileType, $extension, $originalName, $mimeType), 'max_size' => $maxDirectUploadBytes, 'max_size_text' => $maxDirectUploadLabel, 'expires_at' => $expiration, ]; } public function delete(string $fileUrl): void { $relativePath = $this->storage()->storagePath($fileUrl); if (!$this->isManagedUploadPath($relativePath)) { return; } $this->storage()->delete($relativePath); } public function detectFileType(string $extension): string { if (in_array($extension, self::IMAGE_EXTENSIONS, true)) { return 'image'; } if (in_array($extension, self::VIDEO_EXTENSIONS, true)) { return 'video'; } if (in_array($extension, self::PDF_EXTENSIONS, true)) { return 'pdf'; } return 'file'; } public function mimeType(string $fileType, string $extension): string { return match ($fileType) { 'image' => 'image/' . ($extension === 'jpg' ? 'jpeg' : ($extension ?: 'jpeg')), 'video' => 'video/' . ($extension ?: 'mp4'), 'pdf' => 'application/pdf', default => 'application/octet-stream', }; } private function buildAsset( Request $request, string $relativePath, string $fileType, string $extension, string $originalName, string $mimeType ): array { $fileUrl = $this->storage()->publicUrl($request, $relativePath); $name = trim($originalName) !== '' ? trim($originalName) : pathinfo($relativePath, PATHINFO_BASENAME); return [ 'file_id' => md5($relativePath), 'file_url' => $fileUrl, 'thumbnail_url' => $fileType === 'image' ? $fileUrl : '', 'name' => $name, 'file_type' => $fileType, 'mime_type' => trim($mimeType) !== '' ? trim($mimeType) : $this->mimeType($fileType, $extension), ]; } private function buildRelativePath(array $sceneConfig, string $extension): string { return sprintf( '%s/%s/%s_%s.%s', $sceneConfig['base_dir'], date('Ymd'), $sceneConfig['filename_prefix'], uniqid(), $extension ); } private function sceneConfig(string $scene): array { $scene = trim($scene); if (!isset(self::SCENES[$scene])) { throw new \RuntimeException('未知上传场景'); } return self::SCENES[$scene]; } private function assertAllowedFileType(string $fileType, array $sceneConfig): void { if (!in_array($fileType, $sceneConfig['allowed_file_types'], true)) { throw new \RuntimeException((string)$sceneConfig['invalid_type_message']); } } private function extensionFromNameOrMimeType(string $originalName, string $mimeType): string { $extension = strtolower((string)pathinfo(trim($originalName), PATHINFO_EXTENSION)); $extension = preg_replace('/[^a-z0-9]/', '', $extension) ?: ''; if ($extension !== '') { return $extension; } return match (strtolower(trim($mimeType))) { 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', 'image/gif' => 'gif', 'image/bmp' => 'bmp', 'image/heic' => 'heic', 'video/mp4' => 'mp4', 'video/quicktime' => 'mov', 'video/x-m4v' => 'm4v', 'video/webm' => 'webm', 'video/x-msvideo' => 'avi', 'video/mpeg' => 'mpeg', 'application/pdf' => 'pdf', default => '', }; } private function isManagedUploadPath(string $relativePath): bool { $relativePath = ltrim($relativePath, '/'); foreach (self::SCENES as $sceneConfig) { if (str_starts_with($relativePath, $sceneConfig['base_dir'] . '/')) { return true; } } return false; } private function storage(): FileStorageService { return new FileStorageService(); } private function configService(): FileStorageConfigService { return new FileStorageConfigService(); } }