oss 直传

分片上传
This commit is contained in:
2026-05-08 10:49:16 +08:00
parent 4dc9ec1878
commit 7d7a4aa4d2
26 changed files with 1644 additions and 72 deletions
@@ -0,0 +1,150 @@
<?php
namespace app\common\service;
use app\common\enum\FileEnum;
use app\common\model\file\File;
use app\common\service\storage\engine\Qcloud as QcloudEngine;
use Exception;
/**
* 浏览器直传 OSS 服务
* - 目前仅支持腾讯云 COSqcloud)
* - 其他 driver 返回 fallback=true,由前端降级到老链路
*
* Class DirectUploadService
* @package app\common\service
*/
class DirectUploadService
{
/** 视频允许的扩展名(沿用 config/project.file_video */
public const TYPE_VIDEO = 'video';
/** 默认凭证有效期 30 分钟 */
public const DEFAULT_DURATION = 1800;
/** 允许扩展类型 → 大小上限(字节) */
private const MAX_SIZE = [
self::TYPE_VIDEO => 2 * 1024 * 1024 * 1024, // 2GB
];
/**
* @notes 签发临时凭证
* @param string $type
* @return array
* @throws Exception
*/
public static function issueCredentials(string $type): array
{
if (!isset(self::MAX_SIZE[$type])) {
throw new Exception('不支持的上传类型: ' . $type);
}
$default = ConfigService::get('storage', 'default', 'local');
if ($default !== 'qcloud') {
// 其他 driver 不支持直传,前端降级
return ['provider' => $default, 'fallback' => true];
}
$storageConfig = ConfigService::get('storage', 'qcloud');
if (empty($storageConfig['bucket']) || empty($storageConfig['region'])
|| empty($storageConfig['access_key']) || empty($storageConfig['secret_key'])) {
throw new Exception('腾讯云 COS 配置不完整');
}
$keyPrefix = self::buildKeyPrefix($type);
$engine = new QcloudEngine($storageConfig);
$sts = $engine->getStsCredentials($keyPrefix, self::MAX_SIZE[$type], self::DEFAULT_DURATION);
return [
'provider' => 'qcloud',
'fallback' => false,
'bucket' => $sts['bucket'],
'region' => $sts['region'],
'host' => $sts['host'],
'cdn_domain' => rtrim((string)($storageConfig['domain'] ?? ''), '/'),
'key_prefix' => $keyPrefix,
'max_size' => self::MAX_SIZE[$type],
'duration' => self::DEFAULT_DURATION,
'expired_time' => $sts['expiredTime'],
'start_time' => $sts['startTime'],
'credentials' => $sts['credentials'],
];
}
/**
* @notes 直传完成后的回执:HEAD 校验 + 写 file 表
* @param array $params {key, type, name, size, content_type, cid, admin_id}
* @return array {id, cid, type, name, uri, url}
* @throws Exception
*/
public static function confirm(array $params): array
{
$type = (string)($params['type'] ?? '');
if (!isset(self::MAX_SIZE[$type])) {
throw new Exception('不支持的上传类型');
}
$default = ConfigService::get('storage', 'default', 'local');
if ($default !== 'qcloud') {
throw new Exception('当前存储驱动不支持直传回执');
}
$key = ltrim((string)($params['key'] ?? ''), '/');
$allowedPrefix = self::buildKeyPrefix($type);
if ($key === '' || strpos($key, $allowedPrefix) !== 0) {
throw new Exception('对象 Key 非法');
}
$storageConfig = ConfigService::get('storage', 'qcloud');
$engine = new QcloudEngine($storageConfig);
$head = $engine->headObject($key);
if ($head === false) {
throw new Exception('对象未找到,请确认上传是否完成');
}
if ($head['size'] <= 0 || $head['size'] > self::MAX_SIZE[$type]) {
throw new Exception('文件大小超出限制');
}
$name = trim((string)($params['name'] ?? ''));
if ($name === '') {
$name = basename($key);
}
if (strlen($name) > 128) {
$name = substr($name, 0, 123) . substr($name, -5);
}
$file = File::create([
'cid' => (int)($params['cid'] ?? 0),
'type' => self::resolveFileType($type),
'name' => $name,
'uri' => $key,
'source' => FileEnum::SOURCE_ADMIN,
'source_id' => (int)($params['admin_id'] ?? 0),
'create_time' => time(),
]);
$url = FileService::getFileUrl($key);
return [
'id' => $file['id'],
'cid' => $file['cid'],
'type' => $file['type'],
'name' => $file['name'],
'uri' => $url,
'url' => $url,
];
}
private static function buildKeyPrefix(string $type): string
{
return 'uploads/' . $type . '/' . date('Ymd') . '/';
}
private static function resolveFileType(string $type): int
{
return match ($type) {
self::TYPE_VIDEO => FileEnum::VIDEO_TYPE,
default => FileEnum::FILE_TYPE,
};
}
}