This commit is contained in:
Your Name
2026-09-01 17:18:42 +08:00
parent 398f9f3726
commit 486acc465d
9 changed files with 262 additions and 51 deletions
@@ -20,6 +20,7 @@ class DirectUploadService
/** 视频允许的扩展名(沿用 config/project.file_video */
public const TYPE_VIDEO = 'video';
public const TYPE_VOICE = 'voice';
public const TYPE_DESKTOP_PACKAGE = 'desktop_package';
/** 默认凭证有效期 30 分钟 */
public const DEFAULT_DURATION = 1800;
@@ -28,6 +29,7 @@ class DirectUploadService
private const MAX_SIZE = [
self::TYPE_VIDEO => 2 * 1024 * 1024 * 1024, // 2GB
self::TYPE_VOICE => 500 * 1024 * 1024, // 500MB
self::TYPE_DESKTOP_PACKAGE => 2 * 1024 * 1024 * 1024, // 2GB
];
/**
@@ -36,7 +38,7 @@ class DirectUploadService
* @return array
* @throws Exception
*/
public static function issueCredentials(string $type): array
public static function issueCredentials(string $type, int $adminId = 0, string $name = ''): array
{
if (!isset(self::MAX_SIZE[$type])) {
throw new Exception('不支持的上传类型: ' . $type);
@@ -54,9 +56,27 @@ class DirectUploadService
throw new Exception('腾讯云 COS 配置不完整');
}
$keyPrefix = self::buildKeyPrefix($type);
$keyPrefix = self::buildKeyPrefix($type, $adminId);
$objectKey = '';
// 兼容前后端错峰发布:旧 uploader 只传 type,不传 name。
// 新 uploader 仍使用更严格的单对象授权;旧版则限制在当前管理员当天目录,
// 并在 confirm 阶段校验文件名、扩展名与实际对象。
if ($type === self::TYPE_DESKTOP_PACKAGE && trim($name) !== '') {
$extension = strtolower((string)pathinfo($name, PATHINFO_EXTENSION));
$objectKey = $keyPrefix
. (int)round(microtime(true) * 1000)
. '-'
. bin2hex(random_bytes(8))
. ($extension !== '' ? '.' . $extension : '');
self::validateFileExtension($type, $objectKey, $name);
}
$engine = new QcloudEngine($storageConfig);
$sts = $engine->getStsCredentials($keyPrefix, self::MAX_SIZE[$type], self::DEFAULT_DURATION);
$sts = $engine->getStsCredentials(
$objectKey !== '' ? $objectKey : $keyPrefix,
self::MAX_SIZE[$type],
self::DEFAULT_DURATION,
$objectKey !== ''
);
return [
'provider' => 'qcloud',
@@ -66,6 +86,7 @@ class DirectUploadService
'host' => $sts['host'],
'cdn_domain' => rtrim((string)($storageConfig['domain'] ?? ''), '/'),
'key_prefix' => $keyPrefix,
'object_key' => $objectKey,
'max_size' => self::MAX_SIZE[$type],
'duration' => self::DEFAULT_DURATION,
'expired_time' => $sts['expiredTime'],
@@ -93,8 +114,7 @@ class DirectUploadService
}
$key = ltrim((string)($params['key'] ?? ''), '/');
$allowedPrefix = self::buildKeyPrefix($type);
if ($key === '' || strpos($key, $allowedPrefix) !== 0) {
if (!self::isAllowedObjectKey($type, $key, (int)($params['admin_id'] ?? 0))) {
throw new Exception('对象 Key 非法');
}
@@ -112,6 +132,7 @@ class DirectUploadService
if ($name === '') {
$name = basename($key);
}
self::validateFileExtension($type, $key, $name);
if (strlen($name) > 128) {
$name = substr($name, 0, 123) . substr($name, -5);
}
@@ -137,9 +158,16 @@ class DirectUploadService
];
}
private static function buildKeyPrefix(string $type): string
private static function buildKeyPrefix(string $type, int $adminId = 0): string
{
return 'uploads/' . $type . '/' . date('Ymd') . '/';
$prefix = 'uploads/' . $type . '/';
if ($type === self::TYPE_DESKTOP_PACKAGE) {
if ($adminId <= 0) {
throw new Exception('安装包上传账号无效');
}
$prefix .= $adminId . '/';
}
return $prefix . date('Ymd') . '/';
}
private static function resolveFileType(string $type): int
@@ -150,4 +178,46 @@ class DirectUploadService
default => FileEnum::FILE_TYPE,
};
}
/**
* 桌面安装包是可执行文件,只允许发布流程所需的 EXE / ZIP。
*/
private static function validateFileExtension(string $type, string $key, string $name): void
{
if ($type !== self::TYPE_DESKTOP_PACKAGE) {
return;
}
$nameExtension = strtolower((string)pathinfo($name, PATHINFO_EXTENSION));
$keyExtension = strtolower((string)pathinfo($key, PATHINFO_EXTENSION));
$allowedExtensions = ['exe', 'zip'];
if (!in_array($nameExtension, $allowedExtensions, true)
|| $nameExtension !== $keyExtension) {
throw new Exception('桌面安装包仅支持 EXE 或 ZIP 文件');
}
}
/**
* 安装包 Key 绑定上传管理员,并兼容跨午夜完成的上传。
*/
private static function isAllowedObjectKey(string $type, string $key, int $adminId): bool
{
if ($key === '') {
return false;
}
if ($type !== self::TYPE_DESKTOP_PACKAGE) {
return strpos($key, self::buildKeyPrefix($type)) === 0;
}
if ($adminId <= 0) {
return false;
}
$ownerPrefix = 'uploads/' . self::TYPE_DESKTOP_PACKAGE . '/' . $adminId . '/';
if (strpos($key, $ownerPrefix) !== 0) {
return false;
}
$date = substr($key, strlen($ownerPrefix), 8);
return in_array($date, [date('Ymd'), date('Ymd', time() - 86400)], true)
&& substr($key, strlen($ownerPrefix) + 8, 1) === '/';
}
}
@@ -116,13 +116,19 @@ class Qcloud extends Server
/**
* @notes 获取 STS 临时凭证(用于浏览器直传)
* @param string $keyPrefix 资源前缀,如 uploads/video/20260508/
* @param string $keyScope 资源前缀或完整对象 Key
* @param int $maxSizeBytes 单文件大小上限(字节)
* @param int $durationSeconds 凭证有效期(秒)
* @param bool $exactObject 是否只授权单个对象 Key
* @return array {credentials, expiredTime, requestId}
* @throws Exception
*/
public function getStsCredentials(string $keyPrefix, int $maxSizeBytes, int $durationSeconds = 1800): array
public function getStsCredentials(
string $keyScope,
int $maxSizeBytes,
int $durationSeconds = 1800,
bool $exactObject = false
): array
{
$bucket = $this->config['bucket'];
// bucket 形如 likeadmin-1300000000appId 即末段
@@ -137,15 +143,19 @@ class Qcloud extends Server
$shortBucket = substr($bucket, 0, strrpos($bucket, '-'));
$region = $this->config['region'];
$prefix = ltrim($keyPrefix, '/');
if ($prefix === '' || substr($prefix, -1) !== '/') {
$prefix = $prefix . '/';
$scope = ltrim($keyScope, '/');
if ($scope === '') {
throw new Exception('COS 授权对象不能为空');
}
if (!$exactObject && substr($scope, -1) !== '/') {
$scope .= '/';
}
$duration = max(900, min($durationSeconds, 7200));
// 自行构造 policy:对象级写动作收紧 + bucket 级 ListMultipartUploadscos-js-sdk-v5 续传探测必需)
$objectArn = sprintf('qcs::cos:%s:uid/%s:%s/%s*', $region, $appId, $bucket, $prefix);
$objectResource = $exactObject ? $scope : $scope . '*';
$objectArn = sprintf('qcs::cos:%s:uid/%s:%s/%s', $region, $appId, $bucket, $objectResource);
$bucketArn = sprintf('qcs::cos:%s:uid/%s:%s/*', $region, $appId, $bucket);
$policy = [