From f8607ce0b23e43c7b50b3d786a9f76d22d0939bd Mon Sep 17 00:00:00 2001 From: Developer Date: Wed, 3 Dec 2025 06:57:26 -0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E7=A7=BB=E5=8A=A8?= =?UTF-8?q?=E7=AB=AF=E7=89=88=E6=9C=AC=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## admin-service - 添加 APK/IPA 预解析 API (/api/v1/versions/parse) - 添加断点续传下载控制器 (/api/v1/downloads/:filename) - 配置 uploads volume 持久化存储 - 下载 URL 从 /uploads 改为 /downloads (支持 Range 请求) ## mobile-upgrade (前端) - 上传文件后自动解析并填充版本信息 - 添加 ParsedPackageInfo 类型和 parsePackage API ## mobile-app (Flutter) - DownloadManager 支持断点续传 (HTTP Range) - 添加临时文件管理和清理功能 - 添加构建脚本自动增加版本号 (scripts/build.sh) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../services/admin-service/docker-compose.yml | 7 + .../api/controllers/download.controller.ts | 106 ++++++++++++++ .../src/api/controllers/version.controller.ts | 73 ++++++++++ .../services/admin-service/src/app.module.ts | 3 +- .../storage/file-storage.service.ts | 2 +- .../lib/core/updater/download_manager.dart | 93 +++++++++++-- frontend/mobile-app/scripts/build.ps1 | 116 ++++++++++++++++ frontend/mobile-app/scripts/build.sh | 130 ++++++++++++++++++ .../src/domain/entities/version.ts | 8 ++ .../domain/repositories/version-repository.ts | 3 + .../repositories/version-repository-impl.ts | 20 +++ .../presentation/components/upload-modal.tsx | 49 +++++-- 12 files changed, 584 insertions(+), 26 deletions(-) create mode 100644 backend/services/admin-service/src/api/controllers/download.controller.ts create mode 100644 frontend/mobile-app/scripts/build.ps1 create mode 100644 frontend/mobile-app/scripts/build.sh diff --git a/backend/services/admin-service/docker-compose.yml b/backend/services/admin-service/docker-compose.yml index 300fedda..86c5fca0 100644 --- a/backend/services/admin-service/docker-compose.yml +++ b/backend/services/admin-service/docker-compose.yml @@ -26,6 +26,11 @@ services: - REDIS_PORT=6379 - REDIS_PASSWORD= - REDIS_DB=9 + # File Storage + - UPLOAD_DIR=/app/uploads + - BASE_URL=${BASE_URL:-https://rwaapi.szaiai.com/api/v1} + volumes: + - uploads_data:/app/uploads depends_on: postgres: condition: service_healthy @@ -82,6 +87,8 @@ volumes: name: admin-service-postgres-data redis_data: name: admin-service-redis-data + uploads_data: + name: admin-service-uploads-data networks: admin-network: diff --git a/backend/services/admin-service/src/api/controllers/download.controller.ts b/backend/services/admin-service/src/api/controllers/download.controller.ts new file mode 100644 index 00000000..b02cec56 --- /dev/null +++ b/backend/services/admin-service/src/api/controllers/download.controller.ts @@ -0,0 +1,106 @@ +import { + Controller, + Get, + Param, + Res, + Req, + NotFoundException, + Logger, +} from '@nestjs/common' +import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger' +import { Request, Response } from 'express' +import { ConfigService } from '@nestjs/config' +import * as fs from 'fs' +import * as path from 'path' + +@ApiTags('Downloads') +@Controller('downloads') +export class DownloadController { + private readonly logger = new Logger(DownloadController.name) + private readonly uploadDir: string + + constructor(private readonly configService: ConfigService) { + this.uploadDir = this.configService.get('UPLOAD_DIR') || './uploads' + } + + @Get(':filename') + @ApiOperation({ summary: '下载文件 (支持断点续传)' }) + @ApiParam({ name: 'filename', description: '文件名' }) + @ApiResponse({ status: 200, description: '文件内容' }) + @ApiResponse({ status: 206, description: '部分内容 (断点续传)' }) + @ApiResponse({ status: 404, description: '文件不存在' }) + @ApiResponse({ status: 416, description: 'Range 不满足' }) + async downloadFile( + @Param('filename') filename: string, + @Req() req: Request, + @Res() res: Response, + ): Promise { + // 安全检查:防止路径遍历攻击 + const sanitizedFilename = path.basename(filename) + const filePath = path.join(this.uploadDir, sanitizedFilename) + + // 检查文件是否存在 + if (!fs.existsSync(filePath)) { + throw new NotFoundException('文件不存在') + } + + const stat = fs.statSync(filePath) + const fileSize = stat.size + const range = req.headers.range + + // 设置通用响应头 + const ext = path.extname(filename).toLowerCase() + const mimeTypes: Record = { + '.apk': 'application/vnd.android.package-archive', + '.ipa': 'application/octet-stream', + } + const contentType = mimeTypes[ext] || 'application/octet-stream' + + // 设置缓存和下载头 + res.setHeader('Accept-Ranges', 'bytes') + res.setHeader('Content-Type', contentType) + res.setHeader( + 'Content-Disposition', + `attachment; filename="${encodeURIComponent(sanitizedFilename)}"`, + ) + res.setHeader('Cache-Control', 'public, max-age=86400') // 缓存1天 + res.setHeader('ETag', `"${stat.mtime.getTime()}-${fileSize}"`) + res.setHeader('Last-Modified', stat.mtime.toUTCString()) + + if (range) { + // 断点续传请求 + const parts = range.replace(/bytes=/, '').split('-') + const start = parseInt(parts[0], 10) + const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1 + + // 验证 Range 有效性 + if (start >= fileSize || end >= fileSize || start > end) { + res.status(416) + res.setHeader('Content-Range', `bytes */${fileSize}`) + res.end() + return + } + + const chunkSize = end - start + 1 + + this.logger.log( + `Range request: ${filename}, bytes ${start}-${end}/${fileSize}`, + ) + + res.status(206) + res.setHeader('Content-Range', `bytes ${start}-${end}/${fileSize}`) + res.setHeader('Content-Length', chunkSize) + + const stream = fs.createReadStream(filePath, { start, end }) + stream.pipe(res) + } else { + // 完整文件请求 + this.logger.log(`Full download: ${filename}, size: ${fileSize}`) + + res.setHeader('Content-Length', fileSize) + + const stream = fs.createReadStream(filePath) + stream.pipe(res) + } + } +} diff --git a/backend/services/admin-service/src/api/controllers/version.controller.ts b/backend/services/admin-service/src/api/controllers/version.controller.ts index 02a04256..b031207a 100644 --- a/backend/services/admin-service/src/api/controllers/version.controller.ts +++ b/backend/services/admin-service/src/api/controllers/version.controller.ts @@ -43,6 +43,7 @@ import { UploadVersionHandler } from '@/application/commands/upload-version/uplo import { UploadVersionCommand } from '@/application/commands/upload-version/upload-version.command' import { Platform } from '@/domain/enums/platform.enum' import { AppVersion } from '@/domain/entities/app-version.entity' +import { PackageParserService } from '@/infrastructure/parsers/package-parser.service' // Maximum file size: 500MB const MAX_FILE_SIZE = 500 * 1024 * 1024 @@ -59,6 +60,7 @@ export class VersionController { private readonly deleteVersionHandler: DeleteVersionHandler, private readonly toggleVersionHandler: ToggleVersionHandler, private readonly uploadVersionHandler: UploadVersionHandler, + private readonly packageParserService: PackageParserService, ) {} private toVersionDto(version: AppVersion): VersionDto { @@ -196,6 +198,77 @@ export class VersionController { return { success: true } } + @Post('parse') + @UseInterceptors(FileInterceptor('file')) + @ApiOperation({ summary: '解析APK/IPA包信息 (不保存)' }) + @ApiBearerAuth() + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + required: ['file', 'platform'], + properties: { + file: { + type: 'string', + format: 'binary', + description: 'APK或IPA安装包文件', + }, + platform: { + type: 'string', + enum: ['android', 'ios'], + description: '平台', + }, + }, + }, + }) + @ApiResponse({ + status: 200, + description: '解析成功', + schema: { + type: 'object', + properties: { + packageName: { type: 'string', description: '包名' }, + versionCode: { type: 'number', description: '版本号' }, + versionName: { type: 'string', description: '版本名称' }, + minSdkVersion: { type: 'string', description: '最低SDK版本' }, + targetSdkVersion: { type: 'string', description: '目标SDK版本' }, + }, + }, + }) + @ApiResponse({ status: 400, description: '解析失败' }) + async parsePackage( + @UploadedFile( + new ParseFilePipe({ + validators: [new MaxFileSizeValidator({ maxSize: MAX_FILE_SIZE })], + fileIsRequired: true, + }), + ) + file: Express.Multer.File, + @Body('platform') platform: Platform, + ): Promise<{ + packageName: string + versionCode: number + versionName: string + minSdkVersion?: string + targetSdkVersion?: string + }> { + // Validate file extension + const ext = file.originalname.toLowerCase().split('.').pop() + if (platform === Platform.ANDROID && ext !== 'apk') { + throw new BadRequestException('Android平台只能上传APK文件') + } + if (platform === Platform.IOS && ext !== 'ipa') { + throw new BadRequestException('iOS平台只能上传IPA文件') + } + + const parsed = await this.packageParserService.parsePackage(file.buffer, platform) + if (!parsed) { + throw new BadRequestException('无法解析安装包信息,请确认文件格式正确') + } + + return parsed + } + @Post('upload') @UseInterceptors(FileInterceptor('file')) @ApiOperation({ summary: '上传APK/IPA并创建版本 (管理员)' }) diff --git a/backend/services/admin-service/src/app.module.ts b/backend/services/admin-service/src/app.module.ts index 7847ce37..2f555d59 100644 --- a/backend/services/admin-service/src/app.module.ts +++ b/backend/services/admin-service/src/app.module.ts @@ -20,6 +20,7 @@ import { UploadVersionHandler } from './application/commands/upload-version/uplo import { VersionController } from './api/controllers/version.controller'; import { MobileVersionController } from './api/controllers/mobile-version.controller'; import { HealthController } from './api/controllers/health.controller'; +import { DownloadController } from './api/controllers/download.controller'; @Module({ imports: [ @@ -33,7 +34,7 @@ import { HealthController } from './api/controllers/health.controller'; serveRoot: '/uploads', }), ], - controllers: [VersionController, MobileVersionController, HealthController], + controllers: [VersionController, MobileVersionController, HealthController, DownloadController], providers: [ PrismaService, AppVersionMapper, diff --git a/backend/services/admin-service/src/infrastructure/storage/file-storage.service.ts b/backend/services/admin-service/src/infrastructure/storage/file-storage.service.ts index 9223515e..fcaaabbe 100644 --- a/backend/services/admin-service/src/infrastructure/storage/file-storage.service.ts +++ b/backend/services/admin-service/src/infrastructure/storage/file-storage.service.ts @@ -62,7 +62,7 @@ export class FileStorageService { path: filePath, size: buffer.length, sha256, - url: `${this.baseUrl}/uploads/${filename}`, + url: `${this.baseUrl}/downloads/${filename}`, } } diff --git a/frontend/mobile-app/lib/core/updater/download_manager.dart b/frontend/mobile-app/lib/core/updater/download_manager.dart index 18fdd5a2..49cca277 100644 --- a/frontend/mobile-app/lib/core/updater/download_manager.dart +++ b/frontend/mobile-app/lib/core/updater/download_manager.dart @@ -19,6 +19,7 @@ typedef DownloadProgressCallback = void Function(int received, int total); /// 下载管理器 /// 负责下载 APK 文件并验证完整性 +/// 支持断点续传 class DownloadManager { final Dio _dio = Dio(); CancelToken? _cancelToken; @@ -27,7 +28,7 @@ class DownloadManager { /// 当前下载状态 DownloadStatus get status => _status; - /// 下载 APK 文件 + /// 下载 APK 文件(支持断点续传) /// [url] 下载地址(必须是 HTTPS) /// [sha256Expected] SHA-256 校验值 /// [onProgress] 下载进度回调 (已下载字节, 总字节) @@ -50,32 +51,78 @@ class DownloadManager { // 使用应用专属目录(无需额外权限) final dir = await getApplicationDocumentsDirectory(); final savePath = '${dir.path}/app_update.apk'; + final tempPath = '${dir.path}/app_update.apk.tmp'; final file = File(savePath); + final tempFile = File(tempPath); - // 如果已存在则删除 + // 检查是否有未完成的下载(断点续传) + int downloadedBytes = 0; + if (await tempFile.exists()) { + downloadedBytes = await tempFile.length(); + debugPrint('Found partial download: $downloadedBytes bytes'); + } + + // 如果完整文件已存在则删除(可能是上次失败的) if (await file.exists()) { await file.delete(); } - debugPrint('Downloading APK to: $savePath'); + debugPrint('Downloading APK to: $savePath (resume from: $downloadedBytes bytes)'); - await _dio.download( + // 使用流式下载以支持断点续传 + final response = await _dio.get( url, - savePath, cancelToken: _cancelToken, - onReceiveProgress: (received, total) { - if (total != -1) { - final progress = (received / total * 100).toStringAsFixed(0); - debugPrint('Download progress: $progress%'); - onProgress?.call(received, total); - } - }, options: Options( + responseType: ResponseType.stream, receiveTimeout: const Duration(minutes: 10), sendTimeout: const Duration(minutes: 10), + headers: downloadedBytes > 0 + ? {'Range': 'bytes=$downloadedBytes-'} + : null, ), ); + // 获取总文件大小 + int totalBytes = 0; + final contentLength = response.headers.value('content-length'); + final contentRange = response.headers.value('content-range'); + + if (contentRange != null) { + // 断点续传响应: "bytes 1000-9999/10000" + final match = RegExp(r'bytes \d+-\d+/(\d+)').firstMatch(contentRange); + if (match != null) { + totalBytes = int.parse(match.group(1)!); + } + } else if (contentLength != null) { + totalBytes = int.parse(contentLength) + downloadedBytes; + } + + debugPrint('Total file size: $totalBytes bytes'); + + // 打开文件进行追加写入 + final sink = tempFile.openWrite(mode: FileMode.append); + int receivedBytes = downloadedBytes; + + try { + await for (final chunk in response.data!.stream) { + sink.add(chunk); + receivedBytes += chunk.length; + + if (totalBytes > 0) { + final progress = (receivedBytes / totalBytes * 100).toStringAsFixed(0); + debugPrint('Download progress: $progress%'); + onProgress?.call(receivedBytes, totalBytes); + } + } + await sink.flush(); + } finally { + await sink.close(); + } + + // 重命名临时文件为最终文件 + await tempFile.rename(savePath); + debugPrint('Download completed'); _status = DownloadStatus.verifying; @@ -136,17 +183,37 @@ class DownloadManager { } } - /// 删除已下载的 APK 文件 + /// 删除已下载的 APK 文件和临时文件 Future cleanupDownloadedApk() async { try { final dir = await getApplicationDocumentsDirectory(); final file = File('${dir.path}/app_update.apk'); + final tempFile = File('${dir.path}/app_update.apk.tmp'); + if (await file.exists()) { await file.delete(); debugPrint('Cleaned up downloaded APK'); } + if (await tempFile.exists()) { + await tempFile.delete(); + debugPrint('Cleaned up temporary APK file'); + } } catch (e) { debugPrint('Cleanup failed: $e'); } } + + /// 清除断点续传的临时文件(用于强制重新下载) + Future clearPartialDownload() async { + try { + final dir = await getApplicationDocumentsDirectory(); + final tempFile = File('${dir.path}/app_update.apk.tmp'); + if (await tempFile.exists()) { + await tempFile.delete(); + debugPrint('Cleared partial download'); + } + } catch (e) { + debugPrint('Clear partial download failed: $e'); + } + } } diff --git a/frontend/mobile-app/scripts/build.ps1 b/frontend/mobile-app/scripts/build.ps1 new file mode 100644 index 00000000..1f94e462 --- /dev/null +++ b/frontend/mobile-app/scripts/build.ps1 @@ -0,0 +1,116 @@ +# ============================================================================= +# Flutter APK 构建脚本(自动增加构建号)- Windows PowerShell 版本 +# ============================================================================= +# 用法: +# .\scripts\build.ps1 # 构建 release APK,自动增加构建号 +# .\scripts\build.ps1 -NoBump # 构建但不增加构建号 +# .\scripts\build.ps1 -SetBuild 100 # 设置指定构建号 +# ============================================================================= + +param( + [switch]$NoBump, + [int]$SetBuild = 0 +) + +$ErrorActionPreference = "Stop" + +# 获取脚本目录 +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$ProjectDir = Split-Path -Parent $ScriptDir +$PubspecFile = Join-Path $ProjectDir "pubspec.yaml" + +function Write-Info { param($msg) Write-Host "[INFO] $msg" -ForegroundColor Blue } +function Write-Success { param($msg) Write-Host "[OK] $msg" -ForegroundColor Green } +function Write-Warn { param($msg) Write-Host "[WARN] $msg" -ForegroundColor Yellow } + +# 读取当前版本 +function Get-CurrentVersion { + $content = Get-Content $PubspecFile -Raw + if ($content -match "version:\s*(.+)") { + return $Matches[1].Trim() + } + return "1.0.0+1" +} + +# 解析版本号 +function Parse-Version { + param($version) + $parts = $version -split '\+' + return @{ + Name = $parts[0] + Build = [int]$parts[1] + } +} + +# 更新版本号 +function Update-Version { + param($newVersion) + $content = Get-Content $PubspecFile -Raw + $content = $content -replace "version:\s*.+", "version: $newVersion" + Set-Content $PubspecFile $content -NoNewline +} + +# 主函数 +function Main { + Set-Location $ProjectDir + + # 获取当前版本 + $currentVersion = Get-CurrentVersion + $version = Parse-Version $currentVersion + + Write-Info "当前版本: $currentVersion" + Write-Info "版本名: $($version.Name), 构建号: $($version.Build)" + + # 更新构建号 + $buildNumber = $version.Build + + if ($SetBuild -gt 0) { + $buildNumber = $SetBuild + Write-Info "设置构建号为: $buildNumber" + } elseif (-not $NoBump) { + $buildNumber = $buildNumber + 1 + Write-Info "自动增加构建号为: $buildNumber" + } + + $newVersion = "$($version.Name)+$buildNumber" + + if ($currentVersion -ne $newVersion) { + Update-Version $newVersion + Write-Success "版本更新为: $newVersion" + } + + # 清理并获取依赖 + Write-Info "获取依赖..." + flutter pub get + + # 构建 APK + Write-Info "构建 Release APK..." + flutter build apk --release + + # 输出结果 + $apkPath = Join-Path $ProjectDir "build\app\outputs\flutter-apk\app-release.apk" + if (Test-Path $apkPath) { + $apkSize = (Get-Item $apkPath).Length / 1MB + Write-Success "构建完成!" + Write-Host "" + Write-Host "APK 信息:" + Write-Host " 版本: $newVersion" + Write-Host " 路径: $apkPath" + Write-Host " 大小: $([math]::Round($apkSize, 2)) MB" + Write-Host "" + + # 复制到 publish 目录 + $publishDir = Join-Path $ProjectDir "publish" + if (-not (Test-Path $publishDir)) { + New-Item -ItemType Directory -Path $publishDir | Out-Null + } + $publishPath = Join-Path $publishDir "rwa-durian-$($version.Name)-$buildNumber.apk" + Copy-Item $apkPath $publishPath + Write-Success "已复制到: $publishPath" + } else { + Write-Warn "APK 文件未找到" + exit 1 + } +} + +Main diff --git a/frontend/mobile-app/scripts/build.sh b/frontend/mobile-app/scripts/build.sh new file mode 100644 index 00000000..a78dcbe1 --- /dev/null +++ b/frontend/mobile-app/scripts/build.sh @@ -0,0 +1,130 @@ +#!/bin/bash +# ============================================================================= +# Flutter APK 构建脚本(自动增加构建号) +# ============================================================================= +# 用法: +# ./scripts/build.sh # 构建 release APK,自动增加构建号 +# ./scripts/build.sh --no-bump # 构建但不增加构建号 +# ./scripts/build.sh --set 100 # 设置指定构建号 +# ============================================================================= + +set -e + +# 颜色 +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +log_info() { echo -e "${BLUE}[INFO]${NC} $1"; } +log_success() { echo -e "${GREEN}[OK]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } + +# 获取脚本目录 +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +PUBSPEC_FILE="$PROJECT_DIR/pubspec.yaml" + +# 读取当前版本 +get_current_version() { + grep "^version:" "$PUBSPEC_FILE" | sed 's/version: //' +} + +# 解析版本号 +parse_version() { + local version="$1" + VERSION_NAME=$(echo "$version" | cut -d'+' -f1) + BUILD_NUMBER=$(echo "$version" | cut -d'+' -f2) +} + +# 更新版本号 +update_version() { + local new_version="$1" + if [[ "$OSTYPE" == "darwin"* ]]; then + # macOS + sed -i '' "s/^version:.*/version: $new_version/" "$PUBSPEC_FILE" + else + # Linux + sed -i "s/^version:.*/version: $new_version/" "$PUBSPEC_FILE" + fi +} + +# 主函数 +main() { + cd "$PROJECT_DIR" + + # 获取当前版本 + CURRENT_VERSION=$(get_current_version) + parse_version "$CURRENT_VERSION" + + log_info "当前版本: $CURRENT_VERSION" + log_info "版本名: $VERSION_NAME, 构建号: $BUILD_NUMBER" + + # 处理参数 + BUMP_VERSION=true + NEW_BUILD_NUMBER="" + + while [[ $# -gt 0 ]]; do + case $1 in + --no-bump) + BUMP_VERSION=false + shift + ;; + --set) + NEW_BUILD_NUMBER="$2" + shift 2 + ;; + *) + shift + ;; + esac + done + + # 更新构建号 + if [ -n "$NEW_BUILD_NUMBER" ]; then + BUILD_NUMBER="$NEW_BUILD_NUMBER" + log_info "设置构建号为: $BUILD_NUMBER" + elif [ "$BUMP_VERSION" = true ]; then + BUILD_NUMBER=$((BUILD_NUMBER + 1)) + log_info "自动增加构建号为: $BUILD_NUMBER" + fi + + NEW_VERSION="${VERSION_NAME}+${BUILD_NUMBER}" + + if [ "$CURRENT_VERSION" != "$NEW_VERSION" ]; then + update_version "$NEW_VERSION" + log_success "版本更新为: $NEW_VERSION" + fi + + # 清理并获取依赖 + log_info "获取依赖..." + flutter pub get + + # 构建 APK + log_info "构建 Release APK..." + flutter build apk --release + + # 输出结果 + APK_PATH="$PROJECT_DIR/build/app/outputs/flutter-apk/app-release.apk" + if [ -f "$APK_PATH" ]; then + APK_SIZE=$(du -h "$APK_PATH" | cut -f1) + log_success "构建完成!" + echo "" + echo "APK 信息:" + echo " 版本: $NEW_VERSION" + echo " 路径: $APK_PATH" + echo " 大小: $APK_SIZE" + echo "" + + # 复制到 publish 目录 + PUBLISH_DIR="$PROJECT_DIR/publish" + mkdir -p "$PUBLISH_DIR" + cp "$APK_PATH" "$PUBLISH_DIR/rwa-durian-${VERSION_NAME}-${BUILD_NUMBER}.apk" + log_success "已复制到: $PUBLISH_DIR/rwa-durian-${VERSION_NAME}-${BUILD_NUMBER}.apk" + else + log_warn "APK 文件未找到" + exit 1 + fi +} + +main "$@" diff --git a/frontend/mobile-upgrade/src/domain/entities/version.ts b/frontend/mobile-upgrade/src/domain/entities/version.ts index 0a24c1c5..296733fd 100644 --- a/frontend/mobile-upgrade/src/domain/entities/version.ts +++ b/frontend/mobile-upgrade/src/domain/entities/version.ts @@ -58,3 +58,11 @@ export interface VersionListFilter { platform?: Platform includeDisabled?: boolean } + +export interface ParsedPackageInfo { + packageName: string + versionCode: number + versionName: string + minSdkVersion?: string + targetSdkVersion?: string +} diff --git a/frontend/mobile-upgrade/src/domain/repositories/version-repository.ts b/frontend/mobile-upgrade/src/domain/repositories/version-repository.ts index de22532b..15416792 100644 --- a/frontend/mobile-upgrade/src/domain/repositories/version-repository.ts +++ b/frontend/mobile-upgrade/src/domain/repositories/version-repository.ts @@ -4,6 +4,8 @@ import { UpdateVersionInput, UploadVersionInput, VersionListFilter, + ParsedPackageInfo, + Platform, } from '../entities/version' export interface IVersionRepository { @@ -14,4 +16,5 @@ export interface IVersionRepository { delete(id: string): Promise toggle(id: string, isEnabled: boolean): Promise upload(input: UploadVersionInput): Promise + parsePackage(file: File, platform: Platform): Promise } diff --git a/frontend/mobile-upgrade/src/infrastructure/repositories/version-repository-impl.ts b/frontend/mobile-upgrade/src/infrastructure/repositories/version-repository-impl.ts index 16d1eba7..69df5d62 100644 --- a/frontend/mobile-upgrade/src/infrastructure/repositories/version-repository-impl.ts +++ b/frontend/mobile-upgrade/src/infrastructure/repositories/version-repository-impl.ts @@ -6,6 +6,8 @@ import { UploadVersionInput, VersionListFilter, IVersionRepository, + ParsedPackageInfo, + Platform, } from '@/domain' import { apiClient } from '../http/api-client' @@ -87,6 +89,24 @@ export class VersionRepositoryImpl implements IVersionRepository { ) return response.data } + + async parsePackage(file: File, platform: Platform): Promise { + const formData = new FormData() + formData.append('file', file) + formData.append('platform', platform) + + const response = await this.client.post( + '/api/v1/versions/parse', + formData, + { + headers: { + 'Content-Type': 'multipart/form-data', + }, + timeout: 120000, // 2 minutes for parsing + } + ) + return response.data + } } export const versionRepository = new VersionRepositoryImpl() diff --git a/frontend/mobile-upgrade/src/presentation/components/upload-modal.tsx b/frontend/mobile-upgrade/src/presentation/components/upload-modal.tsx index d8de1c48..5ed200ae 100644 --- a/frontend/mobile-upgrade/src/presentation/components/upload-modal.tsx +++ b/frontend/mobile-upgrade/src/presentation/components/upload-modal.tsx @@ -3,6 +3,7 @@ import { useState, useRef } from 'react' import { Platform } from '@/domain' import { useVersionActions } from '@/application' +import { versionRepository } from '@/infrastructure/repositories/version-repository-impl' interface UploadModalProps { onClose: () => void @@ -14,6 +15,7 @@ export function UploadModal({ onClose, onSuccess }: UploadModalProps) { const fileInputRef = useRef(null) const [isSubmitting, setIsSubmitting] = useState(false) + const [isParsing, setIsParsing] = useState(false) const [formData, setFormData] = useState({ platform: 'android' as Platform, versionName: '', @@ -25,16 +27,38 @@ export function UploadModal({ onClose, onSuccess }: UploadModalProps) { const [file, setFile] = useState(null) const [error, setError] = useState(null) - const handleFileChange = (e: React.ChangeEvent) => { + const handleFileChange = async (e: React.ChangeEvent) => { const selectedFile = e.target.files?.[0] - if (selectedFile) { - setFile(selectedFile) - // Auto-detect platform from file extension - if (selectedFile.name.endsWith('.apk')) { - setFormData((prev) => ({ ...prev, platform: 'android' })) - } else if (selectedFile.name.endsWith('.ipa')) { - setFormData((prev) => ({ ...prev, platform: 'ios' })) - } + if (!selectedFile) return + + setFile(selectedFile) + setError(null) + + // Auto-detect platform from file extension + let detectedPlatform: Platform = 'android' + if (selectedFile.name.endsWith('.apk')) { + detectedPlatform = 'android' + } else if (selectedFile.name.endsWith('.ipa')) { + detectedPlatform = 'ios' + } + setFormData((prev) => ({ ...prev, platform: detectedPlatform })) + + // Auto-parse package info + setIsParsing(true) + try { + const parsed = await versionRepository.parsePackage(selectedFile, detectedPlatform) + setFormData((prev) => ({ + ...prev, + platform: detectedPlatform, + versionName: parsed.versionName || prev.versionName, + buildNumber: parsed.versionCode?.toString() || prev.buildNumber, + minOsVersion: parsed.minSdkVersion || prev.minOsVersion, + })) + } catch (err) { + console.error('Failed to parse package:', err) + // Don't show error, just let user fill in manually + } finally { + setIsParsing(false) } } @@ -120,6 +144,9 @@ export function UploadModal({ onClose, onSuccess }: UploadModalProps) {

{(file.size / (1024 * 1024)).toFixed(2)} MB

+ {isParsing && ( +

正在解析包信息...

+ )} ) : (
@@ -238,9 +265,9 @@ export function UploadModal({ onClose, onSuccess }: UploadModalProps) {