diff --git a/apps/ops-web/src/lib/desktop-client-releases.ts b/apps/ops-web/src/lib/desktop-client-releases.ts index 318743f..22acd4e 100644 --- a/apps/ops-web/src/lib/desktop-client-releases.ts +++ b/apps/ops-web/src/lib/desktop-client-releases.ts @@ -91,6 +91,7 @@ export interface DesktopClientReleaseUploadProgress { total?: number percent?: number stage?: 'hashing' | 'signing' | 'uploading' | 'verifying' | 'complete' + message?: string chunkIndex?: number chunkCount?: number } @@ -128,6 +129,19 @@ interface DesktopClientReleaseFileHashes { sha256: string } +interface DesktopClientReleaseCompleteJob { + job_id: string + status: 'running' | 'succeeded' | 'failed' + stage: string + message: string + percent: number + result?: DesktopClientReleaseUploadResult + error_code?: string + error?: string + created_at: string + updated_at: string +} + export const platformOptions = [ { label: 'macOS', value: 'darwin' }, { label: 'Windows', value: 'win32' }, @@ -298,14 +312,8 @@ async function completeDirectUpload( session: DesktopClientReleaseDirectUploadSession, target: DesktopClientReleaseUploadTarget, ): Promise { - target.onProgress?.({ - loaded: session.file_size_bytes, - total: session.file_size_bytes, - percent: 100, - stage: 'verifying', - }) - const result = await http.post( - '/desktop-client/releases/direct-upload/complete', + const job = await http.post( + '/desktop-client/releases/direct-upload/complete-jobs', { platform: target.platform, arch: target.arch, @@ -318,13 +326,7 @@ async function completeDirectUpload( sha256: session.sha256, }, ) - target.onProgress?.({ - loaded: result.file_size_bytes, - total: result.file_size_bytes, - percent: 100, - stage: 'complete', - }) - return result + return pollCompleteJob(job.job_id, session.file_size_bytes, target) } function isDirectUploadNotFoundError(error: unknown): boolean { @@ -343,6 +345,45 @@ function shouldFallbackToChunkedUpload(error: unknown): boolean { return error instanceof Error && error.message === 'missing_direct_upload_url' } +async function pollCompleteJob( + jobID: string, + totalBytes: number, + target: DesktopClientReleaseUploadTarget, + chunkCount?: number, +): Promise { + for (;;) { + const job = await http.get( + `/desktop-client/release-upload-complete-jobs/${jobID}`, + ) + target.onProgress?.({ + loaded: totalBytes, + total: totalBytes, + percent: Math.min(99, Math.max(1, job.percent)), + stage: 'verifying', + message: job.message, + chunkCount, + }) + if (job.status === 'succeeded' && job.result) { + target.onProgress?.({ + loaded: job.result.file_size_bytes, + total: job.result.file_size_bytes, + percent: 100, + stage: 'complete', + message: job.message || '上传完成', + chunkCount, + }) + return job.result + } + if (job.status === 'failed') { + throw new OpsApiError({ + message: job.error_code || 'desktop_client_release_upload_failed', + detail: job.error || job.message || '上传确认失败', + }) + } + await delay(1000) + } +} + async function hashFile( file: File, onProgress?: (loaded: number) => void, @@ -404,18 +445,12 @@ async function uploadOSSInChunks( emitChunkProgress(target, confirmedBytes, file.size, chunkCount, index) } - target.onProgress?.({ - loaded: file.size, - total: file.size, - percent: 100, - stage: 'complete', - chunkCount, - }) - return await http.post( - `/desktop-client/release-uploads/${session.upload_id}/complete`, + const job = await http.post( + `/desktop-client/release-uploads/${session.upload_id}/complete-jobs`, undefined, { timeout: DESKTOP_CLIENT_RELEASE_COMPLETE_TIMEOUT_MS }, ) + return await pollCompleteJob(job.job_id, file.size, target, chunkCount) } catch (error) { void http.delete(`/desktop-client/release-uploads/${session.upload_id}`).catch(() => undefined) throw error diff --git a/apps/ops-web/src/views/DesktopClientReleasesView.vue b/apps/ops-web/src/views/DesktopClientReleasesView.vue index a75c2a6..279d32c 100644 --- a/apps/ops-web/src/views/DesktopClientReleasesView.vue +++ b/apps/ops-web/src/views/DesktopClientReleasesView.vue @@ -205,6 +205,14 @@ {{ uploadButtonText('installer') }} +
+ + {{ uploadProgressText('installer') }} +
{{ form.file_name }} {{ formatBytes(form.file_size_bytes) }} @@ -271,6 +279,14 @@ {{ uploadButtonText('updater') }} +
+ + {{ uploadProgressText('updater') }} +
{{ form.updater_file_name }} {{ formatBytes(form.updater_file_size_bytes) }} @@ -403,6 +419,8 @@ const uploadProgress = ref(null) const updaterUploadProgress = ref(null) const uploadStage = ref(null) const updaterUploadStage = ref(null) +const uploadMessage = ref(null) +const updaterUploadMessage = ref(null) const enabledRows = computed(() => rows.value.filter((row) => row.enabled).length) const forceRows = computed(() => rows.value.filter((row) => row.force_update).length) @@ -573,10 +591,12 @@ async function beforeUploadReleasePackage(file: File, kind: 'installer' | 'updat uploading.value = true uploadProgress.value = 0 uploadStage.value = 'hashing' + uploadMessage.value = null } else { updaterUploading.value = true updaterUploadProgress.value = 0 updaterUploadStage.value = 'hashing' + updaterUploadMessage.value = null } try { const result = await desktopClientReleaseApi.uploadOSS(file, { @@ -590,9 +610,11 @@ async function beforeUploadReleasePackage(file: File, kind: 'installer' | 'updat if (kind === 'installer') { uploadProgress.value = progress.percent uploadStage.value = progress.stage ?? null + uploadMessage.value = progress.message ?? null } else { updaterUploadProgress.value = progress.percent updaterUploadStage.value = progress.stage ?? null + updaterUploadMessage.value = progress.message ?? null } }, }) @@ -620,6 +642,8 @@ async function beforeUploadReleasePackage(file: File, kind: 'installer' | 'updat updaterUploadProgress.value = null uploadStage.value = null updaterUploadStage.value = null + uploadMessage.value = null + updaterUploadMessage.value = null } return false } @@ -648,10 +672,21 @@ function uploadButtonText(kind: 'installer' | 'updater') { return hasUploaded ? '重新上传' : '选择文件并上传' } -function uploadButtonLoading(kind: 'installer' | 'updater') { - const isUploading = kind === 'installer' ? uploading.value : updaterUploading.value +function uploadProgressText(kind: 'installer' | 'updater') { const progress = kind === 'installer' ? uploadProgress.value : updaterUploadProgress.value - return isUploading && progress !== 100 + const stage = kind === 'installer' ? uploadStage.value : updaterUploadStage.value + const message = kind === 'installer' ? uploadMessage.value : updaterUploadMessage.value + if (message) return `${message} ${progress ?? 0}%` + if (stage === 'hashing') return `正在计算 SHA256 ${progress ?? 0}%` + if (stage === 'signing') return '正在获取 OSS 直传地址' + if (stage === 'uploading') return `正在上传 ${progress ?? 0}%` + if (stage === 'verifying') return '服务端正在确认文件完整性' + if (stage === 'complete') return '上传完成' + return '准备上传' +} + +function uploadButtonLoading(kind: 'installer' | 'updater') { + return kind === 'installer' ? uploading.value : updaterUploading.value } function beforeUploadInstallerPackage(file: File) { @@ -1011,6 +1046,18 @@ onMounted(() => { font-size: 12px; } +.upload-progress { + display: grid; + max-width: 360px; + gap: 4px; + margin-top: 10px; +} + +.upload-progress span { + color: #64748b; + font-size: 12px; +} + .upload-result strong { color: #0f172a; font-weight: 750; diff --git a/server/internal/ops/app/desktop_client_release.go b/server/internal/ops/app/desktop_client_release.go index 7dc0aa2..3136f83 100644 --- a/server/internal/ops/app/desktop_client_release.go +++ b/server/internal/ops/app/desktop_client_release.go @@ -66,6 +66,7 @@ type DesktopClientReleaseService struct { audits *AuditService releaseUploadDir string releaseUploadLocks map[string]*sync.Mutex + completeJobs map[string]*desktopClientReleaseCompleteJob releaseUploadMu sync.Mutex } @@ -80,6 +81,7 @@ func NewDesktopClientReleaseService( audits: audits, releaseUploadDir: defaultDesktopClientReleaseUploadDir(), releaseUploadLocks: make(map[string]*sync.Mutex), + completeJobs: make(map[string]*desktopClientReleaseCompleteJob), } } @@ -201,6 +203,19 @@ type DesktopClientReleaseUploadResult struct { SHA256 string `json:"sha256"` } +type DesktopClientReleaseCompleteJobResult struct { + JobID string `json:"job_id"` + Status string `json:"status"` + Stage string `json:"stage"` + Message string `json:"message"` + Percent int `json:"percent"` + Result *DesktopClientReleaseUploadResult `json:"result,omitempty"` + ErrorCode string `json:"error_code,omitempty"` + Error string `json:"error,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + type DesktopClientReleaseChunkedUploadInitInput struct { Platform string Arch string @@ -270,6 +285,20 @@ type DesktopClientReleaseDirectUploadCompleteInput struct { SHA256 string } +type desktopClientReleaseCompleteJob struct { + mu sync.RWMutex + id string + status string + stage string + message string + percent int + result *DesktopClientReleaseUploadResult + errorCode string + err string + createdAt time.Time + updatedAt time.Time +} + type desktopClientReleaseUploadManifest struct { UploadID string `json:"upload_id"` Platform string `json:"platform"` @@ -658,6 +687,14 @@ func (s *DesktopClientReleaseService) InitiateDirectUpload( func (s *DesktopClientReleaseService) CompleteDirectUpload( ctx context.Context, in DesktopClientReleaseDirectUploadCompleteInput, +) (*DesktopClientReleaseUploadResult, error) { + return s.completeDirectUpload(ctx, in, nil) +} + +func (s *DesktopClientReleaseService) completeDirectUpload( + ctx context.Context, + in DesktopClientReleaseDirectUploadCompleteInput, + onProgress func(stage string, percent int, message string), ) (*DesktopClientReleaseUploadResult, error) { if s.storage == nil { return nil, response.ErrInternal(50091, "object_storage_unavailable", "object storage is unavailable") @@ -699,6 +736,7 @@ func (s *DesktopClientReleaseService) CompleteDirectUpload( return nil, response.ErrBadRequest(40066, "invalid_oss_object_key", "OSS Object Key 与上传文件校验信息不匹配") } + reportDesktopReleaseUploadProgress(onProgress, "checking_object", 20, "正在检查 OSS 对象") if management, ok := s.storage.(objectstorage.ManagementClient); ok { info, err := management.Stat(ctx, objectKey) if err != nil { @@ -723,6 +761,7 @@ func (s *DesktopClientReleaseService) CompleteDirectUpload( return nil, response.ErrBadRequest(40078, "direct_upload_not_found", "OSS 直传文件尚未完成") } } + reportDesktopReleaseUploadProgress(onProgress, "hashing_object", 55, "正在计算 OSS 文件 SHA256") actualSHA, actualSize, err := hashDesktopClientReleaseStorageObject(ctx, s.storage, objectKey) if err != nil { if errors.Is(err, objectstorage.ErrObjectNotFound) { @@ -738,6 +777,7 @@ func (s *DesktopClientReleaseService) CompleteDirectUpload( if actualSHA != sha256Hex { return nil, response.ErrBadRequest(40070, "invalid_sha256", "OSS 直传文件 SHA256 不匹配,请重新上传") } + reportDesktopReleaseUploadProgress(onProgress, "finalizing", 95, "正在确认上传结果") return &DesktopClientReleaseUploadResult{ OSSObjectKey: objectKey, @@ -775,6 +815,157 @@ func hashDesktopClientReleaseStorageObject( return hex.EncodeToString(actualSHA[:]), int64(len(content)), nil } +func (s *DesktopClientReleaseService) StartDirectUploadCompleteJob( + in DesktopClientReleaseDirectUploadCompleteInput, +) *DesktopClientReleaseCompleteJobResult { + job := newDesktopReleaseCompleteJob() + s.storeDesktopReleaseCompleteJob(job) + go func() { + job.setProgress("checking_object", 10, "正在检查 OSS 对象") + result, err := s.completeDirectUpload(context.Background(), in, job.setProgress) + if err != nil { + job.fail(err) + return + } + job.succeed(result) + }() + return job.snapshot() +} + +func (s *DesktopClientReleaseService) StartChunkedUploadCompleteJob(uploadID string) (*DesktopClientReleaseCompleteJobResult, error) { + if _, err := normalizeDesktopClientReleaseUploadID(uploadID); err != nil { + return nil, err + } + job := newDesktopReleaseCompleteJob() + s.storeDesktopReleaseCompleteJob(job) + go func() { + job.setProgress("validating_chunks", 10, "正在校验上传分片") + result, err := s.completeChunkedUpload(context.Background(), uploadID, job.setProgress) + if err != nil { + job.fail(err) + return + } + job.succeed(result) + }() + return job.snapshot(), nil +} + +func (s *DesktopClientReleaseService) GetCompleteJob(jobID string) (*DesktopClientReleaseCompleteJobResult, error) { + jobID = strings.TrimSpace(jobID) + if jobID == "" { + return nil, response.ErrBadRequest(40081, "invalid_upload_job_id", "上传任务 ID 无效") + } + s.releaseUploadMu.Lock() + defer s.releaseUploadMu.Unlock() + job, ok := s.completeJobs[jobID] + if !ok { + return nil, response.ErrNotFound(40481, "upload_job_not_found", "上传任务不存在或已过期") + } + return job.snapshot(), nil +} + +func (s *DesktopClientReleaseService) storeDesktopReleaseCompleteJob(job *desktopClientReleaseCompleteJob) { + s.releaseUploadMu.Lock() + defer s.releaseUploadMu.Unlock() + if s.completeJobs == nil { + s.completeJobs = map[string]*desktopClientReleaseCompleteJob{} + } + cutoff := time.Now().UTC().Add(-desktopClientReleaseUploadSessionTTL) + for id, existing := range s.completeJobs { + if existing.snapshot().UpdatedAt.Before(cutoff) { + delete(s.completeJobs, id) + } + } + s.completeJobs[job.id] = job +} + +func newDesktopReleaseCompleteJob() *desktopClientReleaseCompleteJob { + now := time.Now().UTC() + return &desktopClientReleaseCompleteJob{ + id: uuid.NewString(), + status: "running", + stage: "queued", + message: "上传确认任务已创建", + percent: 1, + createdAt: now, + updatedAt: now, + } +} + +func (j *desktopClientReleaseCompleteJob) setProgress(stage string, percent int, message string) { + if j == nil { + return + } + j.mu.Lock() + defer j.mu.Unlock() + if j.status == "succeeded" || j.status == "failed" { + return + } + j.status = "running" + j.stage = strings.TrimSpace(stage) + j.message = strings.TrimSpace(message) + if percent < 1 { + percent = 1 + } + if percent > 99 { + percent = 99 + } + j.percent = percent + j.updatedAt = time.Now().UTC() +} + +func (j *desktopClientReleaseCompleteJob) succeed(result *DesktopClientReleaseUploadResult) { + j.mu.Lock() + defer j.mu.Unlock() + j.status = "succeeded" + j.stage = "complete" + j.message = "上传完成" + j.percent = 100 + j.result = result + j.updatedAt = time.Now().UTC() +} + +func (j *desktopClientReleaseCompleteJob) fail(err error) { + appErr := response.Normalize(err) + j.mu.Lock() + defer j.mu.Unlock() + j.status = "failed" + j.stage = "failed" + j.message = appErr.Detail + if j.message == "" { + j.message = appErr.Message + } + j.errorCode = appErr.Message + j.err = j.message + if j.percent < 1 { + j.percent = 1 + } + j.updatedAt = time.Now().UTC() +} + +func (j *desktopClientReleaseCompleteJob) snapshot() *DesktopClientReleaseCompleteJobResult { + j.mu.RLock() + defer j.mu.RUnlock() + return &DesktopClientReleaseCompleteJobResult{ + JobID: j.id, + Status: j.status, + Stage: j.stage, + Message: j.message, + Percent: j.percent, + Result: j.result, + ErrorCode: j.errorCode, + Error: j.err, + CreatedAt: j.createdAt, + UpdatedAt: j.updatedAt, + } +} + +func reportDesktopReleaseUploadProgress(onProgress func(stage string, percent int, message string), stage string, percent int, message string) { + if onProgress != nil { + onProgress(stage, percent, message) + } +} + func MaxDesktopClientReleaseMultipartBytes() int64 { return MaxDesktopClientReleaseUploadBytes + desktopClientReleaseMultipartOverheadBytes } @@ -934,6 +1125,14 @@ func (s *DesktopClientReleaseService) UploadChunk( func (s *DesktopClientReleaseService) CompleteChunkedUpload( ctx context.Context, uploadIDValue string, +) (*DesktopClientReleaseUploadResult, error) { + return s.completeChunkedUpload(ctx, uploadIDValue, nil) +} + +func (s *DesktopClientReleaseService) completeChunkedUpload( + ctx context.Context, + uploadIDValue string, + onProgress func(stage string, percent int, message string), ) (*DesktopClientReleaseUploadResult, error) { uploadID, err := normalizeDesktopClientReleaseUploadID(uploadIDValue) if err != nil { @@ -950,9 +1149,11 @@ func (s *DesktopClientReleaseService) CompleteChunkedUpload( if time.Now().UTC().After(manifest.ExpiresAt) { return nil, response.ErrBadRequest(40075, "release_upload_expired", "上传会话已过期,请重新选择文件上传") } + reportDesktopReleaseUploadProgress(onProgress, "validating_chunks", 20, "正在校验上传分片") if err := manifest.validateComplete(); err != nil { return nil, err } + reportDesktopReleaseUploadProgress(onProgress, "hashing_chunks", 45, "正在计算完整文件 SHA256") sha256Hex, err := s.hashDesktopReleaseUploadChunks(manifest) if err != nil { return nil, err @@ -960,6 +1161,7 @@ func (s *DesktopClientReleaseService) CompleteChunkedUpload( objectKey := buildDesktopClientReleaseObjectKey(sha256Hex, manifest.FileName) contentType := detectDesktopReleaseContentType(manifest.FileName, nil) + reportDesktopReleaseUploadProgress(onProgress, "checking_object", 65, "正在检查 OSS 对象") exists, err := s.storage.Exists(ctx, objectKey) if err != nil { appErr := response.ErrInternal(50092, "desktop_client_release_upload_failed", err.Error()) @@ -967,6 +1169,7 @@ func (s *DesktopClientReleaseService) CompleteChunkedUpload( return nil, appErr } if !exists { + reportDesktopReleaseUploadProgress(onProgress, "uploading_object", 75, "正在合并并上传到 OSS") reader, err := s.openDesktopReleaseUploadChunks(manifest) if err != nil { return nil, err @@ -978,6 +1181,7 @@ func (s *DesktopClientReleaseService) CompleteChunkedUpload( return nil, appErr } } + reportDesktopReleaseUploadProgress(onProgress, "finalizing", 95, "正在确认上传结果") result := &DesktopClientReleaseUploadResult{ OSSObjectKey: objectKey, diff --git a/server/internal/ops/app/desktop_client_release_test.go b/server/internal/ops/app/desktop_client_release_test.go index e236f45..43ec6da 100644 --- a/server/internal/ops/app/desktop_client_release_test.go +++ b/server/internal/ops/app/desktop_client_release_test.go @@ -424,6 +424,41 @@ func TestDesktopClientReleaseDirectUploadRejectsSHA256Mismatch(t *testing.T) { require.Contains(t, err.Error(), "invalid_sha256") } +func TestDesktopClientReleaseDirectUploadCompleteJobReportsStatus(t *testing.T) { + t.Parallel() + + content := []byte("release-package") + sum := sha256.Sum256(content) + sha256Hex := hex.EncodeToString(sum[:]) + objectKey := buildDesktopClientReleaseObjectKey(sha256Hex, "client.zip") + storage := &desktopReleaseDownloadURLStorage{ + existsByKey: map[string]bool{objectKey: true}, + objects: map[string][]byte{objectKey: content}, + objectSizes: map[string]int64{objectKey: int64(len(content))}, + } + svc := NewDesktopClientReleaseService(nil, storage, nil) + + job := svc.StartDirectUploadCompleteJob(DesktopClientReleaseDirectUploadCompleteInput{ + Platform: "darwin", + Arch: "arm64", + Channel: "stable", + Version: "1.2.3", + Kind: DesktopClientAssetKindUpdater, + OSSObjectKey: objectKey, + FileName: "client.zip", + FileSizeBytes: int64(len(content)), + SHA256: sha256Hex, + }) + require.NotEmpty(t, job.JobID) + require.Equal(t, "running", job.Status) + + require.Eventually(t, func() bool { + current, err := svc.GetCompleteJob(job.JobID) + require.NoError(t, err) + return current.Status == "succeeded" && current.Result != nil && current.Result.SHA256 == sha256Hex + }, time.Second, 10*time.Millisecond) +} + type desktopReleaseDownloadURLStorage struct { publicURLCalls []string downloadURLCalls []string diff --git a/server/internal/ops/transport/desktop_client_release_handler.go b/server/internal/ops/transport/desktop_client_release_handler.go index 4bbd7cc..766da3d 100644 --- a/server/internal/ops/transport/desktop_client_release_handler.go +++ b/server/internal/ops/transport/desktop_client_release_handler.go @@ -284,6 +284,39 @@ func completeDesktopClientReleaseDirectUploadHandler(svc *app.DesktopClientRelea } } +func startDesktopClientReleaseDirectUploadCompleteJobHandler(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 := svc.StartDirectUploadCompleteJob(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, + }) + response.Success(c, result) + } +} + +func getDesktopClientReleaseCompleteJobHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc { + return func(c *gin.Context) { + result, err := svc.GetCompleteJob(c.Param("job_id")) + 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 @@ -370,6 +403,17 @@ func completeDesktopClientReleaseUploadHandler(svc *app.DesktopClientReleaseServ } } +func startDesktopClientReleaseUploadCompleteJobHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc { + return func(c *gin.Context) { + result, err := svc.StartChunkedUploadCompleteJob(c.Param("upload_id")) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, result) + } +} + func abortDesktopClientReleaseUploadHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc { return func(c *gin.Context) { if err := svc.AbortChunkedUpload(c.Param("upload_id")); err != nil { diff --git a/server/internal/ops/transport/router.go b/server/internal/ops/transport/router.go index aeb0966..a01c4c6 100644 --- a/server/internal/ops/transport/router.go +++ b/server/internal/ops/transport/router.go @@ -175,9 +175,12 @@ func RegisterRoutes(d Deps) { 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/releases/direct-upload/complete-jobs", startDesktopClientReleaseDirectUploadCompleteJobHandler(d.Releases)) + authed.GET("/desktop-client/release-upload-complete-jobs/:job_id", getDesktopClientReleaseCompleteJobHandler(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)) + authed.POST("/desktop-client/release-uploads/:upload_id/complete-jobs", startDesktopClientReleaseUploadCompleteJobHandler(d.Releases)) authed.DELETE("/desktop-client/release-uploads/:upload_id", abortDesktopClientReleaseUploadHandler(d.Releases)) authed.POST("/desktop-client/releases", createDesktopClientReleaseHandler(d.Releases)) authed.PATCH("/desktop-client/releases/:id", updateDesktopClientReleaseHandler(d.Releases))