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:
parent
5ce4dd2442
commit
7c8b79161a
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue