feat: Implement direct upload functionality for desktop client releases
Desktop Client Build / Resolve Build Metadata (push) Successful in 52s
Frontend CI / Frontend (push) Successful in 3m49s
Backend CI / Backend (push) Successful in 16m10s
Desktop Client Build / Build Desktop Client (push) Successful in 23m22s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 31s

- 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.
This commit is contained in:
2026-06-20 12:44:28 +08:00
parent f26802c76e
commit 62d0b22988
15 changed files with 884 additions and 58 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@geo/desktop-client", "name": "@geo/desktop-client",
"version": "0.1.1", "version": "0.1.2",
"private": true, "private": true,
"description": "省心推客户端 — 连接和管理您的媒体账号,轻松发布和监控内容表现。", "description": "省心推客户端 — 连接和管理您的媒体账号,轻松发布和监控内容表现。",
"author": { "author": {
+1
View File
@@ -17,6 +17,7 @@
"ant-design-vue": "^4.2.6", "ant-design-vue": "^4.2.6",
"axios": "^1.7.9", "axios": "^1.7.9",
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"hash-wasm": "^4.12.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"vue": "^3.5.31", "vue": "^3.5.31",
"vue-router": "^4.5.1" "vue-router": "^4.5.1"
+145 -26
View File
@@ -1,3 +1,6 @@
import axios from 'axios'
import { createMD5, createSHA256 } from 'hash-wasm'
import { http } from '@/lib/http' import { http } from '@/lib/http'
export type DesktopReleasePlatform = 'darwin' | 'win32' | 'linux' export type DesktopReleasePlatform = 'darwin' | 'win32' | 'linux'
@@ -80,17 +83,27 @@ const DESKTOP_CLIENT_RELEASE_CHUNK_TIMEOUT_MS = 5 * 60 * 1000
// complete 阶段服务端需要合并分片、计算 SHA256 并把整个安装包上传到 OSS,远超默认 30s // complete 阶段服务端需要合并分片、计算 SHA256 并把整个安装包上传到 OSS,远超默认 30s
const DESKTOP_CLIENT_RELEASE_COMPLETE_TIMEOUT_MS = 10 * 60 * 1000 const DESKTOP_CLIENT_RELEASE_COMPLETE_TIMEOUT_MS = 10 * 60 * 1000
const DESKTOP_CLIENT_RELEASE_CHUNK_SIZE_BYTES = 16 * 1024 * 1024 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 const DESKTOP_CLIENT_RELEASE_CHUNK_RETRIES = 2
export interface DesktopClientReleaseUploadProgress { export interface DesktopClientReleaseUploadProgress {
loaded: number loaded: number
total?: number total?: number
percent?: number percent?: number
stage?: 'uploading' | 'complete' stage?: 'hashing' | 'signing' | 'uploading' | 'verifying' | 'complete'
chunkIndex?: number chunkIndex?: number
chunkCount?: number chunkCount?: number
} }
interface DesktopClientReleaseUploadTarget {
platform: string
arch: string
channel: string
version: string
kind?: 'installer' | 'updater'
onProgress?: (progress: DesktopClientReleaseUploadProgress) => void
}
interface DesktopClientReleaseUploadSession { interface DesktopClientReleaseUploadSession {
upload_id: string upload_id: string
chunk_size_bytes: number chunk_size_bytes: number
@@ -99,6 +112,23 @@ interface DesktopClientReleaseUploadSession {
expires_at: string expires_at: string
} }
interface DesktopClientReleaseDirectUploadSession {
upload_url?: string
method: 'PUT'
headers: Record<string, string>
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 = [ export const platformOptions = [
{ label: 'macOS', value: 'darwin' }, { label: 'macOS', value: 'darwin' },
{ label: 'Windows', value: 'win32' }, { label: 'Windows', value: 'win32' },
@@ -143,27 +173,13 @@ export const desktopClientReleaseApi = {
}, },
async uploadOSS( async uploadOSS(
file: File, file: File,
target: { target: DesktopClientReleaseUploadTarget,
platform: string
arch: string
channel: string
version: string
kind?: 'installer' | 'updater'
onProgress?: (progress: DesktopClientReleaseUploadProgress) => void
},
) { ) {
return uploadOSSInChunks(file, target) return uploadOSSDirect(file, target)
}, },
async uploadOSSLegacy( async uploadOSSLegacy(
file: File, file: File,
target: { target: DesktopClientReleaseUploadTarget,
platform: string
arch: string
channel: string
version: string
kind?: 'installer' | 'updater'
onProgress?: (progress: DesktopClientReleaseUploadProgress) => void
},
) { ) {
const form = new FormData() const form = new FormData()
form.append('file', file, file.name) form.append('file', file, file.name)
@@ -200,16 +216,119 @@ export const desktopClientReleaseApi = {
}, },
} }
async function uploadOSSDirect(
file: File,
target: DesktopClientReleaseUploadTarget,
): Promise<DesktopClientReleaseUploadResult> {
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<DesktopClientReleaseDirectUploadSession>(
'/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<DesktopClientReleaseUploadResult>(
'/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<DesktopClientReleaseFileHashes> {
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( async function uploadOSSInChunks(
file: File, file: File,
target: { target: DesktopClientReleaseUploadTarget,
platform: string
arch: string
channel: string
version: string
kind?: 'installer' | 'updater'
onProgress?: (progress: DesktopClientReleaseUploadProgress) => void
},
): Promise<DesktopClientReleaseUploadResult> { ): Promise<DesktopClientReleaseUploadResult> {
const session = await http.post<DesktopClientReleaseUploadSession>( const session = await http.post<DesktopClientReleaseUploadSession>(
'/desktop-client/release-uploads', '/desktop-client/release-uploads',
@@ -200,7 +200,7 @@
:show-upload-list="false" :show-upload-list="false"
accept=".dmg,.pkg,.exe,.msi,.zip,.AppImage,.appimage,.deb,.rpm,.tar.gz,.tgz" accept=".dmg,.pkg,.exe,.msi,.zip,.AppImage,.appimage,.deb,.rpm,.tar.gz,.tgz"
> >
<a-button :loading="uploading"> <a-button :loading="uploadButtonLoading('installer')" :disabled="uploading">
<template #icon><UploadOutlined /></template> <template #icon><UploadOutlined /></template>
{{ uploadButtonText('installer') }} {{ uploadButtonText('installer') }}
</a-button> </a-button>
@@ -266,7 +266,7 @@
:show-upload-list="false" :show-upload-list="false"
accept=".zip,.exe,.AppImage,.appimage" accept=".zip,.exe,.AppImage,.appimage"
> >
<a-button :loading="updaterUploading"> <a-button :loading="uploadButtonLoading('updater')" :disabled="updaterUploading">
<template #icon><UploadOutlined /></template> <template #icon><UploadOutlined /></template>
{{ uploadButtonText('updater') }} {{ uploadButtonText('updater') }}
</a-button> </a-button>
@@ -352,6 +352,7 @@ import {
platformLabel, platformLabel,
platformOptions, platformOptions,
type DesktopClientRelease, type DesktopClientRelease,
type DesktopClientReleaseUploadProgress,
type DesktopReleaseArch, type DesktopReleaseArch,
type DesktopReleasePlatform, type DesktopReleasePlatform,
type DesktopReleaseSource, type DesktopReleaseSource,
@@ -400,6 +401,8 @@ const uploading = ref(false)
const updaterUploading = ref(false) const updaterUploading = ref(false)
const uploadProgress = ref<number | null>(null) const uploadProgress = ref<number | null>(null)
const updaterUploadProgress = ref<number | null>(null) const updaterUploadProgress = ref<number | null>(null)
const uploadStage = ref<DesktopClientReleaseUploadProgress['stage'] | null>(null)
const updaterUploadStage = ref<DesktopClientReleaseUploadProgress['stage'] | null>(null)
const enabledRows = computed(() => rows.value.filter((row) => row.enabled).length) const enabledRows = computed(() => rows.value.filter((row) => row.enabled).length)
const forceRows = computed(() => rows.value.filter((row) => row.force_update).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') { if (kind === 'installer') {
uploading.value = true uploading.value = true
uploadProgress.value = 0 uploadProgress.value = 0
uploadStage.value = 'hashing'
} else { } else {
updaterUploading.value = true updaterUploading.value = true
updaterUploadProgress.value = 0 updaterUploadProgress.value = 0
updaterUploadStage.value = 'hashing'
} }
try { try {
const result = await desktopClientReleaseApi.uploadOSS(file, { 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 (typeof progress.percent !== 'number') return
if (kind === 'installer') { if (kind === 'installer') {
uploadProgress.value = progress.percent uploadProgress.value = progress.percent
uploadStage.value = progress.stage ?? null
} else { } else {
updaterUploadProgress.value = progress.percent updaterUploadProgress.value = progress.percent
updaterUploadStage.value = progress.stage ?? null
} }
}, },
}) })
@@ -611,6 +618,8 @@ async function beforeUploadReleasePackage(file: File, kind: 'installer' | 'updat
updaterUploading.value = false updaterUploading.value = false
uploadProgress.value = null uploadProgress.value = null
updaterUploadProgress.value = null updaterUploadProgress.value = null
uploadStage.value = null
updaterUploadStage.value = null
} }
return false return false
} }
@@ -618,20 +627,33 @@ async function beforeUploadReleasePackage(file: File, kind: 'installer' | 'updat
function uploadButtonText(kind: 'installer' | 'updater') { function uploadButtonText(kind: 'installer' | 'updater') {
const isUploading = kind === 'installer' ? uploading.value : updaterUploading.value const isUploading = kind === 'installer' ? uploading.value : updaterUploading.value
const progress = kind === 'installer' ? uploadProgress.value : updaterUploadProgress.value const progress = kind === 'installer' ? uploadProgress.value : updaterUploadProgress.value
const stage = kind === 'installer' ? uploadStage.value : updaterUploadStage.value
const hasUploaded = const hasUploaded =
kind === 'installer' ? Boolean(form.oss_object_key) : Boolean(form.updater_oss_object_key) 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}%` return `上传中 ${progress}%`
} }
if (isUploading && progress === 100) { if (isUploading && (stage === 'verifying' || progress === 100)) {
return '校验并写入 OSS...' return '确认上传...'
} }
if (isUploading) { if (isUploading) {
return '写入 OSS...' return '准备上传...'
} }
return hasUploaded ? '重新上传' : '选择文件并上传' 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) { function beforeUploadInstallerPackage(file: File) {
return beforeUploadReleasePackage(file, 'installer') return beforeUploadReleasePackage(file, 'installer')
} }
+8
View File
@@ -284,6 +284,9 @@ importers:
dayjs: dayjs:
specifier: ^1.11.20 specifier: ^1.11.20
version: 1.11.20 version: 1.11.20
hash-wasm:
specifier: ^4.12.0
version: 4.12.0
pinia: pinia:
specifier: ^3.0.4 specifier: ^3.0.4
version: 3.0.4(typescript@5.9.3)(vue@3.5.31(typescript@5.9.3)) 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: has-unicode@2.0.1:
resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==} resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==}
hash-wasm@4.12.0:
resolution: {integrity: sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==}
hash.js@1.1.7: hash.js@1.1.7:
resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==} resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==}
@@ -8626,6 +8632,8 @@ snapshots:
has-unicode@2.0.1: {} has-unicode@2.0.1: {}
hash-wasm@4.12.0: {}
hash.js@1.1.7: hash.js@1.1.7:
dependencies: dependencies:
inherits: 2.0.4 inherits: 2.0.4
@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/base64"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -54,7 +55,9 @@ const (
DefaultDesktopClientReleaseChunkSizeBytes = 16 * 1024 * 1024 DefaultDesktopClientReleaseChunkSizeBytes = 16 * 1024 * 1024
MinDesktopClientReleaseChunkSizeBytes = 1 * 1024 * 1024 MinDesktopClientReleaseChunkSizeBytes = 1 * 1024 * 1024
MaxDesktopClientReleaseChunkSizeBytes = 64 * 1024 * 1024 MaxDesktopClientReleaseChunkSizeBytes = 64 * 1024 * 1024
desktopClientReleaseContentMD5Bytes = 16
desktopClientReleaseUploadSessionTTL = 24 * time.Hour desktopClientReleaseUploadSessionTTL = 24 * time.Hour
desktopClientReleaseDirectUploadTTL = 2 * time.Hour
) )
type DesktopClientReleaseService struct { type DesktopClientReleaseService struct {
@@ -231,6 +234,42 @@ type DesktopClientReleaseChunkUploadResult struct {
UploadedChunks []int `json:"uploaded_chunks"` 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 { type desktopClientReleaseUploadManifest struct {
UploadID string `json:"upload_id"` UploadID string `json:"upload_id"`
Platform string `json:"platform"` Platform string `json:"platform"`
@@ -529,6 +568,171 @@ func (s *DesktopClientReleaseService) UploadOSS(ctx context.Context, in DesktopC
}, nil }, 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 { func MaxDesktopClientReleaseMultipartBytes() int64 {
return MaxDesktopClientReleaseUploadBytes + desktopClientReleaseMultipartOverheadBytes return MaxDesktopClientReleaseUploadBytes + desktopClientReleaseMultipartOverheadBytes
} }
@@ -1399,6 +1603,29 @@ func normalizeDesktopReleaseSHA256(value *string) (*string, error) {
return &normalized, nil 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) { func normalizeDesktopClientReleaseUploadInput(in DesktopClientReleaseUploadInput) (repository.DesktopClientReleaseTarget, string, error) {
size, err := desktopClientReleaseUploadSize(in) size, err := desktopClientReleaseUploadSize(in)
if err != nil { if err != nil {
@@ -1520,6 +1747,25 @@ func putDesktopClientReleaseReader(
return storage.PutBytes(ctx, objectKey, content, contentType) 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 { func defaultDesktopClientReleaseUploadDir() string {
return filepath.Join(os.TempDir(), "geo-rankly-desktop-release-uploads") return filepath.Join(os.TempDir(), "geo-rankly-desktop-release-uploads")
} }
@@ -7,11 +7,13 @@ import (
"encoding/hex" "encoding/hex"
"errors" "errors"
"io" "io"
"net/http"
"os" "os"
"testing" "testing"
"time" "time"
"github.com/geo-platform/tenant-api/internal/ops/domain" "github.com/geo-platform/tenant-api/internal/ops/domain"
"github.com/geo-platform/tenant-api/internal/shared/objectstorage"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -311,11 +313,92 @@ func TestDesktopClientReleaseChunkedUploadCompletesOutOfOrder(t *testing.T) {
require.True(t, errors.Is(err, os.ErrNotExist)) 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 { type desktopReleaseDownloadURLStorage struct {
publicURLCalls []string publicURLCalls []string
downloadURLCalls []string downloadURLCalls []string
existsByKey map[string]bool existsByKey map[string]bool
objects map[string][]byte objects map[string][]byte
objectSizes map[string]int64
presignedPutCalls []string
} }
func (s *desktopReleaseDownloadURLStorage) Validate() error { func (s *desktopReleaseDownloadURLStorage) Validate() error {
@@ -335,6 +418,10 @@ func (s *desktopReleaseDownloadURLStorage) PutReader(_ context.Context, objectKe
return err return err
} }
s.objects[objectKey] = content s.objects[objectKey] = content
if s.objectSizes == nil {
s.objectSizes = map[string]int64{}
}
s.objectSizes[objectKey] = int64(len(content))
if s.existsByKey == nil { if s.existsByKey == nil {
s.existsByKey = map[string]bool{} s.existsByKey = map[string]bool{}
} }
@@ -353,6 +440,26 @@ func (s *desktopReleaseDownloadURLStorage) Exists(_ context.Context, objectKey s
return true, nil 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 { func (s *desktopReleaseDownloadURLStorage) Delete(context.Context, string) error {
return nil return nil
} }
@@ -366,3 +473,25 @@ func (s *desktopReleaseDownloadURLStorage) DownloadURL(objectKey string) (string
s.downloadURLCalls = append(s.downloadURLCalls, objectKey) s.downloadURLCalls = append(s.downloadURLCalls, objectKey)
return "https://oss.example.test/signed/client.zip?Expires=123", nil 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
}
@@ -50,6 +50,30 @@ type desktopClientReleaseUploadInitRequest struct {
ChunkSizeBytes int64 `json:"chunk_size_bytes"` 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 { func listDesktopClientReleasesHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) 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 { func initiateDesktopClientReleaseUploadHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
var body desktopClientReleaseUploadInitRequest var body desktopClientReleaseUploadInitRequest
+2
View File
@@ -173,6 +173,8 @@ func RegisterRoutes(d Deps) {
authed.GET("/desktop-client/releases", listDesktopClientReleasesHandler(d.Releases)) authed.GET("/desktop-client/releases", listDesktopClientReleasesHandler(d.Releases))
authed.POST("/desktop-client/releases/upload", uploadDesktopClientReleaseHandler(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", 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/chunks/:chunk_index", uploadDesktopClientReleaseChunkHandler(d.Releases))
authed.POST("/desktop-client/release-uploads/:upload_id/complete", completeDesktopClientReleaseUploadHandler(d.Releases)) authed.POST("/desktop-client/release-uploads/:upload_id/complete", completeDesktopClientReleaseUploadHandler(d.Releases))
+73 -5
View File
@@ -154,19 +154,41 @@ func (c *aliyunClient) bucketExists(ctx context.Context) (bool, error) {
} }
func aliyunPutObjectOptions(cfg config.ObjectStorageConfig, contentType string) ([]oss.Option, 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)} 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)) acl := strings.ToLower(strings.TrimSpace(cfg.ObjectACL))
switch acl { switch acl {
case "", "default": case "", "default":
return options, nil return options, headers, nil
case "private": 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": 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": 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: 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))) 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) { func (c *aliyunClient) Usage(ctx context.Context) (*UsageInfo, error) {
if err := c.Validate(); err != nil { if err := c.Validate(); err != nil {
return nil, err return nil, err
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net/http"
"strings" "strings"
"time" "time"
@@ -32,6 +33,16 @@ type ReaderClient interface {
PutReader(ctx context.Context, objectKey string, reader io.Reader, size int64, contentType string) error 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 { type ManagementClient interface {
Client Client
List(ctx context.Context, in ListInput) (*ListResult, error) List(ctx context.Context, in ListInput) (*ListResult, error)
@@ -5,6 +5,7 @@ import (
"context" "context"
"fmt" "fmt"
"io" "io"
"net/http"
"net/url" "net/url"
"strings" "strings"
"time" "time"
@@ -290,6 +291,51 @@ func (c *minioClient) DownloadURL(objectKey string) (string, error) {
return u.String(), nil 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) { func (c *minioClient) List(ctx context.Context, in ListInput) (*ListResult, error) {
if err := c.Validate(); err != nil { if err := c.Validate(); err != nil {
return nil, err return nil, err
@@ -100,7 +100,9 @@ var routeDocs = map[string]routeDoc{
// --- Ops:桌面客户端版本 --- // --- Ops:桌面客户端版本 ---
"GET /api/ops/desktop-client/releases": {"桌面客户端版本列表", "分页查询桌面客户端版本配置。"}, "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、分片大小和分片数量。"},
"POST /api/ops/desktop-client/release-uploads/:upload_id/chunks/:chunk_index": {"上传客户端包分片", "Multipart 上传单个客户端安装包或更新包分片。"}, "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。"}, "POST /api/ops/desktop-client/release-uploads/:upload_id/complete": {"完成客户端包分片上传", "校验全部分片,流式写入 OSS,并返回 Object Key、文件大小和 SHA256。"},
@@ -153,6 +153,23 @@ type publishTaskListOwner struct {
UserID int64 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) { func (s *DesktopTaskService) Lease(ctx context.Context, client *repository.DesktopClient, req LeaseDesktopTaskRequest, routeTaskID *uuid.UUID) (*LeaseDesktopTaskResponse, error) {
if client == nil { if client == nil {
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required") return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
@@ -1225,6 +1242,24 @@ func (s *DesktopTaskService) listPublishTasksByStatuses(
offset int, offset int,
orderBy string, orderBy string,
) ([]DesktopTaskView, error) { ) ([]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 := ` query := `
SELECT SELECT
t.desktop_id, t.desktop_id,
@@ -1263,7 +1298,7 @@ func (s *DesktopTaskService) listPublishTasksByStatuses(
$5 = '' $5 = ''
OR COALESCE(t.payload->>'title', j.title, '') ILIKE '%' || $5 || '%' OR COALESCE(t.payload->>'title', j.title, '') ILIKE '%' || $5 || '%'
) )
` ` + publishTaskVisibleRecordFilterSQL
args := []any{owner.TenantID, owner.WorkspaceID, owner.UserID, statuses, title} args := []any{owner.TenantID, owner.WorkspaceID, owner.UserID, statuses, title}
if strings.TrimSpace(orderBy) != "" { if strings.TrimSpace(orderBy) != "" {
@@ -1273,14 +1308,7 @@ func (s *DesktopTaskService) listPublishTasksByStatuses(
query += "\nLIMIT $6 OFFSET $7" query += "\nLIMIT $6 OFFSET $7"
args = append(args, limit, offset) args = append(args, limit, offset)
} }
return query, args
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 (s *DesktopTaskService) countPublishTasksByStatuses( func (s *DesktopTaskService) countPublishTasksByStatuses(
@@ -1290,7 +1318,20 @@ func (s *DesktopTaskService) countPublishTasksByStatuses(
title string, title string,
) (int, error) { ) (int, error) {
var count int 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) SELECT COUNT(1)
FROM desktop_tasks AS t FROM desktop_tasks AS t
JOIN desktop_publish_jobs AS j JOIN desktop_publish_jobs AS j
@@ -1306,11 +1347,8 @@ func (s *DesktopTaskService) countPublishTasksByStatuses(
$5 = '' $5 = ''
OR COALESCE(t.payload->>'title', j.title, '') ILIKE '%' || $5 || '%' OR COALESCE(t.payload->>'title', j.title, '') ILIKE '%' || $5 || '%'
) )
`, owner.TenantID, owner.WorkspaceID, owner.UserID, statuses, title).Scan(&count) ` + publishTaskVisibleRecordFilterSQL
if err != nil { return query, []any{owner.TenantID, owner.WorkspaceID, owner.UserID, statuses, title}
return 0, response.ErrInternal(50109, "desktop_publish_tasks_query_failed", "failed to list desktop publish tasks")
}
return count, nil
} }
func scanDesktopTaskRows(rows pgx.Rows) ([]DesktopTaskView, error) { func scanDesktopTaskRows(rows pgx.Rows) ([]DesktopTaskView, error) {
@@ -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 { func recoverDesktopTaskSelectColumns(query string) []string {
re := regexp.MustCompile(`(?is)select\s+(.*?)\s+from\s+desktop_tasks`) re := regexp.MustCompile(`(?is)select\s+(.*?)\s+from\s+desktop_tasks`)
match := re.FindStringSubmatch(query) match := re.FindStringSubmatch(query)
@@ -165,3 +219,7 @@ func recoverDesktopTaskSelectColumns(query string) []string {
} }
return columns return columns
} }
func normalizeSQLForDesktopTaskTest(sql string) string {
return strings.Join(strings.Fields(sql), " ")
}