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
+5 -2
View File
@@ -100,6 +100,7 @@ export interface OssCredentialsResponse {
host?: string
cdn_domain?: string
key_prefix?: string
object_key?: string
max_size?: number
duration?: number
expired_time?: number
@@ -111,8 +112,10 @@ export interface OssCredentialsResponse {
}
}
export type OssDirectUploadType = 'video' | 'voice' | 'desktop_package'
/** 申请 STS 临时凭证 */
export function getOssCredentials(params: { type: 'video' }) {
export function getOssCredentials(params: { type: OssDirectUploadType; name?: string }) {
return request.post({
url: '/upload/ossCredentials',
params
@@ -121,7 +124,7 @@ export function getOssCredentials(params: { type: 'video' }) {
/** 直传完成回执:写 file 表 + HEAD 校验 */
export function confirmOssUpload(params: {
type: 'video'
type: OssDirectUploadType
key: string
name: string
size: number
+23 -8
View File
@@ -51,8 +51,10 @@ import useAppStore from '@/stores/modules/app'
import useUserStore from '@/stores/modules/user'
import feedback from '@/utils/feedback'
import {
DirectUploadApiError,
DirectUploadFallbackError,
uploadVideoDirectToCos
uploadDirectToCos,
type DirectUploadType
} from '@/utils/oss-direct-upload'
export default defineComponent({
@@ -83,7 +85,7 @@ export default defineComponent({
type: Boolean,
default: false
},
// 视频直传到 OSS绕开服务器中转,仅 type=video 生效)
// 直传到对象存储,绕开服务器中转
direct: {
type: Boolean,
default: false
@@ -102,8 +104,10 @@ export default defineComponent({
const visible = ref(false)
const fileList = ref<any[]>([])
// 仅 video/voice + direct 时才接管 http-request
const useDirect = computed(() => props.direct && ['video', 'voice'].includes(props.type))
const directTypes: DirectUploadType[] = ['video', 'voice', 'desktop_package']
const useDirect = computed(
() => props.direct && directTypes.includes(props.type as DirectUploadType)
)
const handleProgress = () => {
visible.value = true
@@ -131,7 +135,10 @@ export default defineComponent({
fileList.value = []
emit('allSuccess')
}
feedback.msgError(`${file.name}文件上传失败`)
if (!(event instanceof DirectUploadApiError)) {
const message = event instanceof Error ? event.message : ''
feedback.msgError(message || `${file.name}文件上传失败`)
}
uploadRefs.value?.abort(file)
visible.value = false
emit('change', file)
@@ -153,18 +160,20 @@ export default defineComponent({
return '.wmv,.avi,.mpg,.mpeg,.3gp,.mov,.mp4,.flv,.rmvb,.mkv'
case 'voice':
return '.mp3,.wav,.wma,.m4a,.aac,.amr'
case 'desktop_package':
return '.exe,.zip'
default:
return '*'
}
})
// 走 COS 直传:成功时模拟老接口的响应 envelope,失败/降级时回到默认 XHR
// 走 COS 直传:成功时模拟老接口的响应 envelope
const httpRequest = async (options: UploadRequestOptions) => {
visible.value = true
try {
const data = await uploadVideoDirectToCos({
const data = await uploadDirectToCos({
file: options.file,
type: props.type as any,
type: props.type as DirectUploadType,
cid: Number((options.data as any)?.cid ?? 0),
onProgress(info) {
// 触发 ElUpload 内部进度(保持与默认上传一致的体验)
@@ -178,6 +187,12 @@ export default defineComponent({
;(options as any).onSuccess?.({ code: RequestCodeEnum.SUCCESS, msg: 'ok', data })
} catch (err: any) {
if (err instanceof DirectUploadFallbackError) {
if (props.type === 'desktop_package') {
;(options as any).onError?.(
new Error('当前未启用腾讯云 COS,安装包无法直传,请配置 COS 后重试')
)
return
}
feedback.msgWarning('当前存储不支持直传,已切换为普通上传')
await defaultXhrUpload(options)
return
+43 -15
View File
@@ -3,10 +3,11 @@ import COS from 'cos-js-sdk-v5'
import {
confirmOssUpload,
getOssCredentials,
type OssCredentialsResponse
type OssCredentialsResponse,
type OssDirectUploadType
} from '@/api/file'
export type DirectUploadType = 'video'
export type DirectUploadType = OssDirectUploadType
export interface DirectUploadProgress {
/** 0-100 */
@@ -37,8 +38,17 @@ export interface DirectUploadOptions {
const SLICE_SIZE = 5 * 1024 * 1024 // 5MB
const ASYNC_LIMIT = 3
async function callDirectUploadApi<T>(request: () => Promise<T>): Promise<T> {
try {
return await request()
} catch (error) {
// request 拦截器已经展示过接口/网络错误,上传组件只负责收口失败状态
throw new DirectUploadApiError(error)
}
}
function buildKey(prefix: string, file: File): string {
const ext = (file.name.split('.').pop() || 'mp4').toLowerCase()
const ext = (file.name.split('.').pop() || 'bin').toLowerCase()
const ts = Date.now()
const rand = Math.random().toString(36).slice(2, 10)
return `${prefix}${ts}-${rand}.${ext}`
@@ -48,8 +58,13 @@ function buildKey(prefix: string, file: File): string {
* 直传到腾讯云 COS(含 STS 凭证申请、分片上传、回执)
* 不支持降级 / fallback=true 时抛错,由调用方决定走老链路。
*/
export async function uploadVideoDirectToCos(options: DirectUploadOptions): Promise<DirectUploadResult> {
const credentials: OssCredentialsResponse = await getOssCredentials({ type: options.type })
export async function uploadDirectToCos(options: DirectUploadOptions): Promise<DirectUploadResult> {
const credentials: OssCredentialsResponse = await callDirectUploadApi(() =>
getOssCredentials({
type: options.type,
name: options.file.name
})
)
if (credentials.fallback) {
const handled = options.onFallback?.(credentials.provider) ?? false
@@ -66,7 +81,7 @@ export async function uploadVideoDirectToCos(options: DirectUploadOptions): Prom
if (credentials.max_size && options.file.size > credentials.max_size) {
const mb = Math.round(credentials.max_size / 1024 / 1024)
throw new Error(`视频体积超出上限(${mb}MB`)
throw new Error(`文件体积超出上限(${mb}MB`)
}
const cred = credentials.credentials
@@ -85,7 +100,7 @@ export async function uploadVideoDirectToCos(options: DirectUploadOptions): Prom
}
})
const key = buildKey(credentials.key_prefix, options.file)
const key = credentials.object_key || buildKey(credentials.key_prefix, options.file)
await new Promise<void>((resolve, reject) => {
cos.uploadFile(
@@ -117,14 +132,16 @@ export async function uploadVideoDirectToCos(options: DirectUploadOptions): Prom
)
})
const confirmed = await confirmOssUpload({
type: options.type,
key,
name: options.file.name,
size: options.file.size,
content_type: options.file.type || '',
cid: options.cid ?? 0
})
const confirmed = await callDirectUploadApi(() =>
confirmOssUpload({
type: options.type,
key,
name: options.file.name,
size: options.file.size,
content_type: options.file.type || '',
cid: options.cid ?? 0
})
)
options.onProgress?.({ percent: 100, loaded: options.file.size, total: options.file.size, speed: 0 })
@@ -140,3 +157,14 @@ export class DirectUploadFallbackError extends Error {
this.provider = provider
}
}
/** 请求层已经展示过错误,避免 ElUpload 再弹一条通用失败提示。 */
export class DirectUploadApiError extends Error {
readonly originalError: unknown
constructor(error: unknown) {
super('')
this.name = 'DirectUploadApiError'
this.originalError = error
}
}
@@ -16,7 +16,8 @@
<code>一键打包</code>
产物一致的安装包并填入打包目录中的 SHA-256Windows 推荐使用
Setup.exe用户点击立即更新后会自动安装并重启macOS 继续使用 ZIP
安装包通常超过 200MB优先传到对象存储 / CDN 后粘贴地址
安装包通常超过 200MB本页上传按钮会直传到已配置的腾讯云 COS也可以
自行上传到其他对象存储 / CDN 后粘贴地址
</div>
</el-alert>
<div class="text-xl font-medium mb-[20px]">升级策略</div>
@@ -125,7 +126,9 @@
<el-form-item label="上传安装包">
<div>
<upload
type="file"
v-perms="['setting.desktop_workstation/setConfig']"
type="desktop_package"
direct
:limit="1"
:multiple="false"
:show-progress="true"
@@ -136,8 +139,8 @@
<el-button type="primary" plain>选择安装包并上传</el-button>
</upload>
<div class="form-tips">
仅建议上传较小的包大文件请先传到对象存储再把地址和 SHA-256
填到上方Windows 自动安装程序必须使用 HTTPS 地址并开启证书校验
安装包将分片直传腾讯云 COS不经过业务服务器支持 EXE / ZIP最大
2GBWindows 自动安装程序必须使用 HTTPS 地址并开启证书校验
</div>
</div>
</el-form-item>
+1 -1
View File
@@ -3,7 +3,7 @@
__all__ = ["DEBUG_MODE", "ONLINE_API_BASE_URL", "__version__"]
# Single source of truth for runtime, package, installer, and executable versions.
__version__ = "1.2.0"
__version__ = "1.4.0"
# 调试模式开启时,登录页显示“演示模式”和“服务器设置”。
# 正式发布请保持 False;此时程序只使用下面配置的线上域名。
@@ -15,6 +15,7 @@
namespace app\adminapi\controller;
use app\common\cache\AdminAuthCache;
use app\common\service\DirectUploadService;
use app\common\service\UploadService;
use Exception;
@@ -86,7 +87,12 @@ class UploadController extends BaseAdminController
{
$type = trim((string)$this->request->post('type', 'video'));
try {
$result = DirectUploadService::issueCredentials($type);
$this->assertDirectUploadPermission($type);
$result = DirectUploadService::issueCredentials(
$type,
$this->adminId,
trim((string)$this->request->post('name', ''))
);
return $this->success('ok', $result);
} catch (Exception $e) {
return $this->fail($e->getMessage());
@@ -100,8 +106,10 @@ class UploadController extends BaseAdminController
public function ossConfirm()
{
try {
$type = trim((string)$this->request->post('type', 'video'));
$this->assertDirectUploadPermission($type);
$result = DirectUploadService::confirm([
'type' => trim((string)$this->request->post('type', 'video')),
'type' => $type,
'key' => trim((string)$this->request->post('key', '')),
'name' => trim((string)$this->request->post('name', '')),
'size' => (int)$this->request->post('size', 0),
@@ -115,4 +123,22 @@ class UploadController extends BaseAdminController
}
}
/**
* 安装包属于发布能力,不能沿用普通素材上传的“登录即放行”。
* @throws Exception
*/
private function assertDirectUploadPermission(string $type): void
{
if ($type !== DirectUploadService::TYPE_DESKTOP_PACKAGE
|| (int)($this->adminInfo['root'] ?? 0) === 1) {
return;
}
$permissions = (new AdminAuthCache($this->adminId))->getAdminUri() ?? [];
$permissions = array_map('strtolower', $permissions);
if (!in_array('setting.desktop_workstation/setconfig', $permissions, true)) {
throw new Exception('权限不足,无法上传医生工作站安装包');
}
}
}
@@ -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 = [
@@ -4,7 +4,8 @@ declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\logic\setting\DesktopWorkstationLogic;
use app\adminapi\logic\setting\DesktopWorkstationLogic;
use app\common\service\DirectUploadService;
function desktopUpdateExpect(bool $condition, string $message): void
{
@@ -92,14 +93,69 @@ desktopUpdateExpect(
$adminView = file_get_contents(dirname(__DIR__, 2) . '/admin/src/views/setting/desktop_workstation/index.vue');
desktopUpdateExpect(is_string($adminView), 'admin view source is readable');
desktopUpdateExpect(
desktopUpdateExpect(
str_contains($adminView, 'setting.desktop_workstation/setConfig')
&& str_contains($adminView, 'force_update')
&& str_contains($adminView, 'inno_setup'),
'admin page can save force-update and Inno Setup configuration'
&& str_contains($adminView, 'inno_setup')
&& str_contains($adminView, 'type="desktop_package"')
&& str_contains($adminView, 'direct'),
'admin page saves update settings and sends installers through direct upload'
);
$migration = file_get_contents(
$directUploadReflection = new ReflectionClass(DirectUploadService::class);
$validateExtension = $directUploadReflection->getMethod('validateFileExtension');
$validateExtension->invoke(
null,
DirectUploadService::TYPE_DESKTOP_PACKAGE,
'uploads/desktop_package/7/' . date('Ymd') . '/package.exe',
'DoctorWorkstation.EXE'
);
$invalidExtensionRejected = false;
try {
$validateExtension->invoke(
null,
DirectUploadService::TYPE_DESKTOP_PACKAGE,
'uploads/desktop_package/7/' . date('Ymd') . '/package.php',
'package.php'
);
} catch (Throwable $e) {
$invalidExtensionRejected = true;
}
desktopUpdateExpect($invalidExtensionRejected, 'desktop direct upload rejects non-EXE/ZIP files');
$validateObjectKey = $directUploadReflection->getMethod('isAllowedObjectKey');
$ownedKey = 'uploads/desktop_package/7/' . date('Ymd') . '/package.exe';
desktopUpdateExpect(
$validateObjectKey->invoke(null, DirectUploadService::TYPE_DESKTOP_PACKAGE, $ownedKey, 7) === true
&& $validateObjectKey->invoke(null, DirectUploadService::TYPE_DESKTOP_PACKAGE, $ownedKey, 8) === false,
'desktop package keys are bound to the issuing admin'
);
$uploadController = file_get_contents(dirname(__DIR__) . '/app/adminapi/controller/UploadController.php');
$qcloudEngine = file_get_contents(dirname(__DIR__) . '/app/common/service/storage/engine/Qcloud.php');
$directUploadService = file_get_contents(
dirname(__DIR__) . '/app/common/service/DirectUploadService.php'
);
desktopUpdateExpect(
is_string($uploadController)
&& str_contains($uploadController, 'assertDirectUploadPermission')
&& str_contains($uploadController, 'setting.desktop_workstation/setconfig'),
'desktop package credentials and confirmation require publish permission'
);
desktopUpdateExpect(
is_string($qcloudEngine)
&& str_contains($qcloudEngine, 'bool $exactObject = false')
&& str_contains($qcloudEngine, '$exactObject ? $scope : $scope .'),
'COS credentials can be restricted to one server-issued object key'
);
desktopUpdateExpect(
is_string($directUploadService)
&& str_contains($directUploadService, "trim(\$name) !== ''")
&& str_contains($directUploadService, '$objectKey !== \'\''),
'desktop credentials remain compatible with uploaders that do not send a filename'
);
$migration = file_get_contents(
dirname(__DIR__) . '/sql/1.9.20260821/add_desktop_workstation_update_menu.sql'
);
desktopUpdateExpect(is_string($migration), 'menu migration is readable');