From 62d0b2298877b5e33e00282f0f613d160c6e25e7 Mon Sep 17 00:00:00 2001 From: liangxu Date: Sat, 20 Jun 2026 12:44:28 +0800 Subject: [PATCH] feat: Implement direct upload functionality for desktop client releases - Added new endpoints for initiating and completing direct uploads of desktop client packages. - Introduced request and response structures for direct upload operations. - Enhanced the upload process to support SHA256 and Content-MD5 validation. - Updated the frontend to reflect changes in upload button states and progress indicators. - Modified object storage clients to support presigned PUT URLs with content type and MD5 headers. - Added tests for direct upload functionality and ensured existing upload processes remain intact. --- apps/desktop-client/package.json | 2 +- apps/ops-web/package.json | 1 + .../src/lib/desktop-client-releases.ts | 171 ++++++++++-- .../src/views/DesktopClientReleasesView.vue | 34 ++- pnpm-lock.yaml | 8 + .../ops/app/desktop_client_release.go | 246 ++++++++++++++++++ .../ops/app/desktop_client_release_test.go | 137 +++++++++- .../desktop_client_release_handler.go | 76 ++++++ server/internal/ops/transport/router.go | 2 + .../internal/shared/objectstorage/aliyun.go | 78 +++++- .../internal/shared/objectstorage/client.go | 11 + server/internal/shared/objectstorage/minio.go | 46 ++++ .../internal/shared/swagger/descriptions.go | 4 +- .../tenant/app/desktop_task_service.go | 68 +++-- .../tenant/app/desktop_task_service_test.go | 58 +++++ 15 files changed, 884 insertions(+), 58 deletions(-) diff --git a/apps/desktop-client/package.json b/apps/desktop-client/package.json index 4b192c2..f13d0ea 100644 --- a/apps/desktop-client/package.json +++ b/apps/desktop-client/package.json @@ -1,6 +1,6 @@ { "name": "@geo/desktop-client", - "version": "0.1.1", + "version": "0.1.2", "private": true, "description": "省心推客户端 — 连接和管理您的媒体账号,轻松发布和监控内容表现。", "author": { diff --git a/apps/ops-web/package.json b/apps/ops-web/package.json index 18227c3..25a0d52 100644 --- a/apps/ops-web/package.json +++ b/apps/ops-web/package.json @@ -17,6 +17,7 @@ "ant-design-vue": "^4.2.6", "axios": "^1.7.9", "dayjs": "^1.11.20", + "hash-wasm": "^4.12.0", "pinia": "^3.0.4", "vue": "^3.5.31", "vue-router": "^4.5.1" diff --git a/apps/ops-web/src/lib/desktop-client-releases.ts b/apps/ops-web/src/lib/desktop-client-releases.ts index cfc6483..c32aae6 100644 --- a/apps/ops-web/src/lib/desktop-client-releases.ts +++ b/apps/ops-web/src/lib/desktop-client-releases.ts @@ -1,3 +1,6 @@ +import axios from 'axios' +import { createMD5, createSHA256 } from 'hash-wasm' + import { http } from '@/lib/http' export type DesktopReleasePlatform = 'darwin' | 'win32' | 'linux' @@ -80,17 +83,27 @@ const DESKTOP_CLIENT_RELEASE_CHUNK_TIMEOUT_MS = 5 * 60 * 1000 // complete 阶段服务端需要合并分片、计算 SHA256 并把整个安装包上传到 OSS,远超默认 30s const DESKTOP_CLIENT_RELEASE_COMPLETE_TIMEOUT_MS = 10 * 60 * 1000 const DESKTOP_CLIENT_RELEASE_CHUNK_SIZE_BYTES = 16 * 1024 * 1024 +const DESKTOP_CLIENT_RELEASE_HASH_CHUNK_SIZE_BYTES = 8 * 1024 * 1024 const DESKTOP_CLIENT_RELEASE_CHUNK_RETRIES = 2 export interface DesktopClientReleaseUploadProgress { loaded: number total?: number percent?: number - stage?: 'uploading' | 'complete' + stage?: 'hashing' | 'signing' | 'uploading' | 'verifying' | 'complete' chunkIndex?: number chunkCount?: number } +interface DesktopClientReleaseUploadTarget { + platform: string + arch: string + channel: string + version: string + kind?: 'installer' | 'updater' + onProgress?: (progress: DesktopClientReleaseUploadProgress) => void +} + interface DesktopClientReleaseUploadSession { upload_id: string chunk_size_bytes: number @@ -99,6 +112,23 @@ interface DesktopClientReleaseUploadSession { expires_at: string } +interface DesktopClientReleaseDirectUploadSession { + upload_url?: string + method: 'PUT' + headers: Record + oss_object_key: string + file_name: string + file_size_bytes: number + sha256: string + object_exists: boolean + expires_at: string +} + +interface DesktopClientReleaseFileHashes { + sha256: string + contentMD5: string +} + export const platformOptions = [ { label: 'macOS', value: 'darwin' }, { label: 'Windows', value: 'win32' }, @@ -143,27 +173,13 @@ export const desktopClientReleaseApi = { }, async uploadOSS( file: File, - target: { - platform: string - arch: string - channel: string - version: string - kind?: 'installer' | 'updater' - onProgress?: (progress: DesktopClientReleaseUploadProgress) => void - }, + target: DesktopClientReleaseUploadTarget, ) { - return uploadOSSInChunks(file, target) + return uploadOSSDirect(file, target) }, async uploadOSSLegacy( file: File, - target: { - platform: string - arch: string - channel: string - version: string - kind?: 'installer' | 'updater' - onProgress?: (progress: DesktopClientReleaseUploadProgress) => void - }, + target: DesktopClientReleaseUploadTarget, ) { const form = new FormData() form.append('file', file, file.name) @@ -200,16 +216,119 @@ export const desktopClientReleaseApi = { }, } +async function uploadOSSDirect( + file: File, + target: DesktopClientReleaseUploadTarget, +): Promise { + const hashes = await hashFile(file, (loaded) => { + target.onProgress?.({ + loaded, + total: file.size, + percent: file.size ? Math.min(99, Math.round((loaded / file.size) * 100)) : undefined, + stage: 'hashing', + }) + }) + + target.onProgress?.({ + loaded: file.size, + total: file.size, + percent: 99, + stage: 'signing', + }) + const session = await http.post( + '/desktop-client/releases/direct-upload', + { + platform: target.platform, + arch: target.arch, + channel: target.channel, + version: target.version, + kind: target.kind ?? 'installer', + file_name: file.name, + file_size_bytes: file.size, + sha256: hashes.sha256, + content_md5: hashes.contentMD5, + }, + ) + + if (!session.object_exists) { + if (!session.upload_url) { + throw new Error('missing_direct_upload_url') + } + await axios.put(session.upload_url, file, { + headers: session.headers, + timeout: DESKTOP_CLIENT_RELEASE_UPLOAD_TIMEOUT_MS, + onUploadProgress(event) { + const total = event.total ?? file.size + target.onProgress?.({ + loaded: event.loaded, + total, + percent: total ? Math.min(99, Math.round((event.loaded / total) * 100)) : undefined, + stage: 'uploading', + }) + }, + }) + } + + target.onProgress?.({ + loaded: file.size, + total: file.size, + percent: 100, + stage: 'verifying', + }) + const result = await http.post( + '/desktop-client/releases/direct-upload/complete', + { + platform: target.platform, + arch: target.arch, + channel: target.channel, + version: target.version, + kind: target.kind ?? 'installer', + oss_object_key: session.oss_object_key, + file_name: session.file_name, + file_size_bytes: session.file_size_bytes, + sha256: session.sha256, + }, + ) + target.onProgress?.({ + loaded: result.file_size_bytes, + total: result.file_size_bytes, + percent: 100, + stage: 'complete', + }) + return result +} + +async function hashFile( + file: File, + onProgress?: (loaded: number) => void, +): Promise { + const [sha256Hasher, md5Hasher] = await Promise.all([createSHA256(), createMD5()]) + let loaded = 0 + while (loaded < file.size) { + const end = Math.min(loaded + DESKTOP_CLIENT_RELEASE_HASH_CHUNK_SIZE_BYTES, file.size) + const chunk = new Uint8Array(await file.slice(loaded, end).arrayBuffer()) + sha256Hasher.update(chunk) + md5Hasher.update(chunk) + loaded = end + onProgress?.(loaded) + } + return { + sha256: sha256Hasher.digest('hex') as string, + contentMD5: uint8ArrayToBase64(md5Hasher.digest('binary') as Uint8Array), + } +} + +function uint8ArrayToBase64(value: Uint8Array): string { + let binary = '' + for (let index = 0; index < value.length; index += 1) { + binary += String.fromCharCode(value[index]) + } + return window.btoa(binary) +} + async function uploadOSSInChunks( file: File, - target: { - platform: string - arch: string - channel: string - version: string - kind?: 'installer' | 'updater' - onProgress?: (progress: DesktopClientReleaseUploadProgress) => void - }, + target: DesktopClientReleaseUploadTarget, ): Promise { const session = await http.post( '/desktop-client/release-uploads', diff --git a/apps/ops-web/src/views/DesktopClientReleasesView.vue b/apps/ops-web/src/views/DesktopClientReleasesView.vue index 5d003af..a75c2a6 100644 --- a/apps/ops-web/src/views/DesktopClientReleasesView.vue +++ b/apps/ops-web/src/views/DesktopClientReleasesView.vue @@ -200,7 +200,7 @@ :show-upload-list="false" accept=".dmg,.pkg,.exe,.msi,.zip,.AppImage,.appimage,.deb,.rpm,.tar.gz,.tgz" > - + {{ uploadButtonText('installer') }} @@ -266,7 +266,7 @@ :show-upload-list="false" accept=".zip,.exe,.AppImage,.appimage" > - + {{ uploadButtonText('updater') }} @@ -352,6 +352,7 @@ import { platformLabel, platformOptions, type DesktopClientRelease, + type DesktopClientReleaseUploadProgress, type DesktopReleaseArch, type DesktopReleasePlatform, type DesktopReleaseSource, @@ -400,6 +401,8 @@ const uploading = ref(false) const updaterUploading = ref(false) const uploadProgress = ref(null) const updaterUploadProgress = ref(null) +const uploadStage = ref(null) +const updaterUploadStage = ref(null) const enabledRows = computed(() => rows.value.filter((row) => row.enabled).length) const forceRows = computed(() => rows.value.filter((row) => row.force_update).length) @@ -569,9 +572,11 @@ async function beforeUploadReleasePackage(file: File, kind: 'installer' | 'updat if (kind === 'installer') { uploading.value = true uploadProgress.value = 0 + uploadStage.value = 'hashing' } else { updaterUploading.value = true updaterUploadProgress.value = 0 + updaterUploadStage.value = 'hashing' } try { const result = await desktopClientReleaseApi.uploadOSS(file, { @@ -584,8 +589,10 @@ async function beforeUploadReleasePackage(file: File, kind: 'installer' | 'updat if (typeof progress.percent !== 'number') return if (kind === 'installer') { uploadProgress.value = progress.percent + uploadStage.value = progress.stage ?? null } else { updaterUploadProgress.value = progress.percent + updaterUploadStage.value = progress.stage ?? null } }, }) @@ -611,6 +618,8 @@ async function beforeUploadReleasePackage(file: File, kind: 'installer' | 'updat updaterUploading.value = false uploadProgress.value = null updaterUploadProgress.value = null + uploadStage.value = null + updaterUploadStage.value = null } return false } @@ -618,20 +627,33 @@ async function beforeUploadReleasePackage(file: File, kind: 'installer' | 'updat function uploadButtonText(kind: 'installer' | 'updater') { const isUploading = kind === 'installer' ? uploading.value : updaterUploading.value const progress = kind === 'installer' ? uploadProgress.value : updaterUploadProgress.value + const stage = kind === 'installer' ? uploadStage.value : updaterUploadStage.value const hasUploaded = kind === 'installer' ? Boolean(form.oss_object_key) : Boolean(form.updater_oss_object_key) - if (isUploading && typeof progress === 'number' && progress < 100) { + if (isUploading && stage === 'hashing' && typeof progress === 'number') { + return `计算 SHA256 ${progress}%` + } + if (isUploading && stage === 'signing') { + return '获取直传地址...' + } + if (isUploading && stage === 'uploading' && typeof progress === 'number' && progress < 100) { return `上传中 ${progress}%` } - if (isUploading && progress === 100) { - return '校验并写入 OSS...' + if (isUploading && (stage === 'verifying' || progress === 100)) { + return '确认上传...' } if (isUploading) { - return '写入 OSS...' + return '准备上传...' } return hasUploaded ? '重新上传' : '选择文件并上传' } +function uploadButtonLoading(kind: 'installer' | 'updater') { + const isUploading = kind === 'installer' ? uploading.value : updaterUploading.value + const progress = kind === 'installer' ? uploadProgress.value : updaterUploadProgress.value + return isUploading && progress !== 100 +} + function beforeUploadInstallerPackage(file: File) { return beforeUploadReleasePackage(file, 'installer') } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d75a4c3..bae640b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -284,6 +284,9 @@ importers: dayjs: specifier: ^1.11.20 version: 1.11.20 + hash-wasm: + specifier: ^4.12.0 + version: 4.12.0 pinia: specifier: ^3.0.4 version: 3.0.4(typescript@5.9.3)(vue@3.5.31(typescript@5.9.3)) @@ -3054,6 +3057,9 @@ packages: has-unicode@2.0.1: resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==} + hash-wasm@4.12.0: + resolution: {integrity: sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==} + hash.js@1.1.7: resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==} @@ -8626,6 +8632,8 @@ snapshots: has-unicode@2.0.1: {} + hash-wasm@4.12.0: {} + hash.js@1.1.7: dependencies: inherits: 2.0.4 diff --git a/server/internal/ops/app/desktop_client_release.go b/server/internal/ops/app/desktop_client_release.go index 6770185..daf6031 100644 --- a/server/internal/ops/app/desktop_client_release.go +++ b/server/internal/ops/app/desktop_client_release.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "crypto/sha256" + "encoding/base64" "encoding/hex" "encoding/json" "errors" @@ -54,7 +55,9 @@ const ( DefaultDesktopClientReleaseChunkSizeBytes = 16 * 1024 * 1024 MinDesktopClientReleaseChunkSizeBytes = 1 * 1024 * 1024 MaxDesktopClientReleaseChunkSizeBytes = 64 * 1024 * 1024 + desktopClientReleaseContentMD5Bytes = 16 desktopClientReleaseUploadSessionTTL = 24 * time.Hour + desktopClientReleaseDirectUploadTTL = 2 * time.Hour ) type DesktopClientReleaseService struct { @@ -231,6 +234,42 @@ type DesktopClientReleaseChunkUploadResult struct { UploadedChunks []int `json:"uploaded_chunks"` } +type DesktopClientReleaseDirectUploadInitInput struct { + Platform string + Arch string + Channel string + Version string + Kind string + FileName string + FileSizeBytes int64 + SHA256 string + ContentMD5 string +} + +type DesktopClientReleaseDirectUploadInitResult struct { + UploadURL string `json:"upload_url,omitempty"` + Method string `json:"method"` + Headers map[string]string `json:"headers"` + OSSObjectKey string `json:"oss_object_key"` + FileName string `json:"file_name"` + FileSizeBytes int64 `json:"file_size_bytes"` + SHA256 string `json:"sha256"` + ObjectExists bool `json:"object_exists"` + ExpiresAt time.Time `json:"expires_at"` +} + +type DesktopClientReleaseDirectUploadCompleteInput struct { + Platform string + Arch string + Channel string + Version string + Kind string + OSSObjectKey string + FileName string + FileSizeBytes int64 + SHA256 string +} + type desktopClientReleaseUploadManifest struct { UploadID string `json:"upload_id"` Platform string `json:"platform"` @@ -529,6 +568,171 @@ func (s *DesktopClientReleaseService) UploadOSS(ctx context.Context, in DesktopC }, nil } +func (s *DesktopClientReleaseService) InitiateDirectUpload( + ctx context.Context, + in DesktopClientReleaseDirectUploadInitInput, +) (*DesktopClientReleaseDirectUploadInitResult, error) { + if s.storage == nil { + return nil, response.ErrInternal(50091, "object_storage_unavailable", "object storage is unavailable") + } + if err := s.storage.Validate(); err != nil { + return nil, response.ErrInternal(50091, "object_storage_unavailable", "object storage is unavailable") + } + + uploadInput := DesktopClientReleaseUploadInput{ + Platform: in.Platform, + Arch: in.Arch, + Channel: in.Channel, + Version: in.Version, + Kind: in.Kind, + FileName: in.FileName, + ContentSizeBytes: in.FileSizeBytes, + } + target, fileName, err := normalizeDesktopClientReleaseUploadInput(uploadInput) + if err != nil { + return nil, err + } + sha256Hex, err := normalizeRequiredDesktopReleaseSHA256(in.SHA256) + if err != nil { + return nil, err + } + kind := normalizeDesktopClientAssetKind(in.Kind) + if kind == DesktopClientAssetKindUpdater { + if err := validateDesktopClientUpdaterPackage(target.Platform, &fileName, ""); err != nil { + return nil, err + } + } + contentMD5, err := normalizeDesktopReleaseContentMD5(in.ContentMD5) + if err != nil { + return nil, err + } + + objectKey := buildDesktopClientReleaseObjectKey(sha256Hex, fileName) + exists, err := s.storage.Exists(ctx, objectKey) + if err != nil { + appErr := response.ErrInternal(50092, "desktop_client_release_upload_failed", err.Error()) + appErr.Cause = err + return nil, appErr + } + if exists { + return &DesktopClientReleaseDirectUploadInitResult{ + Method: http.MethodPut, + Headers: map[string]string{}, + OSSObjectKey: objectKey, + FileName: fileName, + FileSizeBytes: in.FileSizeBytes, + SHA256: sha256Hex, + ObjectExists: true, + ExpiresAt: time.Now().UTC(), + }, nil + } + + presigner, ok := s.storage.(objectstorage.PresignedPutClient) + if !ok { + return nil, response.ErrInternal(50091, "object_storage_direct_upload_unsupported", "object storage direct upload is unsupported") + } + presigned, err := presigner.PresignedPutURL( + ctx, + objectKey, + detectDesktopReleaseContentType(fileName, nil), + contentMD5, + desktopClientReleaseDirectUploadTTL, + ) + if err != nil { + appErr := response.ErrInternal(50092, "desktop_client_release_upload_failed", err.Error()) + appErr.Cause = err + return nil, appErr + } + return &DesktopClientReleaseDirectUploadInitResult{ + UploadURL: presigned.URL, + Method: http.MethodPut, + Headers: flattenHTTPHeaders(presigned.Headers), + OSSObjectKey: objectKey, + FileName: fileName, + FileSizeBytes: in.FileSizeBytes, + SHA256: sha256Hex, + ObjectExists: false, + ExpiresAt: presigned.ExpiresAt, + }, nil +} + +func (s *DesktopClientReleaseService) CompleteDirectUpload( + ctx context.Context, + in DesktopClientReleaseDirectUploadCompleteInput, +) (*DesktopClientReleaseUploadResult, error) { + if s.storage == nil { + return nil, response.ErrInternal(50091, "object_storage_unavailable", "object storage is unavailable") + } + if err := s.storage.Validate(); err != nil { + return nil, response.ErrInternal(50091, "object_storage_unavailable", "object storage is unavailable") + } + + uploadInput := DesktopClientReleaseUploadInput{ + Platform: in.Platform, + Arch: in.Arch, + Channel: in.Channel, + Version: in.Version, + Kind: in.Kind, + FileName: in.FileName, + ContentSizeBytes: in.FileSizeBytes, + } + target, fileName, err := normalizeDesktopClientReleaseUploadInput(uploadInput) + if err != nil { + return nil, err + } + sha256Hex, err := normalizeRequiredDesktopReleaseSHA256(in.SHA256) + if err != nil { + return nil, err + } + kind := normalizeDesktopClientAssetKind(in.Kind) + if kind == DesktopClientAssetKindUpdater { + if err := validateDesktopClientUpdaterPackage(target.Platform, &fileName, ""); err != nil { + return nil, err + } + } + + objectKey := strings.TrimSpace(in.OSSObjectKey) + expectedObjectKey := buildDesktopClientReleaseObjectKey(sha256Hex, fileName) + if objectKey == "" { + objectKey = expectedObjectKey + } + if objectKey != expectedObjectKey || !isValidDesktopReleaseObjectKey(objectKey) { + return nil, response.ErrBadRequest(40066, "invalid_oss_object_key", "OSS Object Key 与上传文件校验信息不匹配") + } + + if management, ok := s.storage.(objectstorage.ManagementClient); ok { + info, err := management.Stat(ctx, objectKey) + if err != nil { + if errors.Is(err, objectstorage.ErrObjectNotFound) { + return nil, response.ErrBadRequest(40078, "direct_upload_not_found", "OSS 直传文件尚未完成") + } + appErr := response.ErrInternal(50092, "desktop_client_release_upload_failed", err.Error()) + appErr.Cause = err + return nil, appErr + } + if info.Size != in.FileSizeBytes { + return nil, response.ErrBadRequest(40079, "direct_upload_size_mismatch", "OSS 直传文件大小不匹配,请重新上传") + } + } else { + exists, err := s.storage.Exists(ctx, objectKey) + if err != nil { + appErr := response.ErrInternal(50092, "desktop_client_release_upload_failed", err.Error()) + appErr.Cause = err + return nil, appErr + } + if !exists { + return nil, response.ErrBadRequest(40078, "direct_upload_not_found", "OSS 直传文件尚未完成") + } + } + + return &DesktopClientReleaseUploadResult{ + OSSObjectKey: objectKey, + FileName: fileName, + FileSizeBytes: in.FileSizeBytes, + SHA256: sha256Hex, + }, nil +} + func MaxDesktopClientReleaseMultipartBytes() int64 { return MaxDesktopClientReleaseUploadBytes + desktopClientReleaseMultipartOverheadBytes } @@ -1399,6 +1603,29 @@ func normalizeDesktopReleaseSHA256(value *string) (*string, error) { return &normalized, nil } +func normalizeRequiredDesktopReleaseSHA256(value string) (string, error) { + sha256, err := normalizeDesktopReleaseSHA256(&value) + if err != nil { + return "", err + } + if sha256 == nil { + return "", response.ErrBadRequest(40070, "invalid_sha256", "SHA256 必须是 64 位十六进制") + } + return *sha256, nil +} + +func normalizeDesktopReleaseContentMD5(value string) (string, error) { + normalized := strings.TrimSpace(value) + if normalized == "" { + return "", nil + } + content, err := base64.StdEncoding.DecodeString(normalized) + if err != nil || len(content) != desktopClientReleaseContentMD5Bytes { + return "", response.ErrBadRequest(40080, "invalid_content_md5", "Content-MD5 必须是标准 base64 编码的 16 字节 MD5") + } + return normalized, nil +} + func normalizeDesktopClientReleaseUploadInput(in DesktopClientReleaseUploadInput) (repository.DesktopClientReleaseTarget, string, error) { size, err := desktopClientReleaseUploadSize(in) if err != nil { @@ -1520,6 +1747,25 @@ func putDesktopClientReleaseReader( return storage.PutBytes(ctx, objectKey, content, contentType) } +func flattenHTTPHeaders(headers http.Header) map[string]string { + if len(headers) == 0 { + return map[string]string{} + } + out := make(map[string]string, len(headers)) + for key, values := range headers { + key = strings.TrimSpace(key) + if key == "" || len(values) == 0 { + continue + } + value := strings.TrimSpace(values[0]) + if value == "" { + continue + } + out[key] = value + } + return out +} + func defaultDesktopClientReleaseUploadDir() string { return filepath.Join(os.TempDir(), "geo-rankly-desktop-release-uploads") } diff --git a/server/internal/ops/app/desktop_client_release_test.go b/server/internal/ops/app/desktop_client_release_test.go index f5c8f68..c355355 100644 --- a/server/internal/ops/app/desktop_client_release_test.go +++ b/server/internal/ops/app/desktop_client_release_test.go @@ -7,11 +7,13 @@ import ( "encoding/hex" "errors" "io" + "net/http" "os" "testing" "time" "github.com/geo-platform/tenant-api/internal/ops/domain" + "github.com/geo-platform/tenant-api/internal/shared/objectstorage" "github.com/stretchr/testify/require" ) @@ -311,11 +313,92 @@ func TestDesktopClientReleaseChunkedUploadCompletesOutOfOrder(t *testing.T) { require.True(t, errors.Is(err, os.ErrNotExist)) } +func TestDesktopClientReleaseDirectUploadUsesPresignedPutURL(t *testing.T) { + t.Parallel() + + content := []byte("release-package") + sum := sha256.Sum256(content) + wantSHA := hex.EncodeToString(sum[:]) + storage := &desktopReleaseDownloadURLStorage{ + existsByKey: map[string]bool{}, + objects: map[string][]byte{}, + objectSizes: map[string]int64{}, + } + svc := NewDesktopClientReleaseService(nil, storage, nil) + + initResult, err := svc.InitiateDirectUpload(context.Background(), DesktopClientReleaseDirectUploadInitInput{ + Platform: "darwin", + Arch: "arm64", + Channel: "stable", + Version: "1.2.3", + Kind: DesktopClientAssetKindInstaller, + FileName: "client.dmg", + FileSizeBytes: int64(len(content)), + SHA256: wantSHA, + ContentMD5: "pt74NOtqjIwBJtPUYvJ8AA==", + }) + require.NoError(t, err) + require.False(t, initResult.ObjectExists) + require.Equal(t, http.MethodPut, initResult.Method) + require.Equal(t, "desktop-client/releases/blobs/"+wantSHA+".dmg", initResult.OSSObjectKey) + require.Equal(t, "https://oss.example.test/upload/"+initResult.OSSObjectKey, initResult.UploadURL) + require.Equal(t, "application/x-apple-diskimage", initResult.Headers["Content-Type"]) + require.Equal(t, "pt74NOtqjIwBJtPUYvJ8AA==", initResult.Headers["Content-Md5"]) + require.Len(t, storage.presignedPutCalls, 1) + require.Equal(t, initResult.OSSObjectKey, storage.presignedPutCalls[0]) + + storage.existsByKey[initResult.OSSObjectKey] = true + storage.objectSizes[initResult.OSSObjectKey] = int64(len(content)) + completeResult, err := svc.CompleteDirectUpload(context.Background(), DesktopClientReleaseDirectUploadCompleteInput{ + Platform: "darwin", + Arch: "arm64", + Channel: "stable", + Version: "1.2.3", + Kind: DesktopClientAssetKindInstaller, + OSSObjectKey: initResult.OSSObjectKey, + FileName: initResult.FileName, + FileSizeBytes: initResult.FileSizeBytes, + SHA256: initResult.SHA256, + }) + require.NoError(t, err) + require.Equal(t, initResult.OSSObjectKey, completeResult.OSSObjectKey) + require.Equal(t, wantSHA, completeResult.SHA256) +} + +func TestDesktopClientReleaseDirectUploadRejectsSizeMismatch(t *testing.T) { + t.Parallel() + + sum := sha256.Sum256([]byte("release-package")) + sha256Hex := hex.EncodeToString(sum[:]) + objectKey := buildDesktopClientReleaseObjectKey(sha256Hex, "client.zip") + storage := &desktopReleaseDownloadURLStorage{ + existsByKey: map[string]bool{objectKey: true}, + objectSizes: map[string]int64{objectKey: 10}, + } + svc := NewDesktopClientReleaseService(nil, storage, nil) + + _, err := svc.CompleteDirectUpload(context.Background(), DesktopClientReleaseDirectUploadCompleteInput{ + Platform: "darwin", + Arch: "arm64", + Channel: "stable", + Version: "1.2.3", + Kind: DesktopClientAssetKindUpdater, + OSSObjectKey: objectKey, + FileName: "client.zip", + FileSizeBytes: 20, + SHA256: sha256Hex, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "direct_upload_size_mismatch") +} + type desktopReleaseDownloadURLStorage struct { - publicURLCalls []string - downloadURLCalls []string - existsByKey map[string]bool - objects map[string][]byte + publicURLCalls []string + downloadURLCalls []string + existsByKey map[string]bool + objects map[string][]byte + objectSizes map[string]int64 + presignedPutCalls []string } func (s *desktopReleaseDownloadURLStorage) Validate() error { @@ -335,6 +418,10 @@ func (s *desktopReleaseDownloadURLStorage) PutReader(_ context.Context, objectKe return err } s.objects[objectKey] = content + if s.objectSizes == nil { + s.objectSizes = map[string]int64{} + } + s.objectSizes[objectKey] = int64(len(content)) if s.existsByKey == nil { s.existsByKey = map[string]bool{} } @@ -353,6 +440,26 @@ func (s *desktopReleaseDownloadURLStorage) Exists(_ context.Context, objectKey s return true, nil } +func (s *desktopReleaseDownloadURLStorage) PresignedPutURL( + _ context.Context, + objectKey string, + contentType string, + contentMD5Base64 string, + ttl time.Duration, +) (*objectstorage.PresignedPutResult, error) { + s.presignedPutCalls = append(s.presignedPutCalls, objectKey) + headers := http.Header{} + headers.Set("Content-Type", contentType) + if contentMD5Base64 != "" { + headers.Set("Content-MD5", contentMD5Base64) + } + return &objectstorage.PresignedPutResult{ + URL: "https://oss.example.test/upload/" + objectKey, + Headers: headers, + ExpiresAt: time.Now().UTC().Add(ttl), + }, nil +} + func (s *desktopReleaseDownloadURLStorage) Delete(context.Context, string) error { return nil } @@ -366,3 +473,25 @@ func (s *desktopReleaseDownloadURLStorage) DownloadURL(objectKey string) (string s.downloadURLCalls = append(s.downloadURLCalls, objectKey) return "https://oss.example.test/signed/client.zip?Expires=123", nil } + +func (s *desktopReleaseDownloadURLStorage) List(context.Context, objectstorage.ListInput) (*objectstorage.ListResult, error) { + return &objectstorage.ListResult{}, nil +} + +func (s *desktopReleaseDownloadURLStorage) Stat(_ context.Context, objectKey string) (*objectstorage.ObjectInfo, error) { + exists, _ := s.Exists(context.Background(), objectKey) + if !exists { + return nil, objectstorage.ErrObjectNotFound + } + if s.objectSizes != nil { + return &objectstorage.ObjectInfo{Key: objectKey, Size: s.objectSizes[objectKey], LastModified: time.Now()}, nil + } + if s.objects != nil { + return &objectstorage.ObjectInfo{Key: objectKey, Size: int64(len(s.objects[objectKey])), LastModified: time.Now()}, nil + } + return &objectstorage.ObjectInfo{Key: objectKey, LastModified: time.Now()}, nil +} + +func (s *desktopReleaseDownloadURLStorage) Copy(context.Context, string, string) error { + return nil +} diff --git a/server/internal/ops/transport/desktop_client_release_handler.go b/server/internal/ops/transport/desktop_client_release_handler.go index 7684aa4..4bbd7cc 100644 --- a/server/internal/ops/transport/desktop_client_release_handler.go +++ b/server/internal/ops/transport/desktop_client_release_handler.go @@ -50,6 +50,30 @@ type desktopClientReleaseUploadInitRequest struct { ChunkSizeBytes int64 `json:"chunk_size_bytes"` } +type desktopClientReleaseDirectUploadInitRequest struct { + Platform string `json:"platform" binding:"required"` + Arch string `json:"arch"` + Channel string `json:"channel"` + Version string `json:"version" binding:"required"` + Kind string `json:"kind"` + FileName string `json:"file_name" binding:"required"` + FileSizeBytes int64 `json:"file_size_bytes" binding:"required"` + SHA256 string `json:"sha256" binding:"required"` + ContentMD5 string `json:"content_md5"` +} + +type desktopClientReleaseDirectUploadCompleteRequest struct { + Platform string `json:"platform" binding:"required"` + Arch string `json:"arch"` + Channel string `json:"channel"` + Version string `json:"version" binding:"required"` + Kind string `json:"kind"` + OSSObjectKey string `json:"oss_object_key" binding:"required"` + FileName string `json:"file_name" binding:"required"` + FileSizeBytes int64 `json:"file_size_bytes" binding:"required"` + SHA256 string `json:"sha256" binding:"required"` +} + func listDesktopClientReleasesHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc { return func(c *gin.Context) { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) @@ -208,6 +232,58 @@ func uploadDesktopClientReleaseHandler(svc *app.DesktopClientReleaseService) gin } } +func initiateDesktopClientReleaseDirectUploadHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc { + return func(c *gin.Context) { + var body desktopClientReleaseDirectUploadInitRequest + if err := c.ShouldBindJSON(&body); err != nil { + response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error())) + return + } + result, err := svc.InitiateDirectUpload(c.Request.Context(), app.DesktopClientReleaseDirectUploadInitInput{ + Platform: body.Platform, + Arch: body.Arch, + Channel: body.Channel, + Version: body.Version, + Kind: body.Kind, + FileName: body.FileName, + FileSizeBytes: body.FileSizeBytes, + SHA256: body.SHA256, + ContentMD5: body.ContentMD5, + }) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, result) + } +} + +func completeDesktopClientReleaseDirectUploadHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc { + return func(c *gin.Context) { + var body desktopClientReleaseDirectUploadCompleteRequest + if err := c.ShouldBindJSON(&body); err != nil { + response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error())) + return + } + result, err := svc.CompleteDirectUpload(c.Request.Context(), app.DesktopClientReleaseDirectUploadCompleteInput{ + Platform: body.Platform, + Arch: body.Arch, + Channel: body.Channel, + Version: body.Version, + Kind: body.Kind, + OSSObjectKey: body.OSSObjectKey, + FileName: body.FileName, + FileSizeBytes: body.FileSizeBytes, + SHA256: body.SHA256, + }) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, result) + } +} + func initiateDesktopClientReleaseUploadHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc { return func(c *gin.Context) { var body desktopClientReleaseUploadInitRequest diff --git a/server/internal/ops/transport/router.go b/server/internal/ops/transport/router.go index 96169ed..aeb0966 100644 --- a/server/internal/ops/transport/router.go +++ b/server/internal/ops/transport/router.go @@ -173,6 +173,8 @@ func RegisterRoutes(d Deps) { authed.GET("/desktop-client/releases", listDesktopClientReleasesHandler(d.Releases)) authed.POST("/desktop-client/releases/upload", uploadDesktopClientReleaseHandler(d.Releases)) + authed.POST("/desktop-client/releases/direct-upload", initiateDesktopClientReleaseDirectUploadHandler(d.Releases)) + authed.POST("/desktop-client/releases/direct-upload/complete", completeDesktopClientReleaseDirectUploadHandler(d.Releases)) authed.POST("/desktop-client/release-uploads", initiateDesktopClientReleaseUploadHandler(d.Releases)) authed.POST("/desktop-client/release-uploads/:upload_id/chunks/:chunk_index", uploadDesktopClientReleaseChunkHandler(d.Releases)) authed.POST("/desktop-client/release-uploads/:upload_id/complete", completeDesktopClientReleaseUploadHandler(d.Releases)) diff --git a/server/internal/shared/objectstorage/aliyun.go b/server/internal/shared/objectstorage/aliyun.go index 2adaddd..6456d17 100644 --- a/server/internal/shared/objectstorage/aliyun.go +++ b/server/internal/shared/objectstorage/aliyun.go @@ -154,19 +154,41 @@ func (c *aliyunClient) bucketExists(ctx context.Context) (bool, error) { } func aliyunPutObjectOptions(cfg config.ObjectStorageConfig, contentType string) ([]oss.Option, error) { + options, _, err := aliyunPutObjectOptionsAndHeaders(cfg, contentType, "") + return options, err +} + +func aliyunPutObjectOptionsAndHeaders( + cfg config.ObjectStorageConfig, + contentType string, + contentMD5Base64 string, +) ([]oss.Option, http.Header, error) { + contentType = strings.TrimSpace(contentType) + if contentType == "" { + contentType = "application/octet-stream" + } options := []oss.Option{oss.ContentType(contentType)} + headers := http.Header{} + headers.Set(oss.HTTPHeaderContentType, contentType) + if contentMD5Base64 = strings.TrimSpace(contentMD5Base64); contentMD5Base64 != "" { + options = append(options, oss.ContentMD5(contentMD5Base64)) + headers.Set(oss.HTTPHeaderContentMD5, contentMD5Base64) + } acl := strings.ToLower(strings.TrimSpace(cfg.ObjectACL)) switch acl { case "", "default": - return options, nil + return options, headers, nil case "private": - return append(options, oss.ObjectACL(oss.ACLPrivate)), nil + headers.Set(oss.HTTPHeaderOssObjectACL, string(oss.ACLPrivate)) + return append(options, oss.ObjectACL(oss.ACLPrivate)), headers, nil case "public-read": - return append(options, oss.ObjectACL(oss.ACLPublicRead)), nil + headers.Set(oss.HTTPHeaderOssObjectACL, string(oss.ACLPublicRead)) + return append(options, oss.ObjectACL(oss.ACLPublicRead)), headers, nil case "public-read-write": - return append(options, oss.ObjectACL(oss.ACLPublicReadWrite)), nil + headers.Set(oss.HTTPHeaderOssObjectACL, string(oss.ACLPublicReadWrite)) + return append(options, oss.ObjectACL(oss.ACLPublicReadWrite)), headers, nil default: - return nil, fmt.Errorf("unsupported aliyun oss object_acl %q", cfg.ObjectACL) + return nil, nil, fmt.Errorf("unsupported aliyun oss object_acl %q", cfg.ObjectACL) } } @@ -341,6 +363,52 @@ func (c *aliyunClient) DownloadURL(objectKey string) (string, error) { return c.signedURL(objectKey, oss.ResponseContentDisposition(downloadContentDisposition(objectKey))) } +func (c *aliyunClient) PresignedPutURL( + ctx context.Context, + objectKey string, + contentType string, + contentMD5Base64 string, + ttl time.Duration, +) (*PresignedPutResult, error) { + if err := c.Validate(); err != nil { + return nil, err + } + objectKey = normalizeObjectKeyPath(objectKey) + if objectKey == "" { + return nil, fmt.Errorf("object key is required") + } + if ttl <= 0 { + ttl = c.cfg.SignedURLTTL + } + if ttl <= 0 { + ttl = 30 * time.Minute + } + if err := ctx.Err(); err != nil { + return nil, err + } + + bucket, err := c.client.Bucket(c.bucket) + if err != nil { + return nil, fmt.Errorf("get aliyun bucket: %w", err) + } + options, headers, err := aliyunPutObjectOptionsAndHeaders(c.cfg, contentType, contentMD5Base64) + if err != nil { + return nil, err + } + signedURL, err := bucket.SignURL(objectKey, oss.HTTPPut, int64(ttl.Seconds()), options...) + if err != nil { + return nil, fmt.Errorf("presign aliyun put url: %w", err) + } + if err := ctx.Err(); err != nil { + return nil, err + } + return &PresignedPutResult{ + URL: signedURL, + Headers: headers, + ExpiresAt: time.Now().UTC().Add(ttl), + }, nil +} + func (c *aliyunClient) Usage(ctx context.Context) (*UsageInfo, error) { if err := c.Validate(); err != nil { return nil, err diff --git a/server/internal/shared/objectstorage/client.go b/server/internal/shared/objectstorage/client.go index 7417496..eb48b8d 100644 --- a/server/internal/shared/objectstorage/client.go +++ b/server/internal/shared/objectstorage/client.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "net/http" "strings" "time" @@ -32,6 +33,16 @@ type ReaderClient interface { PutReader(ctx context.Context, objectKey string, reader io.Reader, size int64, contentType string) error } +type PresignedPutResult struct { + URL string + Headers http.Header + ExpiresAt time.Time +} + +type PresignedPutClient interface { + PresignedPutURL(ctx context.Context, objectKey string, contentType string, contentMD5Base64 string, ttl time.Duration) (*PresignedPutResult, error) +} + type ManagementClient interface { Client List(ctx context.Context, in ListInput) (*ListResult, error) diff --git a/server/internal/shared/objectstorage/minio.go b/server/internal/shared/objectstorage/minio.go index 35c0e1f..2e5a5be 100644 --- a/server/internal/shared/objectstorage/minio.go +++ b/server/internal/shared/objectstorage/minio.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "io" + "net/http" "net/url" "strings" "time" @@ -290,6 +291,51 @@ func (c *minioClient) DownloadURL(objectKey string) (string, error) { return u.String(), nil } +func (c *minioClient) PresignedPutURL( + ctx context.Context, + objectKey string, + contentType string, + contentMD5Base64 string, + ttl time.Duration, +) (*PresignedPutResult, error) { + if err := c.Validate(); err != nil { + return nil, err + } + objectKey = normalizeObjectKeyPath(objectKey) + if objectKey == "" { + return nil, fmt.Errorf("object key is required") + } + if ttl <= 0 { + ttl = c.cfg.SignedURLTTL + } + if ttl <= 0 { + ttl = 30 * time.Minute + } + + headers := http.Header{} + if contentType = strings.TrimSpace(contentType); contentType != "" { + headers.Set("Content-Type", contentType) + } + if contentMD5Base64 = strings.TrimSpace(contentMD5Base64); contentMD5Base64 != "" { + headers.Set("Content-MD5", contentMD5Base64) + } + var u *url.URL + var err error + if len(headers) > 0 { + u, err = c.client.PresignHeader(ctx, http.MethodPut, c.bucket, objectKey, ttl, nil, headers) + } else { + u, err = c.client.PresignedPutObject(ctx, c.bucket, objectKey, ttl) + } + if err != nil { + return nil, fmt.Errorf("presign minio put url: %w", err) + } + return &PresignedPutResult{ + URL: u.String(), + Headers: headers, + ExpiresAt: time.Now().UTC().Add(ttl), + }, nil +} + func (c *minioClient) List(ctx context.Context, in ListInput) (*ListResult, error) { if err := c.Validate(); err != nil { return nil, err diff --git a/server/internal/shared/swagger/descriptions.go b/server/internal/shared/swagger/descriptions.go index 89318a4..79da067 100644 --- a/server/internal/shared/swagger/descriptions.go +++ b/server/internal/shared/swagger/descriptions.go @@ -100,7 +100,9 @@ var routeDocs = map[string]routeDoc{ // --- Ops:桌面客户端版本 --- "GET /api/ops/desktop-client/releases": {"桌面客户端版本列表", "分页查询桌面客户端版本配置。"}, - "POST /api/ops/desktop-client/releases/upload": {"上传桌面客户端包(兼容)", "兼容旧版单请求 Multipart 上传;新版前端使用分片上传。"}, + "POST /api/ops/desktop-client/releases/upload": {"上传桌面客户端包(兼容)", "兼容旧版单请求 Multipart 上传。"}, + "POST /api/ops/desktop-client/releases/direct-upload": {"初始化客户端包 OSS 直传", "校验文件信息并生成浏览器直传 OSS 的短期 PUT 签名 URL。"}, + "POST /api/ops/desktop-client/releases/direct-upload/complete": {"完成客户端包 OSS 直传", "校验直传对象是否存在、大小是否匹配,并返回 Object Key、文件大小和 SHA256。"}, "POST /api/ops/desktop-client/release-uploads": {"初始化客户端包分片上传", "创建分片上传会话,返回 upload_id、分片大小和分片数量。"}, "POST /api/ops/desktop-client/release-uploads/:upload_id/chunks/:chunk_index": {"上传客户端包分片", "Multipart 上传单个客户端安装包或更新包分片。"}, "POST /api/ops/desktop-client/release-uploads/:upload_id/complete": {"完成客户端包分片上传", "校验全部分片,流式写入 OSS,并返回 Object Key、文件大小和 SHA256。"}, diff --git a/server/internal/tenant/app/desktop_task_service.go b/server/internal/tenant/app/desktop_task_service.go index b2cc652..28f2bd9 100644 --- a/server/internal/tenant/app/desktop_task_service.go +++ b/server/internal/tenant/app/desktop_task_service.go @@ -153,6 +153,23 @@ type publishTaskListOwner struct { UserID int64 } +const publishTaskVisibleRecordFilterSQL = ` + AND ( + NOT (t.payload ? 'publish_record_id') + OR EXISTS ( + SELECT 1 + FROM publish_records pr + WHERE pr.tenant_id = t.tenant_id + AND pr.id = CASE + WHEN (t.payload->>'publish_record_id') ~ '^[0-9]+$' + THEN (t.payload->>'publish_record_id')::bigint + ELSE NULL + END + AND pr.deleted_at IS NULL + ) + ) +` + func (s *DesktopTaskService) Lease(ctx context.Context, client *repository.DesktopClient, req LeaseDesktopTaskRequest, routeTaskID *uuid.UUID) (*LeaseDesktopTaskResponse, error) { if client == nil { return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required") @@ -1225,6 +1242,24 @@ func (s *DesktopTaskService) listPublishTasksByStatuses( offset int, orderBy string, ) ([]DesktopTaskView, error) { + query, args := buildListPublishTasksByStatusesQuery(owner, statuses, title, limit, offset, orderBy) + rows, err := s.pool.Query(ctx, query, args...) + if err != nil { + return nil, response.ErrInternal(50109, "desktop_publish_tasks_query_failed", "failed to list desktop publish tasks") + } + defer rows.Close() + + return scanDesktopTaskRows(rows) +} + +func buildListPublishTasksByStatusesQuery( + owner publishTaskListOwner, + statuses []string, + title string, + limit int, + offset int, + orderBy string, +) (string, []any) { query := ` SELECT t.desktop_id, @@ -1263,7 +1298,7 @@ func (s *DesktopTaskService) listPublishTasksByStatuses( $5 = '' OR COALESCE(t.payload->>'title', j.title, '') ILIKE '%' || $5 || '%' ) - ` + ` + publishTaskVisibleRecordFilterSQL args := []any{owner.TenantID, owner.WorkspaceID, owner.UserID, statuses, title} if strings.TrimSpace(orderBy) != "" { @@ -1273,14 +1308,7 @@ func (s *DesktopTaskService) listPublishTasksByStatuses( query += "\nLIMIT $6 OFFSET $7" args = append(args, limit, offset) } - - rows, err := s.pool.Query(ctx, query, args...) - if err != nil { - return nil, response.ErrInternal(50109, "desktop_publish_tasks_query_failed", "failed to list desktop publish tasks") - } - defer rows.Close() - - return scanDesktopTaskRows(rows) + return query, args } func (s *DesktopTaskService) countPublishTasksByStatuses( @@ -1290,7 +1318,20 @@ func (s *DesktopTaskService) countPublishTasksByStatuses( title string, ) (int, error) { var count int - err := s.pool.QueryRow(ctx, ` + query, args := buildCountPublishTasksByStatusesQuery(owner, statuses, title) + err := s.pool.QueryRow(ctx, query, args...).Scan(&count) + if err != nil { + return 0, response.ErrInternal(50109, "desktop_publish_tasks_query_failed", "failed to list desktop publish tasks") + } + return count, nil +} + +func buildCountPublishTasksByStatusesQuery( + owner publishTaskListOwner, + statuses []string, + title string, +) (string, []any) { + query := ` SELECT COUNT(1) FROM desktop_tasks AS t JOIN desktop_publish_jobs AS j @@ -1306,11 +1347,8 @@ func (s *DesktopTaskService) countPublishTasksByStatuses( $5 = '' OR COALESCE(t.payload->>'title', j.title, '') ILIKE '%' || $5 || '%' ) - `, owner.TenantID, owner.WorkspaceID, owner.UserID, statuses, title).Scan(&count) - if err != nil { - return 0, response.ErrInternal(50109, "desktop_publish_tasks_query_failed", "failed to list desktop publish tasks") - } - return count, nil + ` + publishTaskVisibleRecordFilterSQL + return query, []any{owner.TenantID, owner.WorkspaceID, owner.UserID, statuses, title} } func scanDesktopTaskRows(rows pgx.Rows) ([]DesktopTaskView, error) { diff --git a/server/internal/tenant/app/desktop_task_service_test.go b/server/internal/tenant/app/desktop_task_service_test.go index 5c52edf..ef19f67 100644 --- a/server/internal/tenant/app/desktop_task_service_test.go +++ b/server/internal/tenant/app/desktop_task_service_test.go @@ -149,6 +149,60 @@ func TestIsComplianceInvalidArticleVersionError(t *testing.T) { } } +func TestBuildListPublishTasksByStatusesQueryHidesDeletedPublishRecords(t *testing.T) { + t.Parallel() + + query, args := buildListPublishTasksByStatusesQuery( + publishTaskListOwner{TenantID: 7, WorkspaceID: 11, UserID: 23}, + []string{"succeeded", "failed"}, + "合肥", + 10, + 20, + "t.updated_at DESC", + ) + normalized := normalizeSQLForDesktopTaskTest(query) + + for _, fragment := range []string{ + "NOT (t.payload ? 'publish_record_id') OR EXISTS", + "FROM publish_records pr", + "pr.tenant_id = t.tenant_id", + "t.payload->>'publish_record_id')::bigint", + "pr.deleted_at IS NULL", + "LIMIT $6 OFFSET $7", + } { + if !strings.Contains(normalized, fragment) { + t.Fatalf("list publish tasks query missing %q: %s", fragment, normalized) + } + } + if len(args) != 7 { + t.Fatalf("args len = %d, want 7", len(args)) + } +} + +func TestBuildCountPublishTasksByStatusesQueryHidesDeletedPublishRecords(t *testing.T) { + t.Parallel() + + query, args := buildCountPublishTasksByStatusesQuery( + publishTaskListOwner{TenantID: 7, WorkspaceID: 11, UserID: 23}, + []string{"succeeded", "failed"}, + "", + ) + normalized := normalizeSQLForDesktopTaskTest(query) + + for _, fragment := range []string{ + "NOT (t.payload ? 'publish_record_id') OR EXISTS", + "FROM publish_records pr", + "pr.deleted_at IS NULL", + } { + if !strings.Contains(normalized, fragment) { + t.Fatalf("count publish tasks query missing %q: %s", fragment, normalized) + } + } + if len(args) != 5 { + t.Fatalf("args len = %d, want 5", len(args)) + } +} + func recoverDesktopTaskSelectColumns(query string) []string { re := regexp.MustCompile(`(?is)select\s+(.*?)\s+from\s+desktop_tasks`) match := re.FindStringSubmatch(query) @@ -165,3 +219,7 @@ func recoverDesktopTaskSelectColumns(query string) []string { } return columns } + +func normalizeSQLForDesktopTaskTest(sql string) string { + return strings.Join(strings.Fields(sql), " ") +}