224 lines
7.9 KiB
PHP
Executable File
224 lines
7.9 KiB
PHP
Executable File
<?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 服务
|
||
* - 目前仅支持腾讯云 COS(qcloud)
|
||
* - 其他 driver 返回 fallback=true,由前端降级到老链路
|
||
*
|
||
* Class DirectUploadService
|
||
* @package app\common\service
|
||
*/
|
||
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;
|
||
|
||
/** 允许扩展类型 → 大小上限(字节) */
|
||
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
|
||
];
|
||
|
||
/**
|
||
* @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) === '/';
|
||
}
|
||
}
|