chore: sync release updates

This commit is contained in:
wushumin
2026-05-22 15:47:23 +08:00
parent be64b8e5b7
commit baef2fb64c
23 changed files with 879 additions and 131 deletions

View File

@@ -0,0 +1,270 @@
<?php
namespace app\support;
use support\Request;
use function base64_encode;
use function gmdate;
use function hash_hmac;
use function in_array;
use function json_encode;
use function ltrim;
use function pathinfo;
use function preg_replace;
use function sprintf;
use function str_starts_with;
use function strtolower;
use function time;
use function trim;
class FileUploadService
{
public const MAX_SERVER_UPLOAD_BYTES = 50 * 1024 * 1024;
public const MAX_SERVER_UPLOAD_LABEL = '50MB';
private const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'heic'];
private const VIDEO_EXTENSIONS = ['mp4', 'mov', 'm4v', 'webm', 'avi', 'mpeg', 'mpg'];
private const PDF_EXTENSIONS = ['pdf'];
private const SCENES = [
'appraisal_evidence' => [
'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();
}
}