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_PORT=6379
|
||||||
- REDIS_PASSWORD=
|
- REDIS_PASSWORD=
|
||||||
- REDIS_DB=9
|
- 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:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
@ -82,6 +87,8 @@ volumes:
|
||||||
name: admin-service-postgres-data
|
name: admin-service-postgres-data
|
||||||
redis_data:
|
redis_data:
|
||||||
name: admin-service-redis-data
|
name: admin-service-redis-data
|
||||||
|
uploads_data:
|
||||||
|
name: admin-service-uploads-data
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
admin-network:
|
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 { UploadVersionCommand } from '@/application/commands/upload-version/upload-version.command'
|
||||||
import { Platform } from '@/domain/enums/platform.enum'
|
import { Platform } from '@/domain/enums/platform.enum'
|
||||||
import { AppVersion } from '@/domain/entities/app-version.entity'
|
import { AppVersion } from '@/domain/entities/app-version.entity'
|
||||||
|
import { PackageParserService } from '@/infrastructure/parsers/package-parser.service'
|
||||||
|
|
||||||
// Maximum file size: 500MB
|
// Maximum file size: 500MB
|
||||||
const MAX_FILE_SIZE = 500 * 1024 * 1024
|
const MAX_FILE_SIZE = 500 * 1024 * 1024
|
||||||
|
|
@ -59,6 +60,7 @@ export class VersionController {
|
||||||
private readonly deleteVersionHandler: DeleteVersionHandler,
|
private readonly deleteVersionHandler: DeleteVersionHandler,
|
||||||
private readonly toggleVersionHandler: ToggleVersionHandler,
|
private readonly toggleVersionHandler: ToggleVersionHandler,
|
||||||
private readonly uploadVersionHandler: UploadVersionHandler,
|
private readonly uploadVersionHandler: UploadVersionHandler,
|
||||||
|
private readonly packageParserService: PackageParserService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private toVersionDto(version: AppVersion): VersionDto {
|
private toVersionDto(version: AppVersion): VersionDto {
|
||||||
|
|
@ -196,6 +198,77 @@ export class VersionController {
|
||||||
return { success: true }
|
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')
|
@Post('upload')
|
||||||
@UseInterceptors(FileInterceptor('file'))
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
@ApiOperation({ summary: '上传APK/IPA并创建版本 (管理员)' })
|
@ApiOperation({ summary: '上传APK/IPA并创建版本 (管理员)' })
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import { UploadVersionHandler } from './application/commands/upload-version/uplo
|
||||||
import { VersionController } from './api/controllers/version.controller';
|
import { VersionController } from './api/controllers/version.controller';
|
||||||
import { MobileVersionController } from './api/controllers/mobile-version.controller';
|
import { MobileVersionController } from './api/controllers/mobile-version.controller';
|
||||||
import { HealthController } from './api/controllers/health.controller';
|
import { HealthController } from './api/controllers/health.controller';
|
||||||
|
import { DownloadController } from './api/controllers/download.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|
@ -33,7 +34,7 @@ import { HealthController } from './api/controllers/health.controller';
|
||||||
serveRoot: '/uploads',
|
serveRoot: '/uploads',
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
controllers: [VersionController, MobileVersionController, HealthController],
|
controllers: [VersionController, MobileVersionController, HealthController, DownloadController],
|
||||||
providers: [
|
providers: [
|
||||||
PrismaService,
|
PrismaService,
|
||||||
AppVersionMapper,
|
AppVersionMapper,
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ export class FileStorageService {
|
||||||
path: filePath,
|
path: filePath,
|
||||||
size: buffer.length,
|
size: buffer.length,
|
||||||
sha256,
|
sha256,
|
||||||
url: `${this.baseUrl}/uploads/${filename}`,
|
url: `${this.baseUrl}/downloads/${filename}`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ typedef DownloadProgressCallback = void Function(int received, int total);
|
||||||
|
|
||||||
/// 下载管理器
|
/// 下载管理器
|
||||||
/// 负责下载 APK 文件并验证完整性
|
/// 负责下载 APK 文件并验证完整性
|
||||||
|
/// 支持断点续传
|
||||||
class DownloadManager {
|
class DownloadManager {
|
||||||
final Dio _dio = Dio();
|
final Dio _dio = Dio();
|
||||||
CancelToken? _cancelToken;
|
CancelToken? _cancelToken;
|
||||||
|
|
@ -27,7 +28,7 @@ class DownloadManager {
|
||||||
/// 当前下载状态
|
/// 当前下载状态
|
||||||
DownloadStatus get status => _status;
|
DownloadStatus get status => _status;
|
||||||
|
|
||||||
/// 下载 APK 文件
|
/// 下载 APK 文件(支持断点续传)
|
||||||
/// [url] 下载地址(必须是 HTTPS)
|
/// [url] 下载地址(必须是 HTTPS)
|
||||||
/// [sha256Expected] SHA-256 校验值
|
/// [sha256Expected] SHA-256 校验值
|
||||||
/// [onProgress] 下载进度回调 (已下载字节, 总字节)
|
/// [onProgress] 下载进度回调 (已下载字节, 总字节)
|
||||||
|
|
@ -50,32 +51,78 @@ class DownloadManager {
|
||||||
// 使用应用专属目录(无需额外权限)
|
// 使用应用专属目录(无需额外权限)
|
||||||
final dir = await getApplicationDocumentsDirectory();
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
final savePath = '${dir.path}/app_update.apk';
|
final savePath = '${dir.path}/app_update.apk';
|
||||||
|
final tempPath = '${dir.path}/app_update.apk.tmp';
|
||||||
final file = File(savePath);
|
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()) {
|
if (await file.exists()) {
|
||||||
await file.delete();
|
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,
|
url,
|
||||||
savePath,
|
|
||||||
cancelToken: _cancelToken,
|
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(
|
options: Options(
|
||||||
|
responseType: ResponseType.stream,
|
||||||
receiveTimeout: const Duration(minutes: 10),
|
receiveTimeout: const Duration(minutes: 10),
|
||||||
sendTimeout: 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');
|
debugPrint('Download completed');
|
||||||
_status = DownloadStatus.verifying;
|
_status = DownloadStatus.verifying;
|
||||||
|
|
||||||
|
|
@ -136,17 +183,37 @@ class DownloadManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 删除已下载的 APK 文件
|
/// 删除已下载的 APK 文件和临时文件
|
||||||
Future<void> cleanupDownloadedApk() async {
|
Future<void> cleanupDownloadedApk() async {
|
||||||
try {
|
try {
|
||||||
final dir = await getApplicationDocumentsDirectory();
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
final file = File('${dir.path}/app_update.apk');
|
final file = File('${dir.path}/app_update.apk');
|
||||||
|
final tempFile = File('${dir.path}/app_update.apk.tmp');
|
||||||
|
|
||||||
if (await file.exists()) {
|
if (await file.exists()) {
|
||||||
await file.delete();
|
await file.delete();
|
||||||
debugPrint('Cleaned up downloaded APK');
|
debugPrint('Cleaned up downloaded APK');
|
||||||
}
|
}
|
||||||
|
if (await tempFile.exists()) {
|
||||||
|
await tempFile.delete();
|
||||||
|
debugPrint('Cleaned up temporary APK file');
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Cleanup failed: $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
|
platform?: Platform
|
||||||
includeDisabled?: boolean
|
includeDisabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ParsedPackageInfo {
|
||||||
|
packageName: string
|
||||||
|
versionCode: number
|
||||||
|
versionName: string
|
||||||
|
minSdkVersion?: string
|
||||||
|
targetSdkVersion?: string
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import {
|
||||||
UpdateVersionInput,
|
UpdateVersionInput,
|
||||||
UploadVersionInput,
|
UploadVersionInput,
|
||||||
VersionListFilter,
|
VersionListFilter,
|
||||||
|
ParsedPackageInfo,
|
||||||
|
Platform,
|
||||||
} from '../entities/version'
|
} from '../entities/version'
|
||||||
|
|
||||||
export interface IVersionRepository {
|
export interface IVersionRepository {
|
||||||
|
|
@ -14,4 +16,5 @@ export interface IVersionRepository {
|
||||||
delete(id: string): Promise<void>
|
delete(id: string): Promise<void>
|
||||||
toggle(id: string, isEnabled: boolean): Promise<void>
|
toggle(id: string, isEnabled: boolean): Promise<void>
|
||||||
upload(input: UploadVersionInput): Promise<AppVersion>
|
upload(input: UploadVersionInput): Promise<AppVersion>
|
||||||
|
parsePackage(file: File, platform: Platform): Promise<ParsedPackageInfo>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import {
|
||||||
UploadVersionInput,
|
UploadVersionInput,
|
||||||
VersionListFilter,
|
VersionListFilter,
|
||||||
IVersionRepository,
|
IVersionRepository,
|
||||||
|
ParsedPackageInfo,
|
||||||
|
Platform,
|
||||||
} from '@/domain'
|
} from '@/domain'
|
||||||
import { apiClient } from '../http/api-client'
|
import { apiClient } from '../http/api-client'
|
||||||
|
|
||||||
|
|
@ -87,6 +89,24 @@ export class VersionRepositoryImpl implements IVersionRepository {
|
||||||
)
|
)
|
||||||
return response.data
|
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()
|
export const versionRepository = new VersionRepositoryImpl()
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { useState, useRef } from 'react'
|
import { useState, useRef } from 'react'
|
||||||
import { Platform } from '@/domain'
|
import { Platform } from '@/domain'
|
||||||
import { useVersionActions } from '@/application'
|
import { useVersionActions } from '@/application'
|
||||||
|
import { versionRepository } from '@/infrastructure/repositories/version-repository-impl'
|
||||||
|
|
||||||
interface UploadModalProps {
|
interface UploadModalProps {
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
|
@ -14,6 +15,7 @@ export function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const [isParsing, setIsParsing] = useState(false)
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
platform: 'android' as Platform,
|
platform: 'android' as Platform,
|
||||||
versionName: '',
|
versionName: '',
|
||||||
|
|
@ -25,16 +27,38 @@ export function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||||
const [file, setFile] = useState<File | null>(null)
|
const [file, setFile] = useState<File | null>(null)
|
||||||
const [error, setError] = useState<string | 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]
|
const selectedFile = e.target.files?.[0]
|
||||||
if (selectedFile) {
|
if (!selectedFile) return
|
||||||
setFile(selectedFile)
|
|
||||||
// Auto-detect platform from file extension
|
setFile(selectedFile)
|
||||||
if (selectedFile.name.endsWith('.apk')) {
|
setError(null)
|
||||||
setFormData((prev) => ({ ...prev, platform: 'android' }))
|
|
||||||
} else if (selectedFile.name.endsWith('.ipa')) {
|
// Auto-detect platform from file extension
|
||||||
setFormData((prev) => ({ ...prev, platform: 'ios' }))
|
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">
|
<p className="text-gray-500 text-sm">
|
||||||
{(file.size / (1024 * 1024)).toFixed(2)} MB
|
{(file.size / (1024 * 1024)).toFixed(2)} MB
|
||||||
</p>
|
</p>
|
||||||
|
{isParsing && (
|
||||||
|
<p className="text-blue-500 text-sm mt-1">正在解析包信息...</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -238,9 +265,9 @@ export function UploadModal({ onClose, onSuccess }: UploadModalProps) {
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting || isParsing}
|
||||||
>
|
>
|
||||||
{isSubmitting ? '上传中...' : '上传'}
|
{isParsing ? '解析中...' : isSubmitting ? '上传中...' : '上传'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue