重构初始化

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