重构初始化

This commit is contained in:
技术老胡
2026-04-15 11:45:46 +08:00
parent 72d72d735b
commit 7612026773
381 changed files with 28287 additions and 14717 deletions

View 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,
];
}
}

View 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', ''),
];
}
}

View 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);
}
}

View 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,
};
}
}

View 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',
};
}
}

View 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);
}
}
}

View 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('文件目录创建失败');
}
}
}

View 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);
}
}
}

View 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'] ?? '');
}
}

View 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;
}

View 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',
};
}
}