feat(upload): parse=upload+save+metadata, register=JSON only — no double upload

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 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-07 07:19:28 -08:00
parent 5ce4dd2442
commit 7c8b79161a
6 changed files with 221 additions and 33 deletions

View File

@ -147,16 +147,82 @@ export class AdminVersionController {
@Post('parse') @Post('parse')
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 500 * 1024 * 1024 } })) @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 500 * 1024 * 1024 } }))
@ApiConsumes('multipart/form-data') @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) { async parsePackage(@UploadedFile() file: Express.Multer.File) {
const t0 = Date.now(); const t0 = Date.now();
if (!file) { if (!file) {
return { code: 400, message: 'No file uploaded — check Content-Type boundary' }; 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`); 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); 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}`); this.logger.log(`[parse] metadata done in ${Date.now() - t1}ms → pkg=${info.packageName} ver=${info.versionName}`);
return { code: 0, data: info };
// 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') @Put(':id')

View File

@ -4,6 +4,24 @@ export interface ParsedPackageInfo {
versionCode?: number; versionCode?: number;
versionName?: string; versionName?: string;
minSdkVersion?: 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 { export interface UploadVersionInput {
@ -26,7 +44,8 @@ export interface UpdateVersionInput {
export interface IVersionRepository { export interface IVersionRepository {
list(appType: AppType, platform?: AppPlatform, includeDisabled?: boolean): Promise<AppVersion[]>; list(appType: AppType, platform?: AppPlatform, includeDisabled?: boolean): Promise<AppVersion[]>;
parse(file: File): Promise<ParsedPackageInfo>; parse(file: File, onProgress?: (pct: number) => void): Promise<ParsedPackageInfo>;
register(input: RegisterVersionInput): Promise<AppVersion>;
upload(input: UploadVersionInput): Promise<AppVersion>; upload(input: UploadVersionInput): Promise<AppVersion>;
update(id: string, input: UpdateVersionInput): Promise<AppVersion>; update(id: string, input: UpdateVersionInput): Promise<AppVersion>;
toggle(id: string, isEnabled: boolean): Promise<void>; toggle(id: string, isEnabled: boolean): Promise<void>;

View File

@ -1,6 +1,7 @@
import type { import type {
IVersionRepository, IVersionRepository,
ParsedPackageInfo, ParsedPackageInfo,
RegisterVersionInput,
UploadVersionInput, UploadVersionInput,
UpdateVersionInput, UpdateVersionInput,
} from '@/domain/repositories/version.repository.interface'; } from '@/domain/repositories/version.repository.interface';
@ -14,12 +15,40 @@ class VersionRepository implements IVersionRepository {
return httpClient.get<AppVersion[]>('/api/v1/admin/versions', { params }); return httpClient.get<AppVersion[]>('/api/v1/admin/versions', { params });
} }
async parse(file: File): Promise<ParsedPackageInfo> { // Parse = upload file once + get metadata + storageKey (server keeps the file)
async parse(file: File, onProgress?: (pct: number) => void): Promise<ParsedPackageInfo> {
const fd = new FormData(); const fd = new FormData();
fd.append('file', file); fd.append('file', file);
return httpClient.post<ParsedPackageInfo>('/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<ParsedPackageInfo>('/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<AppVersion> {
console.log(`[VersionRepo.register] storageKey=${input.storageKey} ver=${input.versionName}`);
return httpClient.post<AppVersion>('/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<AppVersion> { async upload(input: UploadVersionInput): Promise<AppVersion> {
const fd = new FormData(); const fd = new FormData();
fd.append('file', input.file); fd.append('file', input.file);
@ -30,7 +59,7 @@ class VersionRepository implements IVersionRepository {
fd.append('changelog', input.changelog || ''); fd.append('changelog', input.changelog || '');
fd.append('minOsVersion', input.minOsVersion || ''); fd.append('minOsVersion', input.minOsVersion || '');
fd.append('isForceUpdate', String(input.isForceUpdate)); fd.append('isForceUpdate', String(input.isForceUpdate));
return httpClient.post<AppVersion>('/api/v1/admin/versions/upload', fd, { timeout: 300000 }); return httpClient.post<AppVersion>('/api/v1/admin/versions/upload', fd, { timeout: 600000 });
} }
async update(id: string, input: UpdateVersionInput): Promise<AppVersion> { async update(id: string, input: UpdateVersionInput): Promise<AppVersion> {

View File

@ -17,8 +17,16 @@ interface UploadFormState {
changelog: string; changelog: string;
minOsVersion: string; minOsVersion: string;
isForceUpdate: boolean; isForceUpdate: boolean;
// Parse phase (file upload + metadata extraction)
isParsing: boolean; isParsing: boolean;
parseProgress: number; // 0-100 during file upload to /parse
parseDone: boolean; // true = file on server, ready to register
parseWarning: string; parseWarning: string;
storageKey: string;
fileSize: string;
fileSha256: string;
versionCode: number;
// Register phase (JSON only, instant)
isUploading: boolean; isUploading: boolean;
uploadError: string; uploadError: string;
} }
@ -32,7 +40,13 @@ interface UploadStoreActions {
setMinOsVersion: (v: string) => void; setMinOsVersion: (v: string) => void;
setIsForceUpdate: (v: boolean) => void; setIsForceUpdate: (v: boolean) => void;
setIsParsing: (v: boolean) => void; setIsParsing: (v: boolean) => void;
setParseProgress: (v: number) => void;
setParseDone: (v: boolean) => void;
setParseWarning: (v: string) => 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; setIsUploading: (v: boolean) => void;
setUploadError: (v: string) => void; setUploadError: (v: string) => void;
reset: () => void; reset: () => void;
@ -47,7 +61,13 @@ const initialState: UploadFormState = {
minOsVersion: '', minOsVersion: '',
isForceUpdate: false, isForceUpdate: false,
isParsing: false, isParsing: false,
parseProgress: 0,
parseDone: false,
parseWarning: '', parseWarning: '',
storageKey: '',
fileSize: '',
fileSha256: '',
versionCode: 0,
isUploading: false, isUploading: false,
uploadError: '', uploadError: '',
}; };
@ -62,7 +82,13 @@ export const useUploadStore = create<UploadFormState & UploadStoreActions>()((se
setMinOsVersion: (minOsVersion) => set({ minOsVersion }), setMinOsVersion: (minOsVersion) => set({ minOsVersion }),
setIsForceUpdate: (isForceUpdate) => set({ isForceUpdate }), setIsForceUpdate: (isForceUpdate) => set({ isForceUpdate }),
setIsParsing: (isParsing) => set({ isParsing }), setIsParsing: (isParsing) => set({ isParsing }),
setParseProgress: (parseProgress) => set({ parseProgress }),
setParseDone: (parseDone) => set({ parseDone }),
setParseWarning: (parseWarning) => set({ parseWarning }), setParseWarning: (parseWarning) => set({ parseWarning }),
setStorageKey: (storageKey) => set({ storageKey }),
setFileSize: (fileSize) => set({ fileSize }),
setFileSha256: (fileSha256) => set({ fileSha256 }),
setVersionCode: (versionCode) => set({ versionCode }),
setIsUploading: (isUploading) => set({ isUploading }), setIsUploading: (isUploading) => set({ isUploading }),
setUploadError: (uploadError) => set({ uploadError }), setUploadError: (uploadError) => set({ uploadError }),
reset: () => set(initialState), reset: () => set(initialState),

View File

@ -255,7 +255,8 @@ const UploadModal: React.FC<{
// 读 Zustand 状态 // 读 Zustand 状态
const { const {
file, platform, versionName, buildNumber, changelog, minOsVersion, isForceUpdate, file, platform, versionName, buildNumber, changelog, minOsVersion, isForceUpdate,
isParsing, parseWarning, isUploading, uploadError, isParsing, parseProgress, parseDone, parseWarning,
isUploading, uploadError,
setPlatform, setVersionName, setBuildNumber, setChangelog, setPlatform, setVersionName, setBuildNumber, setChangelog,
setMinOsVersion, setIsForceUpdate, reset, setMinOsVersion, setIsForceUpdate, reset,
} = useUploadStore(); } = useUploadStore();
@ -272,17 +273,38 @@ const UploadModal: React.FC<{
<label style={labelStyle}>{t('app_version_upload_file')}</label> <label style={labelStyle}>{t('app_version_upload_file')}</label>
<input type="file" accept=".apk,.ipa" onChange={handleFileChange} <input type="file" accept=".apk,.ipa" onChange={handleFileChange}
disabled={isParsing}
style={{ ...inputStyle, padding: '8px 12px', height: 'auto' }} /> style={{ ...inputStyle, padding: '8px 12px', height: 'auto' }} />
{file && ( {file && (
<div style={{ color: 'var(--color-text-secondary)', marginTop: 4 }}> <div style={{ color: 'var(--color-text-secondary)', marginTop: 4 }}>
{file.name} ({(file.size / 1024 / 1024).toFixed(1)} MB) {file.name} ({(file.size / 1024 / 1024).toFixed(1)} MB)
</div> </div>
)} )}
{/* 上传进度条(选文件后立即开始上传到 /parse */}
{isParsing && ( {isParsing && (
<div style={{ color: 'var(--color-info)', marginTop: 4 }}> <div style={{ marginTop: 8 }}>
{t('app_version_parsing')}{file && file.size > 30 * 1024 * 1024 ? '(大文件,请耐心等待...' : ''} <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ color: 'var(--color-info)', fontSize: 13 }}>...</span>
<span style={{ color: 'var(--color-info)', fontSize: 13, fontWeight: 600 }}>{parseProgress}%</span>
</div>
<div style={{ height: 8, background: 'var(--color-gray-100)', borderRadius: 4, overflow: 'hidden' }}>
<div style={{
height: '100%',
width: `${parseProgress}%`,
background: 'var(--color-primary)',
borderRadius: 4,
transition: 'width 0.3s ease',
}} />
</div>
{parseProgress === 100 && (
<div style={{ color: 'var(--color-text-tertiary)', fontSize: 12, marginTop: 4 }}>...</div>
)}
</div> </div>
)} )}
{parseDone && !isParsing && (
<div style={{ color: 'var(--color-success, #0a0)', marginTop: 4, fontSize: 13 }}> </div>
)}
{parseWarning && ( {parseWarning && (
<div style={{ color: 'var(--color-warning, #f90)', marginTop: 4 }}>{parseWarning}</div> <div style={{ color: 'var(--color-warning, #f90)', marginTop: 4 }}>{parseWarning}</div>
)} )}
@ -327,9 +349,7 @@ const UploadModal: React.FC<{
{uploadError && <div style={{ color: 'var(--color-error)', marginTop: 8 }}>{uploadError}</div>} {uploadError && <div style={{ color: 'var(--color-error)', marginTop: 8 }}>{uploadError}</div>}
{isUploading && ( {isUploading && (
<div style={{ color: 'var(--color-info)', marginTop: 4 }}> <div style={{ color: 'var(--color-info)', marginTop: 4 }}>...</div>
...
</div>
)} )}
<div style={{ display: 'flex', gap: 12, marginTop: 20, justifyContent: 'flex-end' }}> <div style={{ display: 'flex', gap: 12, marginTop: 20, justifyContent: 'flex-end' }}>
@ -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' }}> style={{ padding: '8px 20px', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-full)', background: 'transparent', cursor: 'pointer' }}>
{t('cancel')} {t('cancel')}
</button> </button>
<button onClick={handleSubmit} disabled={isUploading || isParsing} <button onClick={handleSubmit} disabled={isUploading || isParsing || !parseDone}
style={{ ...primaryBtn, opacity: isUploading || isParsing ? 0.6 : 1 }}> style={{ ...primaryBtn, opacity: (isUploading || isParsing || !parseDone) ? 0.6 : 1 }}>
{isUploading ? t('app_version_uploading') : t('confirm')} {isUploading ? '注册中...' : isParsing ? `上传中 ${parseProgress}%` : t('confirm')}
</button> </button>
</div> </div>
</div> </div>

View File

@ -3,17 +3,22 @@
import type { ChangeEvent } from 'react'; import type { ChangeEvent } from 'react';
import type { AppType } from '@/domain/entities'; import type { AppType } from '@/domain/entities';
import { useUploadStore } from '@/store/zustand/upload.store'; 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) { export function useUpload(appType: AppType, onSuccess: () => void) {
const store = useUploadStore(); const store = useUploadStore();
// 选文件 → 立即上传到 /parse显示进度条服务端保存文件 + 解析元数据
// /parse 完成后form 自动填充,用户确认后只需发 JSON 到 /register瞬间
const handleFileChange = async (e: ChangeEvent<HTMLInputElement>) => { const handleFileChange = async (e: ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0]; const f = e.target.files?.[0];
if (!f) return; if (!f) return;
store.setFile(f); store.setFile(f);
store.setUploadError('');
store.setParseWarning(''); store.setParseWarning('');
store.setParseDone(false);
store.setParseProgress(0);
store.setVersionName(''); store.setVersionName('');
store.setBuildNumber(''); store.setBuildNumber('');
store.setMinOsVersion(''); store.setMinOsVersion('');
@ -21,43 +26,66 @@ export function useUpload(appType: AppType, onSuccess: () => void) {
if (f.name.endsWith('.apk')) store.setPlatform('ANDROID'); if (f.name.endsWith('.apk')) store.setPlatform('ANDROID');
else if (f.name.endsWith('.ipa')) store.setPlatform('IOS'); 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); store.setIsParsing(true);
try { 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.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); if (parsed.minSdkVersion) store.setMinOsVersion(parsed.minSdkVersion);
} catch { if (parsed.storageKey) store.setStorageKey(parsed.storageKey);
store.setParseWarning('无法自动解析安装包信息,请手动填写版本号和构建号'); 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 { } finally {
store.setIsParsing(false); store.setIsParsing(false);
} }
}; };
// 确认上传 → 只发 JSON文件已在服务端瞬间完成
const handleSubmit = async () => { const handleSubmit = async () => {
const { file, platform, versionName, buildNumber, changelog, minOsVersion, isForceUpdate } = const {
useUploadStore.getState(); platform, versionName, buildNumber, changelog, minOsVersion, isForceUpdate,
storageKey, fileSize, fileSha256, versionCode,
} = useUploadStore.getState();
if (!file) { if (!storageKey) {
store.setUploadError('请选择 APK/IPA 文件'); store.setUploadError('请选择文件(等待上传完成)');
return; return;
} }
store.setIsUploading(true); store.setIsUploading(true);
store.setUploadError(''); store.setUploadError('');
console.log(`[useUpload] registering version: storageKey=${storageKey} ver=${versionName} build=${buildNumber}`);
try { try {
console.log('[useUpload] Uploading:', file.name, (file.size / 1024 / 1024).toFixed(1) + 'MB'); const result = await versionRepository.register({
const result = await uploadVersionUseCase.execute({ storageKey, fileSize, fileSha256,
file, appType, platform, versionName, buildNumber, appType, platform, versionCode,
changelog, minOsVersion, isForceUpdate, versionName, buildNumber, changelog, minOsVersion, isForceUpdate,
}); });
console.log('[useUpload] Success:', result); console.log(`[useUpload] registered! id=${result.id} ver=${result.versionName}`);
store.reset(); store.reset();
onSuccess(); onSuccess();
} catch (err) { } catch (err) {
console.error('[useUpload] Failed:', err); console.error('[useUpload] register failed:', err);
store.setUploadError((err as Error)?.message || 'Upload failed'); store.setUploadError((err as Error)?.message || '注册版本失败,请重试');
} }
store.setIsUploading(false); store.setIsUploading(false);
}; };