feat: 增强移动端版本管理功能
## 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 <noreply@anthropic.com>
This commit is contained in:
parent
8932d87df7
commit
f8607ce0b2
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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<string>('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<void> {
|
||||
// 安全检查:防止路径遍历攻击
|
||||
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<string, string> = {
|
||||
'.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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并创建版本 (管理员)' })
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ export class FileStorageService {
|
|||
path: filePath,
|
||||
size: buffer.length,
|
||||
sha256,
|
||||
url: `${this.baseUrl}/uploads/${filename}`,
|
||||
url: `${this.baseUrl}/downloads/${filename}`,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<ResponseBody>(
|
||||
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<void> 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<void> 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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 "$@"
|
||||
|
|
@ -58,3 +58,11 @@ export interface VersionListFilter {
|
|||
platform?: Platform
|
||||
includeDisabled?: boolean
|
||||
}
|
||||
|
||||
export interface ParsedPackageInfo {
|
||||
packageName: string
|
||||
versionCode: number
|
||||
versionName: string
|
||||
minSdkVersion?: string
|
||||
targetSdkVersion?: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void>
|
||||
toggle(id: string, isEnabled: boolean): Promise<void>
|
||||
upload(input: UploadVersionInput): Promise<AppVersion>
|
||||
parsePackage(file: File, platform: Platform): Promise<ParsedPackageInfo>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ParsedPackageInfo> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('platform', platform)
|
||||
|
||||
const response = await this.client.post<ParsedPackageInfo>(
|
||||
'/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()
|
||||
|
|
|
|||
|
|
@ -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<HTMLInputElement>(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<File | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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) {
|
|||
<p className="text-gray-500 text-sm">
|
||||
{(file.size / (1024 * 1024)).toFixed(2)} MB
|
||||
</p>
|
||||
{isParsing && (
|
||||
<p className="text-blue-500 text-sm mt-1">正在解析包信息...</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
|
|
@ -238,9 +265,9 @@ export function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
|||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={isSubmitting}
|
||||
disabled={isSubmitting || isParsing}
|
||||
>
|
||||
{isSubmitting ? '上传中...' : '上传'}
|
||||
{isParsing ? '解析中...' : isSubmitting ? '上传中...' : '上传'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
Loading…
Reference in New Issue