diff --git a/admin/src/api/file.ts b/admin/src/api/file.ts index 2d60dd2c1..9f81ff782 100644 --- a/admin/src/api/file.ts +++ b/admin/src/api/file.ts @@ -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 diff --git a/admin/src/components/upload/index.vue b/admin/src/components/upload/index.vue index eead4783d..98b3a5b62 100644 --- a/admin/src/components/upload/index.vue +++ b/admin/src/components/upload/index.vue @@ -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([]) - // 仅 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 diff --git a/admin/src/utils/oss-direct-upload.ts b/admin/src/utils/oss-direct-upload.ts index 90637d1f1..1cd284624 100644 --- a/admin/src/utils/oss-direct-upload.ts +++ b/admin/src/utils/oss-direct-upload.ts @@ -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(request: () => Promise): Promise { + 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 { - const credentials: OssCredentialsResponse = await getOssCredentials({ type: options.type }) +export async function uploadDirectToCos(options: DirectUploadOptions): Promise { + 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((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 + } +} diff --git a/admin/src/views/setting/desktop_workstation/index.vue b/admin/src/views/setting/desktop_workstation/index.vue index 09842a9e9..9ab6cfa69 100644 --- a/admin/src/views/setting/desktop_workstation/index.vue +++ b/admin/src/views/setting/desktop_workstation/index.vue @@ -16,7 +16,8 @@ 一键打包 产物一致的安装包,并填入打包目录中的 SHA-256。Windows 推荐使用 Setup.exe,用户点击“立即更新”后会自动安装并重启;macOS 继续使用 ZIP。 - 安装包通常超过 200MB,优先传到对象存储 / CDN 后粘贴地址。 + 安装包通常超过 200MB,本页上传按钮会直传到已配置的腾讯云 COS;也可以 + 自行上传到其他对象存储 / CDN 后粘贴地址。
升级策略
@@ -125,7 +126,9 @@
选择安装包并上传
- 仅建议上传较小的包。大文件请先传到对象存储,再把地址和 SHA-256 - 填到上方。Windows 自动安装程序必须使用 HTTPS 地址并开启证书校验。 + 安装包将分片直传腾讯云 COS,不经过业务服务器(支持 EXE / ZIP,最大 + 2GB)。Windows 自动安装程序必须使用 HTTPS 地址并开启证书校验。
diff --git a/app/src/doctor_workstation/__init__.py b/app/src/doctor_workstation/__init__.py index 99be554e4..a33caea94 100644 --- a/app/src/doctor_workstation/__init__.py +++ b/app/src/doctor_workstation/__init__.py @@ -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;此时程序只使用下面配置的线上域名。 diff --git a/server/app/adminapi/controller/UploadController.php b/server/app/adminapi/controller/UploadController.php index 643e79638..939a43443 100755 --- a/server/app/adminapi/controller/UploadController.php +++ b/server/app/adminapi/controller/UploadController.php @@ -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('权限不足,无法上传医生工作站安装包'); + } + } + } diff --git a/server/app/common/service/DirectUploadService.php b/server/app/common/service/DirectUploadService.php index cbe991bd9..e31a0ffc8 100755 --- a/server/app/common/service/DirectUploadService.php +++ b/server/app/common/service/DirectUploadService.php @@ -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) === '/'; + } } diff --git a/server/app/common/service/storage/engine/Qcloud.php b/server/app/common/service/storage/engine/Qcloud.php index d57250a7f..d79724382 100755 --- a/server/app/common/service/storage/engine/Qcloud.php +++ b/server/app/common/service/storage/engine/Qcloud.php @@ -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-1300000000,appId 即末段 @@ -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 级 ListMultipartUploads(cos-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 = [ diff --git a/server/tests/DesktopWorkstationUpdateContractTest.php b/server/tests/DesktopWorkstationUpdateContractTest.php index 68ffa0f0b..f9ffb2f4e 100644 --- a/server/tests/DesktopWorkstationUpdateContractTest.php +++ b/server/tests/DesktopWorkstationUpdateContractTest.php @@ -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');