mirror of
https://gitee.com/technical-laohu/mpay_v2_webman.git
synced 2026-04-26 12:04:28 +08:00
更新统一使用 PHPDoc + PSR-19 标准注释
This commit is contained in:
@@ -4,6 +4,7 @@ namespace app\service\file;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\exception\BusinessStateException;
|
||||
use app\exception\ResourceNotFoundException;
|
||||
use app\exception\ValidationException;
|
||||
use app\repository\file\FileRecordRepository;
|
||||
use app\service\file\storage\StorageManager;
|
||||
@@ -12,9 +13,25 @@ use Webman\Http\UploadFile;
|
||||
|
||||
/**
|
||||
* 文件命令服务。
|
||||
*
|
||||
* 负责上传、远程导入和删除文件,并负责把文件内容同步到存储驱动和数据库。
|
||||
*
|
||||
* @property FileRecordRepository $fileRecordRepository 文件记录仓库
|
||||
* @property FileRecordQueryService $fileRecordQueryService 文件记录查询服务
|
||||
* @property StorageManager $storageManager 存储管理器
|
||||
* @property StorageConfigService $storageConfigService 存储配置服务
|
||||
*/
|
||||
class FileRecordCommandService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造方法。
|
||||
*
|
||||
* @param FileRecordRepository $fileRecordRepository 文件记录仓库
|
||||
* @param FileRecordQueryService $fileRecordQueryService 文件记录查询服务
|
||||
* @param StorageManager $storageManager 存储管理器
|
||||
* @param StorageConfigService $storageConfigService 存储配置服务
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(
|
||||
protected FileRecordRepository $fileRecordRepository,
|
||||
protected FileRecordQueryService $fileRecordQueryService,
|
||||
@@ -23,6 +40,15 @@ class FileRecordCommandService extends BaseService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件并创建记录。
|
||||
*
|
||||
* @param UploadFile $file 上传文件
|
||||
* @param array $data 文件参数
|
||||
* @param int $createdBy 创建人ID
|
||||
* @param string $createdByName 创建人名称
|
||||
* @return array 文件记录
|
||||
*/
|
||||
public function upload(UploadFile $file, array $data, int $createdBy = 0, string $createdByName = ''): array
|
||||
{
|
||||
$this->assertFileUpload($file);
|
||||
@@ -31,7 +57,7 @@ class FileRecordCommandService extends BaseService
|
||||
try {
|
||||
$scene = $this->storageConfigService->normalizeScene($data['scene'] ?? null, (string) $file->getUploadName(), (string) $file->getUploadMimeType());
|
||||
$visibility = $this->storageConfigService->normalizeVisibility($data['visibility'] ?? null, $scene);
|
||||
$engine = $this->storageConfigService->defaultEngine();
|
||||
$engine = $this->resolveStorageEngine($data);
|
||||
|
||||
$result = $this->storageManager->storeFromPath(
|
||||
$sourcePath,
|
||||
@@ -62,7 +88,10 @@ class FileRecordCommandService extends BaseService
|
||||
'created_by_name' => $createdByName,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->storageManager->delete($result);
|
||||
try {
|
||||
$this->storageManager->delete($result);
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
@@ -74,18 +103,28 @@ class FileRecordCommandService extends BaseService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入远程文件并创建记录。
|
||||
*
|
||||
* @param string $remoteUrl 远程地址
|
||||
* @param array $data 文件参数
|
||||
* @param int $createdBy 创建人ID
|
||||
* @param string $createdByName 创建人名称
|
||||
* @return array 文件记录
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function importRemote(string $remoteUrl, array $data, int $createdBy = 0, string $createdByName = ''): array
|
||||
{
|
||||
$remoteUrl = trim($remoteUrl);
|
||||
if ($remoteUrl === '') {
|
||||
throw new ValidationException('远程图片地址不能为空');
|
||||
throw new ValidationException('远程文件地址不能为空');
|
||||
}
|
||||
|
||||
$download = $this->downloadRemoteFile($remoteUrl, (int) ($data['scene'] ?? 0));
|
||||
try {
|
||||
$scene = $this->storageConfigService->normalizeScene($data['scene'] ?? null, $download['name'], $download['mime_type']);
|
||||
$visibility = $this->storageConfigService->normalizeVisibility($data['visibility'] ?? null, $scene);
|
||||
$engine = $this->storageConfigService->defaultEngine();
|
||||
$engine = $this->resolveStorageEngine($data);
|
||||
|
||||
$result = $this->storageManager->storeFromPath(
|
||||
$download['path'],
|
||||
@@ -116,7 +155,10 @@ class FileRecordCommandService extends BaseService
|
||||
'created_by_name' => $createdByName,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->storageManager->delete($result);
|
||||
try {
|
||||
$this->storageManager->delete($result);
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
@@ -128,18 +170,38 @@ class FileRecordCommandService extends BaseService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件记录。
|
||||
*
|
||||
* @param int $id 文件记录命令ID
|
||||
* @return bool 是否删除成功
|
||||
* @throws ResourceNotFoundException
|
||||
* @throws BusinessStateException
|
||||
*/
|
||||
public function delete(int $id): bool
|
||||
{
|
||||
$asset = $this->fileRecordRepository->findById($id);
|
||||
if (!$asset) {
|
||||
return false;
|
||||
throw new ResourceNotFoundException('文件不存在', ['id' => $id]);
|
||||
}
|
||||
|
||||
$this->storageManager->delete($this->fileRecordQueryService->formatModel($asset));
|
||||
|
||||
return $this->fileRecordRepository->deleteById($id);
|
||||
if (!$this->fileRecordRepository->deleteById($id)) {
|
||||
throw new BusinessStateException('文件删除失败');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验上传文件是否合法。
|
||||
*
|
||||
* @param UploadFile $file 文件
|
||||
* @return void
|
||||
* @throws ValidationException
|
||||
* @throws BusinessStateException
|
||||
*/
|
||||
private function assertFileUpload(UploadFile $file): void
|
||||
{
|
||||
if (!$file->isValid()) {
|
||||
@@ -162,10 +224,19 @@ class FileRecordCommandService extends BaseService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载远程文件到临时文件。
|
||||
*
|
||||
* @param string $remoteUrl 远程地址
|
||||
* @param int $scene 场景
|
||||
* @return array 下载结果
|
||||
* @throws ValidationException
|
||||
* @throws BusinessStateException
|
||||
*/
|
||||
private function downloadRemoteFile(string $remoteUrl, int $scene = 0): array
|
||||
{
|
||||
if (!filter_var($remoteUrl, FILTER_VALIDATE_URL)) {
|
||||
throw new ValidationException('远程图片地址格式不正确');
|
||||
throw new ValidationException('远程文件地址格式不正确');
|
||||
}
|
||||
|
||||
$scheme = strtolower((string) parse_url($remoteUrl, PHP_URL_SCHEME));
|
||||
@@ -175,7 +246,7 @@ class FileRecordCommandService extends BaseService
|
||||
|
||||
$host = (string) parse_url($remoteUrl, PHP_URL_HOST);
|
||||
if ($host === '') {
|
||||
throw new ValidationException('远程图片地址格式不正确');
|
||||
throw new ValidationException('远程文件地址格式不正确');
|
||||
}
|
||||
|
||||
if (filter_var($host, FILTER_VALIDATE_IP) && Request::isIntranetIp($host)) {
|
||||
@@ -278,4 +349,22 @@ class FileRecordCommandService extends BaseService
|
||||
'scene' => $scene,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析存储Engine
|
||||
*
|
||||
* @param array $data 数据
|
||||
* @return int 整数结果
|
||||
*/
|
||||
private function resolveStorageEngine(array $data): int
|
||||
{
|
||||
if (!array_key_exists('storage_engine', $data) || $data['storage_engine'] === null || $data['storage_engine'] === '') {
|
||||
return $this->storageConfigService->defaultEngine();
|
||||
}
|
||||
|
||||
return $this->storageConfigService->normalizeEngine($data['storage_engine']);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -10,15 +10,35 @@ use app\service\file\storage\StorageManager;
|
||||
|
||||
/**
|
||||
* 文件查询服务。
|
||||
*
|
||||
* 负责文件记录的分页、详情、选项和展示数据格式化。
|
||||
*
|
||||
* @property FileRecordRepository $fileRecordRepository 文件记录仓库
|
||||
* @property StorageManager $storageManager 存储管理器
|
||||
*/
|
||||
class FileRecordQueryService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造方法。
|
||||
*
|
||||
* @param FileRecordRepository $fileRecordRepository 文件记录仓库
|
||||
* @param StorageManager $storageManager 存储管理器
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(
|
||||
protected FileRecordRepository $fileRecordRepository,
|
||||
protected StorageManager $storageManager
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询文件记录。
|
||||
*
|
||||
* @param array $filters 筛选条件
|
||||
* @param int $page 页码
|
||||
* @param int $pageSize 每页条数
|
||||
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator 分页结果
|
||||
*/
|
||||
public function paginate(array $filters = [], int $page = 1, int $pageSize = 10)
|
||||
{
|
||||
$query = $this->fileRecordRepository->query()->from('ma_file_asset as f');
|
||||
@@ -50,6 +70,13 @@ class FileRecordQueryService extends BaseService
|
||||
return $paginator;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询文件记录详情。
|
||||
*
|
||||
* @param int $id 文件记录查询ID
|
||||
* @return array 文件详情
|
||||
* @throws ResourceNotFoundException
|
||||
*/
|
||||
public function detail(int $id): array
|
||||
{
|
||||
$asset = $this->fileRecordRepository->findById($id);
|
||||
@@ -60,7 +87,13 @@ class FileRecordQueryService extends BaseService
|
||||
return $this->formatModel($asset);
|
||||
}
|
||||
|
||||
public function formatModel(mixed $asset): array
|
||||
/**
|
||||
* 将文件记录格式化为前端展示结构。
|
||||
*
|
||||
* @param array|object|null $asset 文件记录
|
||||
* @return array<string, mixed> 展示数据
|
||||
*/
|
||||
public function formatModel(array|object|null $asset): array
|
||||
{
|
||||
$id = (int) $this->field($asset, 'id', 0);
|
||||
$scene = (int) $this->field($asset, 'scene', FileConstant::SCENE_OTHER);
|
||||
@@ -68,10 +101,19 @@ class FileRecordQueryService extends BaseService
|
||||
$storageEngine = (int) $this->field($asset, 'storage_engine', FileConstant::STORAGE_LOCAL);
|
||||
$sourceType = (int) $this->field($asset, 'source_type', FileConstant::SOURCE_UPLOAD);
|
||||
$size = (int) $this->field($asset, 'size', 0);
|
||||
$publicUrl = (string) $this->field($asset, 'url', '');
|
||||
$previewUrl = $publicUrl !== '' ? $publicUrl : $this->storageManager->temporaryUrl($this->normalizeAsset($asset));
|
||||
if ($previewUrl === '' && $id > 0) {
|
||||
$previewUrl = '/adminapi/file-asset/' . $id . '/preview';
|
||||
$mimeType = strtolower((string) $this->field($asset, 'mime_type', ''));
|
||||
$fileExt = strtolower((string) $this->field($asset, 'file_ext', ''));
|
||||
$normalizedAsset = $this->normalizeAsset($asset);
|
||||
$publicUrl = $visibility === FileConstant::VISIBILITY_PUBLIC
|
||||
? $this->storageManager->publicUrl($normalizedAsset)
|
||||
: '';
|
||||
$previewable = $this->isPreviewable($scene, $mimeType, $fileExt);
|
||||
$previewUrl = '';
|
||||
if ($previewable) {
|
||||
$previewUrl = $publicUrl !== '' ? $publicUrl : $this->storageManager->temporaryUrl($normalizedAsset);
|
||||
if ($previewUrl === '' && $id > 0) {
|
||||
$previewUrl = '/adminapi/file-asset/' . $id . '/preview';
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
@@ -93,10 +135,11 @@ class FileRecordQueryService extends BaseService
|
||||
'md5' => (string) $this->field($asset, 'md5', ''),
|
||||
'object_key' => (string) $this->field($asset, 'object_key', ''),
|
||||
'source_url' => (string) $this->field($asset, 'source_url', ''),
|
||||
'url' => $previewUrl,
|
||||
'url' => $publicUrl,
|
||||
'public_url' => $publicUrl,
|
||||
'preview_url' => $previewUrl,
|
||||
'download_url' => $id > 0 ? '/adminapi/file-asset/' . $id . '/download' : '',
|
||||
'previewable' => $previewable,
|
||||
'created_by' => (int) $this->field($asset, 'created_by', 0),
|
||||
'created_by_name' => (string) $this->field($asset, 'created_by_name', ''),
|
||||
'remark' => (string) $this->field($asset, 'remark', ''),
|
||||
@@ -106,6 +149,11 @@ class FileRecordQueryService extends BaseService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件记录选项。
|
||||
*
|
||||
* @return array<string, array<int, array{label: string, value: int}>> 选项数据
|
||||
*/
|
||||
public function options(): array
|
||||
{
|
||||
return [
|
||||
@@ -117,6 +165,12 @@ class FileRecordQueryService extends BaseService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小。
|
||||
*
|
||||
* @param int $size 文件大小(字节)
|
||||
* @return string 格式化后的大小
|
||||
*/
|
||||
private function formatSize(int $size): string
|
||||
{
|
||||
if ($size <= 0) {
|
||||
@@ -134,6 +188,12 @@ class FileRecordQueryService extends BaseService
|
||||
return $index === 0 ? (string) (int) $value . ' ' . $units[$index] : number_format($value, 2) . ' ' . $units[$index];
|
||||
}
|
||||
|
||||
/**
|
||||
* 将映射表转换为前端选项。
|
||||
*
|
||||
* @param array $map 映射表
|
||||
* @return array 选项列表
|
||||
*/
|
||||
private function toOptions(array $map): array
|
||||
{
|
||||
$options = [];
|
||||
@@ -147,7 +207,15 @@ class FileRecordQueryService extends BaseService
|
||||
return $options;
|
||||
}
|
||||
|
||||
private function field(mixed $asset, string $key, mixed $default = null): mixed
|
||||
/**
|
||||
* 从数组或对象中读取字段值。
|
||||
*
|
||||
* @param array|object|null $asset 文件记录数据
|
||||
* @param string $key 字段名
|
||||
* @param mixed $default 默认值
|
||||
* @return mixed 文件字段值
|
||||
*/
|
||||
private function field(array|object|null $asset, string $key, mixed $default = null): mixed
|
||||
{
|
||||
if (is_array($asset)) {
|
||||
return $asset[$key] ?? $default;
|
||||
@@ -160,7 +228,13 @@ class FileRecordQueryService extends BaseService
|
||||
return $default;
|
||||
}
|
||||
|
||||
private function normalizeAsset(mixed $asset): array
|
||||
/**
|
||||
* 归一化文件记录。
|
||||
*
|
||||
* @param array|object|null $asset 原始记录
|
||||
* @return array<string, mixed> 标准化记录
|
||||
*/
|
||||
private function normalizeAsset(array|object|null $asset): array
|
||||
{
|
||||
return $this->field($asset, 'id', null) === null ? [] : [
|
||||
'id' => (int) $this->field($asset, 'id', 0),
|
||||
@@ -173,4 +247,25 @@ class FileRecordQueryService extends BaseService
|
||||
'mime_type' => (string) $this->field($asset, 'mime_type', ''),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断文件是否支持预览。
|
||||
*
|
||||
* @param int $scene 场景
|
||||
* @param string $mimeType MIME 类型
|
||||
* @param string $fileExt 文件扩展名
|
||||
* @return bool 是否支持预览
|
||||
*/
|
||||
private function isPreviewable(int $scene, string $mimeType, string $fileExt): bool
|
||||
{
|
||||
if ($scene === FileConstant::SCENE_IMAGE || str_starts_with($mimeType, 'image/')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($scene === FileConstant::SCENE_TEXT || str_starts_with($mimeType, 'text/')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($fileExt, ['pem', 'crt', 'cer', 'key'], true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,22 @@ use app\service\file\storage\StorageManager;
|
||||
use Webman\Http\UploadFile;
|
||||
|
||||
/**
|
||||
* 文件门面服务。
|
||||
* 文件记录服务。
|
||||
*
|
||||
* @property FileRecordQueryService $queryService 查询服务
|
||||
* @property FileRecordCommandService $commandService 命令服务
|
||||
* @property StorageManager $storageManager 存储管理器
|
||||
*/
|
||||
class FileRecordService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 构造方法。
|
||||
*
|
||||
* @param FileRecordQueryService $queryService 查询服务
|
||||
* @param FileRecordCommandService $commandService 命令服务
|
||||
* @param StorageManager $storageManager 存储管理器
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(
|
||||
protected FileRecordQueryService $queryService,
|
||||
protected FileRecordCommandService $commandService,
|
||||
@@ -18,36 +30,85 @@ class FileRecordService extends BaseService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询文件记录。
|
||||
*
|
||||
* @param array $filters 筛选条件
|
||||
* @param int $page 页码
|
||||
* @param int $pageSize 每页条数
|
||||
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator 分页结果
|
||||
*/
|
||||
public function paginate(array $filters = [], int $page = 1, int $pageSize = 10)
|
||||
{
|
||||
return $this->queryService->paginate($filters, $page, $pageSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询文件记录详情。
|
||||
*
|
||||
* @param int $id 文件记录ID
|
||||
* @return array 文件详情
|
||||
*/
|
||||
public function detail(int $id): array
|
||||
{
|
||||
return $this->queryService->detail($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件记录选项。
|
||||
*
|
||||
* @return array 选项数据
|
||||
*/
|
||||
public function options(): array
|
||||
{
|
||||
return $this->queryService->options();
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件并创建记录。
|
||||
*
|
||||
* @param UploadFile $file 上传文件
|
||||
* @param array $data 文件参数
|
||||
* @param int $createdBy 创建人ID
|
||||
* @param string $createdByName 创建人名称
|
||||
* @return array 文件记录
|
||||
*/
|
||||
public function upload(UploadFile $file, array $data, int $createdBy = 0, string $createdByName = ''): array
|
||||
{
|
||||
return $this->commandService->upload($file, $data, $createdBy, $createdByName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入远程文件并创建记录。
|
||||
*
|
||||
* @param string $remoteUrl 远程地址
|
||||
* @param array $data 文件参数
|
||||
* @param int $createdBy 创建人ID
|
||||
* @param string $createdByName 创建人名称
|
||||
* @return array 文件记录
|
||||
*/
|
||||
public function importRemote(string $remoteUrl, array $data, int $createdBy = 0, string $createdByName = ''): array
|
||||
{
|
||||
return $this->commandService->importRemote($remoteUrl, $data, $createdBy, $createdByName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件记录。
|
||||
*
|
||||
* @param int $id 文件记录ID
|
||||
* @return bool 是否删除成功
|
||||
*/
|
||||
public function delete(int $id): bool
|
||||
{
|
||||
return $this->commandService->delete($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件预览响应。
|
||||
*
|
||||
* @param int $id 文件记录ID
|
||||
* @return \support\Response 响应对象
|
||||
*/
|
||||
public function previewResponse(int $id)
|
||||
{
|
||||
$asset = $this->queryService->detail($id);
|
||||
@@ -55,6 +116,12 @@ class FileRecordService extends BaseService
|
||||
return $this->storageManager->previewResponse($asset);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件下载响应。
|
||||
*
|
||||
* @param int $id 文件记录ID
|
||||
* @return \support\Response 响应对象
|
||||
*/
|
||||
public function downloadResponse(int $id)
|
||||
{
|
||||
$asset = $this->queryService->detail($id);
|
||||
|
||||
@@ -4,32 +4,51 @@ namespace app\service\file;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\FileConstant;
|
||||
use app\exception\BusinessStateException;
|
||||
|
||||
/**
|
||||
* 文件存储配置服务。
|
||||
*
|
||||
* 负责读取系统配置并统一整理文件场景、可见性、扩展名和存储引擎相关规则。
|
||||
*/
|
||||
class StorageConfigService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 获取默认存储引擎。
|
||||
*
|
||||
* @return int 存储引擎
|
||||
*/
|
||||
public function defaultEngine(): int
|
||||
{
|
||||
return $this->normalizeSelectableEngine((int) sys_config(FileConstant::CONFIG_DEFAULT_ENGINE, FileConstant::STORAGE_LOCAL));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本地公开访问基址。
|
||||
*
|
||||
* @return string 基础地址
|
||||
* @throws BusinessStateException
|
||||
*/
|
||||
public function localPublicBaseUrl(): string
|
||||
{
|
||||
$baseUrl = trim((string) sys_config(FileConstant::CONFIG_LOCAL_PUBLIC_BASE_URL, ''));
|
||||
if ($baseUrl !== '') {
|
||||
return rtrim($baseUrl, '/');
|
||||
}
|
||||
|
||||
$siteUrl = trim((string) sys_config('site_url', ''));
|
||||
if ($siteUrl !== '') {
|
||||
$baseUrl = trim((string) sys_config(FileConstant::CONFIG_LOCAL_PUBLIC_BASE_URL, ''));
|
||||
if ($baseUrl !== '') {
|
||||
return rtrim($baseUrl, '/');
|
||||
}
|
||||
|
||||
return rtrim($siteUrl, '/');
|
||||
}
|
||||
|
||||
return '';
|
||||
throw new BusinessStateException('请先在系统配置中设置站点 URL');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本地公开目录。
|
||||
*
|
||||
* @return string 目录
|
||||
*/
|
||||
public function localPublicDir(): string
|
||||
{
|
||||
$dir = trim((string) sys_config(FileConstant::CONFIG_LOCAL_PUBLIC_DIR, 'storage/uploads'), "/ \t\n\r\0\x0B");
|
||||
@@ -37,6 +56,11 @@ class StorageConfigService extends BaseService
|
||||
return $dir !== '' ? $dir : 'storage/uploads';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本地私有目录。
|
||||
*
|
||||
* @return string 目录
|
||||
*/
|
||||
public function localPrivateDir(): string
|
||||
{
|
||||
$dir = trim((string) sys_config(FileConstant::CONFIG_LOCAL_PRIVATE_DIR, 'storage/private'), "/ \t\n\r\0\x0B");
|
||||
@@ -44,6 +68,11 @@ class StorageConfigService extends BaseService
|
||||
return $dir !== '' ? $dir : 'storage/private';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取上传大小上限。
|
||||
*
|
||||
* @return int 字节数
|
||||
*/
|
||||
public function uploadMaxSizeBytes(): int
|
||||
{
|
||||
$mb = max(1, (int) sys_config(FileConstant::CONFIG_UPLOAD_MAX_SIZE_MB, 20));
|
||||
@@ -51,6 +80,11 @@ class StorageConfigService extends BaseService
|
||||
return $mb * 1024 * 1024;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取远程下载大小上限。
|
||||
*
|
||||
* @return int 字节数
|
||||
*/
|
||||
public function remoteDownloadLimitBytes(): int
|
||||
{
|
||||
$mb = max(1, (int) sys_config(FileConstant::CONFIG_REMOTE_DOWNLOAD_LIMIT_MB, 10));
|
||||
@@ -58,6 +92,11 @@ class StorageConfigService extends BaseService
|
||||
return $mb * 1024 * 1024;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取允许上传的扩展名列表。
|
||||
*
|
||||
* @return array 允许的扩展名列表
|
||||
*/
|
||||
public function allowedExtensions(): array
|
||||
{
|
||||
$raw = trim((string) sys_config(FileConstant::CONFIG_ALLOWED_EXTENSIONS, implode(',', FileConstant::defaultAllowedExtensions())));
|
||||
@@ -70,6 +109,11 @@ class StorageConfigService extends BaseService
|
||||
return array_values(array_unique($extensions));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取阿里云 OSS 配置。
|
||||
*
|
||||
* @return array OSS 配置
|
||||
*/
|
||||
public function ossConfig(): array
|
||||
{
|
||||
return [
|
||||
@@ -82,6 +126,11 @@ class StorageConfigService extends BaseService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取腾讯云 COS 配置。
|
||||
*
|
||||
* @return array COS 配置
|
||||
*/
|
||||
public function cosConfig(): array
|
||||
{
|
||||
return [
|
||||
@@ -93,6 +142,14 @@ class StorageConfigService extends BaseService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化文件场景。
|
||||
*
|
||||
* @param int|string|null $scene 场景
|
||||
* @param string $originalName 原始文件名
|
||||
* @param string $mimeType MIME 类型
|
||||
* @return int 场景值
|
||||
*/
|
||||
public function normalizeScene(int|string|null $scene = null, string $originalName = '', string $mimeType = ''): int
|
||||
{
|
||||
$scene = (int) $scene;
|
||||
@@ -122,6 +179,13 @@ class StorageConfigService extends BaseService
|
||||
return FileConstant::SCENE_OTHER;
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化文件可见性。
|
||||
*
|
||||
* @param int|string|null $visibility 可见性
|
||||
* @param int $scene 场景
|
||||
* @return int 可见性值
|
||||
*/
|
||||
public function normalizeVisibility(int|string|null $visibility = null, int $scene = FileConstant::SCENE_OTHER): int
|
||||
{
|
||||
$visibility = (int) $visibility;
|
||||
@@ -134,6 +198,12 @@ class StorageConfigService extends BaseService
|
||||
: FileConstant::VISIBILITY_PRIVATE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化存储引擎。
|
||||
*
|
||||
* @param int|string|null $engine 存储引擎
|
||||
* @return int 存储引擎值
|
||||
*/
|
||||
public function normalizeEngine(int|string|null $engine = null): int
|
||||
{
|
||||
$engine = (int) $engine;
|
||||
@@ -141,6 +211,12 @@ class StorageConfigService extends BaseService
|
||||
return $this->normalizeSelectableEngine($engine);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取场景对应的目录名。
|
||||
*
|
||||
* @param int $scene 场景
|
||||
* @return string 目录名
|
||||
*/
|
||||
public function sceneFolder(int $scene): string
|
||||
{
|
||||
return match ($scene) {
|
||||
@@ -151,6 +227,14 @@ class StorageConfigService extends BaseService
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建对象键。
|
||||
*
|
||||
* @param int $scene 场景
|
||||
* @param int $visibility 可见性
|
||||
* @param string $extension 扩展名
|
||||
* @return string 对象键
|
||||
*/
|
||||
public function buildObjectKey(int $scene, int $visibility, string $extension): string
|
||||
{
|
||||
$extension = strtolower(trim($extension, ". \t\n\r\0\x0B"));
|
||||
@@ -168,6 +252,13 @@ class StorageConfigService extends BaseService
|
||||
return trim($rootDir . '/' . $this->sceneFolder($scene) . '/' . $timestampPath . '/' . $name, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建本地绝对路径。
|
||||
*
|
||||
* @param int $visibility 可见性
|
||||
* @param string $objectKey 对象键
|
||||
* @return string 绝对路径
|
||||
*/
|
||||
public function buildLocalAbsolutePath(int $visibility, string $objectKey): string
|
||||
{
|
||||
$root = $visibility === FileConstant::VISIBILITY_PUBLIC
|
||||
@@ -178,6 +269,12 @@ class StorageConfigService extends BaseService
|
||||
return rtrim($root, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relativePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建本地公开访问 URL。
|
||||
*
|
||||
* @param string $objectKey 对象键
|
||||
* @return string 访问 URL
|
||||
*/
|
||||
public function buildLocalPublicUrl(string $objectKey): string
|
||||
{
|
||||
$path = trim(str_replace('\\', '/', $objectKey), '/');
|
||||
@@ -190,6 +287,12 @@ class StorageConfigService extends BaseService
|
||||
return '/' . $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化可选存储引擎。
|
||||
*
|
||||
* @param int $engine 存储引擎
|
||||
* @return int 存储引擎值
|
||||
*/
|
||||
private function normalizeSelectableEngine(int $engine): int
|
||||
{
|
||||
return match ($engine) {
|
||||
@@ -200,3 +303,7 @@ class StorageConfigService extends BaseService
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -3,57 +3,69 @@
|
||||
namespace app\service\file\storage;
|
||||
|
||||
use app\common\constant\FileConstant;
|
||||
use app\exception\ResourceNotFoundException;
|
||||
use app\service\file\StorageConfigService;
|
||||
use support\Response;
|
||||
|
||||
/**
|
||||
* 文件存储驱动抽象基类。
|
||||
*
|
||||
* 提供文件存储驱动公共能力。
|
||||
*
|
||||
* @property-read StorageConfigService $storageConfigService 存储配置服务
|
||||
*/
|
||||
abstract class AbstractStorageDriver implements StorageDriverInterface
|
||||
{
|
||||
/**
|
||||
* 注入存储配置服务。
|
||||
*
|
||||
* @param StorageConfigService $storageConfigService 存储配置服务
|
||||
*/
|
||||
public function __construct(
|
||||
protected StorageConfigService $storageConfigService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 从资产数组中读取指定字段。
|
||||
*
|
||||
* @param array<string, mixed> $asset 文件资产数据
|
||||
* @param string $key 字段名
|
||||
* @param mixed $default 默认值
|
||||
* @return mixed 资产字段值
|
||||
*/
|
||||
protected function assetValue(array $asset, string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $asset[$key] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析本地存储文件的绝对路径。
|
||||
*
|
||||
* @param array $asset 文件资产数据
|
||||
* @return string 绝对路径
|
||||
*/
|
||||
protected function resolveLocalAbsolutePath(array $asset): string
|
||||
{
|
||||
$objectKey = trim((string) $this->assetValue($asset, 'object_key', ''));
|
||||
if ($objectKey === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$visibility = (int) $this->assetValue($asset, 'visibility', FileConstant::VISIBILITY_PRIVATE);
|
||||
$candidate = '';
|
||||
|
||||
if ($objectKey !== '') {
|
||||
$candidate = $this->storageConfigService->buildLocalAbsolutePath($visibility, $objectKey);
|
||||
if ($candidate !== '' && is_file($candidate)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (['url', 'public_url'] as $field) {
|
||||
$url = trim((string) $this->assetValue($asset, $field, ''));
|
||||
if ($url === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parsedPath = (string) parse_url($url, PHP_URL_PATH);
|
||||
if ($parsedPath === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$candidate = public_path() . DIRECTORY_SEPARATOR . ltrim($parsedPath, '/');
|
||||
if (is_file($candidate)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
return $this->storageConfigService->buildLocalAbsolutePath($visibility, $objectKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造字符串响应。
|
||||
*
|
||||
* @param string $body 响应内容
|
||||
* @param string $mimeType MIME 类型
|
||||
* @param int $status HTTP 状态码
|
||||
* @param array $headers 额外响应头
|
||||
* @return Response 响应对象
|
||||
*/
|
||||
protected function bodyResponse(string $body, string $mimeType = 'application/octet-stream', int $status = 200, array $headers = []): Response
|
||||
{
|
||||
$responseHeaders = array_merge([
|
||||
@@ -63,6 +75,14 @@ abstract class AbstractStorageDriver implements StorageDriverInterface
|
||||
return response($body, $status, $responseHeaders);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造文件下载响应。
|
||||
*
|
||||
* @param string $body 响应内容
|
||||
* @param string $downloadName 下载文件名
|
||||
* @param string $mimeType MIME 类型
|
||||
* @return Response 响应对象
|
||||
*/
|
||||
protected function downloadBodyResponse(string $body, string $downloadName, string $mimeType = 'application/octet-stream'): Response
|
||||
{
|
||||
$response = $this->bodyResponse($body, $mimeType, 200, [
|
||||
@@ -72,6 +92,14 @@ abstract class AbstractStorageDriver implements StorageDriverInterface
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据本地路径构造预览或下载响应。
|
||||
*
|
||||
* @param string $path 本地文件路径
|
||||
* @param string $downloadName 下载文件名
|
||||
* @param bool $attachment 是否下载附件
|
||||
* @return Response 响应对象
|
||||
*/
|
||||
protected function responseFromPath(string $path, string $downloadName = '', bool $attachment = false): Response
|
||||
{
|
||||
if ($attachment) {
|
||||
@@ -81,26 +109,46 @@ abstract class AbstractStorageDriver implements StorageDriverInterface
|
||||
return response()->file($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造本地文件预览响应。
|
||||
*
|
||||
* @param array $asset 文件资产数据
|
||||
* @return Response 响应对象
|
||||
* @throws ResourceNotFoundException
|
||||
*/
|
||||
protected function localPreviewResponse(array $asset): Response
|
||||
{
|
||||
$path = $this->resolveLocalAbsolutePath($asset);
|
||||
if ($path === '' || !is_file($path)) {
|
||||
return response('文件不存在', 404);
|
||||
throw new ResourceNotFoundException('文件不存在');
|
||||
}
|
||||
|
||||
return $this->responseFromPath($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造本地文件下载响应。
|
||||
*
|
||||
* @param array $asset 文件资产数据
|
||||
* @return Response 响应对象
|
||||
* @throws ResourceNotFoundException
|
||||
*/
|
||||
protected function localDownloadResponse(array $asset): Response
|
||||
{
|
||||
$path = $this->resolveLocalAbsolutePath($asset);
|
||||
if ($path === '' || !is_file($path)) {
|
||||
return response('文件不存在', 404);
|
||||
throw new ResourceNotFoundException('文件不存在');
|
||||
}
|
||||
|
||||
return $this->responseFromPath($path, (string) $this->assetValue($asset, 'original_name', basename($path)), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据文件场景返回目录前缀。
|
||||
*
|
||||
* @param int $scene 文件场景
|
||||
* @return string 目录前缀
|
||||
*/
|
||||
protected function scenePrefix(int $scene): string
|
||||
{
|
||||
return match ($scene) {
|
||||
|
||||
@@ -4,20 +4,36 @@ namespace app\service\file\storage;
|
||||
|
||||
use app\common\constant\FileConstant;
|
||||
use app\exception\BusinessStateException;
|
||||
use app\exception\ResourceNotFoundException;
|
||||
use Qcloud\Cos\Client as CosClient;
|
||||
use support\Response;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 腾讯云 COS 文件存储驱动。
|
||||
*
|
||||
* 负责对象上传、删除、公开地址生成和对象内容响应。
|
||||
*/
|
||||
class CosStorageDriver extends AbstractStorageDriver
|
||||
{
|
||||
/**
|
||||
* 获取 COS 存储引擎标识。
|
||||
*
|
||||
* @return int 存储引擎常量
|
||||
*/
|
||||
public function engine(): int
|
||||
{
|
||||
return FileConstant::STORAGE_TENCENT_COS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将本地临时文件上传到 COS。
|
||||
*
|
||||
* @param string $sourcePath 待上传文件路径
|
||||
* @param array $context 上传上下文,包含 object_key、visibility 等信息
|
||||
* @return array 上传后的资产数据
|
||||
* @throws BusinessStateException
|
||||
*/
|
||||
public function storeFromPath(string $sourcePath, array $context): array
|
||||
{
|
||||
if (!is_file($sourcePath)) {
|
||||
@@ -33,6 +49,7 @@ class CosStorageDriver extends AbstractStorageDriver
|
||||
|
||||
$client = $this->client($config);
|
||||
$objectKey = (string) ($context['object_key'] ?? '');
|
||||
$visibility = (int) ($context['visibility'] ?? FileConstant::VISIBILITY_PRIVATE);
|
||||
$client->putObject([
|
||||
'Bucket' => (string) $config['bucket'],
|
||||
'Key' => $objectKey,
|
||||
@@ -40,23 +57,30 @@ class CosStorageDriver extends AbstractStorageDriver
|
||||
]);
|
||||
|
||||
$publicUrl = $this->publicUrl([
|
||||
'visibility' => $visibility,
|
||||
'object_key' => $objectKey,
|
||||
]);
|
||||
$visibility = (int) ($context['visibility'] ?? FileConstant::VISIBILITY_PRIVATE);
|
||||
|
||||
return [
|
||||
'storage_engine' => $this->engine(),
|
||||
'object_key' => $objectKey,
|
||||
'url' => $visibility === FileConstant::VISIBILITY_PUBLIC ? $publicUrl : '',
|
||||
'public_url' => $publicUrl,
|
||||
'public_url' => $visibility === FileConstant::VISIBILITY_PUBLIC ? $publicUrl : '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 COS 对象。
|
||||
*
|
||||
* @param array $asset 文件资产数据
|
||||
* @return bool 是否删除成功
|
||||
* @throws BusinessStateException
|
||||
*/
|
||||
public function delete(array $asset): bool
|
||||
{
|
||||
$config = $this->storageConfigService->cosConfig();
|
||||
if (trim((string) ($config['bucket'] ?? '')) === '') {
|
||||
return false;
|
||||
throw new BusinessStateException('腾讯云 COS 存储配置未完整');
|
||||
}
|
||||
|
||||
$objectKey = (string) ($asset['object_key'] ?? '');
|
||||
@@ -73,23 +97,41 @@ class CosStorageDriver extends AbstractStorageDriver
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造 COS 文件预览响应。
|
||||
*
|
||||
* @param array $asset 文件资产数据
|
||||
* @return Response 响应对象
|
||||
*/
|
||||
public function previewResponse(array $asset): Response
|
||||
{
|
||||
$url = $this->publicUrl($asset);
|
||||
if ($url !== '') {
|
||||
return redirect($url);
|
||||
}
|
||||
|
||||
return $this->responseFromObject($asset, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造 COS 文件下载响应。
|
||||
*
|
||||
* @param array $asset 文件资产数据
|
||||
* @return Response 响应对象
|
||||
*/
|
||||
public function downloadResponse(array $asset): Response
|
||||
{
|
||||
return $this->responseFromObject($asset, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 COS 公开访问地址。
|
||||
*
|
||||
* @param array $asset 文件资产数据
|
||||
* @return string 公共 URL
|
||||
*/
|
||||
public function publicUrl(array $asset): string
|
||||
{
|
||||
$visibility = (int) ($asset['visibility'] ?? FileConstant::VISIBILITY_PRIVATE);
|
||||
if ($visibility !== FileConstant::VISIBILITY_PUBLIC) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$publicUrl = trim((string) ($asset['url'] ?? $asset['public_url'] ?? ''));
|
||||
if ($publicUrl !== '') {
|
||||
return $publicUrl;
|
||||
@@ -115,11 +157,17 @@ class CosStorageDriver extends AbstractStorageDriver
|
||||
return 'https://' . $bucket . '.cos.' . $region . '.myqcloud.com/' . ltrim($objectKey, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 COS 临时访问地址。
|
||||
*
|
||||
* @param array $asset 文件资产数据
|
||||
* @return string 临时 URL
|
||||
*/
|
||||
public function temporaryUrl(array $asset): string
|
||||
{
|
||||
$config = $this->storageConfigService->cosConfig();
|
||||
if (trim((string) ($config['bucket'] ?? '')) === '' || trim((string) ($config['region'] ?? '')) === '') {
|
||||
return $this->publicUrl($asset);
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -134,10 +182,16 @@ class CosStorageDriver extends AbstractStorageDriver
|
||||
$objectKey
|
||||
);
|
||||
} catch (Throwable) {
|
||||
return $this->publicUrl($asset);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 COS 客户端。
|
||||
*
|
||||
* @param array $config 存储配置
|
||||
* @return CosClient COS 客户端
|
||||
*/
|
||||
private function client(array $config): CosClient
|
||||
{
|
||||
return new CosClient([
|
||||
@@ -149,13 +203,21 @@ class CosStorageDriver extends AbstractStorageDriver
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 COS 对象内容构造预览或下载响应。
|
||||
*
|
||||
* @param array $asset 文件资产数据
|
||||
* @param bool $attachment 是否下载附件
|
||||
* @return Response 响应对象
|
||||
* @throws ResourceNotFoundException
|
||||
*/
|
||||
private function responseFromObject(array $asset, bool $attachment): Response
|
||||
{
|
||||
$config = $this->storageConfigService->cosConfig();
|
||||
$bucket = trim((string) ($config['bucket'] ?? ''));
|
||||
$objectKey = (string) ($asset['object_key'] ?? '');
|
||||
if ($bucket === '' || $objectKey === '') {
|
||||
return response('文件不存在', 404);
|
||||
throw new ResourceNotFoundException('文件不存在');
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -171,7 +233,7 @@ class CosStorageDriver extends AbstractStorageDriver
|
||||
} elseif (is_object($result) && method_exists($result, '__toString')) {
|
||||
$body = (string) $result;
|
||||
} elseif (is_array($result)) {
|
||||
$body = (string) ($result['Body'] ?? $result['body'] ?? '');
|
||||
$body = (string) ($result['Body'] ?? $result['body'] ?? '');
|
||||
}
|
||||
|
||||
$mimeType = (string) ($asset['mime_type'] ?? 'application/octet-stream');
|
||||
@@ -182,7 +244,7 @@ class CosStorageDriver extends AbstractStorageDriver
|
||||
|
||||
return $this->bodyResponse($body, $mimeType);
|
||||
} catch (Throwable) {
|
||||
return response('文件不存在', 404);
|
||||
throw new ResourceNotFoundException('文件不存在');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,14 +8,29 @@ use support\Response;
|
||||
|
||||
/**
|
||||
* 本地文件存储驱动。
|
||||
*
|
||||
* 负责本地文件存储和响应构造。
|
||||
*/
|
||||
class LocalStorageDriver extends AbstractStorageDriver
|
||||
{
|
||||
/**
|
||||
* 获取本地存储引擎标识。
|
||||
*
|
||||
* @return int 存储引擎常量
|
||||
*/
|
||||
public function engine(): int
|
||||
{
|
||||
return FileConstant::STORAGE_LOCAL;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将临时文件写入本地存储目录。
|
||||
*
|
||||
* @param string $sourcePath 待上传文件路径
|
||||
* @param array $context 上传上下文,包含 object_key、visibility、public_url 等信息
|
||||
* @return array 上传后的资产数据
|
||||
* @throws BusinessStateException
|
||||
*/
|
||||
public function storeFromPath(string $sourcePath, array $context): array
|
||||
{
|
||||
if (!is_file($sourcePath)) {
|
||||
@@ -51,6 +66,13 @@ class LocalStorageDriver extends AbstractStorageDriver
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除本地文件。
|
||||
*
|
||||
* @param array $asset 文件资产数据
|
||||
* @return bool 是否删除成功
|
||||
* @throws BusinessStateException
|
||||
*/
|
||||
public function delete(array $asset): bool
|
||||
{
|
||||
$path = $this->resolveLocalAbsolutePath($asset);
|
||||
@@ -58,26 +80,43 @@ class LocalStorageDriver extends AbstractStorageDriver
|
||||
return true;
|
||||
}
|
||||
|
||||
return @unlink($path);
|
||||
if (@unlink($path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new BusinessStateException('本地文件删除失败');
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造本地文件预览响应。
|
||||
*
|
||||
* @param array $asset 文件资产数据
|
||||
* @return Response 响应对象
|
||||
*/
|
||||
public function previewResponse(array $asset): Response
|
||||
{
|
||||
return $this->localPreviewResponse($asset);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造本地文件下载响应。
|
||||
*
|
||||
* @param array $asset 文件资产数据
|
||||
* @return Response 响应对象
|
||||
*/
|
||||
public function downloadResponse(array $asset): Response
|
||||
{
|
||||
return $this->localDownloadResponse($asset);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本地公开访问地址。
|
||||
*
|
||||
* @param array $asset 文件资产数据
|
||||
* @return string 公共 URL
|
||||
*/
|
||||
public function publicUrl(array $asset): string
|
||||
{
|
||||
$url = trim((string) ($asset['url'] ?? $asset['public_url'] ?? ''));
|
||||
if ($url !== '') {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$visibility = (int) ($asset['visibility'] ?? FileConstant::VISIBILITY_PRIVATE);
|
||||
if ($visibility !== FileConstant::VISIBILITY_PUBLIC) {
|
||||
return '';
|
||||
@@ -91,13 +130,14 @@ class LocalStorageDriver extends AbstractStorageDriver
|
||||
return $this->storageConfigService->buildLocalPublicUrl($objectKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取本地临时访问地址。
|
||||
*
|
||||
* @param array $asset 文件资产数据
|
||||
* @return string 临时 URL
|
||||
*/
|
||||
public function temporaryUrl(array $asset): string
|
||||
{
|
||||
$url = trim((string) ($asset['url'] ?? $asset['public_url'] ?? ''));
|
||||
if ($url !== '') {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$visibility = (int) ($asset['visibility'] ?? FileConstant::VISIBILITY_PRIVATE);
|
||||
if ($visibility === FileConstant::VISIBILITY_PUBLIC) {
|
||||
return $this->publicUrl($asset);
|
||||
@@ -108,6 +148,12 @@ class LocalStorageDriver extends AbstractStorageDriver
|
||||
return $id > 0 ? '/adminapi/file-asset/' . $id . '/preview' : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保目标目录存在。
|
||||
*
|
||||
* @param string $directory 目录路径
|
||||
* @throws BusinessStateException
|
||||
*/
|
||||
private function ensureDirectory(string $directory): void
|
||||
{
|
||||
if (is_dir($directory)) {
|
||||
|
||||
@@ -4,20 +4,36 @@ namespace app\service\file\storage;
|
||||
|
||||
use app\common\constant\FileConstant;
|
||||
use app\exception\BusinessStateException;
|
||||
use app\exception\ResourceNotFoundException;
|
||||
use AlibabaCloud\Oss\V2 as Oss;
|
||||
use support\Response;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 阿里云 OSS 文件存储驱动。
|
||||
*
|
||||
* 负责对象上传、删除、公开地址生成和预签名访问。
|
||||
*/
|
||||
class OssStorageDriver extends AbstractStorageDriver
|
||||
{
|
||||
/**
|
||||
* 获取 OSS 存储引擎标识。
|
||||
*
|
||||
* @return int 存储引擎常量
|
||||
*/
|
||||
public function engine(): int
|
||||
{
|
||||
return FileConstant::STORAGE_ALIYUN_OSS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将本地临时文件上传到 OSS。
|
||||
*
|
||||
* @param string $sourcePath 待上传文件路径
|
||||
* @param array $context 上传上下文,包含 object_key、visibility 等信息
|
||||
* @return array 上传后的资产数据
|
||||
* @throws BusinessStateException
|
||||
*/
|
||||
public function storeFromPath(string $sourcePath, array $context): array
|
||||
{
|
||||
if (!is_file($sourcePath)) {
|
||||
@@ -33,6 +49,9 @@ class OssStorageDriver extends AbstractStorageDriver
|
||||
|
||||
$client = $this->client($config);
|
||||
$objectKey = (string) ($context['object_key'] ?? '');
|
||||
$visibility = (int) ($context['visibility'] ?? FileConstant::VISIBILITY_PRIVATE);
|
||||
|
||||
/** @var Oss\Models\PutObjectRequest $request */
|
||||
$request = new Oss\Models\PutObjectRequest(
|
||||
bucket: (string) $config['bucket'],
|
||||
key: $objectKey
|
||||
@@ -42,23 +61,30 @@ class OssStorageDriver extends AbstractStorageDriver
|
||||
$client->putObject($request);
|
||||
|
||||
$publicUrl = $this->publicUrl([
|
||||
'visibility' => $visibility,
|
||||
'object_key' => $objectKey,
|
||||
]);
|
||||
$visibility = (int) ($context['visibility'] ?? FileConstant::VISIBILITY_PRIVATE);
|
||||
|
||||
return [
|
||||
'storage_engine' => $this->engine(),
|
||||
'object_key' => $objectKey,
|
||||
'url' => $visibility === FileConstant::VISIBILITY_PUBLIC ? $publicUrl : '',
|
||||
'public_url' => $publicUrl,
|
||||
'public_url' => $visibility === FileConstant::VISIBILITY_PUBLIC ? $publicUrl : '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 OSS 对象。
|
||||
*
|
||||
* @param array $asset 文件资产数据
|
||||
* @return bool 是否删除成功
|
||||
* @throws BusinessStateException
|
||||
*/
|
||||
public function delete(array $asset): bool
|
||||
{
|
||||
$config = $this->storageConfigService->ossConfig();
|
||||
if (trim((string) ($config['bucket'] ?? '')) === '') {
|
||||
return false;
|
||||
throw new BusinessStateException('阿里云 OSS 存储配置未完整');
|
||||
}
|
||||
|
||||
$objectKey = (string) ($asset['object_key'] ?? '');
|
||||
@@ -67,6 +93,8 @@ class OssStorageDriver extends AbstractStorageDriver
|
||||
}
|
||||
|
||||
$client = $this->client($config);
|
||||
|
||||
/** @var Oss\Models\DeleteObjectRequest $request */
|
||||
$request = new Oss\Models\DeleteObjectRequest(
|
||||
bucket: (string) $config['bucket'],
|
||||
key: $objectKey
|
||||
@@ -76,23 +104,41 @@ class OssStorageDriver extends AbstractStorageDriver
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造 OSS 文件预览响应。
|
||||
*
|
||||
* @param array $asset 文件资产数据
|
||||
* @return Response 响应对象
|
||||
*/
|
||||
public function previewResponse(array $asset): Response
|
||||
{
|
||||
$url = $this->publicUrl($asset);
|
||||
if ($url !== '') {
|
||||
return redirect($url);
|
||||
}
|
||||
|
||||
return $this->responseFromObject($asset, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造 OSS 文件下载响应。
|
||||
*
|
||||
* @param array $asset 文件资产数据
|
||||
* @return Response 响应对象
|
||||
*/
|
||||
public function downloadResponse(array $asset): Response
|
||||
{
|
||||
return $this->responseFromObject($asset, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 OSS 公开访问地址。
|
||||
*
|
||||
* @param array $asset 文件资产数据
|
||||
* @return string 公共 URL
|
||||
*/
|
||||
public function publicUrl(array $asset): string
|
||||
{
|
||||
$visibility = (int) ($asset['visibility'] ?? FileConstant::VISIBILITY_PRIVATE);
|
||||
if ($visibility !== FileConstant::VISIBILITY_PUBLIC) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$publicUrl = trim((string) ($asset['url'] ?? $asset['public_url'] ?? ''));
|
||||
if ($publicUrl !== '') {
|
||||
return $publicUrl;
|
||||
@@ -120,11 +166,17 @@ class OssStorageDriver extends AbstractStorageDriver
|
||||
return 'https://' . $bucket . '.' . ltrim($endpoint, '/') . '/' . ltrim($objectKey, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 OSS 预签名访问地址。
|
||||
*
|
||||
* @param array $asset 文件资产数据
|
||||
* @return string 临时 URL
|
||||
*/
|
||||
public function temporaryUrl(array $asset): string
|
||||
{
|
||||
$config = $this->storageConfigService->ossConfig();
|
||||
if (trim((string) ($config['bucket'] ?? '')) === '' || trim((string) ($config['region'] ?? '')) === '') {
|
||||
return $this->publicUrl($asset);
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -134,6 +186,7 @@ class OssStorageDriver extends AbstractStorageDriver
|
||||
return '';
|
||||
}
|
||||
|
||||
/** @var Oss\Models\GetObjectRequest $request */
|
||||
$request = new Oss\Models\GetObjectRequest(
|
||||
bucket: (string) $config['bucket'],
|
||||
key: $objectKey
|
||||
@@ -142,12 +195,19 @@ class OssStorageDriver extends AbstractStorageDriver
|
||||
|
||||
return (string) ($result->url ?? '');
|
||||
} catch (Throwable) {
|
||||
return $this->publicUrl($asset);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 OSS 客户端。
|
||||
*
|
||||
* @param array $config 存储配置
|
||||
* @return Oss\Client OSS 客户端
|
||||
*/
|
||||
private function client(array $config): Oss\Client
|
||||
{
|
||||
/** @var Oss\Credentials\StaticCredentialsProvider $provider */
|
||||
$provider = new Oss\Credentials\StaticCredentialsProvider(
|
||||
accessKeyId: (string) $config['access_key_id'],
|
||||
accessKeySecret: (string) $config['access_key_secret']
|
||||
@@ -165,17 +225,26 @@ class OssStorageDriver extends AbstractStorageDriver
|
||||
return new Oss\Client($cfg);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 OSS 对象内容构造预览或下载响应。
|
||||
*
|
||||
* @param array $asset 文件资产数据
|
||||
* @param bool $attachment 是否下载附件
|
||||
* @return Response 响应对象
|
||||
* @throws ResourceNotFoundException
|
||||
*/
|
||||
private function responseFromObject(array $asset, bool $attachment): Response
|
||||
{
|
||||
$config = $this->storageConfigService->ossConfig();
|
||||
$bucket = trim((string) ($config['bucket'] ?? ''));
|
||||
$objectKey = (string) ($asset['object_key'] ?? '');
|
||||
if ($bucket === '' || $objectKey === '') {
|
||||
return response('文件不存在', 404);
|
||||
throw new ResourceNotFoundException('文件不存在');
|
||||
}
|
||||
|
||||
try {
|
||||
$client = $this->client($config);
|
||||
/** @var Oss\Models\GetObjectRequest $request */
|
||||
$request = new Oss\Models\GetObjectRequest(
|
||||
bucket: $bucket,
|
||||
key: $objectKey
|
||||
@@ -190,7 +259,7 @@ class OssStorageDriver extends AbstractStorageDriver
|
||||
|
||||
return $this->bodyResponse($body, $mimeType);
|
||||
} catch (Throwable) {
|
||||
return response('文件不存在', 404);
|
||||
throw new ResourceNotFoundException('文件不存在');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,48 +4,95 @@ namespace app\service\file\storage;
|
||||
|
||||
use app\common\constant\FileConstant;
|
||||
use app\exception\BusinessStateException;
|
||||
use app\exception\ResourceNotFoundException;
|
||||
use support\Response;
|
||||
|
||||
/**
|
||||
* 远程引用驱动。
|
||||
* 远程引用文件存储驱动。
|
||||
*
|
||||
* 仅保存原始远程 URL,不做本地落盘或对象存储复制。
|
||||
*/
|
||||
class RemoteUrlStorageDriver extends AbstractStorageDriver
|
||||
{
|
||||
/**
|
||||
* 获取远程引用引擎标识。
|
||||
*
|
||||
* @return int 存储引擎常量
|
||||
*/
|
||||
public function engine(): int
|
||||
{
|
||||
return FileConstant::STORAGE_REMOTE_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* 远程引用模式不支持直接上传。
|
||||
*
|
||||
* @param string $sourcePath 待上传文件路径
|
||||
* @param array $context 上传上下文
|
||||
* @return array 上传后的资产数据
|
||||
* @throws BusinessStateException
|
||||
*/
|
||||
public function storeFromPath(string $sourcePath, array $context): array
|
||||
{
|
||||
throw new BusinessStateException('远程引用模式不支持直接上传,请先下载后再入库');
|
||||
}
|
||||
|
||||
/**
|
||||
* 远程引用模式不需要真正删除对象。
|
||||
*
|
||||
* @param array $asset 文件资产数据
|
||||
* @return bool 是否删除成功
|
||||
*/
|
||||
public function delete(array $asset): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接跳转到源站地址进行预览。
|
||||
*
|
||||
* @param array $asset 文件资产数据
|
||||
* @return Response 响应对象
|
||||
* @throws ResourceNotFoundException
|
||||
*/
|
||||
public function previewResponse(array $asset): Response
|
||||
{
|
||||
$url = (string) ($asset['source_url'] ?? $asset['url'] ?? '');
|
||||
if ($url === '') {
|
||||
return response('文件不存在', 404);
|
||||
throw new ResourceNotFoundException('文件不存在');
|
||||
}
|
||||
|
||||
return redirect($url);
|
||||
}
|
||||
|
||||
/**
|
||||
* 远程引用文件的下载行为与预览保持一致。
|
||||
*
|
||||
* @param array $asset 文件资产数据
|
||||
* @return Response 响应对象
|
||||
*/
|
||||
public function downloadResponse(array $asset): Response
|
||||
{
|
||||
return $this->previewResponse($asset);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取原始远程地址。
|
||||
*
|
||||
* @param array $asset 文件资产数据
|
||||
* @return string 远程 URL
|
||||
*/
|
||||
public function publicUrl(array $asset): string
|
||||
{
|
||||
return (string) ($asset['source_url'] ?? $asset['url'] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取原始远程地址。
|
||||
*
|
||||
* @param array $asset 文件资产数据
|
||||
* @return string 远程 URL
|
||||
*/
|
||||
public function temporaryUrl(array $asset): string
|
||||
{
|
||||
return (string) ($asset['source_url'] ?? $asset['url'] ?? '');
|
||||
|
||||
@@ -6,20 +6,64 @@ use support\Response;
|
||||
|
||||
/**
|
||||
* 文件存储驱动接口。
|
||||
*
|
||||
* 统一定义文件存储驱动能力。
|
||||
*/
|
||||
interface StorageDriverInterface
|
||||
{
|
||||
/**
|
||||
* 获取存储引擎标识。
|
||||
*
|
||||
* @return int 存储引擎常量
|
||||
*/
|
||||
public function engine(): int;
|
||||
|
||||
/**
|
||||
* 将本地临时文件写入存储后端。
|
||||
*
|
||||
* @param string $sourcePath 待上传的本地临时文件路径
|
||||
* @param array $context 上传上下文,通常包含 object_key、visibility 等信息
|
||||
* @return array 上传后的资产数据
|
||||
*/
|
||||
public function storeFromPath(string $sourcePath, array $context): array;
|
||||
|
||||
/**
|
||||
* 删除指定文件资产。
|
||||
*
|
||||
* @param array $asset 文件资产数据
|
||||
* @return bool 是否删除成功
|
||||
*/
|
||||
public function delete(array $asset): bool;
|
||||
|
||||
/**
|
||||
* 构造文件预览响应。
|
||||
*
|
||||
* @param array $asset 文件资产数据
|
||||
* @return Response 响应对象
|
||||
*/
|
||||
public function previewResponse(array $asset): Response;
|
||||
|
||||
/**
|
||||
* 构造文件下载响应。
|
||||
*
|
||||
* @param array $asset 文件资产数据
|
||||
* @return Response 响应对象
|
||||
*/
|
||||
public function downloadResponse(array $asset): Response;
|
||||
|
||||
/**
|
||||
* 获取公开访问地址。
|
||||
*
|
||||
* @param array $asset 文件资产数据
|
||||
* @return string 公开 URL
|
||||
*/
|
||||
public function publicUrl(array $asset): string;
|
||||
|
||||
/**
|
||||
* 获取临时访问地址。
|
||||
*
|
||||
* @param array $asset 文件资产数据
|
||||
* @return string 临时 URL
|
||||
*/
|
||||
public function temporaryUrl(array $asset): string;
|
||||
}
|
||||
|
||||
@@ -8,9 +8,27 @@ use support\Response;
|
||||
|
||||
/**
|
||||
* 文件存储驱动管理器。
|
||||
*
|
||||
* 负责根据存储引擎分发文件操作。
|
||||
*
|
||||
* @property StorageConfigService $storageConfigService 存储配置服务
|
||||
* @property LocalStorageDriver $localStorageDriver 本地存储驱动
|
||||
* @property OssStorageDriver $ossStorageDriver oss存储驱动
|
||||
* @property CosStorageDriver $cosStorageDriver cos存储驱动
|
||||
* @property RemoteUrlStorageDriver $remoteUrlStorageDriver remoteUrl存储驱动
|
||||
*/
|
||||
class StorageManager
|
||||
{
|
||||
/**
|
||||
* 构造方法。
|
||||
*
|
||||
* @param StorageConfigService $storageConfigService 存储配置服务
|
||||
* @param LocalStorageDriver $localStorageDriver 本地存储驱动
|
||||
* @param OssStorageDriver $ossStorageDriver oss存储驱动
|
||||
* @param CosStorageDriver $cosStorageDriver cos存储驱动
|
||||
* @param RemoteUrlStorageDriver $remoteUrlStorageDriver remoteUrl存储驱动
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(
|
||||
protected StorageConfigService $storageConfigService,
|
||||
protected LocalStorageDriver $localStorageDriver,
|
||||
@@ -20,6 +38,18 @@ class StorageManager
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建存储上下文。
|
||||
*
|
||||
* @param string $sourcePath 源文件路径
|
||||
* @param string $originalName 原始文件名
|
||||
* @param int|null $scene 场景
|
||||
* @param int|null $visibility 可见性
|
||||
* @param int|null $engine 存储引擎
|
||||
* @param string|null $sourceUrl 源地址
|
||||
* @param string $sourceType 来源类型
|
||||
* @return array 上下文数据
|
||||
*/
|
||||
public function buildContext(
|
||||
string $sourcePath,
|
||||
string $originalName,
|
||||
@@ -58,6 +88,18 @@ class StorageManager
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文件路径保存文件。
|
||||
*
|
||||
* @param string $sourcePath 源文件路径
|
||||
* @param string $originalName 原始文件名
|
||||
* @param int|null $scene 场景
|
||||
* @param int|null $visibility 可见性
|
||||
* @param int|null $engine 存储引擎
|
||||
* @param string|null $sourceUrl 源地址
|
||||
* @param string $sourceType 来源类型
|
||||
* @return array 保存结果
|
||||
*/
|
||||
public function storeFromPath(
|
||||
string $sourcePath,
|
||||
string $originalName,
|
||||
@@ -73,36 +115,72 @@ class StorageManager
|
||||
return array_merge($context, $driver->storeFromPath($sourcePath, $context));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除存储对象。
|
||||
*
|
||||
* @param array $asset 文件记录
|
||||
* @return bool 是否删除成功
|
||||
*/
|
||||
public function delete(array $asset): bool
|
||||
{
|
||||
return $this->resolveDriver((int) ($asset['storage_engine'] ?? FileConstant::STORAGE_LOCAL))
|
||||
->delete($asset);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预览响应。
|
||||
*
|
||||
* @param array $asset 文件记录
|
||||
* @return Response 响应对象
|
||||
*/
|
||||
public function previewResponse(array $asset): Response
|
||||
{
|
||||
return $this->resolveDriver((int) ($asset['storage_engine'] ?? FileConstant::STORAGE_LOCAL))
|
||||
->previewResponse($asset);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取下载响应。
|
||||
*
|
||||
* @param array $asset 文件记录
|
||||
* @return Response 响应对象
|
||||
*/
|
||||
public function downloadResponse(array $asset): Response
|
||||
{
|
||||
return $this->resolveDriver((int) ($asset['storage_engine'] ?? FileConstant::STORAGE_LOCAL))
|
||||
->downloadResponse($asset);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取公开访问 URL。
|
||||
*
|
||||
* @param array $asset 文件记录
|
||||
* @return string 访问 URL
|
||||
*/
|
||||
public function publicUrl(array $asset): string
|
||||
{
|
||||
return $this->resolveDriver((int) ($asset['storage_engine'] ?? FileConstant::STORAGE_LOCAL))
|
||||
->publicUrl($asset);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取临时访问 URL。
|
||||
*
|
||||
* @param array $asset 文件记录
|
||||
* @return string 访问 URL
|
||||
*/
|
||||
public function temporaryUrl(array $asset): string
|
||||
{
|
||||
return $this->resolveDriver((int) ($asset['storage_engine'] ?? FileConstant::STORAGE_LOCAL))
|
||||
->temporaryUrl($asset);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析对应的存储驱动。
|
||||
*
|
||||
* @param int $engine 存储引擎
|
||||
* @return StorageDriverInterface 存储驱动
|
||||
*/
|
||||
public function resolveDriver(int $engine): StorageDriverInterface
|
||||
{
|
||||
return match ($engine) {
|
||||
@@ -114,6 +192,14 @@ class StorageManager
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 按存储引擎构建公开访问 URL。
|
||||
*
|
||||
* @param int $engine 存储引擎
|
||||
* @param int $visibility 可见性
|
||||
* @param string $objectKey 对象键
|
||||
* @return string 访问 URL
|
||||
*/
|
||||
private function buildPublicUrlByEngine(int $engine, int $visibility, string $objectKey): string
|
||||
{
|
||||
if ($engine === FileConstant::STORAGE_LOCAL && $visibility === FileConstant::VISIBILITY_PUBLIC) {
|
||||
@@ -123,6 +209,13 @@ class StorageManager
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 估算 MIME 类型。
|
||||
*
|
||||
* @param string $sourcePath 源文件路径
|
||||
* @param string $originalName 原始文件名
|
||||
* @return string MIME 类型
|
||||
*/
|
||||
private function guessMimeType(string $sourcePath, string $originalName): string
|
||||
{
|
||||
$mimeType = '';
|
||||
@@ -156,3 +249,5 @@ class StorageManager
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user