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')
@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')

View File

@ -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<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>;
update(id: string, input: UpdateVersionInput): Promise<AppVersion>;
toggle(id: string, isEnabled: boolean): Promise<void>;

View File

@ -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<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();
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> {
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<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> {

View File

@ -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<UploadFormState & UploadStoreActions>()((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),

View File

@ -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<{
<label style={labelStyle}>{t('app_version_upload_file')}</label>
<input type="file" accept=".apk,.ipa" onChange={handleFileChange}
disabled={isParsing}
style={{ ...inputStyle, padding: '8px 12px', height: 'auto' }} />
{file && (
<div style={{ color: 'var(--color-text-secondary)', marginTop: 4 }}>
{file.name} ({(file.size / 1024 / 1024).toFixed(1)} MB)
</div>
)}
{/* 上传进度条(选文件后立即开始上传到 /parse */}
{isParsing && (
<div style={{ color: 'var(--color-info)', marginTop: 4 }}>
{t('app_version_parsing')}{file && file.size > 30 * 1024 * 1024 ? '(大文件,请耐心等待...' : ''}
<div style={{ marginTop: 8 }}>
<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>
)}
{parseDone && !isParsing && (
<div style={{ color: 'var(--color-success, #0a0)', marginTop: 4, fontSize: 13 }}> </div>
)}
{parseWarning && (
<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>}
{isUploading && (
<div style={{ color: 'var(--color-info)', marginTop: 4 }}>
...
</div>
<div style={{ color: 'var(--color-info)', marginTop: 4 }}>...</div>
)}
<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' }}>
{t('cancel')}
</button>
<button onClick={handleSubmit} disabled={isUploading || isParsing}
style={{ ...primaryBtn, opacity: isUploading || isParsing ? 0.6 : 1 }}>
{isUploading ? t('app_version_uploading') : t('confirm')}
<button onClick={handleSubmit} disabled={isUploading || isParsing || !parseDone}
style={{ ...primaryBtn, opacity: (isUploading || isParsing || !parseDone) ? 0.6 : 1 }}>
{isUploading ? '注册中...' : isParsing ? `上传中 ${parseProgress}%` : t('confirm')}
</button>
</div>
</div>

View File

@ -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<HTMLInputElement>) => {
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);
};