feat: report desktop release upload completion progress
This commit is contained in:
@@ -91,6 +91,7 @@ export interface DesktopClientReleaseUploadProgress {
|
|||||||
total?: number
|
total?: number
|
||||||
percent?: number
|
percent?: number
|
||||||
stage?: 'hashing' | 'signing' | 'uploading' | 'verifying' | 'complete'
|
stage?: 'hashing' | 'signing' | 'uploading' | 'verifying' | 'complete'
|
||||||
|
message?: string
|
||||||
chunkIndex?: number
|
chunkIndex?: number
|
||||||
chunkCount?: number
|
chunkCount?: number
|
||||||
}
|
}
|
||||||
@@ -128,6 +129,19 @@ interface DesktopClientReleaseFileHashes {
|
|||||||
sha256: string
|
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 = [
|
export const platformOptions = [
|
||||||
{ label: 'macOS', value: 'darwin' },
|
{ label: 'macOS', value: 'darwin' },
|
||||||
{ label: 'Windows', value: 'win32' },
|
{ label: 'Windows', value: 'win32' },
|
||||||
@@ -298,14 +312,8 @@ async function completeDirectUpload(
|
|||||||
session: DesktopClientReleaseDirectUploadSession,
|
session: DesktopClientReleaseDirectUploadSession,
|
||||||
target: DesktopClientReleaseUploadTarget,
|
target: DesktopClientReleaseUploadTarget,
|
||||||
): Promise<DesktopClientReleaseUploadResult> {
|
): Promise<DesktopClientReleaseUploadResult> {
|
||||||
target.onProgress?.({
|
const job = await http.post<DesktopClientReleaseCompleteJob>(
|
||||||
loaded: session.file_size_bytes,
|
'/desktop-client/releases/direct-upload/complete-jobs',
|
||||||
total: session.file_size_bytes,
|
|
||||||
percent: 100,
|
|
||||||
stage: 'verifying',
|
|
||||||
})
|
|
||||||
const result = await http.post<DesktopClientReleaseUploadResult>(
|
|
||||||
'/desktop-client/releases/direct-upload/complete',
|
|
||||||
{
|
{
|
||||||
platform: target.platform,
|
platform: target.platform,
|
||||||
arch: target.arch,
|
arch: target.arch,
|
||||||
@@ -318,13 +326,7 @@ async function completeDirectUpload(
|
|||||||
sha256: session.sha256,
|
sha256: session.sha256,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
target.onProgress?.({
|
return pollCompleteJob(job.job_id, session.file_size_bytes, target)
|
||||||
loaded: result.file_size_bytes,
|
|
||||||
total: result.file_size_bytes,
|
|
||||||
percent: 100,
|
|
||||||
stage: 'complete',
|
|
||||||
})
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isDirectUploadNotFoundError(error: unknown): boolean {
|
function isDirectUploadNotFoundError(error: unknown): boolean {
|
||||||
@@ -343,6 +345,45 @@ function shouldFallbackToChunkedUpload(error: unknown): boolean {
|
|||||||
return error instanceof Error && error.message === 'missing_direct_upload_url'
|
return error instanceof Error && error.message === 'missing_direct_upload_url'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function pollCompleteJob(
|
||||||
|
jobID: string,
|
||||||
|
totalBytes: number,
|
||||||
|
target: DesktopClientReleaseUploadTarget,
|
||||||
|
chunkCount?: number,
|
||||||
|
): Promise<DesktopClientReleaseUploadResult> {
|
||||||
|
for (;;) {
|
||||||
|
const job = await http.get<DesktopClientReleaseCompleteJob>(
|
||||||
|
`/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(
|
async function hashFile(
|
||||||
file: File,
|
file: File,
|
||||||
onProgress?: (loaded: number) => void,
|
onProgress?: (loaded: number) => void,
|
||||||
@@ -404,18 +445,12 @@ async function uploadOSSInChunks(
|
|||||||
emitChunkProgress(target, confirmedBytes, file.size, chunkCount, index)
|
emitChunkProgress(target, confirmedBytes, file.size, chunkCount, index)
|
||||||
}
|
}
|
||||||
|
|
||||||
target.onProgress?.({
|
const job = await http.post<DesktopClientReleaseCompleteJob>(
|
||||||
loaded: file.size,
|
`/desktop-client/release-uploads/${session.upload_id}/complete-jobs`,
|
||||||
total: file.size,
|
|
||||||
percent: 100,
|
|
||||||
stage: 'complete',
|
|
||||||
chunkCount,
|
|
||||||
})
|
|
||||||
return await http.post<DesktopClientReleaseUploadResult>(
|
|
||||||
`/desktop-client/release-uploads/${session.upload_id}/complete`,
|
|
||||||
undefined,
|
undefined,
|
||||||
{ timeout: DESKTOP_CLIENT_RELEASE_COMPLETE_TIMEOUT_MS },
|
{ timeout: DESKTOP_CLIENT_RELEASE_COMPLETE_TIMEOUT_MS },
|
||||||
)
|
)
|
||||||
|
return await pollCompleteJob(job.job_id, file.size, target, chunkCount)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
void http.delete(`/desktop-client/release-uploads/${session.upload_id}`).catch(() => undefined)
|
void http.delete(`/desktop-client/release-uploads/${session.upload_id}`).catch(() => undefined)
|
||||||
throw error
|
throw error
|
||||||
|
|||||||
@@ -205,6 +205,14 @@
|
|||||||
{{ uploadButtonText('installer') }}
|
{{ uploadButtonText('installer') }}
|
||||||
</a-button>
|
</a-button>
|
||||||
</a-upload>
|
</a-upload>
|
||||||
|
<div v-if="uploading" class="upload-progress">
|
||||||
|
<a-progress
|
||||||
|
:percent="uploadProgress ?? 0"
|
||||||
|
:status="uploadStage === 'verifying' ? 'active' : 'normal'"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<span>{{ uploadProgressText('installer') }}</span>
|
||||||
|
</div>
|
||||||
<div v-if="form.file_name" class="upload-result">
|
<div v-if="form.file_name" class="upload-result">
|
||||||
<strong>{{ form.file_name }}</strong>
|
<strong>{{ form.file_name }}</strong>
|
||||||
<span>{{ formatBytes(form.file_size_bytes) }}</span>
|
<span>{{ formatBytes(form.file_size_bytes) }}</span>
|
||||||
@@ -271,6 +279,14 @@
|
|||||||
{{ uploadButtonText('updater') }}
|
{{ uploadButtonText('updater') }}
|
||||||
</a-button>
|
</a-button>
|
||||||
</a-upload>
|
</a-upload>
|
||||||
|
<div v-if="updaterUploading" class="upload-progress">
|
||||||
|
<a-progress
|
||||||
|
:percent="updaterUploadProgress ?? 0"
|
||||||
|
:status="updaterUploadStage === 'verifying' ? 'active' : 'normal'"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<span>{{ uploadProgressText('updater') }}</span>
|
||||||
|
</div>
|
||||||
<div v-if="form.updater_file_name" class="upload-result">
|
<div v-if="form.updater_file_name" class="upload-result">
|
||||||
<strong>{{ form.updater_file_name }}</strong>
|
<strong>{{ form.updater_file_name }}</strong>
|
||||||
<span>{{ formatBytes(form.updater_file_size_bytes) }}</span>
|
<span>{{ formatBytes(form.updater_file_size_bytes) }}</span>
|
||||||
@@ -403,6 +419,8 @@ 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 uploadStage = ref<DesktopClientReleaseUploadProgress['stage'] | null>(null)
|
||||||
const updaterUploadStage = ref<DesktopClientReleaseUploadProgress['stage'] | null>(null)
|
const updaterUploadStage = ref<DesktopClientReleaseUploadProgress['stage'] | null>(null)
|
||||||
|
const uploadMessage = ref<string | null>(null)
|
||||||
|
const updaterUploadMessage = ref<string | 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)
|
||||||
@@ -573,10 +591,12 @@ async function beforeUploadReleasePackage(file: File, kind: 'installer' | 'updat
|
|||||||
uploading.value = true
|
uploading.value = true
|
||||||
uploadProgress.value = 0
|
uploadProgress.value = 0
|
||||||
uploadStage.value = 'hashing'
|
uploadStage.value = 'hashing'
|
||||||
|
uploadMessage.value = null
|
||||||
} else {
|
} else {
|
||||||
updaterUploading.value = true
|
updaterUploading.value = true
|
||||||
updaterUploadProgress.value = 0
|
updaterUploadProgress.value = 0
|
||||||
updaterUploadStage.value = 'hashing'
|
updaterUploadStage.value = 'hashing'
|
||||||
|
updaterUploadMessage.value = null
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await desktopClientReleaseApi.uploadOSS(file, {
|
const result = await desktopClientReleaseApi.uploadOSS(file, {
|
||||||
@@ -590,9 +610,11 @@ async function beforeUploadReleasePackage(file: File, kind: 'installer' | 'updat
|
|||||||
if (kind === 'installer') {
|
if (kind === 'installer') {
|
||||||
uploadProgress.value = progress.percent
|
uploadProgress.value = progress.percent
|
||||||
uploadStage.value = progress.stage ?? null
|
uploadStage.value = progress.stage ?? null
|
||||||
|
uploadMessage.value = progress.message ?? null
|
||||||
} else {
|
} else {
|
||||||
updaterUploadProgress.value = progress.percent
|
updaterUploadProgress.value = progress.percent
|
||||||
updaterUploadStage.value = progress.stage ?? null
|
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
|
updaterUploadProgress.value = null
|
||||||
uploadStage.value = null
|
uploadStage.value = null
|
||||||
updaterUploadStage.value = null
|
updaterUploadStage.value = null
|
||||||
|
uploadMessage.value = null
|
||||||
|
updaterUploadMessage.value = null
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -648,10 +672,21 @@ function uploadButtonText(kind: 'installer' | 'updater') {
|
|||||||
return hasUploaded ? '重新上传' : '选择文件并上传'
|
return hasUploaded ? '重新上传' : '选择文件并上传'
|
||||||
}
|
}
|
||||||
|
|
||||||
function uploadButtonLoading(kind: 'installer' | 'updater') {
|
function uploadProgressText(kind: 'installer' | 'updater') {
|
||||||
const isUploading = kind === 'installer' ? uploading.value : updaterUploading.value
|
|
||||||
const progress = kind === 'installer' ? uploadProgress.value : updaterUploadProgress.value
|
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) {
|
function beforeUploadInstallerPackage(file: File) {
|
||||||
@@ -1011,6 +1046,18 @@ onMounted(() => {
|
|||||||
font-size: 12px;
|
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 {
|
.upload-result strong {
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
font-weight: 750;
|
font-weight: 750;
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ type DesktopClientReleaseService struct {
|
|||||||
audits *AuditService
|
audits *AuditService
|
||||||
releaseUploadDir string
|
releaseUploadDir string
|
||||||
releaseUploadLocks map[string]*sync.Mutex
|
releaseUploadLocks map[string]*sync.Mutex
|
||||||
|
completeJobs map[string]*desktopClientReleaseCompleteJob
|
||||||
releaseUploadMu sync.Mutex
|
releaseUploadMu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +81,7 @@ func NewDesktopClientReleaseService(
|
|||||||
audits: audits,
|
audits: audits,
|
||||||
releaseUploadDir: defaultDesktopClientReleaseUploadDir(),
|
releaseUploadDir: defaultDesktopClientReleaseUploadDir(),
|
||||||
releaseUploadLocks: make(map[string]*sync.Mutex),
|
releaseUploadLocks: make(map[string]*sync.Mutex),
|
||||||
|
completeJobs: make(map[string]*desktopClientReleaseCompleteJob),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,6 +203,19 @@ type DesktopClientReleaseUploadResult struct {
|
|||||||
SHA256 string `json:"sha256"`
|
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 {
|
type DesktopClientReleaseChunkedUploadInitInput struct {
|
||||||
Platform string
|
Platform string
|
||||||
Arch string
|
Arch string
|
||||||
@@ -270,6 +285,20 @@ type DesktopClientReleaseDirectUploadCompleteInput struct {
|
|||||||
SHA256 string
|
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 {
|
type desktopClientReleaseUploadManifest struct {
|
||||||
UploadID string `json:"upload_id"`
|
UploadID string `json:"upload_id"`
|
||||||
Platform string `json:"platform"`
|
Platform string `json:"platform"`
|
||||||
@@ -658,6 +687,14 @@ func (s *DesktopClientReleaseService) InitiateDirectUpload(
|
|||||||
func (s *DesktopClientReleaseService) CompleteDirectUpload(
|
func (s *DesktopClientReleaseService) CompleteDirectUpload(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
in DesktopClientReleaseDirectUploadCompleteInput,
|
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) {
|
) (*DesktopClientReleaseUploadResult, error) {
|
||||||
if s.storage == nil {
|
if s.storage == nil {
|
||||||
return nil, response.ErrInternal(50091, "object_storage_unavailable", "object storage is unavailable")
|
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 与上传文件校验信息不匹配")
|
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 {
|
if management, ok := s.storage.(objectstorage.ManagementClient); ok {
|
||||||
info, err := management.Stat(ctx, objectKey)
|
info, err := management.Stat(ctx, objectKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -723,6 +761,7 @@ func (s *DesktopClientReleaseService) CompleteDirectUpload(
|
|||||||
return nil, response.ErrBadRequest(40078, "direct_upload_not_found", "OSS 直传文件尚未完成")
|
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)
|
actualSHA, actualSize, err := hashDesktopClientReleaseStorageObject(ctx, s.storage, objectKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, objectstorage.ErrObjectNotFound) {
|
if errors.Is(err, objectstorage.ErrObjectNotFound) {
|
||||||
@@ -738,6 +777,7 @@ func (s *DesktopClientReleaseService) CompleteDirectUpload(
|
|||||||
if actualSHA != sha256Hex {
|
if actualSHA != sha256Hex {
|
||||||
return nil, response.ErrBadRequest(40070, "invalid_sha256", "OSS 直传文件 SHA256 不匹配,请重新上传")
|
return nil, response.ErrBadRequest(40070, "invalid_sha256", "OSS 直传文件 SHA256 不匹配,请重新上传")
|
||||||
}
|
}
|
||||||
|
reportDesktopReleaseUploadProgress(onProgress, "finalizing", 95, "正在确认上传结果")
|
||||||
|
|
||||||
return &DesktopClientReleaseUploadResult{
|
return &DesktopClientReleaseUploadResult{
|
||||||
OSSObjectKey: objectKey,
|
OSSObjectKey: objectKey,
|
||||||
@@ -775,6 +815,157 @@ func hashDesktopClientReleaseStorageObject(
|
|||||||
return hex.EncodeToString(actualSHA[:]), int64(len(content)), nil
|
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 {
|
func MaxDesktopClientReleaseMultipartBytes() int64 {
|
||||||
return MaxDesktopClientReleaseUploadBytes + desktopClientReleaseMultipartOverheadBytes
|
return MaxDesktopClientReleaseUploadBytes + desktopClientReleaseMultipartOverheadBytes
|
||||||
}
|
}
|
||||||
@@ -934,6 +1125,14 @@ func (s *DesktopClientReleaseService) UploadChunk(
|
|||||||
func (s *DesktopClientReleaseService) CompleteChunkedUpload(
|
func (s *DesktopClientReleaseService) CompleteChunkedUpload(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
uploadIDValue string,
|
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) {
|
) (*DesktopClientReleaseUploadResult, error) {
|
||||||
uploadID, err := normalizeDesktopClientReleaseUploadID(uploadIDValue)
|
uploadID, err := normalizeDesktopClientReleaseUploadID(uploadIDValue)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -950,9 +1149,11 @@ func (s *DesktopClientReleaseService) CompleteChunkedUpload(
|
|||||||
if time.Now().UTC().After(manifest.ExpiresAt) {
|
if time.Now().UTC().After(manifest.ExpiresAt) {
|
||||||
return nil, response.ErrBadRequest(40075, "release_upload_expired", "上传会话已过期,请重新选择文件上传")
|
return nil, response.ErrBadRequest(40075, "release_upload_expired", "上传会话已过期,请重新选择文件上传")
|
||||||
}
|
}
|
||||||
|
reportDesktopReleaseUploadProgress(onProgress, "validating_chunks", 20, "正在校验上传分片")
|
||||||
if err := manifest.validateComplete(); err != nil {
|
if err := manifest.validateComplete(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
reportDesktopReleaseUploadProgress(onProgress, "hashing_chunks", 45, "正在计算完整文件 SHA256")
|
||||||
sha256Hex, err := s.hashDesktopReleaseUploadChunks(manifest)
|
sha256Hex, err := s.hashDesktopReleaseUploadChunks(manifest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -960,6 +1161,7 @@ func (s *DesktopClientReleaseService) CompleteChunkedUpload(
|
|||||||
|
|
||||||
objectKey := buildDesktopClientReleaseObjectKey(sha256Hex, manifest.FileName)
|
objectKey := buildDesktopClientReleaseObjectKey(sha256Hex, manifest.FileName)
|
||||||
contentType := detectDesktopReleaseContentType(manifest.FileName, nil)
|
contentType := detectDesktopReleaseContentType(manifest.FileName, nil)
|
||||||
|
reportDesktopReleaseUploadProgress(onProgress, "checking_object", 65, "正在检查 OSS 对象")
|
||||||
exists, err := s.storage.Exists(ctx, objectKey)
|
exists, err := s.storage.Exists(ctx, objectKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
appErr := response.ErrInternal(50092, "desktop_client_release_upload_failed", err.Error())
|
appErr := response.ErrInternal(50092, "desktop_client_release_upload_failed", err.Error())
|
||||||
@@ -967,6 +1169,7 @@ func (s *DesktopClientReleaseService) CompleteChunkedUpload(
|
|||||||
return nil, appErr
|
return nil, appErr
|
||||||
}
|
}
|
||||||
if !exists {
|
if !exists {
|
||||||
|
reportDesktopReleaseUploadProgress(onProgress, "uploading_object", 75, "正在合并并上传到 OSS")
|
||||||
reader, err := s.openDesktopReleaseUploadChunks(manifest)
|
reader, err := s.openDesktopReleaseUploadChunks(manifest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -978,6 +1181,7 @@ func (s *DesktopClientReleaseService) CompleteChunkedUpload(
|
|||||||
return nil, appErr
|
return nil, appErr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
reportDesktopReleaseUploadProgress(onProgress, "finalizing", 95, "正在确认上传结果")
|
||||||
|
|
||||||
result := &DesktopClientReleaseUploadResult{
|
result := &DesktopClientReleaseUploadResult{
|
||||||
OSSObjectKey: objectKey,
|
OSSObjectKey: objectKey,
|
||||||
|
|||||||
@@ -424,6 +424,41 @@ func TestDesktopClientReleaseDirectUploadRejectsSHA256Mismatch(t *testing.T) {
|
|||||||
require.Contains(t, err.Error(), "invalid_sha256")
|
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 {
|
type desktopReleaseDownloadURLStorage struct {
|
||||||
publicURLCalls []string
|
publicURLCalls []string
|
||||||
downloadURLCalls []string
|
downloadURLCalls []string
|
||||||
|
|||||||
@@ -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 {
|
func initiateDesktopClientReleaseUploadHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
var body desktopClientReleaseUploadInitRequest
|
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 {
|
func abortDesktopClientReleaseUploadHandler(svc *app.DesktopClientReleaseService) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
if err := svc.AbortChunkedUpload(c.Param("upload_id")); err != nil {
|
if err := svc.AbortChunkedUpload(c.Param("upload_id")); err != nil {
|
||||||
|
|||||||
@@ -175,9 +175,12 @@ func RegisterRoutes(d Deps) {
|
|||||||
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", initiateDesktopClientReleaseDirectUploadHandler(d.Releases))
|
||||||
authed.POST("/desktop-client/releases/direct-upload/complete", completeDesktopClientReleaseDirectUploadHandler(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", 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))
|
||||||
|
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.DELETE("/desktop-client/release-uploads/:upload_id", abortDesktopClientReleaseUploadHandler(d.Releases))
|
||||||
authed.POST("/desktop-client/releases", createDesktopClientReleaseHandler(d.Releases))
|
authed.POST("/desktop-client/releases", createDesktopClientReleaseHandler(d.Releases))
|
||||||
authed.PATCH("/desktop-client/releases/:id", updateDesktopClientReleaseHandler(d.Releases))
|
authed.PATCH("/desktop-client/releases/:id", updateDesktopClientReleaseHandler(d.Releases))
|
||||||
|
|||||||
Reference in New Issue
Block a user