mirror of
https://gitee.com/technical-laohu/mpay_v2_webman.git
synced 2026-04-26 03:54:25 +08:00
重构初始化
This commit is contained in:
281
app/service/file/FileRecordCommandService.php
Normal file
281
app/service/file/FileRecordCommandService.php
Normal file
@@ -0,0 +1,281 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\file;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\exception\BusinessStateException;
|
||||
use app\exception\ValidationException;
|
||||
use app\repository\file\FileRecordRepository;
|
||||
use app\service\file\storage\StorageManager;
|
||||
use support\Request;
|
||||
use Webman\Http\UploadFile;
|
||||
|
||||
/**
|
||||
* 文件命令服务。
|
||||
*/
|
||||
class FileRecordCommandService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected FileRecordRepository $fileRecordRepository,
|
||||
protected FileRecordQueryService $fileRecordQueryService,
|
||||
protected StorageManager $storageManager,
|
||||
protected StorageConfigService $storageConfigService
|
||||
) {
|
||||
}
|
||||
|
||||
public function upload(UploadFile $file, array $data, int $createdBy = 0, string $createdByName = ''): array
|
||||
{
|
||||
$this->assertFileUpload($file);
|
||||
|
||||
$sourcePath = $file->getPathname();
|
||||
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();
|
||||
|
||||
$result = $this->storageManager->storeFromPath(
|
||||
$sourcePath,
|
||||
(string) $file->getUploadName(),
|
||||
$scene,
|
||||
$visibility,
|
||||
$engine,
|
||||
null,
|
||||
'upload'
|
||||
);
|
||||
|
||||
try {
|
||||
$asset = $this->fileRecordRepository->create([
|
||||
'scene' => (int) $result['scene'],
|
||||
'source_type' => (int) $result['source_type'],
|
||||
'visibility' => (int) $result['visibility'],
|
||||
'storage_engine' => (int) $result['storage_engine'],
|
||||
'original_name' => (string) $result['original_name'],
|
||||
'file_name' => (string) $result['file_name'],
|
||||
'file_ext' => (string) $result['file_ext'],
|
||||
'mime_type' => (string) $result['mime_type'],
|
||||
'size' => (int) $result['size'],
|
||||
'md5' => (string) $result['md5'],
|
||||
'object_key' => (string) $result['object_key'],
|
||||
'url' => (string) $result['url'],
|
||||
'source_url' => (string) ($result['source_url'] ?? ''),
|
||||
'created_by' => $createdBy,
|
||||
'created_by_name' => $createdByName,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->storageManager->delete($result);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return $this->fileRecordQueryService->formatModel($asset);
|
||||
} finally {
|
||||
if (is_file($sourcePath)) {
|
||||
@unlink($sourcePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function importRemote(string $remoteUrl, array $data, int $createdBy = 0, string $createdByName = ''): array
|
||||
{
|
||||
$remoteUrl = trim($remoteUrl);
|
||||
if ($remoteUrl === '') {
|
||||
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();
|
||||
|
||||
$result = $this->storageManager->storeFromPath(
|
||||
$download['path'],
|
||||
$download['name'],
|
||||
$scene,
|
||||
$visibility,
|
||||
$engine,
|
||||
$remoteUrl,
|
||||
'remote_url'
|
||||
);
|
||||
|
||||
try {
|
||||
$asset = $this->fileRecordRepository->create([
|
||||
'scene' => (int) $result['scene'],
|
||||
'source_type' => (int) $result['source_type'],
|
||||
'visibility' => (int) $result['visibility'],
|
||||
'storage_engine' => (int) $result['storage_engine'],
|
||||
'original_name' => (string) $result['original_name'],
|
||||
'file_name' => (string) $result['file_name'],
|
||||
'file_ext' => (string) $result['file_ext'],
|
||||
'mime_type' => (string) $result['mime_type'],
|
||||
'size' => (int) $result['size'],
|
||||
'md5' => (string) $result['md5'],
|
||||
'object_key' => (string) $result['object_key'],
|
||||
'url' => (string) $result['url'],
|
||||
'source_url' => $remoteUrl,
|
||||
'created_by' => $createdBy,
|
||||
'created_by_name' => $createdByName,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->storageManager->delete($result);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return $this->fileRecordQueryService->formatModel($asset);
|
||||
} finally {
|
||||
if (is_file($download['path'])) {
|
||||
@unlink($download['path']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function delete(int $id): bool
|
||||
{
|
||||
$asset = $this->fileRecordRepository->findById($id);
|
||||
if (!$asset) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->storageManager->delete($this->fileRecordQueryService->formatModel($asset));
|
||||
|
||||
return $this->fileRecordRepository->deleteById($id);
|
||||
}
|
||||
|
||||
private function assertFileUpload(UploadFile $file): void
|
||||
{
|
||||
if (!$file->isValid()) {
|
||||
throw new ValidationException('上传文件无效');
|
||||
}
|
||||
|
||||
$sizeLimit = $this->storageConfigService->uploadMaxSizeBytes();
|
||||
$size = (int) $file->getSize();
|
||||
if ($size > $sizeLimit) {
|
||||
throw new BusinessStateException('文件大小超过系统限制');
|
||||
}
|
||||
|
||||
$extension = strtolower((string) $file->getUploadExtension());
|
||||
if ($extension === '') {
|
||||
$extension = strtolower((string) pathinfo((string) $file->getUploadName(), PATHINFO_EXTENSION));
|
||||
}
|
||||
|
||||
if ($extension !== '' && !in_array($extension, $this->storageConfigService->allowedExtensions(), true)) {
|
||||
throw new BusinessStateException('文件类型暂不支持');
|
||||
}
|
||||
}
|
||||
|
||||
private function downloadRemoteFile(string $remoteUrl, int $scene = 0): array
|
||||
{
|
||||
if (!filter_var($remoteUrl, FILTER_VALIDATE_URL)) {
|
||||
throw new ValidationException('远程图片地址格式不正确');
|
||||
}
|
||||
|
||||
$scheme = strtolower((string) parse_url($remoteUrl, PHP_URL_SCHEME));
|
||||
if (!in_array($scheme, ['http', 'https'], true)) {
|
||||
throw new ValidationException('仅支持 http 或 https 远程地址');
|
||||
}
|
||||
|
||||
$host = (string) parse_url($remoteUrl, PHP_URL_HOST);
|
||||
if ($host === '') {
|
||||
throw new ValidationException('远程图片地址格式不正确');
|
||||
}
|
||||
|
||||
if (filter_var($host, FILTER_VALIDATE_IP) && Request::isIntranetIp($host)) {
|
||||
throw new BusinessStateException('远程地址不允许访问内网资源');
|
||||
}
|
||||
|
||||
$ip = gethostbyname($host);
|
||||
if ($ip !== $host && Request::isIntranetIp($ip)) {
|
||||
throw new BusinessStateException('远程地址不允许访问内网资源');
|
||||
}
|
||||
|
||||
$tempPath = tempnam(sys_get_temp_dir(), 'file_asset_');
|
||||
if ($tempPath === false) {
|
||||
throw new BusinessStateException('创建临时文件失败');
|
||||
}
|
||||
|
||||
$mimeType = 'application/octet-stream';
|
||||
$downloadName = basename((string) parse_url($remoteUrl, PHP_URL_PATH));
|
||||
if ($downloadName === '') {
|
||||
$downloadName = 'remote-file';
|
||||
}
|
||||
|
||||
$ch = curl_init($remoteUrl);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_MAXREDIRS => 3,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_CONNECTTIMEOUT => 10,
|
||||
CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
|
||||
CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
CURLOPT_SSL_VERIFYHOST => false,
|
||||
CURLOPT_USERAGENT => 'MPay File Asset Downloader',
|
||||
]);
|
||||
|
||||
$body = curl_exec($ch);
|
||||
$error = curl_error($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$contentType = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
|
||||
$effectiveUrl = (string) curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
|
||||
|
||||
curl_close($ch);
|
||||
|
||||
if ($body === false || $httpCode >= 400) {
|
||||
@unlink($tempPath);
|
||||
throw new BusinessStateException($error !== '' ? $error : '远程文件下载失败');
|
||||
}
|
||||
|
||||
if ($effectiveUrl !== '') {
|
||||
$effectiveHost = (string) parse_url($effectiveUrl, PHP_URL_HOST);
|
||||
if ($effectiveHost !== '') {
|
||||
$effectiveIp = gethostbyname($effectiveHost);
|
||||
if ($effectiveIp !== $effectiveHost && Request::isIntranetIp($effectiveIp)) {
|
||||
@unlink($tempPath);
|
||||
throw new BusinessStateException('远程地址重定向到了内网资源');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($contentType !== '') {
|
||||
$mimeType = trim(explode(';', $contentType)[0]);
|
||||
}
|
||||
|
||||
if (strlen((string) $body) > $this->storageConfigService->remoteDownloadLimitBytes()) {
|
||||
@unlink($tempPath);
|
||||
throw new BusinessStateException('远程文件大小超过系统限制');
|
||||
}
|
||||
|
||||
if (file_put_contents($tempPath, (string) $body) === false) {
|
||||
@unlink($tempPath);
|
||||
throw new BusinessStateException('远程文件写入失败');
|
||||
}
|
||||
|
||||
$size = is_file($tempPath) ? (int) filesize($tempPath) : 0;
|
||||
$name = $downloadName;
|
||||
$ext = strtolower((string) pathinfo($name, PATHINFO_EXTENSION));
|
||||
if ($ext === '') {
|
||||
$ext = match (true) {
|
||||
str_starts_with($mimeType, 'image/jpeg') => 'jpg',
|
||||
str_starts_with($mimeType, 'image/png') => 'png',
|
||||
str_starts_with($mimeType, 'image/gif') => 'gif',
|
||||
str_starts_with($mimeType, 'image/webp') => 'webp',
|
||||
str_starts_with($mimeType, 'image/svg') => 'svg',
|
||||
str_starts_with($mimeType, 'text/plain') => 'txt',
|
||||
str_starts_with($mimeType, 'application/json') => 'json',
|
||||
str_starts_with($mimeType, 'application/xml') => 'xml',
|
||||
default => '',
|
||||
};
|
||||
if ($ext !== '') {
|
||||
$name .= '.' . $ext;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'path' => $tempPath,
|
||||
'name' => $name,
|
||||
'mime_type' => $mimeType,
|
||||
'size' => $size,
|
||||
'scene' => $scene,
|
||||
];
|
||||
}
|
||||
}
|
||||
176
app/service/file/FileRecordQueryService.php
Normal file
176
app/service/file/FileRecordQueryService.php
Normal file
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\file;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\FileConstant;
|
||||
use app\exception\ResourceNotFoundException;
|
||||
use app\repository\file\FileRecordRepository;
|
||||
use app\service\file\storage\StorageManager;
|
||||
|
||||
/**
|
||||
* 文件查询服务。
|
||||
*/
|
||||
class FileRecordQueryService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected FileRecordRepository $fileRecordRepository,
|
||||
protected StorageManager $storageManager
|
||||
) {
|
||||
}
|
||||
|
||||
public function paginate(array $filters = [], int $page = 1, int $pageSize = 10)
|
||||
{
|
||||
$query = $this->fileRecordRepository->query()->from('ma_file_asset as f');
|
||||
|
||||
$keyword = trim((string) ($filters['keyword'] ?? ''));
|
||||
if ($keyword !== '') {
|
||||
$query->where(function ($builder) use ($keyword) {
|
||||
$builder->where('f.original_name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('f.file_name', 'like', '%' . $keyword . '%')
|
||||
->orWhere('f.object_key', 'like', '%' . $keyword . '%')
|
||||
->orWhere('f.source_url', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
}
|
||||
|
||||
foreach (['scene', 'source_type', 'visibility', 'storage_engine'] as $field) {
|
||||
if (array_key_exists($field, $filters) && $filters[$field] !== '' && $filters[$field] !== null) {
|
||||
$query->where('f.' . $field, (int) $filters[$field]);
|
||||
}
|
||||
}
|
||||
|
||||
$query->orderByDesc('f.id');
|
||||
|
||||
$paginator = $query->paginate(max(1, $pageSize), ['f.*'], 'page', max(1, $page));
|
||||
$collection = $paginator->getCollection();
|
||||
$collection->transform(function ($row): array {
|
||||
return $this->formatModel($row);
|
||||
});
|
||||
|
||||
return $paginator;
|
||||
}
|
||||
|
||||
public function detail(int $id): array
|
||||
{
|
||||
$asset = $this->fileRecordRepository->findById($id);
|
||||
if (!$asset) {
|
||||
throw new ResourceNotFoundException('文件不存在', ['id' => $id]);
|
||||
}
|
||||
|
||||
return $this->formatModel($asset);
|
||||
}
|
||||
|
||||
public function formatModel(mixed $asset): array
|
||||
{
|
||||
$id = (int) $this->field($asset, 'id', 0);
|
||||
$scene = (int) $this->field($asset, 'scene', FileConstant::SCENE_OTHER);
|
||||
$visibility = (int) $this->field($asset, 'visibility', FileConstant::VISIBILITY_PRIVATE);
|
||||
$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';
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $id,
|
||||
'scene' => $scene,
|
||||
'scene_text' => (string) (FileConstant::sceneMap()[$scene] ?? '未知'),
|
||||
'source_type' => $sourceType,
|
||||
'source_type_text' => (string) (FileConstant::sourceTypeMap()[$sourceType] ?? '未知'),
|
||||
'visibility' => $visibility,
|
||||
'visibility_text' => (string) (FileConstant::visibilityMap()[$visibility] ?? '未知'),
|
||||
'storage_engine' => $storageEngine,
|
||||
'storage_engine_text' => (string) (FileConstant::storageEngineMap()[$storageEngine] ?? '未知'),
|
||||
'original_name' => (string) $this->field($asset, 'original_name', ''),
|
||||
'file_name' => (string) $this->field($asset, 'file_name', ''),
|
||||
'file_ext' => (string) $this->field($asset, 'file_ext', ''),
|
||||
'mime_type' => (string) $this->field($asset, 'mime_type', ''),
|
||||
'size' => $size,
|
||||
'size_text' => $this->formatSize($size),
|
||||
'md5' => (string) $this->field($asset, 'md5', ''),
|
||||
'object_key' => (string) $this->field($asset, 'object_key', ''),
|
||||
'source_url' => (string) $this->field($asset, 'source_url', ''),
|
||||
'url' => $previewUrl,
|
||||
'public_url' => $publicUrl,
|
||||
'preview_url' => $previewUrl,
|
||||
'download_url' => $id > 0 ? '/adminapi/file-asset/' . $id . '/download' : '',
|
||||
'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', ''),
|
||||
'is_image' => $scene === FileConstant::SCENE_IMAGE || str_starts_with(strtolower((string) $this->field($asset, 'mime_type', '')), 'image/'),
|
||||
'created_at' => $this->formatDateTime($this->field($asset, 'created_at', null)),
|
||||
'updated_at' => $this->formatDateTime($this->field($asset, 'updated_at', null)),
|
||||
];
|
||||
}
|
||||
|
||||
public function options(): array
|
||||
{
|
||||
return [
|
||||
'sourceTypes' => $this->toOptions(FileConstant::sourceTypeMap()),
|
||||
'visibilities' => $this->toOptions(FileConstant::visibilityMap()),
|
||||
'scenes' => $this->toOptions(FileConstant::sceneMap()),
|
||||
'storageEngines' => $this->toOptions(FileConstant::storageEngineMap()),
|
||||
'selectableStorageEngines' => $this->toOptions(FileConstant::selectableStorageEngineMap()),
|
||||
];
|
||||
}
|
||||
|
||||
private function formatSize(int $size): string
|
||||
{
|
||||
if ($size <= 0) {
|
||||
return '0 B';
|
||||
}
|
||||
|
||||
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
$index = 0;
|
||||
$value = (float) $size;
|
||||
while ($value >= 1024 && $index < count($units) - 1) {
|
||||
$value /= 1024;
|
||||
$index++;
|
||||
}
|
||||
|
||||
return $index === 0 ? (string) (int) $value . ' ' . $units[$index] : number_format($value, 2) . ' ' . $units[$index];
|
||||
}
|
||||
|
||||
private function toOptions(array $map): array
|
||||
{
|
||||
$options = [];
|
||||
foreach ($map as $value => $label) {
|
||||
$options[] = [
|
||||
'label' => (string) $label,
|
||||
'value' => (int) $value,
|
||||
];
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
private function field(mixed $asset, string $key, mixed $default = null): mixed
|
||||
{
|
||||
if (is_array($asset)) {
|
||||
return $asset[$key] ?? $default;
|
||||
}
|
||||
|
||||
if (is_object($asset) && isset($asset->{$key})) {
|
||||
return $asset->{$key};
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
|
||||
private function normalizeAsset(mixed $asset): array
|
||||
{
|
||||
return $this->field($asset, 'id', null) === null ? [] : [
|
||||
'id' => (int) $this->field($asset, 'id', 0),
|
||||
'storage_engine' => (int) $this->field($asset, 'storage_engine', FileConstant::STORAGE_LOCAL),
|
||||
'visibility' => (int) $this->field($asset, 'visibility', FileConstant::VISIBILITY_PRIVATE),
|
||||
'original_name' => (string) $this->field($asset, 'original_name', ''),
|
||||
'object_key' => (string) $this->field($asset, 'object_key', ''),
|
||||
'source_url' => (string) $this->field($asset, 'source_url', ''),
|
||||
'url' => (string) $this->field($asset, 'url', ''),
|
||||
'mime_type' => (string) $this->field($asset, 'mime_type', ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
64
app/service/file/FileRecordService.php
Normal file
64
app/service/file/FileRecordService.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\file;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\service\file\storage\StorageManager;
|
||||
use Webman\Http\UploadFile;
|
||||
|
||||
/**
|
||||
* 文件门面服务。
|
||||
*/
|
||||
class FileRecordService extends BaseService
|
||||
{
|
||||
public function __construct(
|
||||
protected FileRecordQueryService $queryService,
|
||||
protected FileRecordCommandService $commandService,
|
||||
protected StorageManager $storageManager
|
||||
) {
|
||||
}
|
||||
|
||||
public function paginate(array $filters = [], int $page = 1, int $pageSize = 10)
|
||||
{
|
||||
return $this->queryService->paginate($filters, $page, $pageSize);
|
||||
}
|
||||
|
||||
public function detail(int $id): array
|
||||
{
|
||||
return $this->queryService->detail($id);
|
||||
}
|
||||
|
||||
public function options(): array
|
||||
{
|
||||
return $this->queryService->options();
|
||||
}
|
||||
|
||||
public function upload(UploadFile $file, array $data, int $createdBy = 0, string $createdByName = ''): array
|
||||
{
|
||||
return $this->commandService->upload($file, $data, $createdBy, $createdByName);
|
||||
}
|
||||
|
||||
public function importRemote(string $remoteUrl, array $data, int $createdBy = 0, string $createdByName = ''): array
|
||||
{
|
||||
return $this->commandService->importRemote($remoteUrl, $data, $createdBy, $createdByName);
|
||||
}
|
||||
|
||||
public function delete(int $id): bool
|
||||
{
|
||||
return $this->commandService->delete($id);
|
||||
}
|
||||
|
||||
public function previewResponse(int $id)
|
||||
{
|
||||
$asset = $this->queryService->detail($id);
|
||||
|
||||
return $this->storageManager->previewResponse($asset);
|
||||
}
|
||||
|
||||
public function downloadResponse(int $id)
|
||||
{
|
||||
$asset = $this->queryService->detail($id);
|
||||
|
||||
return $this->storageManager->downloadResponse($asset);
|
||||
}
|
||||
}
|
||||
202
app/service/file/StorageConfigService.php
Normal file
202
app/service/file/StorageConfigService.php
Normal file
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\file;
|
||||
|
||||
use app\common\base\BaseService;
|
||||
use app\common\constant\FileConstant;
|
||||
|
||||
/**
|
||||
* 文件存储配置服务。
|
||||
*/
|
||||
class StorageConfigService extends BaseService
|
||||
{
|
||||
public function defaultEngine(): int
|
||||
{
|
||||
return $this->normalizeSelectableEngine((int) sys_config(FileConstant::CONFIG_DEFAULT_ENGINE, FileConstant::STORAGE_LOCAL));
|
||||
}
|
||||
|
||||
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 !== '') {
|
||||
return rtrim($siteUrl, '/');
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
public function localPublicDir(): string
|
||||
{
|
||||
$dir = trim((string) sys_config(FileConstant::CONFIG_LOCAL_PUBLIC_DIR, 'storage/uploads'), "/ \t\n\r\0\x0B");
|
||||
|
||||
return $dir !== '' ? $dir : 'storage/uploads';
|
||||
}
|
||||
|
||||
public function localPrivateDir(): string
|
||||
{
|
||||
$dir = trim((string) sys_config(FileConstant::CONFIG_LOCAL_PRIVATE_DIR, 'storage/private'), "/ \t\n\r\0\x0B");
|
||||
|
||||
return $dir !== '' ? $dir : 'storage/private';
|
||||
}
|
||||
|
||||
public function uploadMaxSizeBytes(): int
|
||||
{
|
||||
$mb = max(1, (int) sys_config(FileConstant::CONFIG_UPLOAD_MAX_SIZE_MB, 20));
|
||||
|
||||
return $mb * 1024 * 1024;
|
||||
}
|
||||
|
||||
public function remoteDownloadLimitBytes(): int
|
||||
{
|
||||
$mb = max(1, (int) sys_config(FileConstant::CONFIG_REMOTE_DOWNLOAD_LIMIT_MB, 10));
|
||||
|
||||
return $mb * 1024 * 1024;
|
||||
}
|
||||
|
||||
public function allowedExtensions(): array
|
||||
{
|
||||
$raw = trim((string) sys_config(FileConstant::CONFIG_ALLOWED_EXTENSIONS, implode(',', FileConstant::defaultAllowedExtensions())));
|
||||
if ($raw === '') {
|
||||
return FileConstant::defaultAllowedExtensions();
|
||||
}
|
||||
|
||||
$extensions = array_filter(array_map(static fn (string $value): string => strtolower(trim($value)), explode(',', $raw)));
|
||||
|
||||
return array_values(array_unique($extensions));
|
||||
}
|
||||
|
||||
public function ossConfig(): array
|
||||
{
|
||||
return [
|
||||
'region' => trim((string) sys_config(FileConstant::CONFIG_OSS_REGION, '')),
|
||||
'endpoint' => trim((string) sys_config(FileConstant::CONFIG_OSS_ENDPOINT, '')),
|
||||
'bucket' => trim((string) sys_config(FileConstant::CONFIG_OSS_BUCKET, '')),
|
||||
'access_key_id' => trim((string) sys_config(FileConstant::CONFIG_OSS_ACCESS_KEY_ID, '')),
|
||||
'access_key_secret' => trim((string) sys_config(FileConstant::CONFIG_OSS_ACCESS_KEY_SECRET, '')),
|
||||
'public_domain' => trim((string) sys_config(FileConstant::CONFIG_OSS_PUBLIC_DOMAIN, '')),
|
||||
];
|
||||
}
|
||||
|
||||
public function cosConfig(): array
|
||||
{
|
||||
return [
|
||||
'region' => trim((string) sys_config(FileConstant::CONFIG_COS_REGION, '')),
|
||||
'bucket' => trim((string) sys_config(FileConstant::CONFIG_COS_BUCKET, '')),
|
||||
'secret_id' => trim((string) sys_config(FileConstant::CONFIG_COS_SECRET_ID, '')),
|
||||
'secret_key' => trim((string) sys_config(FileConstant::CONFIG_COS_SECRET_KEY, '')),
|
||||
'public_domain' => trim((string) sys_config(FileConstant::CONFIG_COS_PUBLIC_DOMAIN, '')),
|
||||
];
|
||||
}
|
||||
|
||||
public function normalizeScene(int|string|null $scene = null, string $originalName = '', string $mimeType = ''): int
|
||||
{
|
||||
$scene = (int) $scene;
|
||||
if ($scene === FileConstant::SCENE_IMAGE
|
||||
|| $scene === FileConstant::SCENE_CERTIFICATE
|
||||
|| $scene === FileConstant::SCENE_TEXT
|
||||
|| $scene === FileConstant::SCENE_OTHER
|
||||
) {
|
||||
return $scene;
|
||||
}
|
||||
|
||||
$ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
|
||||
if ($ext !== '') {
|
||||
if (isset(FileConstant::imageExtensionMap()[$ext]) || str_starts_with(strtolower($mimeType), 'image/')) {
|
||||
return FileConstant::SCENE_IMAGE;
|
||||
}
|
||||
|
||||
if (isset(FileConstant::certificateExtensionMap()[$ext])) {
|
||||
return FileConstant::SCENE_CERTIFICATE;
|
||||
}
|
||||
|
||||
if (isset(FileConstant::textExtensionMap()[$ext]) || str_starts_with(strtolower($mimeType), 'text/')) {
|
||||
return FileConstant::SCENE_TEXT;
|
||||
}
|
||||
}
|
||||
|
||||
return FileConstant::SCENE_OTHER;
|
||||
}
|
||||
|
||||
public function normalizeVisibility(int|string|null $visibility = null, int $scene = FileConstant::SCENE_OTHER): int
|
||||
{
|
||||
$visibility = (int) $visibility;
|
||||
if ($visibility === FileConstant::VISIBILITY_PUBLIC || $visibility === FileConstant::VISIBILITY_PRIVATE) {
|
||||
return $visibility;
|
||||
}
|
||||
|
||||
return $scene === FileConstant::SCENE_IMAGE
|
||||
? FileConstant::VISIBILITY_PUBLIC
|
||||
: FileConstant::VISIBILITY_PRIVATE;
|
||||
}
|
||||
|
||||
public function normalizeEngine(int|string|null $engine = null): int
|
||||
{
|
||||
$engine = (int) $engine;
|
||||
|
||||
return $this->normalizeSelectableEngine($engine);
|
||||
}
|
||||
|
||||
public function sceneFolder(int $scene): string
|
||||
{
|
||||
return match ($scene) {
|
||||
FileConstant::SCENE_IMAGE => 'image',
|
||||
FileConstant::SCENE_CERTIFICATE => 'certificate',
|
||||
FileConstant::SCENE_TEXT => 'text',
|
||||
default => 'other',
|
||||
};
|
||||
}
|
||||
|
||||
public function buildObjectKey(int $scene, int $visibility, string $extension): string
|
||||
{
|
||||
$extension = strtolower(trim($extension, ". \t\n\r\0\x0B"));
|
||||
$timestampPath = date('Y/m/d');
|
||||
$random = bin2hex(random_bytes(8));
|
||||
$name = date('YmdHis') . '_' . $random;
|
||||
if ($extension !== '') {
|
||||
$name .= '.' . $extension;
|
||||
}
|
||||
|
||||
$rootDir = $visibility === FileConstant::VISIBILITY_PUBLIC
|
||||
? $this->localPublicDir()
|
||||
: $this->localPrivateDir();
|
||||
|
||||
return trim($rootDir . '/' . $this->sceneFolder($scene) . '/' . $timestampPath . '/' . $name, '/');
|
||||
}
|
||||
|
||||
public function buildLocalAbsolutePath(int $visibility, string $objectKey): string
|
||||
{
|
||||
$root = $visibility === FileConstant::VISIBILITY_PUBLIC
|
||||
? public_path()
|
||||
: runtime_path();
|
||||
$relativePath = trim(str_replace('\\', '/', $objectKey), '/');
|
||||
|
||||
return rtrim($root, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relativePath);
|
||||
}
|
||||
|
||||
public function buildLocalPublicUrl(string $objectKey): string
|
||||
{
|
||||
$path = trim(str_replace('\\', '/', $objectKey), '/');
|
||||
$baseUrl = $this->localPublicBaseUrl();
|
||||
|
||||
if ($baseUrl !== '') {
|
||||
return rtrim($baseUrl, '/') . '/' . $path;
|
||||
}
|
||||
|
||||
return '/' . $path;
|
||||
}
|
||||
|
||||
private function normalizeSelectableEngine(int $engine): int
|
||||
{
|
||||
return match ($engine) {
|
||||
FileConstant::STORAGE_LOCAL,
|
||||
FileConstant::STORAGE_ALIYUN_OSS,
|
||||
FileConstant::STORAGE_TENCENT_COS => $engine,
|
||||
default => FileConstant::STORAGE_LOCAL,
|
||||
};
|
||||
}
|
||||
}
|
||||
113
app/service/file/storage/AbstractStorageDriver.php
Normal file
113
app/service/file/storage/AbstractStorageDriver.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\file\storage;
|
||||
|
||||
use app\common\constant\FileConstant;
|
||||
use app\service\file\StorageConfigService;
|
||||
use support\Response;
|
||||
|
||||
/**
|
||||
* 文件存储驱动抽象基类。
|
||||
*/
|
||||
abstract class AbstractStorageDriver implements StorageDriverInterface
|
||||
{
|
||||
public function __construct(
|
||||
protected StorageConfigService $storageConfigService
|
||||
) {
|
||||
}
|
||||
|
||||
protected function assetValue(array $asset, string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $asset[$key] ?? $default;
|
||||
}
|
||||
|
||||
protected function resolveLocalAbsolutePath(array $asset): string
|
||||
{
|
||||
$objectKey = trim((string) $this->assetValue($asset, 'object_key', ''));
|
||||
$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;
|
||||
}
|
||||
|
||||
protected function bodyResponse(string $body, string $mimeType = 'application/octet-stream', int $status = 200, array $headers = []): Response
|
||||
{
|
||||
$responseHeaders = array_merge([
|
||||
'Content-Type' => $mimeType !== '' ? $mimeType : 'application/octet-stream',
|
||||
], $headers);
|
||||
|
||||
return response($body, $status, $responseHeaders);
|
||||
}
|
||||
|
||||
protected function downloadBodyResponse(string $body, string $downloadName, string $mimeType = 'application/octet-stream'): Response
|
||||
{
|
||||
$response = $this->bodyResponse($body, $mimeType, 200, [
|
||||
'Content-Disposition' => 'attachment; filename="' . str_replace(['"', "\r", "\n", "\0"], '', $downloadName) . '"',
|
||||
]);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
protected function responseFromPath(string $path, string $downloadName = '', bool $attachment = false): Response
|
||||
{
|
||||
if ($attachment) {
|
||||
return response()->download($path, $downloadName);
|
||||
}
|
||||
|
||||
return response()->file($path);
|
||||
}
|
||||
|
||||
protected function localPreviewResponse(array $asset): Response
|
||||
{
|
||||
$path = $this->resolveLocalAbsolutePath($asset);
|
||||
if ($path === '' || !is_file($path)) {
|
||||
return response('文件不存在', 404);
|
||||
}
|
||||
|
||||
return $this->responseFromPath($path);
|
||||
}
|
||||
|
||||
protected function localDownloadResponse(array $asset): Response
|
||||
{
|
||||
$path = $this->resolveLocalAbsolutePath($asset);
|
||||
if ($path === '' || !is_file($path)) {
|
||||
return response('文件不存在', 404);
|
||||
}
|
||||
|
||||
return $this->responseFromPath($path, (string) $this->assetValue($asset, 'original_name', basename($path)), true);
|
||||
}
|
||||
|
||||
protected function scenePrefix(int $scene): string
|
||||
{
|
||||
return match ($scene) {
|
||||
FileConstant::SCENE_IMAGE => 'image',
|
||||
FileConstant::SCENE_CERTIFICATE => 'certificate',
|
||||
FileConstant::SCENE_TEXT => 'text',
|
||||
default => 'other',
|
||||
};
|
||||
}
|
||||
}
|
||||
188
app/service/file/storage/CosStorageDriver.php
Normal file
188
app/service/file/storage/CosStorageDriver.php
Normal file
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\file\storage;
|
||||
|
||||
use app\common\constant\FileConstant;
|
||||
use app\exception\BusinessStateException;
|
||||
use Qcloud\Cos\Client as CosClient;
|
||||
use support\Response;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 腾讯云 COS 文件存储驱动。
|
||||
*/
|
||||
class CosStorageDriver extends AbstractStorageDriver
|
||||
{
|
||||
public function engine(): int
|
||||
{
|
||||
return FileConstant::STORAGE_TENCENT_COS;
|
||||
}
|
||||
|
||||
public function storeFromPath(string $sourcePath, array $context): array
|
||||
{
|
||||
if (!is_file($sourcePath)) {
|
||||
throw new BusinessStateException('待上传文件不存在');
|
||||
}
|
||||
|
||||
$config = $this->storageConfigService->cosConfig();
|
||||
foreach (['region', 'bucket', 'secret_id', 'secret_key'] as $key) {
|
||||
if (trim((string) ($config[$key] ?? '')) === '') {
|
||||
throw new BusinessStateException('腾讯云 COS 存储配置未完整');
|
||||
}
|
||||
}
|
||||
|
||||
$client = $this->client($config);
|
||||
$objectKey = (string) ($context['object_key'] ?? '');
|
||||
$client->putObject([
|
||||
'Bucket' => (string) $config['bucket'],
|
||||
'Key' => $objectKey,
|
||||
'Body' => fopen($sourcePath, 'rb'),
|
||||
]);
|
||||
|
||||
$publicUrl = $this->publicUrl([
|
||||
'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 function delete(array $asset): bool
|
||||
{
|
||||
$config = $this->storageConfigService->cosConfig();
|
||||
if (trim((string) ($config['bucket'] ?? '')) === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$objectKey = (string) ($asset['object_key'] ?? '');
|
||||
if ($objectKey === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$client = $this->client($config);
|
||||
$client->deleteObject([
|
||||
'Bucket' => (string) $config['bucket'],
|
||||
'Key' => $objectKey,
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function previewResponse(array $asset): Response
|
||||
{
|
||||
$url = $this->publicUrl($asset);
|
||||
if ($url !== '') {
|
||||
return redirect($url);
|
||||
}
|
||||
|
||||
return $this->responseFromObject($asset, false);
|
||||
}
|
||||
|
||||
public function downloadResponse(array $asset): Response
|
||||
{
|
||||
return $this->responseFromObject($asset, true);
|
||||
}
|
||||
|
||||
public function publicUrl(array $asset): string
|
||||
{
|
||||
$publicUrl = trim((string) ($asset['url'] ?? $asset['public_url'] ?? ''));
|
||||
if ($publicUrl !== '') {
|
||||
return $publicUrl;
|
||||
}
|
||||
|
||||
$config = $this->storageConfigService->cosConfig();
|
||||
$objectKey = (string) ($asset['object_key'] ?? '');
|
||||
if ($objectKey === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$customDomain = trim((string) ($config['public_domain'] ?? ''));
|
||||
if ($customDomain !== '') {
|
||||
return rtrim($customDomain, '/') . '/' . ltrim($objectKey, '/');
|
||||
}
|
||||
|
||||
$region = trim((string) ($config['region'] ?? ''));
|
||||
$bucket = trim((string) ($config['bucket'] ?? ''));
|
||||
if ($region === '' || $bucket === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return 'https://' . $bucket . '.cos.' . $region . '.myqcloud.com/' . ltrim($objectKey, '/');
|
||||
}
|
||||
|
||||
public function temporaryUrl(array $asset): string
|
||||
{
|
||||
$config = $this->storageConfigService->cosConfig();
|
||||
if (trim((string) ($config['bucket'] ?? '')) === '' || trim((string) ($config['region'] ?? '')) === '') {
|
||||
return $this->publicUrl($asset);
|
||||
}
|
||||
|
||||
try {
|
||||
$client = $this->client($config);
|
||||
$objectKey = (string) ($asset['object_key'] ?? '');
|
||||
if ($objectKey === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $client->getObjectUrl(
|
||||
(string) $config['bucket'],
|
||||
$objectKey
|
||||
);
|
||||
} catch (Throwable) {
|
||||
return $this->publicUrl($asset);
|
||||
}
|
||||
}
|
||||
|
||||
private function client(array $config): CosClient
|
||||
{
|
||||
return new CosClient([
|
||||
'region' => (string) $config['region'],
|
||||
'credentials' => [
|
||||
'secretId' => (string) $config['secret_id'],
|
||||
'secretKey' => (string) $config['secret_key'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
try {
|
||||
$client = $this->client($config);
|
||||
$result = $client->getObject([
|
||||
'Bucket' => $bucket,
|
||||
'Key' => $objectKey,
|
||||
]);
|
||||
|
||||
$body = '';
|
||||
if (is_string($result)) {
|
||||
$body = $result;
|
||||
} elseif (is_object($result) && method_exists($result, '__toString')) {
|
||||
$body = (string) $result;
|
||||
} elseif (is_array($result)) {
|
||||
$body = (string) ($result['Body'] ?? $result['body'] ?? '');
|
||||
}
|
||||
|
||||
$mimeType = (string) ($asset['mime_type'] ?? 'application/octet-stream');
|
||||
|
||||
if ($attachment) {
|
||||
return $this->downloadBodyResponse($body, (string) ($asset['original_name'] ?? basename($objectKey)), $mimeType);
|
||||
}
|
||||
|
||||
return $this->bodyResponse($body, $mimeType);
|
||||
} catch (Throwable) {
|
||||
return response('文件不存在', 404);
|
||||
}
|
||||
}
|
||||
}
|
||||
121
app/service/file/storage/LocalStorageDriver.php
Normal file
121
app/service/file/storage/LocalStorageDriver.php
Normal file
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\file\storage;
|
||||
|
||||
use app\common\constant\FileConstant;
|
||||
use app\exception\BusinessStateException;
|
||||
use support\Response;
|
||||
|
||||
/**
|
||||
* 本地文件存储驱动。
|
||||
*/
|
||||
class LocalStorageDriver extends AbstractStorageDriver
|
||||
{
|
||||
public function engine(): int
|
||||
{
|
||||
return FileConstant::STORAGE_LOCAL;
|
||||
}
|
||||
|
||||
public function storeFromPath(string $sourcePath, array $context): array
|
||||
{
|
||||
if (!is_file($sourcePath)) {
|
||||
throw new BusinessStateException('待上传文件不存在');
|
||||
}
|
||||
|
||||
$visibility = (int) ($context['visibility'] ?? FileConstant::VISIBILITY_PRIVATE);
|
||||
$objectKey = (string) ($context['object_key'] ?? '');
|
||||
$absolutePath = $this->storageConfigService->buildLocalAbsolutePath($visibility, $objectKey);
|
||||
$publicUrl = (string) ($context['public_url'] ?? '');
|
||||
|
||||
if ($objectKey === '' || $absolutePath === '') {
|
||||
throw new BusinessStateException('文件存储路径无效');
|
||||
}
|
||||
|
||||
$this->ensureDirectory(dirname($absolutePath));
|
||||
|
||||
if (@rename($sourcePath, $absolutePath) === false) {
|
||||
if (!@copy($sourcePath, $absolutePath)) {
|
||||
throw new BusinessStateException('本地文件保存失败');
|
||||
}
|
||||
|
||||
@unlink($sourcePath);
|
||||
}
|
||||
|
||||
@chmod($absolutePath, 0666 & ~umask());
|
||||
|
||||
return [
|
||||
'storage_engine' => $this->engine(),
|
||||
'object_key' => $objectKey,
|
||||
'url' => $visibility === FileConstant::VISIBILITY_PUBLIC ? $publicUrl : '',
|
||||
'public_url' => $publicUrl,
|
||||
];
|
||||
}
|
||||
|
||||
public function delete(array $asset): bool
|
||||
{
|
||||
$path = $this->resolveLocalAbsolutePath($asset);
|
||||
if ($path === '' || !is_file($path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return @unlink($path);
|
||||
}
|
||||
|
||||
public function previewResponse(array $asset): Response
|
||||
{
|
||||
return $this->localPreviewResponse($asset);
|
||||
}
|
||||
|
||||
public function downloadResponse(array $asset): Response
|
||||
{
|
||||
return $this->localDownloadResponse($asset);
|
||||
}
|
||||
|
||||
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 '';
|
||||
}
|
||||
|
||||
$objectKey = trim((string) ($asset['object_key'] ?? ''));
|
||||
if ($objectKey === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $this->storageConfigService->buildLocalPublicUrl($objectKey);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
$id = (int) ($asset['id'] ?? 0);
|
||||
|
||||
return $id > 0 ? '/adminapi/file-asset/' . $id . '/preview' : '';
|
||||
}
|
||||
|
||||
private function ensureDirectory(string $directory): void
|
||||
{
|
||||
if (is_dir($directory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!@mkdir($directory, 0777, true) && !is_dir($directory)) {
|
||||
throw new BusinessStateException('文件目录创建失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
196
app/service/file/storage/OssStorageDriver.php
Normal file
196
app/service/file/storage/OssStorageDriver.php
Normal file
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\file\storage;
|
||||
|
||||
use app\common\constant\FileConstant;
|
||||
use app\exception\BusinessStateException;
|
||||
use AlibabaCloud\Oss\V2 as Oss;
|
||||
use support\Response;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 阿里云 OSS 文件存储驱动。
|
||||
*/
|
||||
class OssStorageDriver extends AbstractStorageDriver
|
||||
{
|
||||
public function engine(): int
|
||||
{
|
||||
return FileConstant::STORAGE_ALIYUN_OSS;
|
||||
}
|
||||
|
||||
public function storeFromPath(string $sourcePath, array $context): array
|
||||
{
|
||||
if (!is_file($sourcePath)) {
|
||||
throw new BusinessStateException('待上传文件不存在');
|
||||
}
|
||||
|
||||
$config = $this->storageConfigService->ossConfig();
|
||||
foreach (['region', 'bucket', 'access_key_id', 'access_key_secret'] as $key) {
|
||||
if (trim((string) ($config[$key] ?? '')) === '') {
|
||||
throw new BusinessStateException('阿里云 OSS 存储配置未完整');
|
||||
}
|
||||
}
|
||||
|
||||
$client = $this->client($config);
|
||||
$objectKey = (string) ($context['object_key'] ?? '');
|
||||
$request = new Oss\Models\PutObjectRequest(
|
||||
bucket: (string) $config['bucket'],
|
||||
key: $objectKey
|
||||
);
|
||||
$request->body = Oss\Utils::streamFor(fopen($sourcePath, 'rb'));
|
||||
|
||||
$client->putObject($request);
|
||||
|
||||
$publicUrl = $this->publicUrl([
|
||||
'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 function delete(array $asset): bool
|
||||
{
|
||||
$config = $this->storageConfigService->ossConfig();
|
||||
if (trim((string) ($config['bucket'] ?? '')) === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$objectKey = (string) ($asset['object_key'] ?? '');
|
||||
if ($objectKey === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$client = $this->client($config);
|
||||
$request = new Oss\Models\DeleteObjectRequest(
|
||||
bucket: (string) $config['bucket'],
|
||||
key: $objectKey
|
||||
);
|
||||
$client->deleteObject($request);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function previewResponse(array $asset): Response
|
||||
{
|
||||
$url = $this->publicUrl($asset);
|
||||
if ($url !== '') {
|
||||
return redirect($url);
|
||||
}
|
||||
|
||||
return $this->responseFromObject($asset, false);
|
||||
}
|
||||
|
||||
public function downloadResponse(array $asset): Response
|
||||
{
|
||||
return $this->responseFromObject($asset, true);
|
||||
}
|
||||
|
||||
public function publicUrl(array $asset): string
|
||||
{
|
||||
$publicUrl = trim((string) ($asset['url'] ?? $asset['public_url'] ?? ''));
|
||||
if ($publicUrl !== '') {
|
||||
return $publicUrl;
|
||||
}
|
||||
|
||||
$config = $this->storageConfigService->ossConfig();
|
||||
$objectKey = (string) ($asset['object_key'] ?? '');
|
||||
if ($objectKey === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$customDomain = trim((string) ($config['public_domain'] ?? ''));
|
||||
if ($customDomain !== '') {
|
||||
return rtrim($customDomain, '/') . '/' . ltrim($objectKey, '/');
|
||||
}
|
||||
|
||||
$endpoint = trim((string) ($config['endpoint'] ?? ''));
|
||||
$bucket = trim((string) ($config['bucket'] ?? ''));
|
||||
if ($endpoint === '' || $bucket === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$endpoint = preg_replace('#^https?://#i', '', $endpoint) ?: $endpoint;
|
||||
|
||||
return 'https://' . $bucket . '.' . ltrim($endpoint, '/') . '/' . ltrim($objectKey, '/');
|
||||
}
|
||||
|
||||
public function temporaryUrl(array $asset): string
|
||||
{
|
||||
$config = $this->storageConfigService->ossConfig();
|
||||
if (trim((string) ($config['bucket'] ?? '')) === '' || trim((string) ($config['region'] ?? '')) === '') {
|
||||
return $this->publicUrl($asset);
|
||||
}
|
||||
|
||||
try {
|
||||
$client = $this->client($config);
|
||||
$objectKey = (string) ($asset['object_key'] ?? '');
|
||||
if ($objectKey === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$request = new Oss\Models\GetObjectRequest(
|
||||
bucket: (string) $config['bucket'],
|
||||
key: $objectKey
|
||||
);
|
||||
$result = $client->presign($request);
|
||||
|
||||
return (string) ($result->url ?? '');
|
||||
} catch (Throwable) {
|
||||
return $this->publicUrl($asset);
|
||||
}
|
||||
}
|
||||
|
||||
private function client(array $config): Oss\Client
|
||||
{
|
||||
$provider = new Oss\Credentials\StaticCredentialsProvider(
|
||||
accessKeyId: (string) $config['access_key_id'],
|
||||
accessKeySecret: (string) $config['access_key_secret']
|
||||
);
|
||||
|
||||
$cfg = Oss\Config::loadDefault();
|
||||
$cfg->setCredentialsProvider(credentialsProvider: $provider);
|
||||
$cfg->setRegion(region: (string) $config['region']);
|
||||
|
||||
$endpoint = trim((string) ($config['endpoint'] ?? ''));
|
||||
if ($endpoint !== '') {
|
||||
$cfg->setEndpoint(endpoint: $endpoint);
|
||||
}
|
||||
|
||||
return new Oss\Client($cfg);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
try {
|
||||
$client = $this->client($config);
|
||||
$request = new Oss\Models\GetObjectRequest(
|
||||
bucket: $bucket,
|
||||
key: $objectKey
|
||||
);
|
||||
$result = $client->getObject($request);
|
||||
$body = (string) $result->body->getContents();
|
||||
$mimeType = (string) ($asset['mime_type'] ?? 'application/octet-stream');
|
||||
|
||||
if ($attachment) {
|
||||
return $this->downloadBodyResponse($body, (string) ($asset['original_name'] ?? basename($objectKey)), $mimeType);
|
||||
}
|
||||
|
||||
return $this->bodyResponse($body, $mimeType);
|
||||
} catch (Throwable) {
|
||||
return response('文件不存在', 404);
|
||||
}
|
||||
}
|
||||
}
|
||||
53
app/service/file/storage/RemoteUrlStorageDriver.php
Normal file
53
app/service/file/storage/RemoteUrlStorageDriver.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\file\storage;
|
||||
|
||||
use app\common\constant\FileConstant;
|
||||
use app\exception\BusinessStateException;
|
||||
use support\Response;
|
||||
|
||||
/**
|
||||
* 远程引用驱动。
|
||||
*/
|
||||
class RemoteUrlStorageDriver extends AbstractStorageDriver
|
||||
{
|
||||
public function engine(): int
|
||||
{
|
||||
return FileConstant::STORAGE_REMOTE_URL;
|
||||
}
|
||||
|
||||
public function storeFromPath(string $sourcePath, array $context): array
|
||||
{
|
||||
throw new BusinessStateException('远程引用模式不支持直接上传,请先下载后再入库');
|
||||
}
|
||||
|
||||
public function delete(array $asset): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function previewResponse(array $asset): Response
|
||||
{
|
||||
$url = (string) ($asset['source_url'] ?? $asset['url'] ?? '');
|
||||
if ($url === '') {
|
||||
return response('文件不存在', 404);
|
||||
}
|
||||
|
||||
return redirect($url);
|
||||
}
|
||||
|
||||
public function downloadResponse(array $asset): Response
|
||||
{
|
||||
return $this->previewResponse($asset);
|
||||
}
|
||||
|
||||
public function publicUrl(array $asset): string
|
||||
{
|
||||
return (string) ($asset['source_url'] ?? $asset['url'] ?? '');
|
||||
}
|
||||
|
||||
public function temporaryUrl(array $asset): string
|
||||
{
|
||||
return (string) ($asset['source_url'] ?? $asset['url'] ?? '');
|
||||
}
|
||||
}
|
||||
25
app/service/file/storage/StorageDriverInterface.php
Normal file
25
app/service/file/storage/StorageDriverInterface.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\file\storage;
|
||||
|
||||
use support\Response;
|
||||
|
||||
/**
|
||||
* 文件存储驱动接口。
|
||||
*/
|
||||
interface StorageDriverInterface
|
||||
{
|
||||
public function engine(): int;
|
||||
|
||||
public function storeFromPath(string $sourcePath, array $context): array;
|
||||
|
||||
public function delete(array $asset): bool;
|
||||
|
||||
public function previewResponse(array $asset): Response;
|
||||
|
||||
public function downloadResponse(array $asset): Response;
|
||||
|
||||
public function publicUrl(array $asset): string;
|
||||
|
||||
public function temporaryUrl(array $asset): string;
|
||||
}
|
||||
158
app/service/file/storage/StorageManager.php
Normal file
158
app/service/file/storage/StorageManager.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
namespace app\service\file\storage;
|
||||
|
||||
use app\common\constant\FileConstant;
|
||||
use app\service\file\StorageConfigService;
|
||||
use support\Response;
|
||||
|
||||
/**
|
||||
* 文件存储驱动管理器。
|
||||
*/
|
||||
class StorageManager
|
||||
{
|
||||
public function __construct(
|
||||
protected StorageConfigService $storageConfigService,
|
||||
protected LocalStorageDriver $localStorageDriver,
|
||||
protected OssStorageDriver $ossStorageDriver,
|
||||
protected CosStorageDriver $cosStorageDriver,
|
||||
protected RemoteUrlStorageDriver $remoteUrlStorageDriver
|
||||
) {
|
||||
}
|
||||
|
||||
public function buildContext(
|
||||
string $sourcePath,
|
||||
string $originalName,
|
||||
?int $scene = null,
|
||||
?int $visibility = null,
|
||||
?int $engine = null,
|
||||
?string $sourceUrl = null,
|
||||
string $sourceType = 'upload'
|
||||
): array {
|
||||
$mimeType = $this->guessMimeType($sourcePath, $originalName);
|
||||
$scene = $this->storageConfigService->normalizeScene($scene, $originalName, $mimeType);
|
||||
$visibility = $this->storageConfigService->normalizeVisibility($visibility, $scene);
|
||||
$engine = $this->storageConfigService->normalizeEngine($engine ?? $this->storageConfigService->defaultEngine());
|
||||
$ext = strtolower(trim(pathinfo($originalName, PATHINFO_EXTENSION)));
|
||||
if ($ext === '') {
|
||||
$ext = strtolower((string) pathinfo($sourcePath, PATHINFO_EXTENSION));
|
||||
}
|
||||
|
||||
$objectKey = $this->storageConfigService->buildObjectKey($scene, $visibility, $ext);
|
||||
$publicUrl = $this->buildPublicUrlByEngine($engine, $visibility, $objectKey);
|
||||
|
||||
return [
|
||||
'scene' => $scene,
|
||||
'visibility' => $visibility,
|
||||
'storage_engine' => $engine,
|
||||
'source_type' => $sourceType === 'remote_url' ? FileConstant::SOURCE_REMOTE_URL : FileConstant::SOURCE_UPLOAD,
|
||||
'source_url' => (string) ($sourceUrl ?? ''),
|
||||
'original_name' => $originalName,
|
||||
'file_name' => basename($objectKey),
|
||||
'file_ext' => $ext,
|
||||
'mime_type' => $mimeType,
|
||||
'size' => is_file($sourcePath) ? (int) filesize($sourcePath) : 0,
|
||||
'md5' => is_file($sourcePath) ? (string) md5_file($sourcePath) : '',
|
||||
'object_key' => $objectKey,
|
||||
'public_url' => $publicUrl,
|
||||
];
|
||||
}
|
||||
|
||||
public function storeFromPath(
|
||||
string $sourcePath,
|
||||
string $originalName,
|
||||
?int $scene = null,
|
||||
?int $visibility = null,
|
||||
?int $engine = null,
|
||||
?string $sourceUrl = null,
|
||||
string $sourceType = 'upload'
|
||||
): array {
|
||||
$context = $this->buildContext($sourcePath, $originalName, $scene, $visibility, $engine, $sourceUrl, $sourceType);
|
||||
$driver = $this->resolveDriver((int) $context['storage_engine']);
|
||||
|
||||
return array_merge($context, $driver->storeFromPath($sourcePath, $context));
|
||||
}
|
||||
|
||||
public function delete(array $asset): bool
|
||||
{
|
||||
return $this->resolveDriver((int) ($asset['storage_engine'] ?? FileConstant::STORAGE_LOCAL))
|
||||
->delete($asset);
|
||||
}
|
||||
|
||||
public function previewResponse(array $asset): Response
|
||||
{
|
||||
return $this->resolveDriver((int) ($asset['storage_engine'] ?? FileConstant::STORAGE_LOCAL))
|
||||
->previewResponse($asset);
|
||||
}
|
||||
|
||||
public function downloadResponse(array $asset): Response
|
||||
{
|
||||
return $this->resolveDriver((int) ($asset['storage_engine'] ?? FileConstant::STORAGE_LOCAL))
|
||||
->downloadResponse($asset);
|
||||
}
|
||||
|
||||
public function publicUrl(array $asset): string
|
||||
{
|
||||
return $this->resolveDriver((int) ($asset['storage_engine'] ?? FileConstant::STORAGE_LOCAL))
|
||||
->publicUrl($asset);
|
||||
}
|
||||
|
||||
public function temporaryUrl(array $asset): string
|
||||
{
|
||||
return $this->resolveDriver((int) ($asset['storage_engine'] ?? FileConstant::STORAGE_LOCAL))
|
||||
->temporaryUrl($asset);
|
||||
}
|
||||
|
||||
public function resolveDriver(int $engine): StorageDriverInterface
|
||||
{
|
||||
return match ($engine) {
|
||||
FileConstant::STORAGE_LOCAL => $this->localStorageDriver,
|
||||
FileConstant::STORAGE_ALIYUN_OSS => $this->ossStorageDriver,
|
||||
FileConstant::STORAGE_TENCENT_COS => $this->cosStorageDriver,
|
||||
FileConstant::STORAGE_REMOTE_URL => $this->remoteUrlStorageDriver,
|
||||
default => $this->localStorageDriver,
|
||||
};
|
||||
}
|
||||
|
||||
private function buildPublicUrlByEngine(int $engine, int $visibility, string $objectKey): string
|
||||
{
|
||||
if ($engine === FileConstant::STORAGE_LOCAL && $visibility === FileConstant::VISIBILITY_PUBLIC) {
|
||||
return $this->storageConfigService->buildLocalPublicUrl($objectKey);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function guessMimeType(string $sourcePath, string $originalName): string
|
||||
{
|
||||
$mimeType = '';
|
||||
if (is_file($sourcePath) && function_exists('mime_content_type')) {
|
||||
$detected = @mime_content_type($sourcePath);
|
||||
if (is_string($detected)) {
|
||||
$mimeType = trim($detected);
|
||||
}
|
||||
}
|
||||
|
||||
if ($mimeType !== '') {
|
||||
return $mimeType;
|
||||
}
|
||||
|
||||
$ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
|
||||
return match ($ext) {
|
||||
'jpg', 'jpeg' => 'image/jpeg',
|
||||
'png' => 'image/png',
|
||||
'gif' => 'image/gif',
|
||||
'webp' => 'image/webp',
|
||||
'svg' => 'image/svg+xml',
|
||||
'bmp' => 'image/bmp',
|
||||
'txt', 'log', 'md', 'ini', 'conf', 'yml', 'yaml' => 'text/plain',
|
||||
'json' => 'application/json',
|
||||
'xml' => 'application/xml',
|
||||
'csv' => 'text/csv',
|
||||
'pem' => 'application/x-pem-file',
|
||||
'crt', 'cer' => 'application/x-x509-ca-cert',
|
||||
'key' => 'application/octet-stream',
|
||||
default => 'application/octet-stream',
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user