2 * 1024 * 1024 * 1024, // 2GB self::TYPE_VOICE => 500 * 1024 * 1024, // 500MB self::TYPE_DESKTOP_PACKAGE => 2 * 1024 * 1024 * 1024, // 2GB ]; /** * @notes 签发临时凭证 * @param string $type * @return array * @throws Exception */ public static function issueCredentials(string $type, int $adminId = 0, string $name = ''): 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, $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( $objectKey !== '' ? $objectKey : $keyPrefix, self::MAX_SIZE[$type], self::DEFAULT_DURATION, $objectKey !== '' ); return [ 'provider' => 'qcloud', 'fallback' => false, 'bucket' => $sts['bucket'], 'region' => $sts['region'], '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'], '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'] ?? ''), '/'); if (!self::isAllowedObjectKey($type, $key, (int)($params['admin_id'] ?? 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); } self::validateFileExtension($type, $key, $name); 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, int $adminId = 0): string { $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 { return match ($type) { self::TYPE_VIDEO => FileEnum::VIDEO_TYPE, self::TYPE_VOICE => FileEnum::FILE_TYPE, 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) === '/'; } }