From 7c8b79161aa451fc49aa5e36369ba184c04c756d Mon Sep 17 00:00:00 2001 From: hailin Date: Sat, 7 Mar 2026 07:19:28 -0800 Subject: [PATCH] =?UTF-8?q?feat(upload):=20parse=3Dupload+save+metadata,?= =?UTF-8?q?=20register=3DJSON=20only=20=E2=80=94=20no=20double=20upload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the flow uploaded the 53MB file twice: 1. POST /parse → parse metadata (file discarded) 2. POST /upload → parse again + save (file sent again) New flow — file sent exactly once: 1. POST /parse → upload file, save to disk, parse metadata returns {versionName, versionCode, minSdkVersion, storageKey, fileSize, fileSha256} 2. POST /register → JSON only (no file), creates DB record using storageKey Frontend: - handleFileChange: async, immediately uploads to /parse with progress bar (0-100%) - handleSubmit: calls /register with storageKey + form metadata (instant) - Upload modal: real-time progress bar, "confirm" button disabled until parse complete - Console logs at every step for debugging Backend: - POST /parse: saves file after parsing, returns storageKey in response - POST /register: new endpoint, accepts JSON + storageKey, creates version record Co-Authored-By: Claude Sonnet 4.6 --- .../controllers/admin-version.controller.ts | 72 ++++++++++++++++++- .../version.repository.interface.ts | 21 +++++- .../repositories/version.repository.ts | 35 ++++++++- .../src/store/zustand/upload.store.ts | 26 +++++++ .../app-versions/AppVersionManagementPage.tsx | 38 +++++++--- .../views/app-versions/hooks/use-upload.ts | 62 +++++++++++----- 6 files changed, 221 insertions(+), 33 deletions(-) diff --git a/backend/services/admin-service/src/interface/http/controllers/admin-version.controller.ts b/backend/services/admin-service/src/interface/http/controllers/admin-version.controller.ts index 73bdafe..fb2e33d 100644 --- a/backend/services/admin-service/src/interface/http/controllers/admin-version.controller.ts +++ b/backend/services/admin-service/src/interface/http/controllers/admin-version.controller.ts @@ -147,16 +147,82 @@ export class AdminVersionController { @Post('parse') @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 500 * 1024 * 1024 } })) @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: 'Parse APK/IPA without saving (preview metadata)' }) + @ApiOperation({ summary: 'Parse APK/IPA — saves file, returns metadata + storageKey for /register' }) async parsePackage(@UploadedFile() file: Express.Multer.File) { const t0 = Date.now(); if (!file) { return { code: 400, message: 'No file uploaded — check Content-Type boundary' }; } this.logger.log(`[parse] received: ${file.originalname} size=${(file.size / 1024 / 1024).toFixed(1)}MB`); + + const t1 = Date.now(); const info = await this.packageParser.parse(file.buffer, file.originalname); - this.logger.log(`[parse] done in ${Date.now() - t0}ms → pkg=${info.packageName} ver=${info.versionName}`); - return { code: 0, data: info }; + this.logger.log(`[parse] metadata done in ${Date.now() - t1}ms → pkg=${info.packageName} ver=${info.versionName}`); + + // Save file so /register can reference it without re-uploading + const platform = file.originalname.toLowerCase().endsWith('.ipa') ? 'IOS' : 'ANDROID'; + const t2 = Date.now(); + const storageKey = this.fileStorage.generateObjectName(file.originalname, platform, info.versionName || 'unknown'); + const saved = await this.fileStorage.saveFile(file.buffer, storageKey); + this.logger.log(`[parse] file saved in ${Date.now() - t2}ms → ${storageKey} (total ${Date.now() - t0}ms)`); + + return { + code: 0, + data: { + ...info, + storageKey, + fileSize: String(saved.size), + fileSha256: saved.sha256, + }, + }; + } + + @Post('register') + @ApiOperation({ summary: 'Register version using storageKey from /parse — no file upload needed' }) + async registerVersion( + @Body() body: { + storageKey: string; + fileSize: string; + fileSha256: string; + appType?: string; + platform?: string; + versionCode?: number; + versionName?: string; + buildNumber?: string; + changelog?: string; + isForceUpdate?: boolean; + minOsVersion?: string; + releaseDate?: string; + }, + @Req() req: any, + ) { + if (!body.storageKey) return { code: 400, message: 'storageKey is required' }; + + const platform = (body.platform || 'ANDROID').toUpperCase() as Platform; + const appType = (body.appType || 'GENEX_MOBILE').toUpperCase() as AppType; + const versionCode = body.versionCode || 0; + + this.logger.log(`[register] storageKey=${body.storageKey} ver=${body.versionName} build=${body.buildNumber}`); + + const version = await this.versionService.createVersion({ + appType, + platform, + versionCode, + versionName: body.versionName || '0.0.0', + buildNumber: body.buildNumber || String(versionCode), + storageKey: body.storageKey, + downloadUrl: `/api/v1/app/version/download/`, + fileSize: body.fileSize || '0', + fileSha256: body.fileSha256 || '', + changelog: body.changelog || '', + isForceUpdate: body.isForceUpdate === true, + minOsVersion: body.minOsVersion, + releaseDate: body.releaseDate ? new Date(body.releaseDate) : undefined, + createdBy: req.user?.sub, + }); + + this.logger.log(`[register] created version id=${version.id}`); + return { code: 0, data: version }; } @Put(':id') diff --git a/frontend/admin-web/src/domain/repositories/version.repository.interface.ts b/frontend/admin-web/src/domain/repositories/version.repository.interface.ts index 291b37e..7997112 100644 --- a/frontend/admin-web/src/domain/repositories/version.repository.interface.ts +++ b/frontend/admin-web/src/domain/repositories/version.repository.interface.ts @@ -4,6 +4,24 @@ export interface ParsedPackageInfo { versionCode?: number; versionName?: string; minSdkVersion?: string; + // Returned by /parse — file already saved server-side, use for /register + storageKey?: string; + fileSize?: string; + fileSha256?: string; +} + +export interface RegisterVersionInput { + storageKey: string; + fileSize: string; + fileSha256: string; + appType: AppType; + platform: AppPlatform; + versionCode?: number; + versionName?: string; + buildNumber?: string; + changelog?: string; + minOsVersion?: string; + isForceUpdate: boolean; } export interface UploadVersionInput { @@ -26,7 +44,8 @@ export interface UpdateVersionInput { export interface IVersionRepository { list(appType: AppType, platform?: AppPlatform, includeDisabled?: boolean): Promise; - parse(file: File): Promise; + parse(file: File, onProgress?: (pct: number) => void): Promise; + register(input: RegisterVersionInput): Promise; upload(input: UploadVersionInput): Promise; update(id: string, input: UpdateVersionInput): Promise; toggle(id: string, isEnabled: boolean): Promise; diff --git a/frontend/admin-web/src/infrastructure/repositories/version.repository.ts b/frontend/admin-web/src/infrastructure/repositories/version.repository.ts index b6bf4bd..d4a4cf9 100644 --- a/frontend/admin-web/src/infrastructure/repositories/version.repository.ts +++ b/frontend/admin-web/src/infrastructure/repositories/version.repository.ts @@ -1,6 +1,7 @@ import type { IVersionRepository, ParsedPackageInfo, + RegisterVersionInput, UploadVersionInput, UpdateVersionInput, } from '@/domain/repositories/version.repository.interface'; @@ -14,12 +15,40 @@ class VersionRepository implements IVersionRepository { return httpClient.get('/api/v1/admin/versions', { params }); } - async parse(file: File): Promise { + // Parse = upload file once + get metadata + storageKey (server keeps the file) + async parse(file: File, onProgress?: (pct: number) => void): Promise { const fd = new FormData(); fd.append('file', file); - return httpClient.post('/api/v1/admin/versions/parse', fd, { timeout: 300000 }); + console.log(`[VersionRepo.parse] ${file.name} (${(file.size / 1024 / 1024).toFixed(1)}MB) → POST /api/v1/admin/versions/parse`); + return httpClient.post('/api/v1/admin/versions/parse', fd, { + timeout: 600000, + onUploadProgress: (evt) => { + const pct = evt.total ? Math.round((evt.loaded / evt.total) * 100) : 0; + console.log(`[VersionRepo.parse] upload ${pct}% (${(evt.loaded / 1024 / 1024).toFixed(1)}MB / ${((evt.total ?? 0) / 1024 / 1024).toFixed(1)}MB)`); + onProgress?.(pct); + }, + }); } + // Register = send JSON only (no file), uses storageKey from parse step + async register(input: RegisterVersionInput): Promise { + console.log(`[VersionRepo.register] storageKey=${input.storageKey} ver=${input.versionName}`); + return httpClient.post('/api/v1/admin/versions/register', { + storageKey: input.storageKey, + fileSize: input.fileSize, + fileSha256: input.fileSha256, + appType: input.appType, + platform: input.platform, + versionCode: input.versionCode, + versionName: input.versionName, + buildNumber: input.buildNumber, + changelog: input.changelog, + minOsVersion: input.minOsVersion, + isForceUpdate: input.isForceUpdate, + }); + } + + // Legacy: upload = parse + save in one request (kept for compatibility) async upload(input: UploadVersionInput): Promise { const fd = new FormData(); fd.append('file', input.file); @@ -30,7 +59,7 @@ class VersionRepository implements IVersionRepository { fd.append('changelog', input.changelog || ''); fd.append('minOsVersion', input.minOsVersion || ''); fd.append('isForceUpdate', String(input.isForceUpdate)); - return httpClient.post('/api/v1/admin/versions/upload', fd, { timeout: 300000 }); + return httpClient.post('/api/v1/admin/versions/upload', fd, { timeout: 600000 }); } async update(id: string, input: UpdateVersionInput): Promise { diff --git a/frontend/admin-web/src/store/zustand/upload.store.ts b/frontend/admin-web/src/store/zustand/upload.store.ts index 9dfaaaf..db1c590 100644 --- a/frontend/admin-web/src/store/zustand/upload.store.ts +++ b/frontend/admin-web/src/store/zustand/upload.store.ts @@ -17,8 +17,16 @@ interface UploadFormState { changelog: string; minOsVersion: string; isForceUpdate: boolean; + // Parse phase (file upload + metadata extraction) isParsing: boolean; + parseProgress: number; // 0-100 during file upload to /parse + parseDone: boolean; // true = file on server, ready to register parseWarning: string; + storageKey: string; + fileSize: string; + fileSha256: string; + versionCode: number; + // Register phase (JSON only, instant) isUploading: boolean; uploadError: string; } @@ -32,7 +40,13 @@ interface UploadStoreActions { setMinOsVersion: (v: string) => void; setIsForceUpdate: (v: boolean) => void; setIsParsing: (v: boolean) => void; + setParseProgress: (v: number) => void; + setParseDone: (v: boolean) => void; setParseWarning: (v: string) => void; + setStorageKey: (v: string) => void; + setFileSize: (v: string) => void; + setFileSha256: (v: string) => void; + setVersionCode: (v: number) => void; setIsUploading: (v: boolean) => void; setUploadError: (v: string) => void; reset: () => void; @@ -47,7 +61,13 @@ const initialState: UploadFormState = { minOsVersion: '', isForceUpdate: false, isParsing: false, + parseProgress: 0, + parseDone: false, parseWarning: '', + storageKey: '', + fileSize: '', + fileSha256: '', + versionCode: 0, isUploading: false, uploadError: '', }; @@ -62,7 +82,13 @@ export const useUploadStore = create()((se setMinOsVersion: (minOsVersion) => set({ minOsVersion }), setIsForceUpdate: (isForceUpdate) => set({ isForceUpdate }), setIsParsing: (isParsing) => set({ isParsing }), + setParseProgress: (parseProgress) => set({ parseProgress }), + setParseDone: (parseDone) => set({ parseDone }), setParseWarning: (parseWarning) => set({ parseWarning }), + setStorageKey: (storageKey) => set({ storageKey }), + setFileSize: (fileSize) => set({ fileSize }), + setFileSha256: (fileSha256) => set({ fileSha256 }), + setVersionCode: (versionCode) => set({ versionCode }), setIsUploading: (isUploading) => set({ isUploading }), setUploadError: (uploadError) => set({ uploadError }), reset: () => set(initialState), diff --git a/frontend/admin-web/src/views/app-versions/AppVersionManagementPage.tsx b/frontend/admin-web/src/views/app-versions/AppVersionManagementPage.tsx index 6a93f61..933cbd0 100644 --- a/frontend/admin-web/src/views/app-versions/AppVersionManagementPage.tsx +++ b/frontend/admin-web/src/views/app-versions/AppVersionManagementPage.tsx @@ -255,7 +255,8 @@ const UploadModal: React.FC<{ // 读 Zustand 状态 const { file, platform, versionName, buildNumber, changelog, minOsVersion, isForceUpdate, - isParsing, parseWarning, isUploading, uploadError, + isParsing, parseProgress, parseDone, parseWarning, + isUploading, uploadError, setPlatform, setVersionName, setBuildNumber, setChangelog, setMinOsVersion, setIsForceUpdate, reset, } = useUploadStore(); @@ -272,17 +273,38 @@ const UploadModal: React.FC<{ {file && (
{file.name} ({(file.size / 1024 / 1024).toFixed(1)} MB)
)} + + {/* 上传进度条(选文件后立即开始上传到 /parse) */} {isParsing && ( -
- {t('app_version_parsing')}{file && file.size > 30 * 1024 * 1024 ? '(大文件,请耐心等待...)' : ''} +
+
+ 正在上传并解析... + {parseProgress}% +
+
+
+
+ {parseProgress === 100 && ( +
文件已到达服务器,正在解析元数据...
+ )}
)} + {parseDone && !isParsing && ( +
✓ 上传解析完成,请确认版本信息后点击确认
+ )} {parseWarning && (
{parseWarning}
)} @@ -327,9 +349,7 @@ const UploadModal: React.FC<{ {uploadError &&
{uploadError}
} {isUploading && ( -
- 正在上传,大文件需要较长时间,请耐心等待... -
+
正在注册版本...
)}
@@ -337,9 +357,9 @@ const UploadModal: React.FC<{ style={{ padding: '8px 20px', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-full)', background: 'transparent', cursor: 'pointer' }}> {t('cancel')} -
diff --git a/frontend/admin-web/src/views/app-versions/hooks/use-upload.ts b/frontend/admin-web/src/views/app-versions/hooks/use-upload.ts index ade4f59..cfcf702 100644 --- a/frontend/admin-web/src/views/app-versions/hooks/use-upload.ts +++ b/frontend/admin-web/src/views/app-versions/hooks/use-upload.ts @@ -3,17 +3,22 @@ import type { ChangeEvent } from 'react'; import type { AppType } from '@/domain/entities'; import { useUploadStore } from '@/store/zustand/upload.store'; -import { parsePackageUseCase, uploadVersionUseCase } from '@/application/use-cases/version.use-cases'; +import { versionRepository } from '@/infrastructure/repositories/version.repository'; export function useUpload(appType: AppType, onSuccess: () => void) { const store = useUploadStore(); + // 选文件 → 立即上传到 /parse(显示进度条),服务端保存文件 + 解析元数据 + // /parse 完成后,form 自动填充,用户确认后只需发 JSON 到 /register(瞬间) const handleFileChange = async (e: ChangeEvent) => { const f = e.target.files?.[0]; if (!f) return; store.setFile(f); + store.setUploadError(''); store.setParseWarning(''); + store.setParseDone(false); + store.setParseProgress(0); store.setVersionName(''); store.setBuildNumber(''); store.setMinOsVersion(''); @@ -21,43 +26,66 @@ export function useUpload(appType: AppType, onSuccess: () => void) { if (f.name.endsWith('.apk')) store.setPlatform('ANDROID'); else if (f.name.endsWith('.ipa')) store.setPlatform('IOS'); - // 先解析,等解析完成后才允许点击上传(与 RWADurian 一致) + console.log(`[useUpload] file selected: ${f.name} (${(f.size / 1024 / 1024).toFixed(1)}MB) — uploading to /parse`); + const t0 = Date.now(); store.setIsParsing(true); + try { - const parsed = await parsePackageUseCase.execute(f); + const parsed = await versionRepository.parse(f, (pct) => { + store.setParseProgress(pct); + console.log(`[useUpload] parse upload progress: ${pct}%`); + }); + + const elapsed = ((Date.now() - t0) / 1000).toFixed(1); + console.log(`[useUpload] parse done in ${elapsed}s → ver=${parsed.versionName} build=${parsed.versionCode} storageKey=${parsed.storageKey}`); + if (parsed.versionName) store.setVersionName(parsed.versionName); - if (parsed.versionCode) store.setBuildNumber(String(parsed.versionCode)); + if (parsed.versionCode) { + store.setVersionCode(parsed.versionCode); + store.setBuildNumber(String(parsed.versionCode)); + } if (parsed.minSdkVersion) store.setMinOsVersion(parsed.minSdkVersion); - } catch { - store.setParseWarning('无法自动解析安装包信息,请手动填写版本号和构建号'); + if (parsed.storageKey) store.setStorageKey(parsed.storageKey); + if (parsed.fileSize) store.setFileSize(parsed.fileSize); + if (parsed.fileSha256) store.setFileSha256(parsed.fileSha256); + + store.setParseDone(true); + } catch (err) { + console.error('[useUpload] parse failed:', err); + store.setParseWarning('解析失败,请重试或手动填写版本号和构建号'); } finally { store.setIsParsing(false); } }; + // 确认上传 → 只发 JSON(文件已在服务端),瞬间完成 const handleSubmit = async () => { - const { file, platform, versionName, buildNumber, changelog, minOsVersion, isForceUpdate } = - useUploadStore.getState(); + const { + platform, versionName, buildNumber, changelog, minOsVersion, isForceUpdate, + storageKey, fileSize, fileSha256, versionCode, + } = useUploadStore.getState(); - if (!file) { - store.setUploadError('请选择 APK/IPA 文件'); + if (!storageKey) { + store.setUploadError('请先选择文件(等待上传完成)'); return; } store.setIsUploading(true); store.setUploadError(''); + console.log(`[useUpload] registering version: storageKey=${storageKey} ver=${versionName} build=${buildNumber}`); + try { - console.log('[useUpload] Uploading:', file.name, (file.size / 1024 / 1024).toFixed(1) + 'MB'); - const result = await uploadVersionUseCase.execute({ - file, appType, platform, versionName, buildNumber, - changelog, minOsVersion, isForceUpdate, + const result = await versionRepository.register({ + storageKey, fileSize, fileSha256, + appType, platform, versionCode, + versionName, buildNumber, changelog, minOsVersion, isForceUpdate, }); - console.log('[useUpload] Success:', result); + console.log(`[useUpload] registered! id=${result.id} ver=${result.versionName}`); store.reset(); onSuccess(); } catch (err) { - console.error('[useUpload] Failed:', err); - store.setUploadError((err as Error)?.message || 'Upload failed'); + console.error('[useUpload] register failed:', err); + store.setUploadError((err as Error)?.message || '注册版本失败,请重试'); } store.setIsUploading(false); };