feat: add chunked desktop client release uploads
Deployment Config CI / Deployment Config (push) Successful in 28s
Frontend CI / Frontend (push) Successful in 2m12s
Backend CI / Backend (push) Successful in 14m44s

This commit is contained in:
2026-06-01 01:31:54 +08:00
parent c5da0cf507
commit 76df672133
14 changed files with 1195 additions and 57 deletions
+183 -1
View File
@@ -75,7 +75,27 @@ export interface DesktopClientReleaseDownloadURLResult {
expires_in?: number | null
}
const DESKTOP_CLIENT_RELEASE_UPLOAD_TIMEOUT_MS = 15 * 60 * 1000
const DESKTOP_CLIENT_RELEASE_UPLOAD_TIMEOUT_MS = 30 * 60 * 1000
const DESKTOP_CLIENT_RELEASE_CHUNK_TIMEOUT_MS = 5 * 60 * 1000
const DESKTOP_CLIENT_RELEASE_CHUNK_SIZE_BYTES = 16 * 1024 * 1024
const DESKTOP_CLIENT_RELEASE_CHUNK_RETRIES = 2
export interface DesktopClientReleaseUploadProgress {
loaded: number
total?: number
percent?: number
stage?: 'uploading' | 'complete'
chunkIndex?: number
chunkCount?: number
}
interface DesktopClientReleaseUploadSession {
upload_id: string
chunk_size_bytes: number
chunk_count: number
uploaded_chunks: number[]
expires_at: string
}
export const platformOptions = [
{ label: 'macOS', value: 'darwin' },
@@ -127,6 +147,20 @@ export const desktopClientReleaseApi = {
channel: string
version: string
kind?: 'installer' | 'updater'
onProgress?: (progress: DesktopClientReleaseUploadProgress) => void
},
) {
return uploadOSSInChunks(file, target)
},
async uploadOSSLegacy(
file: File,
target: {
platform: string
arch: string
channel: string
version: string
kind?: 'installer' | 'updater'
onProgress?: (progress: DesktopClientReleaseUploadProgress) => void
},
) {
const form = new FormData()
@@ -142,6 +176,15 @@ export const desktopClientReleaseApi = {
data: DesktopClientReleaseUploadResult
}>('/desktop-client/releases/upload', form, {
timeout: DESKTOP_CLIENT_RELEASE_UPLOAD_TIMEOUT_MS,
onUploadProgress(event) {
const total = event.total
target.onProgress?.({
loaded: event.loaded,
total,
percent: total ? Math.min(100, Math.round((event.loaded / total) * 100)) : undefined,
stage: 'uploading',
})
},
})
return response.data.data
},
@@ -155,6 +198,145 @@ export const desktopClientReleaseApi = {
},
}
async function uploadOSSInChunks(
file: File,
target: {
platform: string
arch: string
channel: string
version: string
kind?: 'installer' | 'updater'
onProgress?: (progress: DesktopClientReleaseUploadProgress) => void
},
): Promise<DesktopClientReleaseUploadResult> {
const session = await http.post<DesktopClientReleaseUploadSession>('/desktop-client/release-uploads', {
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,
chunk_size_bytes: DESKTOP_CLIENT_RELEASE_CHUNK_SIZE_BYTES,
})
const chunkSize = session.chunk_size_bytes || DESKTOP_CLIENT_RELEASE_CHUNK_SIZE_BYTES
const chunkCount = session.chunk_count || Math.ceil(file.size / chunkSize)
const uploaded = new Set(session.uploaded_chunks ?? [])
let confirmedBytes = 0
for (const index of uploaded) {
confirmedBytes += chunkByteSize(file.size, chunkSize, index, chunkCount)
}
emitChunkProgress(target, confirmedBytes, file.size, chunkCount)
try {
for (let index = 0; index < chunkCount; index += 1) {
if (uploaded.has(index)) continue
await uploadChunkWithRetry(file, session.upload_id, index, chunkSize, chunkCount, confirmedBytes, target)
confirmedBytes += chunkByteSize(file.size, chunkSize, index, chunkCount)
emitChunkProgress(target, confirmedBytes, file.size, chunkCount, index)
}
target.onProgress?.({
loaded: file.size,
total: file.size,
percent: 100,
stage: 'complete',
chunkCount,
})
return await http.post<DesktopClientReleaseUploadResult>(
`/desktop-client/release-uploads/${session.upload_id}/complete`,
)
} catch (error) {
void http.delete(`/desktop-client/release-uploads/${session.upload_id}`).catch(() => undefined)
throw error
}
}
async function uploadChunkWithRetry(
file: File,
uploadID: string,
index: number,
chunkSize: number,
chunkCount: number,
confirmedBytes: number,
target: {
onProgress?: (progress: DesktopClientReleaseUploadProgress) => void
},
) {
let lastError: unknown
for (let attempt = 0; attempt <= DESKTOP_CLIENT_RELEASE_CHUNK_RETRIES; attempt += 1) {
try {
await uploadChunk(file, uploadID, index, chunkSize, chunkCount, confirmedBytes, target)
return
} catch (error) {
lastError = error
if (attempt === DESKTOP_CLIENT_RELEASE_CHUNK_RETRIES) break
await delay(400 * (attempt + 1))
}
}
throw lastError
}
async function uploadChunk(
file: File,
uploadID: string,
index: number,
chunkSize: number,
chunkCount: number,
confirmedBytes: number,
target: {
onProgress?: (progress: DesktopClientReleaseUploadProgress) => void
},
) {
const start = index * chunkSize
const end = Math.min(start + chunkSize, file.size)
const chunk = file.slice(start, end)
const form = new FormData()
form.append('file', chunk, file.name)
await http.raw.post(`/desktop-client/release-uploads/${uploadID}/chunks/${index}`, form, {
timeout: DESKTOP_CLIENT_RELEASE_CHUNK_TIMEOUT_MS,
onUploadProgress(event) {
const loaded = confirmedBytes + Math.min(event.loaded, chunk.size)
target.onProgress?.({
loaded,
total: file.size,
percent: file.size ? Math.min(99, Math.round((loaded / file.size) * 100)) : undefined,
stage: 'uploading',
chunkIndex: index,
chunkCount,
})
},
})
}
function emitChunkProgress(
target: { onProgress?: (progress: DesktopClientReleaseUploadProgress) => void },
loaded: number,
total: number,
chunkCount: number,
chunkIndex?: number,
) {
target.onProgress?.({
loaded,
total,
percent: total ? Math.min(99, Math.round((loaded / total) * 100)) : undefined,
stage: 'uploading',
chunkIndex,
chunkCount,
})
}
function chunkByteSize(fileSize: number, chunkSize: number, index: number, chunkCount: number) {
if (index < 0 || index >= chunkCount) return 0
const start = index * chunkSize
return Math.max(0, Math.min(chunkSize, fileSize - start))
}
function delay(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms))
}
export function platformLabel(value: string): string {
return platformOptions.find((item) => item.value === value)?.label ?? value
}
@@ -202,7 +202,7 @@
>
<a-button :loading="uploading">
<template #icon><UploadOutlined /></template>
{{ form.oss_object_key ? '重新上传' : '选择文件并上传' }}
{{ uploadButtonText('installer') }}
</a-button>
</a-upload>
<div v-if="form.file_name" class="upload-result">
@@ -268,7 +268,7 @@
>
<a-button :loading="updaterUploading">
<template #icon><UploadOutlined /></template>
{{ form.updater_oss_object_key ? '重新上传' : '选择文件并上传' }}
{{ uploadButtonText('updater') }}
</a-button>
</a-upload>
<div v-if="form.updater_file_name" class="upload-result">
@@ -400,6 +400,8 @@ const total = ref(0)
const statusLoadingId = ref<number | null>(null)
const uploading = ref(false)
const updaterUploading = ref(false)
const uploadProgress = ref<number | null>(null)
const updaterUploadProgress = ref<number | null>(null)
const enabledRows = computed(() => rows.value.filter((row) => row.enabled).length)
const forceRows = computed(() => rows.value.filter((row) => row.force_update).length)
@@ -568,8 +570,10 @@ async function beforeUploadReleasePackage(file: File, kind: 'installer' | 'updat
}
if (kind === 'installer') {
uploading.value = true
uploadProgress.value = 0
} else {
updaterUploading.value = true
updaterUploadProgress.value = 0
}
try {
const result = await desktopClientReleaseApi.uploadOSS(file, {
@@ -578,6 +582,14 @@ async function beforeUploadReleasePackage(file: File, kind: 'installer' | 'updat
channel: form.channel.trim() || 'stable',
version: form.version.trim(),
kind,
onProgress(progress) {
if (typeof progress.percent !== 'number') return
if (kind === 'installer') {
uploadProgress.value = progress.percent
} else {
updaterUploadProgress.value = progress.percent
}
},
})
if (kind === 'installer') {
form.oss_object_key = result.oss_object_key
@@ -599,10 +611,25 @@ async function beforeUploadReleasePackage(file: File, kind: 'installer' | 'updat
} finally {
uploading.value = false
updaterUploading.value = false
uploadProgress.value = null
updaterUploadProgress.value = null
}
return false
}
function uploadButtonText(kind: 'installer' | 'updater') {
const isUploading = kind === 'installer' ? uploading.value : updaterUploading.value
const progress = kind === 'installer' ? uploadProgress.value : updaterUploadProgress.value
const hasUploaded = kind === 'installer' ? Boolean(form.oss_object_key) : Boolean(form.updater_oss_object_key)
if (isUploading && typeof progress === 'number' && progress < 100) {
return `上传中 ${progress}%`
}
if (isUploading) {
return '写入 OSS...'
}
return hasUploaded ? '重新上传' : '选择文件并上传'
}
function beforeUploadInstallerPackage(file: File) {
return beforeUploadReleasePackage(file, 'installer')
}