feat: 集成 mining-app 升级和遥测功能,扩展 mobile-upgrade 支持多应用
## mining-app (Flutter) - 新增 updater 模块: 版本检查、APK下载(断点续传+SHA256校验)、安装 - 新增 telemetry 模块: 事件上报、会话追踪、心跳检测(DAU统计) - 集成原生 MethodChannel 实现 APK 安装 - 在关于页面添加"检查更新"功能入口 ## mining-admin-service (NestJS) - 新增版本管理 API (/api/v2/versions) - 实现 DDD 架构: Entity, Value Objects, Repository - 支持 APK/IPA 解析 (需安装 adbkit-apkreader, jszip, plist) - 支持文件上传和静态文件服务 ## mobile-upgrade (Next.js) - 扩展支持多后端: 榴莲 App (admin-service) + 股行 App (mining-admin-service) - 添加应用选择器 UI - 配置独立的 API 端点 ## 修复 - 移除未使用的 _apiBaseUrl 字段 (Flutter) - 替换废弃的 WillPopScope 为 PopScope (Flutter) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
219fb7bb69
commit
76d566d145
|
|
@ -891,3 +891,38 @@ model SyncedFeeConfig {
|
|||
|
||||
@@map("synced_fee_configs")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// App 版本管理 (Mining App Upgrade)
|
||||
// =============================================================================
|
||||
|
||||
/// 平台类型
|
||||
enum Platform {
|
||||
ANDROID
|
||||
IOS
|
||||
}
|
||||
|
||||
/// App 版本
|
||||
model AppVersion {
|
||||
id String @id @default(uuid())
|
||||
platform Platform
|
||||
versionCode Int @map("version_code") // Android: versionCode, iOS: CFBundleVersion
|
||||
versionName String @map("version_name") // 用户可见版本号,如 "1.2.3"
|
||||
buildNumber String @map("build_number") // 构建号
|
||||
downloadUrl String @map("download_url") // APK/IPA 下载地址
|
||||
fileSize BigInt @map("file_size") // 文件大小(字节)
|
||||
fileSha256 String @map("file_sha256") // 文件 SHA-256 校验和
|
||||
minOsVersion String? @map("min_os_version") // 最低操作系统版本要求
|
||||
changelog String @db.Text // 更新日志
|
||||
isForceUpdate Boolean @default(false) @map("is_force_update") // 是否强制更新
|
||||
isEnabled Boolean @default(true) @map("is_enabled") // 是否启用
|
||||
releaseDate DateTime? @map("release_date") // 发布日期
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
createdBy String @map("created_by") // 创建人ID
|
||||
updatedBy String? @map("updated_by") // 更新人ID
|
||||
|
||||
@@index([platform, isEnabled])
|
||||
@@index([platform, versionCode])
|
||||
@@map("app_versions")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { MulterModule } from '@nestjs/platform-express';
|
||||
import { ApplicationModule } from '../application/application.module';
|
||||
import { AuthController } from './controllers/auth.controller';
|
||||
import { DashboardController } from './controllers/dashboard.controller';
|
||||
|
|
@ -11,9 +12,18 @@ import { ReportsController } from './controllers/reports.controller';
|
|||
import { ManualMiningController } from './controllers/manual-mining.controller';
|
||||
import { PendingContributionsController } from './controllers/pending-contributions.controller';
|
||||
import { BatchMiningController } from './controllers/batch-mining.controller';
|
||||
import { VersionController } from './controllers/version.controller';
|
||||
import { MobileVersionController } from './controllers/mobile-version.controller';
|
||||
|
||||
@Module({
|
||||
imports: [ApplicationModule],
|
||||
imports: [
|
||||
ApplicationModule,
|
||||
MulterModule.register({
|
||||
limits: {
|
||||
fileSize: 500 * 1024 * 1024, // 500MB
|
||||
},
|
||||
}),
|
||||
],
|
||||
controllers: [
|
||||
AuthController,
|
||||
DashboardController,
|
||||
|
|
@ -26,6 +36,8 @@ import { BatchMiningController } from './controllers/batch-mining.controller';
|
|||
ManualMiningController,
|
||||
PendingContributionsController,
|
||||
BatchMiningController,
|
||||
VersionController,
|
||||
MobileVersionController,
|
||||
],
|
||||
})
|
||||
export class ApiModule {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
import { Controller, Get, Query } from '@nestjs/common'
|
||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'
|
||||
import { VersionService } from '../../application/services/version.service'
|
||||
import { Platform } from '../../domain/version-management'
|
||||
import { CheckUpdateDto, UpdateCheckResultDto } from '../dto/version'
|
||||
import { Public } from '../../shared/guards/admin-auth.guard'
|
||||
|
||||
/**
|
||||
* Mobile App Version API Controller
|
||||
* This endpoint is designed to match the mobile app's expected API format
|
||||
*/
|
||||
@ApiTags('Mobile App Version')
|
||||
@Controller('api/app/version')
|
||||
export class MobileVersionController {
|
||||
constructor(private readonly versionService: VersionService) {}
|
||||
|
||||
private getPlatform(platform: string): Platform {
|
||||
const normalized = platform.toLowerCase()
|
||||
if (normalized === 'ios') return Platform.IOS
|
||||
return Platform.ANDROID
|
||||
}
|
||||
|
||||
@Get('check')
|
||||
@Public()
|
||||
@ApiOperation({ summary: '检查更新 (移动端专用)' })
|
||||
@ApiResponse({ status: 200, type: UpdateCheckResultDto })
|
||||
async checkUpdate(@Query() dto: CheckUpdateDto): Promise<UpdateCheckResultDto> {
|
||||
const platform = this.getPlatform(dto.platform)
|
||||
const result = await this.versionService.checkUpdate(platform, dto.current_version_code)
|
||||
|
||||
if (!result.hasUpdate || !result.latestVersion) {
|
||||
return {
|
||||
needUpdate: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
needUpdate: true,
|
||||
version: result.latestVersion.versionName,
|
||||
versionCode: result.latestVersion.versionCode,
|
||||
downloadUrl: result.latestVersion.downloadUrl,
|
||||
fileSize: Number(BigInt(result.latestVersion.fileSize)),
|
||||
fileSizeFriendly: result.latestVersion.fileSizeFriendly,
|
||||
sha256: result.latestVersion.fileSha256,
|
||||
forceUpdate: result.isForceUpdate,
|
||||
updateLog: result.latestVersion.changelog,
|
||||
releaseDate: result.latestVersion.releaseDate?.toISOString() ?? new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,295 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Patch,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
ParseFilePipe,
|
||||
MaxFileSizeValidator,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common'
|
||||
import { FileInterceptor } from '@nestjs/platform-express'
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiBearerAuth,
|
||||
ApiResponse,
|
||||
ApiQuery,
|
||||
ApiConsumes,
|
||||
ApiBody,
|
||||
} from '@nestjs/swagger'
|
||||
import { VersionService } from '../../application/services/version.service'
|
||||
import { Platform, AppVersion } from '../../domain/version-management'
|
||||
import {
|
||||
CreateVersionDto,
|
||||
UploadVersionDto,
|
||||
UpdateVersionDto,
|
||||
ToggleVersionDto,
|
||||
VersionResponseDto,
|
||||
} from '../dto/version'
|
||||
|
||||
// Maximum file size: 500MB
|
||||
const MAX_FILE_SIZE = 500 * 1024 * 1024
|
||||
|
||||
@ApiTags('Version Management')
|
||||
@Controller('versions')
|
||||
export class VersionController {
|
||||
constructor(private readonly versionService: VersionService) {}
|
||||
|
||||
private toVersionDto(version: AppVersion): VersionResponseDto {
|
||||
return {
|
||||
id: version.id,
|
||||
platform: version.platform,
|
||||
versionCode: version.versionCode.value,
|
||||
versionName: version.versionName.value,
|
||||
buildNumber: version.buildNumber.value,
|
||||
downloadUrl: version.downloadUrl.value,
|
||||
fileSize: version.fileSize.bytes.toString(),
|
||||
fileSha256: version.fileSha256.value,
|
||||
changelog: version.changelog.value,
|
||||
isForceUpdate: version.isForceUpdate,
|
||||
isEnabled: version.isEnabled,
|
||||
minOsVersion: version.minOsVersion?.value ?? null,
|
||||
releaseDate: version.releaseDate,
|
||||
createdAt: version.createdAt,
|
||||
updatedAt: version.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '获取版本列表' })
|
||||
@ApiBearerAuth()
|
||||
@ApiQuery({ name: 'platform', required: false, enum: ['ANDROID', 'IOS', 'android', 'ios'] })
|
||||
@ApiQuery({ name: 'includeDisabled', required: false, type: Boolean })
|
||||
@ApiResponse({ status: 200, type: [VersionResponseDto] })
|
||||
async listVersions(
|
||||
@Query('platform') platform?: string,
|
||||
@Query('includeDisabled') includeDisabled?: string,
|
||||
): Promise<VersionResponseDto[]> {
|
||||
const platformEnum = platform ? (platform.toUpperCase() as Platform) : undefined
|
||||
const versions = await this.versionService.findAll(platformEnum, includeDisabled === 'true')
|
||||
return versions.map((v) => this.toVersionDto(v))
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: '获取版本详情' })
|
||||
@ApiBearerAuth()
|
||||
@ApiResponse({ status: 200, type: VersionResponseDto })
|
||||
@ApiResponse({ status: 404, description: '版本不存在' })
|
||||
async getVersion(@Param('id') id: string): Promise<VersionResponseDto> {
|
||||
const version = await this.versionService.findById(id)
|
||||
return this.toVersionDto(version)
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: '创建新版本' })
|
||||
@ApiBearerAuth()
|
||||
@ApiResponse({ status: 201, type: VersionResponseDto })
|
||||
async createVersion(@Body() dto: CreateVersionDto): Promise<VersionResponseDto> {
|
||||
const version = await this.versionService.create({
|
||||
...dto,
|
||||
releaseDate: dto.releaseDate ? new Date(dto.releaseDate) : undefined,
|
||||
createdBy: 'admin', // TODO: Get from JWT token
|
||||
})
|
||||
return this.toVersionDto(version)
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@ApiOperation({ summary: '更新版本' })
|
||||
@ApiBearerAuth()
|
||||
@ApiResponse({ status: 200, type: VersionResponseDto })
|
||||
@ApiResponse({ status: 404, description: '版本不存在' })
|
||||
async updateVersion(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateVersionDto,
|
||||
): Promise<VersionResponseDto> {
|
||||
const version = await this.versionService.update(id, {
|
||||
...dto,
|
||||
releaseDate: dto.releaseDate ? new Date(dto.releaseDate) : dto.releaseDate === null ? null : undefined,
|
||||
updatedBy: 'admin', // TODO: Get from JWT token
|
||||
})
|
||||
return this.toVersionDto(version)
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: '删除版本' })
|
||||
@ApiBearerAuth()
|
||||
@ApiResponse({ status: 204, description: '删除成功' })
|
||||
@ApiResponse({ status: 404, description: '版本不存在' })
|
||||
async deleteVersion(@Param('id') id: string): Promise<void> {
|
||||
await this.versionService.delete(id)
|
||||
}
|
||||
|
||||
@Patch(':id/toggle')
|
||||
@ApiOperation({ summary: '启用/禁用版本' })
|
||||
@ApiBearerAuth()
|
||||
@ApiResponse({ status: 200, description: '操作成功' })
|
||||
@ApiResponse({ status: 404, description: '版本不存在' })
|
||||
async toggleVersion(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: ToggleVersionDto,
|
||||
): Promise<{ success: boolean }> {
|
||||
await this.versionService.toggleEnabled(id, dto.isEnabled, 'admin')
|
||||
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', 'ANDROID', 'IOS'],
|
||||
description: '平台',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '解析成功' })
|
||||
@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: string,
|
||||
): Promise<{
|
||||
packageName: string
|
||||
versionCode: number
|
||||
versionName: string
|
||||
minSdkVersion?: string
|
||||
targetSdkVersion?: string
|
||||
}> {
|
||||
const platformEnum = platform.toUpperCase() as Platform
|
||||
|
||||
// 验证文件扩展名
|
||||
const ext = file.originalname.toLowerCase().split('.').pop()
|
||||
if (platformEnum === Platform.ANDROID && ext !== 'apk') {
|
||||
throw new BadRequestException('Android平台只能上传APK文件')
|
||||
}
|
||||
if (platformEnum === Platform.IOS && ext !== 'ipa') {
|
||||
throw new BadRequestException('iOS平台只能上传IPA文件')
|
||||
}
|
||||
|
||||
const parsed = await this.versionService.parsePackage(file.buffer, platformEnum)
|
||||
if (!parsed) {
|
||||
throw new BadRequestException('无法解析安装包信息,请确认文件格式正确')
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
@Post('upload')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiOperation({ summary: '上传APK/IPA并创建版本' })
|
||||
@ApiBearerAuth()
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['file', 'platform', 'changelog'],
|
||||
properties: {
|
||||
file: {
|
||||
type: 'string',
|
||||
format: 'binary',
|
||||
description: 'APK或IPA安装包文件',
|
||||
},
|
||||
platform: {
|
||||
type: 'string',
|
||||
enum: ['android', 'ios', 'ANDROID', 'IOS'],
|
||||
description: '平台',
|
||||
},
|
||||
versionCode: {
|
||||
type: 'integer',
|
||||
description: '版本号 (可选,可从APK/IPA自动检测)',
|
||||
},
|
||||
versionName: {
|
||||
type: 'string',
|
||||
description: '版本名称 (可选,可从APK/IPA自动检测)',
|
||||
},
|
||||
buildNumber: {
|
||||
type: 'string',
|
||||
description: '构建号 (可选)',
|
||||
},
|
||||
changelog: {
|
||||
type: 'string',
|
||||
description: '更新日志',
|
||||
},
|
||||
isForceUpdate: {
|
||||
type: 'boolean',
|
||||
description: '是否强制更新',
|
||||
default: false,
|
||||
},
|
||||
minOsVersion: {
|
||||
type: 'string',
|
||||
description: '最低操作系统版本 (可选)',
|
||||
},
|
||||
releaseDate: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
description: '发布日期',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 201, type: VersionResponseDto })
|
||||
@ApiResponse({ status: 400, description: '无效的文件或参数' })
|
||||
async uploadVersion(
|
||||
@UploadedFile(
|
||||
new ParseFilePipe({
|
||||
validators: [new MaxFileSizeValidator({ maxSize: MAX_FILE_SIZE })],
|
||||
fileIsRequired: true,
|
||||
}),
|
||||
)
|
||||
file: Express.Multer.File,
|
||||
@Body() dto: UploadVersionDto,
|
||||
): Promise<VersionResponseDto> {
|
||||
// 验证文件扩展名
|
||||
const ext = file.originalname.toLowerCase().split('.').pop()
|
||||
if (dto.platform === Platform.ANDROID && ext !== 'apk') {
|
||||
throw new BadRequestException('Android平台只能上传APK文件')
|
||||
}
|
||||
if (dto.platform === Platform.IOS && ext !== 'ipa') {
|
||||
throw new BadRequestException('iOS平台只能上传IPA文件')
|
||||
}
|
||||
|
||||
const version = await this.versionService.upload({
|
||||
platform: dto.platform,
|
||||
fileBuffer: file.buffer,
|
||||
originalFilename: file.originalname,
|
||||
changelog: dto.changelog ?? '',
|
||||
isForceUpdate: dto.isForceUpdate ?? false,
|
||||
createdBy: 'admin', // TODO: Get from JWT token
|
||||
versionCode: dto.versionCode,
|
||||
versionName: dto.versionName,
|
||||
buildNumber: dto.buildNumber,
|
||||
minOsVersion: dto.minOsVersion,
|
||||
releaseDate: dto.releaseDate ? new Date(dto.releaseDate) : undefined,
|
||||
})
|
||||
|
||||
return this.toVersionDto(version)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'
|
||||
import { IsString, IsInt, Min, IsOptional } from 'class-validator'
|
||||
import { Type, Transform } from 'class-transformer'
|
||||
|
||||
export class CheckUpdateDto {
|
||||
@ApiProperty({ description: '平台', example: 'android' })
|
||||
@IsString()
|
||||
@Transform(({ value }) => value?.toLowerCase())
|
||||
platform: string
|
||||
|
||||
@ApiPropertyOptional({ description: '当前版本名称', example: '1.0.0' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
current_version?: string
|
||||
|
||||
@ApiProperty({ description: '当前版本代码', example: 100 })
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
current_version_code: number
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'
|
||||
import { IsString, IsInt, IsBoolean, IsOptional, Min, IsEnum, IsDateString } from 'class-validator'
|
||||
import { Transform } from 'class-transformer'
|
||||
import { Platform } from '../../../domain/version-management'
|
||||
|
||||
export class CreateVersionDto {
|
||||
@ApiProperty({ description: '平台', enum: ['ANDROID', 'IOS'] })
|
||||
@IsEnum(Platform)
|
||||
@Transform(({ value }) => value?.toUpperCase())
|
||||
platform: Platform
|
||||
|
||||
@ApiProperty({ description: '版本号', example: 100 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
versionCode: number
|
||||
|
||||
@ApiProperty({ description: '版本名称', example: '1.0.0' })
|
||||
@IsString()
|
||||
versionName: string
|
||||
|
||||
@ApiProperty({ description: '构建号', example: '100' })
|
||||
@IsString()
|
||||
buildNumber: string
|
||||
|
||||
@ApiProperty({ description: '下载地址' })
|
||||
@IsString()
|
||||
downloadUrl: string
|
||||
|
||||
@ApiProperty({ description: '文件大小(字节)', example: '52428800' })
|
||||
@IsString()
|
||||
fileSize: string
|
||||
|
||||
@ApiProperty({ description: 'SHA256校验和' })
|
||||
@IsString()
|
||||
fileSha256: string
|
||||
|
||||
@ApiProperty({ description: '更新日志' })
|
||||
@IsString()
|
||||
changelog: string
|
||||
|
||||
@ApiProperty({ description: '是否强制更新', default: false })
|
||||
@IsBoolean()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
isForceUpdate: boolean
|
||||
|
||||
@ApiPropertyOptional({ description: '最低操作系统版本' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
minOsVersion?: string
|
||||
|
||||
@ApiPropertyOptional({ description: '发布日期' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
releaseDate?: string
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export * from './create-version.dto'
|
||||
export * from './upload-version.dto'
|
||||
export * from './update-version.dto'
|
||||
export * from './toggle-version.dto'
|
||||
export * from './check-update.dto'
|
||||
export * from './version-response.dto'
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { ApiProperty } from '@nestjs/swagger'
|
||||
import { IsBoolean } from 'class-validator'
|
||||
import { Transform } from 'class-transformer'
|
||||
|
||||
export class ToggleVersionDto {
|
||||
@ApiProperty({ description: '是否启用' })
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
@IsBoolean()
|
||||
isEnabled: boolean
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import { ApiPropertyOptional } from '@nestjs/swagger'
|
||||
import { IsString, IsBoolean, IsOptional, IsDateString } from 'class-validator'
|
||||
import { Transform } from 'class-transformer'
|
||||
|
||||
export class UpdateVersionDto {
|
||||
@ApiPropertyOptional({ description: '下载地址' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
downloadUrl?: string
|
||||
|
||||
@ApiPropertyOptional({ description: '文件大小(字节)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
fileSize?: string
|
||||
|
||||
@ApiPropertyOptional({ description: 'SHA256校验和' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
fileSha256?: string
|
||||
|
||||
@ApiPropertyOptional({ description: '更新日志' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
changelog?: string
|
||||
|
||||
@ApiPropertyOptional({ description: '是否强制更新' })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
@IsBoolean()
|
||||
isForceUpdate?: boolean
|
||||
|
||||
@ApiPropertyOptional({ description: '最低操作系统版本' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
minOsVersion?: string | null
|
||||
|
||||
@ApiPropertyOptional({ description: '发布日期' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
releaseDate?: string | null
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'
|
||||
import { IsString, IsInt, IsBoolean, IsOptional, Min, IsEnum, IsDateString } from 'class-validator'
|
||||
import { Transform, Type } from 'class-transformer'
|
||||
import { Platform } from '../../../domain/version-management'
|
||||
|
||||
export class UploadVersionDto {
|
||||
@ApiProperty({ description: '平台', enum: ['ANDROID', 'IOS', 'android', 'ios'] })
|
||||
@IsEnum(Platform)
|
||||
@Transform(({ value }) => value?.toUpperCase())
|
||||
platform: Platform
|
||||
|
||||
@ApiPropertyOptional({ description: '版本号 (可选,可从APK/IPA自动检测)' })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
versionCode?: number
|
||||
|
||||
@ApiPropertyOptional({ description: '版本名称 (可选,可从APK/IPA自动检测)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
versionName?: string
|
||||
|
||||
@ApiPropertyOptional({ description: '构建号 (可选)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
buildNumber?: string
|
||||
|
||||
@ApiProperty({ description: '更新日志' })
|
||||
@IsString()
|
||||
changelog: string
|
||||
|
||||
@ApiProperty({ description: '是否强制更新', default: false })
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
@IsBoolean()
|
||||
isForceUpdate: boolean
|
||||
|
||||
@ApiPropertyOptional({ description: '最低操作系统版本' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
minOsVersion?: string
|
||||
|
||||
@ApiPropertyOptional({ description: '发布日期' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
releaseDate?: string
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'
|
||||
|
||||
export class VersionResponseDto {
|
||||
@ApiProperty({ description: '版本ID' })
|
||||
id: string
|
||||
|
||||
@ApiProperty({ description: '平台' })
|
||||
platform: string
|
||||
|
||||
@ApiProperty({ description: '版本号' })
|
||||
versionCode: number
|
||||
|
||||
@ApiProperty({ description: '版本名称' })
|
||||
versionName: string
|
||||
|
||||
@ApiProperty({ description: '构建号' })
|
||||
buildNumber: string
|
||||
|
||||
@ApiProperty({ description: '下载地址' })
|
||||
downloadUrl: string
|
||||
|
||||
@ApiProperty({ description: '文件大小' })
|
||||
fileSize: string
|
||||
|
||||
@ApiProperty({ description: 'SHA256校验和' })
|
||||
fileSha256: string
|
||||
|
||||
@ApiProperty({ description: '更新日志' })
|
||||
changelog: string
|
||||
|
||||
@ApiProperty({ description: '是否强制更新' })
|
||||
isForceUpdate: boolean
|
||||
|
||||
@ApiProperty({ description: '是否启用' })
|
||||
isEnabled: boolean
|
||||
|
||||
@ApiPropertyOptional({ description: '最低操作系统版本' })
|
||||
minOsVersion: string | null
|
||||
|
||||
@ApiPropertyOptional({ description: '发布日期' })
|
||||
releaseDate: Date | null
|
||||
|
||||
@ApiProperty({ description: '创建时间' })
|
||||
createdAt: Date
|
||||
|
||||
@ApiProperty({ description: '更新时间' })
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
export class UpdateCheckResultDto {
|
||||
@ApiProperty({ description: '是否需要更新' })
|
||||
needUpdate: boolean
|
||||
|
||||
@ApiPropertyOptional({ description: '版本名称' })
|
||||
version?: string
|
||||
|
||||
@ApiPropertyOptional({ description: '版本号' })
|
||||
versionCode?: number
|
||||
|
||||
@ApiPropertyOptional({ description: '下载地址' })
|
||||
downloadUrl?: string
|
||||
|
||||
@ApiPropertyOptional({ description: '文件大小(字节)' })
|
||||
fileSize?: number
|
||||
|
||||
@ApiPropertyOptional({ description: '友好的文件大小' })
|
||||
fileSizeFriendly?: string
|
||||
|
||||
@ApiPropertyOptional({ description: 'SHA256校验和' })
|
||||
sha256?: string
|
||||
|
||||
@ApiPropertyOptional({ description: '是否强制更新' })
|
||||
forceUpdate?: boolean
|
||||
|
||||
@ApiPropertyOptional({ description: '更新日志' })
|
||||
updateLog?: string
|
||||
|
||||
@ApiPropertyOptional({ description: '发布日期' })
|
||||
releaseDate?: string
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import { DailyReportService } from './services/daily-report.service';
|
|||
import { ManualMiningService } from './services/manual-mining.service';
|
||||
import { PendingContributionsService } from './services/pending-contributions.service';
|
||||
import { BatchMiningService } from './services/batch-mining.service';
|
||||
import { VersionService } from './services/version.service';
|
||||
|
||||
@Module({
|
||||
imports: [InfrastructureModule],
|
||||
|
|
@ -22,6 +23,7 @@ import { BatchMiningService } from './services/batch-mining.service';
|
|||
ManualMiningService,
|
||||
PendingContributionsService,
|
||||
BatchMiningService,
|
||||
VersionService,
|
||||
],
|
||||
exports: [
|
||||
AuthService,
|
||||
|
|
@ -33,6 +35,7 @@ import { BatchMiningService } from './services/batch-mining.service';
|
|||
ManualMiningService,
|
||||
PendingContributionsService,
|
||||
BatchMiningService,
|
||||
VersionService,
|
||||
],
|
||||
})
|
||||
export class ApplicationModule implements OnModuleInit {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,263 @@
|
|||
import { Injectable, Inject, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common'
|
||||
import {
|
||||
AppVersion,
|
||||
AppVersionRepository,
|
||||
APP_VERSION_REPOSITORY,
|
||||
Platform,
|
||||
VersionCode,
|
||||
VersionName,
|
||||
BuildNumber,
|
||||
DownloadUrl,
|
||||
FileSize,
|
||||
FileSha256,
|
||||
Changelog,
|
||||
MinOsVersion,
|
||||
} from '../../domain/version-management'
|
||||
import { FileStorageService } from '../../infrastructure/storage/file-storage.service'
|
||||
import { PackageParserService } from '../../infrastructure/parsers/package-parser.service'
|
||||
|
||||
export interface CreateVersionInput {
|
||||
platform: Platform
|
||||
versionCode: number
|
||||
versionName: string
|
||||
buildNumber: string
|
||||
downloadUrl: string
|
||||
fileSize: string
|
||||
fileSha256: string
|
||||
changelog: string
|
||||
isForceUpdate: boolean
|
||||
minOsVersion?: string
|
||||
releaseDate?: Date
|
||||
createdBy: string
|
||||
}
|
||||
|
||||
export interface UploadVersionInput {
|
||||
platform: Platform
|
||||
fileBuffer: Buffer
|
||||
originalFilename: string
|
||||
changelog: string
|
||||
isForceUpdate: boolean
|
||||
createdBy: string
|
||||
versionCode?: number
|
||||
versionName?: string
|
||||
buildNumber?: string
|
||||
minOsVersion?: string
|
||||
releaseDate?: Date
|
||||
}
|
||||
|
||||
export interface UpdateVersionInput {
|
||||
downloadUrl?: string
|
||||
fileSize?: string
|
||||
fileSha256?: string
|
||||
changelog?: string
|
||||
isForceUpdate?: boolean
|
||||
minOsVersion?: string | null
|
||||
releaseDate?: Date | null
|
||||
updatedBy: string
|
||||
}
|
||||
|
||||
export interface CheckUpdateResult {
|
||||
hasUpdate: boolean
|
||||
isForceUpdate: boolean
|
||||
latestVersion: {
|
||||
versionCode: number
|
||||
versionName: string
|
||||
downloadUrl: string
|
||||
fileSize: string
|
||||
fileSizeFriendly: string
|
||||
fileSha256: string
|
||||
changelog: string
|
||||
minOsVersion: string | null
|
||||
releaseDate: Date | null
|
||||
} | null
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class VersionService {
|
||||
constructor(
|
||||
@Inject(APP_VERSION_REPOSITORY)
|
||||
private readonly appVersionRepository: AppVersionRepository,
|
||||
private readonly fileStorageService: FileStorageService,
|
||||
private readonly packageParserService: PackageParserService,
|
||||
) {}
|
||||
|
||||
async create(input: CreateVersionInput): Promise<AppVersion> {
|
||||
// 检查版本是否已存在
|
||||
const existing = await this.appVersionRepository.findByPlatformAndVersionCode(
|
||||
input.platform,
|
||||
VersionCode.create(input.versionCode),
|
||||
)
|
||||
if (existing) {
|
||||
throw new ConflictException(`Version ${input.versionCode} already exists for ${input.platform}`)
|
||||
}
|
||||
|
||||
const appVersion = AppVersion.create({
|
||||
platform: input.platform,
|
||||
versionCode: VersionCode.create(input.versionCode),
|
||||
versionName: VersionName.create(input.versionName),
|
||||
buildNumber: BuildNumber.create(input.buildNumber),
|
||||
downloadUrl: DownloadUrl.create(input.downloadUrl),
|
||||
fileSize: FileSize.create(BigInt(input.fileSize)),
|
||||
fileSha256: FileSha256.create(input.fileSha256),
|
||||
changelog: Changelog.create(input.changelog),
|
||||
isForceUpdate: input.isForceUpdate,
|
||||
minOsVersion: input.minOsVersion ? MinOsVersion.create(input.minOsVersion) : null,
|
||||
releaseDate: input.releaseDate ?? null,
|
||||
createdBy: input.createdBy,
|
||||
})
|
||||
|
||||
return this.appVersionRepository.save(appVersion)
|
||||
}
|
||||
|
||||
async upload(input: UploadVersionInput): Promise<AppVersion> {
|
||||
// 解析包信息
|
||||
const parsedInfo = await this.packageParserService.parsePackage(
|
||||
input.fileBuffer,
|
||||
input.platform,
|
||||
)
|
||||
|
||||
// 确定版本信息
|
||||
const versionCode = input.versionCode ?? parsedInfo?.versionCode
|
||||
const versionName = input.versionName ?? parsedInfo?.versionName
|
||||
const buildNumber = input.buildNumber ?? versionCode?.toString()
|
||||
const minOsVersion = input.minOsVersion ?? parsedInfo?.minSdkVersion
|
||||
|
||||
if (!versionCode) {
|
||||
throw new BadRequestException('Unable to detect version code from package. Please provide it manually.')
|
||||
}
|
||||
if (!versionName) {
|
||||
throw new BadRequestException('Unable to detect version name from package. Please provide it manually.')
|
||||
}
|
||||
|
||||
// 检查版本是否已存在
|
||||
const existing = await this.appVersionRepository.findByPlatformAndVersionCode(
|
||||
input.platform,
|
||||
VersionCode.create(versionCode),
|
||||
)
|
||||
if (existing) {
|
||||
throw new ConflictException(`Version ${versionCode} already exists for ${input.platform}`)
|
||||
}
|
||||
|
||||
// 保存文件
|
||||
const uploadResult = await this.fileStorageService.saveFile(
|
||||
input.fileBuffer,
|
||||
input.originalFilename,
|
||||
input.platform,
|
||||
versionName,
|
||||
)
|
||||
|
||||
const appVersion = AppVersion.create({
|
||||
platform: input.platform,
|
||||
versionCode: VersionCode.create(versionCode),
|
||||
versionName: VersionName.create(versionName),
|
||||
buildNumber: BuildNumber.create(buildNumber || versionCode.toString()),
|
||||
downloadUrl: DownloadUrl.create(uploadResult.url),
|
||||
fileSize: FileSize.create(BigInt(uploadResult.size)),
|
||||
fileSha256: FileSha256.create(uploadResult.sha256),
|
||||
changelog: Changelog.create(input.changelog),
|
||||
isForceUpdate: input.isForceUpdate,
|
||||
minOsVersion: minOsVersion ? MinOsVersion.create(minOsVersion) : null,
|
||||
releaseDate: input.releaseDate ?? null,
|
||||
createdBy: input.createdBy,
|
||||
})
|
||||
|
||||
return this.appVersionRepository.save(appVersion)
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<AppVersion> {
|
||||
const version = await this.appVersionRepository.findById(id)
|
||||
if (!version) {
|
||||
throw new NotFoundException(`Version with id ${id} not found`)
|
||||
}
|
||||
return version
|
||||
}
|
||||
|
||||
async findAll(platform?: Platform, includeDisabled = false): Promise<AppVersion[]> {
|
||||
if (platform) {
|
||||
return this.appVersionRepository.findAllByPlatform(platform, includeDisabled)
|
||||
}
|
||||
return this.appVersionRepository.findAll(includeDisabled)
|
||||
}
|
||||
|
||||
async update(id: string, input: UpdateVersionInput): Promise<AppVersion> {
|
||||
const version = await this.findById(id)
|
||||
|
||||
version.update({
|
||||
downloadUrl: input.downloadUrl ? DownloadUrl.create(input.downloadUrl) : undefined,
|
||||
fileSize: input.fileSize ? FileSize.create(BigInt(input.fileSize)) : undefined,
|
||||
fileSha256: input.fileSha256 ? FileSha256.create(input.fileSha256) : undefined,
|
||||
changelog: input.changelog ? Changelog.create(input.changelog) : undefined,
|
||||
isForceUpdate: input.isForceUpdate,
|
||||
minOsVersion: input.minOsVersion === null
|
||||
? null
|
||||
: input.minOsVersion
|
||||
? MinOsVersion.create(input.minOsVersion)
|
||||
: undefined,
|
||||
releaseDate: input.releaseDate,
|
||||
updatedBy: input.updatedBy,
|
||||
})
|
||||
|
||||
return this.appVersionRepository.update(version)
|
||||
}
|
||||
|
||||
async toggleEnabled(id: string, isEnabled: boolean, updatedBy: string): Promise<AppVersion> {
|
||||
const version = await this.findById(id)
|
||||
if (isEnabled) {
|
||||
version.enable(updatedBy)
|
||||
} else {
|
||||
version.disable(updatedBy)
|
||||
}
|
||||
return this.appVersionRepository.update(version)
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.findById(id) // 确保存在
|
||||
await this.appVersionRepository.delete(id)
|
||||
}
|
||||
|
||||
async checkUpdate(platform: Platform, currentVersionCode: number): Promise<CheckUpdateResult> {
|
||||
const latestVersion = await this.appVersionRepository.findLatestByPlatform(platform)
|
||||
|
||||
if (!latestVersion) {
|
||||
return {
|
||||
hasUpdate: false,
|
||||
isForceUpdate: false,
|
||||
latestVersion: null,
|
||||
}
|
||||
}
|
||||
|
||||
const currentVersion = VersionCode.create(currentVersionCode)
|
||||
const hasUpdate = latestVersion.isNewerThan(currentVersion)
|
||||
|
||||
return {
|
||||
hasUpdate,
|
||||
isForceUpdate: hasUpdate && latestVersion.shouldForceUpdate(),
|
||||
latestVersion: hasUpdate
|
||||
? {
|
||||
versionCode: latestVersion.versionCode.value,
|
||||
versionName: latestVersion.versionName.value,
|
||||
downloadUrl: latestVersion.downloadUrl.value,
|
||||
fileSize: latestVersion.fileSize.bytes.toString(),
|
||||
fileSizeFriendly: latestVersion.fileSize.toFriendlyString(),
|
||||
fileSha256: latestVersion.fileSha256.value,
|
||||
changelog: latestVersion.changelog.value,
|
||||
minOsVersion: latestVersion.minOsVersion?.value ?? null,
|
||||
releaseDate: latestVersion.releaseDate,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
async parsePackage(
|
||||
fileBuffer: Buffer,
|
||||
platform: Platform,
|
||||
): Promise<{
|
||||
packageName: string
|
||||
versionCode: number
|
||||
versionName: string
|
||||
minSdkVersion?: string
|
||||
targetSdkVersion?: string
|
||||
} | null> {
|
||||
return this.packageParserService.parsePackage(fileBuffer, platform)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,234 @@
|
|||
import { Platform } from '../enums/platform.enum'
|
||||
import {
|
||||
VersionCode,
|
||||
VersionName,
|
||||
BuildNumber,
|
||||
DownloadUrl,
|
||||
FileSize,
|
||||
FileSha256,
|
||||
Changelog,
|
||||
MinOsVersion,
|
||||
} from '../value-objects'
|
||||
import * as crypto from 'crypto'
|
||||
|
||||
export class AppVersion {
|
||||
private _id: string
|
||||
private _platform: Platform
|
||||
private _versionCode: VersionCode
|
||||
private _versionName: VersionName
|
||||
private _buildNumber: BuildNumber
|
||||
private _downloadUrl: DownloadUrl
|
||||
private _fileSize: FileSize
|
||||
private _fileSha256: FileSha256
|
||||
private _changelog: Changelog
|
||||
private _isForceUpdate: boolean
|
||||
private _isEnabled: boolean
|
||||
private _minOsVersion: MinOsVersion | null
|
||||
private _releaseDate: Date | null
|
||||
private _createdAt: Date
|
||||
private _updatedAt: Date
|
||||
private _createdBy: string
|
||||
private _updatedBy: string | null
|
||||
|
||||
private constructor() {}
|
||||
|
||||
// Getters
|
||||
get id(): string {
|
||||
return this._id
|
||||
}
|
||||
|
||||
get platform(): Platform {
|
||||
return this._platform
|
||||
}
|
||||
|
||||
get versionCode(): VersionCode {
|
||||
return this._versionCode
|
||||
}
|
||||
|
||||
get versionName(): VersionName {
|
||||
return this._versionName
|
||||
}
|
||||
|
||||
get buildNumber(): BuildNumber {
|
||||
return this._buildNumber
|
||||
}
|
||||
|
||||
get downloadUrl(): DownloadUrl {
|
||||
return this._downloadUrl
|
||||
}
|
||||
|
||||
get fileSize(): FileSize {
|
||||
return this._fileSize
|
||||
}
|
||||
|
||||
get fileSha256(): FileSha256 {
|
||||
return this._fileSha256
|
||||
}
|
||||
|
||||
get changelog(): Changelog {
|
||||
return this._changelog
|
||||
}
|
||||
|
||||
get isForceUpdate(): boolean {
|
||||
return this._isForceUpdate
|
||||
}
|
||||
|
||||
get isEnabled(): boolean {
|
||||
return this._isEnabled
|
||||
}
|
||||
|
||||
get minOsVersion(): MinOsVersion | null {
|
||||
return this._minOsVersion
|
||||
}
|
||||
|
||||
get releaseDate(): Date | null {
|
||||
return this._releaseDate
|
||||
}
|
||||
|
||||
get createdAt(): Date {
|
||||
return this._createdAt
|
||||
}
|
||||
|
||||
get updatedAt(): Date {
|
||||
return this._updatedAt
|
||||
}
|
||||
|
||||
get createdBy(): string {
|
||||
return this._createdBy
|
||||
}
|
||||
|
||||
get updatedBy(): string | null {
|
||||
return this._updatedBy
|
||||
}
|
||||
|
||||
// 工厂方法 - 创建新版本
|
||||
static create(params: {
|
||||
platform: Platform
|
||||
versionCode: VersionCode
|
||||
versionName: VersionName
|
||||
buildNumber: BuildNumber
|
||||
downloadUrl: DownloadUrl
|
||||
fileSize: FileSize
|
||||
fileSha256: FileSha256
|
||||
changelog: Changelog
|
||||
isForceUpdate: boolean
|
||||
minOsVersion?: MinOsVersion | null
|
||||
releaseDate?: Date | null
|
||||
createdBy: string
|
||||
}): AppVersion {
|
||||
const version = new AppVersion()
|
||||
version._id = crypto.randomUUID()
|
||||
version._platform = params.platform
|
||||
version._versionCode = params.versionCode
|
||||
version._versionName = params.versionName
|
||||
version._buildNumber = params.buildNumber
|
||||
version._downloadUrl = params.downloadUrl
|
||||
version._fileSize = params.fileSize
|
||||
version._fileSha256 = params.fileSha256
|
||||
version._changelog = params.changelog
|
||||
version._isForceUpdate = params.isForceUpdate
|
||||
version._isEnabled = true // 默认启用
|
||||
version._minOsVersion = params.minOsVersion ?? null
|
||||
version._releaseDate = params.releaseDate ?? null
|
||||
version._createdAt = new Date()
|
||||
version._updatedAt = new Date()
|
||||
version._createdBy = params.createdBy
|
||||
version._updatedBy = null
|
||||
return version
|
||||
}
|
||||
|
||||
// 从持久化恢复
|
||||
static reconstitute(params: {
|
||||
id: string
|
||||
platform: Platform
|
||||
versionCode: VersionCode
|
||||
versionName: VersionName
|
||||
buildNumber: BuildNumber
|
||||
downloadUrl: DownloadUrl
|
||||
fileSize: FileSize
|
||||
fileSha256: FileSha256
|
||||
changelog: Changelog
|
||||
isForceUpdate: boolean
|
||||
isEnabled: boolean
|
||||
minOsVersion: MinOsVersion | null
|
||||
releaseDate: Date | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
createdBy: string
|
||||
updatedBy: string | null
|
||||
}): AppVersion {
|
||||
const version = new AppVersion()
|
||||
version._id = params.id
|
||||
version._platform = params.platform
|
||||
version._versionCode = params.versionCode
|
||||
version._versionName = params.versionName
|
||||
version._buildNumber = params.buildNumber
|
||||
version._downloadUrl = params.downloadUrl
|
||||
version._fileSize = params.fileSize
|
||||
version._fileSha256 = params.fileSha256
|
||||
version._changelog = params.changelog
|
||||
version._isForceUpdate = params.isForceUpdate
|
||||
version._isEnabled = params.isEnabled
|
||||
version._minOsVersion = params.minOsVersion
|
||||
version._releaseDate = params.releaseDate
|
||||
version._createdAt = params.createdAt
|
||||
version._updatedAt = params.updatedAt
|
||||
version._createdBy = params.createdBy
|
||||
version._updatedBy = params.updatedBy
|
||||
return version
|
||||
}
|
||||
|
||||
// 业务方法
|
||||
isNewerThan(currentVersionCode: VersionCode): boolean {
|
||||
return this._versionCode.isNewerThan(currentVersionCode)
|
||||
}
|
||||
|
||||
shouldForceUpdate(): boolean {
|
||||
return this._isForceUpdate && this._isEnabled
|
||||
}
|
||||
|
||||
disable(updatedBy: string): void {
|
||||
this._isEnabled = false
|
||||
this._updatedBy = updatedBy
|
||||
this._updatedAt = new Date()
|
||||
}
|
||||
|
||||
enable(updatedBy: string): void {
|
||||
this._isEnabled = true
|
||||
this._updatedBy = updatedBy
|
||||
this._updatedAt = new Date()
|
||||
}
|
||||
|
||||
setForceUpdate(force: boolean, updatedBy: string): void {
|
||||
this._isForceUpdate = force
|
||||
this._updatedBy = updatedBy
|
||||
this._updatedAt = new Date()
|
||||
}
|
||||
|
||||
setReleaseDate(date: Date | null, updatedBy: string): void {
|
||||
this._releaseDate = date
|
||||
this._updatedBy = updatedBy
|
||||
this._updatedAt = new Date()
|
||||
}
|
||||
|
||||
update(params: {
|
||||
downloadUrl?: DownloadUrl
|
||||
fileSize?: FileSize
|
||||
fileSha256?: FileSha256
|
||||
changelog?: Changelog
|
||||
isForceUpdate?: boolean
|
||||
minOsVersion?: MinOsVersion | null
|
||||
releaseDate?: Date | null
|
||||
updatedBy: string
|
||||
}): void {
|
||||
if (params.downloadUrl) this._downloadUrl = params.downloadUrl
|
||||
if (params.fileSize) this._fileSize = params.fileSize
|
||||
if (params.fileSha256) this._fileSha256 = params.fileSha256
|
||||
if (params.changelog) this._changelog = params.changelog
|
||||
if (params.isForceUpdate !== undefined) this._isForceUpdate = params.isForceUpdate
|
||||
if (params.minOsVersion !== undefined) this._minOsVersion = params.minOsVersion
|
||||
if (params.releaseDate !== undefined) this._releaseDate = params.releaseDate
|
||||
this._updatedBy = params.updatedBy
|
||||
this._updatedAt = new Date()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export enum Platform {
|
||||
ANDROID = 'ANDROID',
|
||||
IOS = 'IOS',
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export * from './entities/app-version.entity'
|
||||
export * from './enums/platform.enum'
|
||||
export * from './repositories/app-version.repository'
|
||||
export * from './value-objects'
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import { AppVersion } from '../entities/app-version.entity'
|
||||
import { Platform } from '../enums/platform.enum'
|
||||
import { VersionCode } from '../value-objects'
|
||||
|
||||
export const APP_VERSION_REPOSITORY = Symbol('APP_VERSION_REPOSITORY')
|
||||
|
||||
export interface AppVersionRepository {
|
||||
/**
|
||||
* 保存新版本
|
||||
*/
|
||||
save(appVersion: AppVersion): Promise<AppVersion>
|
||||
|
||||
/**
|
||||
* 根据ID查找版本
|
||||
*/
|
||||
findById(id: string): Promise<AppVersion | null>
|
||||
|
||||
/**
|
||||
* 获取指定平台的最新版本
|
||||
*/
|
||||
findLatestByPlatform(platform: Platform): Promise<AppVersion | null>
|
||||
|
||||
/**
|
||||
* 获取指定平台所有版本列表
|
||||
*/
|
||||
findAllByPlatform(platform: Platform, includeDisabled?: boolean): Promise<AppVersion[]>
|
||||
|
||||
/**
|
||||
* 获取所有版本
|
||||
*/
|
||||
findAll(includeDisabled?: boolean): Promise<AppVersion[]>
|
||||
|
||||
/**
|
||||
* 根据平台和版本号查找
|
||||
*/
|
||||
findByPlatformAndVersionCode(
|
||||
platform: Platform,
|
||||
versionCode: VersionCode,
|
||||
): Promise<AppVersion | null>
|
||||
|
||||
/**
|
||||
* 更新版本信息
|
||||
*/
|
||||
update(appVersion: AppVersion): Promise<AppVersion>
|
||||
|
||||
/**
|
||||
* 删除版本
|
||||
*/
|
||||
delete(id: string): Promise<void>
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
export class BuildNumber {
|
||||
private constructor(private readonly _value: string) {}
|
||||
|
||||
static create(value: string): BuildNumber {
|
||||
if (!value || value.trim().length === 0) {
|
||||
throw new Error('Build number cannot be empty')
|
||||
}
|
||||
return new BuildNumber(value.trim())
|
||||
}
|
||||
|
||||
get value(): string {
|
||||
return this._value
|
||||
}
|
||||
|
||||
equals(other: BuildNumber): boolean {
|
||||
return this._value === other._value
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
export class Changelog {
|
||||
private constructor(private readonly _value: string) {}
|
||||
|
||||
static create(value: string): Changelog {
|
||||
// 允许空的更新日志
|
||||
return new Changelog(value?.trim() || '')
|
||||
}
|
||||
|
||||
get value(): string {
|
||||
return this._value
|
||||
}
|
||||
|
||||
equals(other: Changelog): boolean {
|
||||
return this._value === other._value
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
export class DownloadUrl {
|
||||
private constructor(private readonly _value: string) {}
|
||||
|
||||
static create(value: string): DownloadUrl {
|
||||
if (!value || value.trim().length === 0) {
|
||||
throw new Error('Download URL cannot be empty')
|
||||
}
|
||||
// 基本 URL 格式验证
|
||||
try {
|
||||
new URL(value)
|
||||
} catch {
|
||||
throw new Error('Invalid download URL format')
|
||||
}
|
||||
return new DownloadUrl(value.trim())
|
||||
}
|
||||
|
||||
get value(): string {
|
||||
return this._value
|
||||
}
|
||||
|
||||
equals(other: DownloadUrl): boolean {
|
||||
return this._value === other._value
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
export class FileSha256 {
|
||||
private static readonly SHA256_PATTERN = /^[a-fA-F0-9]{64}$/
|
||||
|
||||
private constructor(private readonly _value: string) {}
|
||||
|
||||
static create(value: string): FileSha256 {
|
||||
if (!value || value.trim().length === 0) {
|
||||
throw new Error('SHA256 hash cannot be empty')
|
||||
}
|
||||
const trimmed = value.trim().toLowerCase()
|
||||
if (!FileSha256.SHA256_PATTERN.test(trimmed)) {
|
||||
throw new Error('Invalid SHA256 hash format')
|
||||
}
|
||||
return new FileSha256(trimmed)
|
||||
}
|
||||
|
||||
get value(): string {
|
||||
return this._value
|
||||
}
|
||||
|
||||
equals(other: FileSha256): boolean {
|
||||
return this._value === other._value
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
export class FileSize {
|
||||
private static readonly MAX_SIZE = BigInt(2 * 1024 * 1024 * 1024) // 2GB
|
||||
|
||||
private constructor(private readonly _bytes: bigint) {}
|
||||
|
||||
static create(bytes: bigint | number): FileSize {
|
||||
const bytesValue = typeof bytes === 'number' ? BigInt(bytes) : bytes
|
||||
if (bytesValue < 0n) {
|
||||
throw new Error('File size cannot be negative')
|
||||
}
|
||||
if (bytesValue > FileSize.MAX_SIZE) {
|
||||
throw new Error('File size cannot exceed 2GB')
|
||||
}
|
||||
return new FileSize(bytesValue)
|
||||
}
|
||||
|
||||
get bytes(): bigint {
|
||||
return this._bytes
|
||||
}
|
||||
|
||||
toFriendlyString(): string {
|
||||
const bytesNum = Number(this._bytes)
|
||||
if (bytesNum < 1024) return `${bytesNum} B`
|
||||
if (bytesNum < 1024 * 1024) return `${(bytesNum / 1024).toFixed(1)} KB`
|
||||
if (bytesNum < 1024 * 1024 * 1024) return `${(bytesNum / (1024 * 1024)).toFixed(1)} MB`
|
||||
return `${(bytesNum / (1024 * 1024 * 1024)).toFixed(2)} GB`
|
||||
}
|
||||
|
||||
equals(other: FileSize): boolean {
|
||||
return this._bytes === other._bytes
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
export * from './version-code.vo'
|
||||
export * from './version-name.vo'
|
||||
export * from './build-number.vo'
|
||||
export * from './download-url.vo'
|
||||
export * from './file-size.vo'
|
||||
export * from './file-sha256.vo'
|
||||
export * from './changelog.vo'
|
||||
export * from './min-os-version.vo'
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
export class MinOsVersion {
|
||||
private constructor(private readonly _value: string) {}
|
||||
|
||||
static create(value: string): MinOsVersion {
|
||||
if (!value || value.trim().length === 0) {
|
||||
throw new Error('Minimum OS version cannot be empty')
|
||||
}
|
||||
return new MinOsVersion(value.trim())
|
||||
}
|
||||
|
||||
get value(): string {
|
||||
return this._value
|
||||
}
|
||||
|
||||
equals(other: MinOsVersion): boolean {
|
||||
return this._value === other._value
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
export class VersionCode {
|
||||
private constructor(private readonly _value: number) {}
|
||||
|
||||
static create(value: number): VersionCode {
|
||||
if (!Number.isInteger(value) || value < 1) {
|
||||
throw new Error('Version code must be a positive integer')
|
||||
}
|
||||
return new VersionCode(value)
|
||||
}
|
||||
|
||||
get value(): number {
|
||||
return this._value
|
||||
}
|
||||
|
||||
isNewerThan(other: VersionCode): boolean {
|
||||
return this._value > other._value
|
||||
}
|
||||
|
||||
equals(other: VersionCode): boolean {
|
||||
return this._value === other._value
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
export class VersionName {
|
||||
private constructor(private readonly _value: string) {}
|
||||
|
||||
static create(value: string): VersionName {
|
||||
if (!value || value.trim().length === 0) {
|
||||
throw new Error('Version name cannot be empty')
|
||||
}
|
||||
// 支持 x.y.z 或 x.y.z.w 格式
|
||||
const pattern = /^\d+(\.\d+){1,3}$/
|
||||
if (!pattern.test(value.trim())) {
|
||||
throw new Error('Version name must be in format x.y.z or x.y.z.w')
|
||||
}
|
||||
return new VersionName(value.trim())
|
||||
}
|
||||
|
||||
get value(): string {
|
||||
return this._value
|
||||
}
|
||||
|
||||
equals(other: VersionName): boolean {
|
||||
return this._value === other._value
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,11 @@ import { HttpModule } from '@nestjs/axios';
|
|||
import { PrismaModule } from './persistence/prisma/prisma.module';
|
||||
import { RedisService } from './redis/redis.service';
|
||||
import { KafkaModule } from './kafka/kafka.module';
|
||||
import { APP_VERSION_REPOSITORY } from '../domain/version-management';
|
||||
import { AppVersionRepositoryImpl } from './persistence/repositories/app-version.repository.impl';
|
||||
import { AppVersionMapper } from './persistence/mappers/app-version.mapper';
|
||||
import { FileStorageService } from './storage/file-storage.service';
|
||||
import { PackageParserService } from './parsers/package-parser.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
|
|
@ -27,7 +32,24 @@ import { KafkaModule } from './kafka/kafka.module';
|
|||
inject: [ConfigService],
|
||||
},
|
||||
RedisService,
|
||||
// Version Management
|
||||
AppVersionMapper,
|
||||
{
|
||||
provide: APP_VERSION_REPOSITORY,
|
||||
useClass: AppVersionRepositoryImpl,
|
||||
},
|
||||
FileStorageService,
|
||||
PackageParserService,
|
||||
],
|
||||
exports: [
|
||||
PrismaModule,
|
||||
RedisService,
|
||||
KafkaModule,
|
||||
HttpModule,
|
||||
APP_VERSION_REPOSITORY,
|
||||
AppVersionMapper,
|
||||
FileStorageService,
|
||||
PackageParserService,
|
||||
],
|
||||
exports: [PrismaModule, RedisService, KafkaModule, HttpModule],
|
||||
})
|
||||
export class InfrastructureModule {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
import { Injectable, Logger } from '@nestjs/common'
|
||||
import { Platform } from '../../domain/version-management'
|
||||
|
||||
export interface ParsedPackageInfo {
|
||||
packageName: string
|
||||
versionCode: number
|
||||
versionName: string
|
||||
minSdkVersion?: string
|
||||
targetSdkVersion?: string
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PackageParserService {
|
||||
private readonly logger = new Logger(PackageParserService.name)
|
||||
|
||||
async parsePackage(buffer: Buffer, platform: Platform): Promise<ParsedPackageInfo | null> {
|
||||
try {
|
||||
if (platform === Platform.ANDROID) {
|
||||
return await this.parseApk(buffer)
|
||||
} else if (platform === Platform.IOS) {
|
||||
return await this.parseIpa(buffer)
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to parse ${platform} package:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private async parseApk(buffer: Buffer): Promise<ParsedPackageInfo | null> {
|
||||
try {
|
||||
// 动态导入 adbkit-apkreader
|
||||
const ApkReader = await import('adbkit-apkreader')
|
||||
const reader = await ApkReader.default.open(buffer)
|
||||
const manifest = await reader.readManifest()
|
||||
|
||||
return {
|
||||
packageName: manifest.package || '',
|
||||
versionCode: manifest.versionCode || 0,
|
||||
versionName: manifest.versionName || '',
|
||||
minSdkVersion: manifest.usesSdk?.minSdkVersion?.toString(),
|
||||
targetSdkVersion: manifest.usesSdk?.targetSdkVersion?.toString(),
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to parse APK:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private async parseIpa(buffer: Buffer): Promise<ParsedPackageInfo | null> {
|
||||
try {
|
||||
// 动态导入 jszip 和 plist
|
||||
const JSZip = await import('jszip')
|
||||
const plist = await import('plist')
|
||||
|
||||
const zip = await JSZip.default.loadAsync(buffer)
|
||||
|
||||
// 找到 Info.plist 文件
|
||||
let infoPlistPath: string | null = null
|
||||
for (const filename of Object.keys(zip.files)) {
|
||||
if (filename.match(/^Payload\/[^/]+\.app\/Info\.plist$/)) {
|
||||
infoPlistPath = filename
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!infoPlistPath) {
|
||||
this.logger.error('Info.plist not found in IPA')
|
||||
return null
|
||||
}
|
||||
|
||||
const plistData = await zip.files[infoPlistPath].async('nodebuffer')
|
||||
const plistObj = plist.default.parse(plistData.toString('utf-8')) as Record<string, unknown>
|
||||
|
||||
return {
|
||||
packageName: (plistObj.CFBundleIdentifier as string) || '',
|
||||
versionCode: parseInt((plistObj.CFBundleVersion as string) || '0', 10),
|
||||
versionName: (plistObj.CFBundleShortVersionString as string) || '',
|
||||
minSdkVersion: plistObj.MinimumOSVersion as string | undefined,
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to parse IPA:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import { Injectable } from '@nestjs/common'
|
||||
import { AppVersion as PrismaAppVersion, Platform as PrismaPlatform } from '@prisma/client'
|
||||
import {
|
||||
AppVersion,
|
||||
Platform,
|
||||
VersionCode,
|
||||
VersionName,
|
||||
BuildNumber,
|
||||
DownloadUrl,
|
||||
FileSize,
|
||||
FileSha256,
|
||||
Changelog,
|
||||
MinOsVersion,
|
||||
} from '../../../domain/version-management'
|
||||
|
||||
@Injectable()
|
||||
export class AppVersionMapper {
|
||||
toDomain(prisma: PrismaAppVersion): AppVersion {
|
||||
return AppVersion.reconstitute({
|
||||
id: prisma.id,
|
||||
platform: prisma.platform as Platform,
|
||||
versionCode: VersionCode.create(prisma.versionCode),
|
||||
versionName: VersionName.create(prisma.versionName),
|
||||
buildNumber: BuildNumber.create(prisma.buildNumber),
|
||||
downloadUrl: DownloadUrl.create(prisma.downloadUrl),
|
||||
fileSize: FileSize.create(prisma.fileSize),
|
||||
fileSha256: FileSha256.create(prisma.fileSha256),
|
||||
changelog: Changelog.create(prisma.changelog),
|
||||
isForceUpdate: prisma.isForceUpdate,
|
||||
isEnabled: prisma.isEnabled,
|
||||
minOsVersion: prisma.minOsVersion ? MinOsVersion.create(prisma.minOsVersion) : null,
|
||||
releaseDate: prisma.releaseDate,
|
||||
createdAt: prisma.createdAt,
|
||||
updatedAt: prisma.updatedAt,
|
||||
createdBy: prisma.createdBy,
|
||||
updatedBy: prisma.updatedBy,
|
||||
})
|
||||
}
|
||||
|
||||
toPersistence(
|
||||
domain: AppVersion,
|
||||
): Omit<PrismaAppVersion, 'createdAt' | 'updatedAt'> {
|
||||
return {
|
||||
id: domain.id,
|
||||
platform: domain.platform as PrismaPlatform,
|
||||
versionCode: domain.versionCode.value,
|
||||
versionName: domain.versionName.value,
|
||||
buildNumber: domain.buildNumber.value,
|
||||
downloadUrl: domain.downloadUrl.value,
|
||||
fileSize: domain.fileSize.bytes,
|
||||
fileSha256: domain.fileSha256.value,
|
||||
changelog: domain.changelog.value,
|
||||
isForceUpdate: domain.isForceUpdate,
|
||||
isEnabled: domain.isEnabled,
|
||||
minOsVersion: domain.minOsVersion?.value ?? null,
|
||||
releaseDate: domain.releaseDate,
|
||||
createdBy: domain.createdBy,
|
||||
updatedBy: domain.updatedBy,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import { Injectable } from '@nestjs/common'
|
||||
import { PrismaService } from '../prisma/prisma.service'
|
||||
import { AppVersionMapper } from '../mappers/app-version.mapper'
|
||||
import {
|
||||
AppVersion,
|
||||
AppVersionRepository,
|
||||
Platform,
|
||||
VersionCode,
|
||||
} from '../../../domain/version-management'
|
||||
import { Platform as PrismaPlatform } from '@prisma/client'
|
||||
|
||||
@Injectable()
|
||||
export class AppVersionRepositoryImpl implements AppVersionRepository {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly mapper: AppVersionMapper,
|
||||
) {}
|
||||
|
||||
async save(appVersion: AppVersion): Promise<AppVersion> {
|
||||
const data = this.mapper.toPersistence(appVersion)
|
||||
const created = await this.prisma.appVersion.create({
|
||||
data: {
|
||||
...data,
|
||||
createdAt: appVersion.createdAt,
|
||||
updatedAt: appVersion.updatedAt,
|
||||
},
|
||||
})
|
||||
return this.mapper.toDomain(created)
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<AppVersion | null> {
|
||||
const found = await this.prisma.appVersion.findUnique({ where: { id } })
|
||||
return found ? this.mapper.toDomain(found) : null
|
||||
}
|
||||
|
||||
async findLatestByPlatform(platform: Platform): Promise<AppVersion | null> {
|
||||
const found = await this.prisma.appVersion.findFirst({
|
||||
where: { platform: platform as PrismaPlatform, isEnabled: true },
|
||||
orderBy: { versionCode: 'desc' },
|
||||
})
|
||||
return found ? this.mapper.toDomain(found) : null
|
||||
}
|
||||
|
||||
async findAllByPlatform(
|
||||
platform: Platform,
|
||||
includeDisabled = false,
|
||||
): Promise<AppVersion[]> {
|
||||
const results = await this.prisma.appVersion.findMany({
|
||||
where: includeDisabled
|
||||
? { platform: platform as PrismaPlatform }
|
||||
: { platform: platform as PrismaPlatform, isEnabled: true },
|
||||
orderBy: { versionCode: 'desc' },
|
||||
})
|
||||
return results.map((r) => this.mapper.toDomain(r))
|
||||
}
|
||||
|
||||
async findAll(includeDisabled = false): Promise<AppVersion[]> {
|
||||
const results = await this.prisma.appVersion.findMany({
|
||||
where: includeDisabled ? {} : { isEnabled: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
return results.map((r) => this.mapper.toDomain(r))
|
||||
}
|
||||
|
||||
async findByPlatformAndVersionCode(
|
||||
platform: Platform,
|
||||
versionCode: VersionCode,
|
||||
): Promise<AppVersion | null> {
|
||||
const found = await this.prisma.appVersion.findFirst({
|
||||
where: {
|
||||
platform: platform as PrismaPlatform,
|
||||
versionCode: versionCode.value,
|
||||
},
|
||||
})
|
||||
return found ? this.mapper.toDomain(found) : null
|
||||
}
|
||||
|
||||
async update(appVersion: AppVersion): Promise<AppVersion> {
|
||||
const data = this.mapper.toPersistence(appVersion)
|
||||
const updated = await this.prisma.appVersion.update({
|
||||
where: { id: appVersion.id },
|
||||
data: {
|
||||
downloadUrl: data.downloadUrl,
|
||||
fileSize: data.fileSize,
|
||||
fileSha256: data.fileSha256,
|
||||
changelog: data.changelog,
|
||||
isForceUpdate: data.isForceUpdate,
|
||||
isEnabled: data.isEnabled,
|
||||
minOsVersion: data.minOsVersion,
|
||||
releaseDate: data.releaseDate,
|
||||
updatedBy: data.updatedBy,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
})
|
||||
return this.mapper.toDomain(updated)
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.prisma.appVersion.delete({ where: { id } })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import { Injectable, Logger } from '@nestjs/common'
|
||||
import { ConfigService } from '@nestjs/config'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import * as crypto from 'crypto'
|
||||
import { Platform } from '../../domain/version-management'
|
||||
|
||||
export interface FileUploadResult {
|
||||
url: string
|
||||
size: number
|
||||
sha256: string
|
||||
filename: string
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FileStorageService {
|
||||
private readonly logger = new Logger(FileStorageService.name)
|
||||
private readonly uploadDir: string
|
||||
private readonly baseUrl: string
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.uploadDir = this.configService.get<string>('UPLOAD_DIR', './uploads')
|
||||
this.baseUrl = this.configService.get<string>('UPLOAD_BASE_URL', 'http://localhost:3020/downloads')
|
||||
|
||||
// 确保上传目录存在
|
||||
this.ensureUploadDir()
|
||||
}
|
||||
|
||||
private ensureUploadDir(): void {
|
||||
if (!fs.existsSync(this.uploadDir)) {
|
||||
fs.mkdirSync(this.uploadDir, { recursive: true })
|
||||
this.logger.log(`Created upload directory: ${this.uploadDir}`)
|
||||
}
|
||||
}
|
||||
|
||||
async saveFile(
|
||||
buffer: Buffer,
|
||||
originalFilename: string,
|
||||
platform: Platform,
|
||||
versionName: string,
|
||||
): Promise<FileUploadResult> {
|
||||
// 生成唯一文件名
|
||||
const ext = path.extname(originalFilename).toLowerCase()
|
||||
const timestamp = Date.now()
|
||||
const randomSuffix = crypto.randomBytes(4).toString('hex')
|
||||
const filename = `${platform.toLowerCase()}-${versionName}-${timestamp}-${randomSuffix}${ext}`
|
||||
const filepath = path.join(this.uploadDir, filename)
|
||||
|
||||
// 计算 SHA-256
|
||||
const sha256 = crypto.createHash('sha256').update(buffer).digest('hex')
|
||||
|
||||
// 保存文件
|
||||
await fs.promises.writeFile(filepath, buffer)
|
||||
this.logger.log(`Saved file: ${filepath} (${buffer.length} bytes, SHA256: ${sha256})`)
|
||||
|
||||
return {
|
||||
url: `${this.baseUrl}/${filename}`,
|
||||
size: buffer.length,
|
||||
sha256,
|
||||
filename,
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFile(filename: string): Promise<void> {
|
||||
const filepath = path.join(this.uploadDir, filename)
|
||||
if (fs.existsSync(filepath)) {
|
||||
await fs.promises.unlink(filepath)
|
||||
this.logger.log(`Deleted file: ${filepath}`)
|
||||
}
|
||||
}
|
||||
|
||||
getFilePath(filename: string): string {
|
||||
return path.join(this.uploadDir, filename)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,24 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { AppModule } from './app.module';
|
||||
import { join } from 'path';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true, forbidNonWhitelisted: true }));
|
||||
app.enableCors({ origin: process.env.CORS_ORIGIN || '*', credentials: true });
|
||||
app.setGlobalPrefix('api/v2');
|
||||
app.setGlobalPrefix('api/v2', {
|
||||
exclude: ['api/app/version/check', 'downloads/:filename'], // 移动端版本检查和下载不加前缀
|
||||
});
|
||||
|
||||
// 静态文件服务 - 用于 APK/IPA 下载
|
||||
const uploadDir = process.env.UPLOAD_DIR || './uploads';
|
||||
app.useStaticAssets(join(process.cwd(), uploadDir), {
|
||||
prefix: '/downloads/',
|
||||
});
|
||||
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('Mining Admin Service API')
|
||||
|
|
@ -21,6 +31,8 @@ async function bootstrap() {
|
|||
.addTag('Users', '用户管理')
|
||||
.addTag('Reports', '报表')
|
||||
.addTag('Audit', '审计日志')
|
||||
.addTag('Version Management', '版本管理')
|
||||
.addTag('Mobile App Version', '移动端版本检查')
|
||||
.build();
|
||||
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,11 @@
|
|||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
|
||||
<!-- 升级模块权限 -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
|
||||
|
||||
<application
|
||||
android:label="@string/app_name"
|
||||
android:name="${applicationName}"
|
||||
|
|
@ -35,6 +40,17 @@
|
|||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
|
||||
<!-- FileProvider for APK installation -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
|
|
|
|||
|
|
@ -1,5 +1,93 @@
|
|||
package com.rwadurian.mining_app
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.core.content.FileProvider
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.io.File
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
class MainActivity : FlutterActivity() {
|
||||
private val APK_INSTALLER_CHANNEL = "com.durianqueen.mining/apk_installer"
|
||||
private val APP_MARKET_CHANNEL = "com.durianqueen.mining/app_market"
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
|
||||
// APK 安装 MethodChannel
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, APK_INSTALLER_CHANNEL)
|
||||
.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"installApk" -> {
|
||||
val apkPath = call.argument<String>("apkPath")
|
||||
if (apkPath != null) {
|
||||
val success = installApk(apkPath)
|
||||
result.success(success)
|
||||
} else {
|
||||
result.error("INVALID_ARGUMENT", "APK path is required", null)
|
||||
}
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
// 应用市场 MethodChannel
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, APP_MARKET_CHANNEL)
|
||||
.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"getInstallerPackageName" -> {
|
||||
val installer = getInstallerPackageName()
|
||||
result.success(installer)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun installApk(apkPath: String): Boolean {
|
||||
return try {
|
||||
val apkFile = File(apkPath)
|
||||
if (!apkFile.exists()) {
|
||||
return false
|
||||
}
|
||||
|
||||
val intent = Intent(Intent.ACTION_VIEW)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
// Android 7.0+ 使用 FileProvider
|
||||
val uri = FileProvider.getUriForFile(
|
||||
this,
|
||||
"${packageName}.fileprovider",
|
||||
apkFile
|
||||
)
|
||||
intent.setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
} else {
|
||||
intent.setDataAndType(Uri.fromFile(apkFile), "application/vnd.android.package-archive")
|
||||
}
|
||||
|
||||
startActivity(intent)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun getInstallerPackageName(): String? {
|
||||
return try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
packageManager.getInstallSourceInfo(packageName).installingPackageName
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
packageManager.getInstallerPackageName(packageName)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<external-path name="external_files" path="." />
|
||||
<files-path name="files" path="." />
|
||||
<cache-path name="cache" path="." />
|
||||
<external-files-path name="external_files_path" path="." />
|
||||
<external-cache-path name="external_cache_path" path="." />
|
||||
</paths>
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
import 'dart:io';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/device_context.dart';
|
||||
|
||||
/// 设备信息收集器
|
||||
class DeviceInfoCollector {
|
||||
static DeviceInfoCollector? _instance;
|
||||
DeviceInfoCollector._();
|
||||
|
||||
factory DeviceInfoCollector() {
|
||||
_instance ??= DeviceInfoCollector._();
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
DeviceContext? _cachedContext;
|
||||
|
||||
Future<DeviceContext> collect(BuildContext context) async {
|
||||
if (_cachedContext != null) return _cachedContext!;
|
||||
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
|
||||
DeviceContext result;
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
final androidInfo = await deviceInfo.androidInfo;
|
||||
|
||||
result = DeviceContext(
|
||||
platform: 'android',
|
||||
brand: androidInfo.brand,
|
||||
model: androidInfo.model,
|
||||
manufacturer: androidInfo.manufacturer,
|
||||
isPhysicalDevice: androidInfo.isPhysicalDevice,
|
||||
osVersion: androidInfo.version.release,
|
||||
sdkInt: androidInfo.version.sdkInt,
|
||||
androidId: androidInfo.id,
|
||||
screen: _collectScreenInfo(mediaQuery),
|
||||
appName: packageInfo.appName,
|
||||
packageName: packageInfo.packageName,
|
||||
appVersion: packageInfo.version,
|
||||
buildNumber: packageInfo.buildNumber,
|
||||
buildMode: _getBuildMode(),
|
||||
locale: Platform.localeName,
|
||||
timezone: DateTime.now().timeZoneName,
|
||||
isDarkMode: mediaQuery.platformBrightness == Brightness.dark,
|
||||
networkType: 'unknown',
|
||||
collectedAt: DateTime.now(),
|
||||
);
|
||||
} else if (Platform.isIOS) {
|
||||
final iosInfo = await deviceInfo.iosInfo;
|
||||
|
||||
result = DeviceContext(
|
||||
platform: 'ios',
|
||||
brand: 'Apple',
|
||||
model: iosInfo.model,
|
||||
manufacturer: 'Apple',
|
||||
isPhysicalDevice: iosInfo.isPhysicalDevice,
|
||||
osVersion: iosInfo.systemVersion,
|
||||
sdkInt: 0,
|
||||
androidId: iosInfo.identifierForVendor ?? '',
|
||||
screen: _collectScreenInfo(mediaQuery),
|
||||
appName: packageInfo.appName,
|
||||
packageName: packageInfo.packageName,
|
||||
appVersion: packageInfo.version,
|
||||
buildNumber: packageInfo.buildNumber,
|
||||
buildMode: _getBuildMode(),
|
||||
locale: Platform.localeName,
|
||||
timezone: DateTime.now().timeZoneName,
|
||||
isDarkMode: mediaQuery.platformBrightness == Brightness.dark,
|
||||
networkType: 'unknown',
|
||||
collectedAt: DateTime.now(),
|
||||
);
|
||||
} else {
|
||||
throw UnsupportedError('Unsupported platform');
|
||||
}
|
||||
|
||||
_cachedContext = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
ScreenInfo _collectScreenInfo(MediaQueryData mediaQuery) {
|
||||
final size = mediaQuery.size;
|
||||
final density = mediaQuery.devicePixelRatio;
|
||||
|
||||
return ScreenInfo(
|
||||
widthPx: size.width * density,
|
||||
heightPx: size.height * density,
|
||||
density: density,
|
||||
widthDp: size.width,
|
||||
heightDp: size.height,
|
||||
hasNotch: mediaQuery.padding.top > 24,
|
||||
);
|
||||
}
|
||||
|
||||
String _getBuildMode() {
|
||||
if (kReleaseMode) return 'release';
|
||||
if (kProfileMode) return 'profile';
|
||||
return 'debug';
|
||||
}
|
||||
|
||||
void clearCache() {
|
||||
_cachedContext = null;
|
||||
}
|
||||
|
||||
DeviceContext? get cachedContext => _cachedContext;
|
||||
}
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// 屏幕信息
|
||||
class ScreenInfo extends Equatable {
|
||||
final double widthPx;
|
||||
final double heightPx;
|
||||
final double density;
|
||||
final double widthDp;
|
||||
final double heightDp;
|
||||
final bool hasNotch;
|
||||
|
||||
const ScreenInfo({
|
||||
required this.widthPx,
|
||||
required this.heightPx,
|
||||
required this.density,
|
||||
required this.widthDp,
|
||||
required this.heightDp,
|
||||
required this.hasNotch,
|
||||
});
|
||||
|
||||
factory ScreenInfo.fromJson(Map<String, dynamic> json) {
|
||||
return ScreenInfo(
|
||||
widthPx: (json['widthPx'] as num).toDouble(),
|
||||
heightPx: (json['heightPx'] as num).toDouble(),
|
||||
density: (json['density'] as num).toDouble(),
|
||||
widthDp: (json['widthDp'] as num).toDouble(),
|
||||
heightDp: (json['heightDp'] as num).toDouble(),
|
||||
hasNotch: json['hasNotch'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'widthPx': widthPx,
|
||||
'heightPx': heightPx,
|
||||
'density': density,
|
||||
'widthDp': widthDp,
|
||||
'heightDp': heightDp,
|
||||
'hasNotch': hasNotch,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props =>
|
||||
[widthPx, heightPx, density, widthDp, heightDp, hasNotch];
|
||||
}
|
||||
|
||||
/// 设备上下文
|
||||
class DeviceContext extends Equatable {
|
||||
final String platform;
|
||||
final String brand;
|
||||
final String model;
|
||||
final String manufacturer;
|
||||
final bool isPhysicalDevice;
|
||||
final String osVersion;
|
||||
final int sdkInt;
|
||||
final String androidId;
|
||||
final ScreenInfo screen;
|
||||
final String appName;
|
||||
final String packageName;
|
||||
final String appVersion;
|
||||
final String buildNumber;
|
||||
final String buildMode;
|
||||
final String locale;
|
||||
final String timezone;
|
||||
final bool isDarkMode;
|
||||
final String networkType;
|
||||
final DateTime collectedAt;
|
||||
|
||||
const DeviceContext({
|
||||
required this.platform,
|
||||
required this.brand,
|
||||
required this.model,
|
||||
required this.manufacturer,
|
||||
required this.isPhysicalDevice,
|
||||
required this.osVersion,
|
||||
required this.sdkInt,
|
||||
required this.androidId,
|
||||
required this.screen,
|
||||
required this.appName,
|
||||
required this.packageName,
|
||||
required this.appVersion,
|
||||
required this.buildNumber,
|
||||
required this.buildMode,
|
||||
required this.locale,
|
||||
required this.timezone,
|
||||
required this.isDarkMode,
|
||||
required this.networkType,
|
||||
required this.collectedAt,
|
||||
});
|
||||
|
||||
factory DeviceContext.fromJson(Map<String, dynamic> json) {
|
||||
return DeviceContext(
|
||||
platform: json['platform'] as String,
|
||||
brand: json['brand'] as String,
|
||||
model: json['model'] as String,
|
||||
manufacturer: json['manufacturer'] as String,
|
||||
isPhysicalDevice: json['isPhysicalDevice'] as bool,
|
||||
osVersion: json['osVersion'] as String,
|
||||
sdkInt: json['sdkInt'] as int,
|
||||
androidId: json['androidId'] as String,
|
||||
screen: ScreenInfo.fromJson(json['screen'] as Map<String, dynamic>),
|
||||
appName: json['appName'] as String,
|
||||
packageName: json['packageName'] as String,
|
||||
appVersion: json['appVersion'] as String,
|
||||
buildNumber: json['buildNumber'] as String,
|
||||
buildMode: json['buildMode'] as String,
|
||||
locale: json['locale'] as String,
|
||||
timezone: json['timezone'] as String,
|
||||
isDarkMode: json['isDarkMode'] as bool,
|
||||
networkType: json['networkType'] as String,
|
||||
collectedAt: DateTime.parse(json['collectedAt'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'platform': platform,
|
||||
'brand': brand,
|
||||
'model': model,
|
||||
'manufacturer': manufacturer,
|
||||
'isPhysicalDevice': isPhysicalDevice,
|
||||
'osVersion': osVersion,
|
||||
'sdkInt': sdkInt,
|
||||
'androidId': androidId,
|
||||
'screen': screen.toJson(),
|
||||
'appName': appName,
|
||||
'packageName': packageName,
|
||||
'appVersion': appVersion,
|
||||
'buildNumber': buildNumber,
|
||||
'buildMode': buildMode,
|
||||
'locale': locale,
|
||||
'timezone': timezone,
|
||||
'isDarkMode': isDarkMode,
|
||||
'networkType': networkType,
|
||||
'collectedAt': collectedAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
platform,
|
||||
brand,
|
||||
model,
|
||||
manufacturer,
|
||||
isPhysicalDevice,
|
||||
osVersion,
|
||||
sdkInt,
|
||||
androidId,
|
||||
screen,
|
||||
appName,
|
||||
packageName,
|
||||
appVersion,
|
||||
buildNumber,
|
||||
buildMode,
|
||||
locale,
|
||||
timezone,
|
||||
isDarkMode,
|
||||
networkType,
|
||||
collectedAt,
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
import 'package:dio/dio.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'telemetry_event.dart';
|
||||
import '../presence/presence_config.dart';
|
||||
|
||||
/// 遥测配置
|
||||
class TelemetryConfig {
|
||||
bool globalEnabled = true;
|
||||
bool errorReportEnabled = true;
|
||||
bool performanceEnabled = true;
|
||||
bool userActionEnabled = true;
|
||||
bool pageViewEnabled = true;
|
||||
bool sessionEnabled = true;
|
||||
double samplingRate = 0.1;
|
||||
List<String> disabledEvents = [];
|
||||
String configVersion = '1.0.0';
|
||||
bool userOptIn = true;
|
||||
PresenceConfig? presenceConfig;
|
||||
|
||||
static final TelemetryConfig _instance = TelemetryConfig._();
|
||||
TelemetryConfig._();
|
||||
factory TelemetryConfig() => _instance;
|
||||
|
||||
Future<void> syncFromRemote(String apiBaseUrl) async {
|
||||
try {
|
||||
final dio = Dio(BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 5),
|
||||
receiveTimeout: const Duration(seconds: 5),
|
||||
));
|
||||
final response = await dio.get('$apiBaseUrl/telemetry/config');
|
||||
final data = response.data;
|
||||
|
||||
globalEnabled = data['global_enabled'] ?? true;
|
||||
errorReportEnabled = data['error_report_enabled'] ?? true;
|
||||
performanceEnabled = data['performance_enabled'] ?? true;
|
||||
userActionEnabled = data['user_action_enabled'] ?? true;
|
||||
pageViewEnabled = data['page_view_enabled'] ?? true;
|
||||
sessionEnabled = data['session_enabled'] ?? true;
|
||||
samplingRate = (data['sampling_rate'] ?? 0.1).toDouble();
|
||||
disabledEvents = List<String>.from(data['disabled_events'] ?? []);
|
||||
configVersion = data['version'] ?? '1.0.0';
|
||||
|
||||
if (data['presence_config'] != null) {
|
||||
presenceConfig = PresenceConfig.fromJson(data['presence_config']);
|
||||
}
|
||||
|
||||
await _saveToLocal();
|
||||
|
||||
debugPrint('Telemetry config synced (v$configVersion)');
|
||||
} catch (e) {
|
||||
debugPrint('Failed to sync telemetry config: $e');
|
||||
await _loadFromLocal();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveToLocal() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('telemetry_global_enabled', globalEnabled);
|
||||
await prefs.setBool('telemetry_error_enabled', errorReportEnabled);
|
||||
await prefs.setBool('telemetry_performance_enabled', performanceEnabled);
|
||||
await prefs.setBool('telemetry_user_action_enabled', userActionEnabled);
|
||||
await prefs.setBool('telemetry_page_view_enabled', pageViewEnabled);
|
||||
await prefs.setBool('telemetry_session_enabled', sessionEnabled);
|
||||
await prefs.setDouble('telemetry_sampling_rate', samplingRate);
|
||||
await prefs.setStringList('telemetry_disabled_events', disabledEvents);
|
||||
await prefs.setString('telemetry_config_version', configVersion);
|
||||
}
|
||||
|
||||
Future<void> _loadFromLocal() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
globalEnabled = prefs.getBool('telemetry_global_enabled') ?? true;
|
||||
errorReportEnabled = prefs.getBool('telemetry_error_enabled') ?? true;
|
||||
performanceEnabled = prefs.getBool('telemetry_performance_enabled') ?? true;
|
||||
userActionEnabled = prefs.getBool('telemetry_user_action_enabled') ?? true;
|
||||
pageViewEnabled = prefs.getBool('telemetry_page_view_enabled') ?? true;
|
||||
sessionEnabled = prefs.getBool('telemetry_session_enabled') ?? true;
|
||||
samplingRate = prefs.getDouble('telemetry_sampling_rate') ?? 0.1;
|
||||
disabledEvents = prefs.getStringList('telemetry_disabled_events') ?? [];
|
||||
configVersion = prefs.getString('telemetry_config_version') ?? '1.0.0';
|
||||
}
|
||||
|
||||
bool shouldLog(EventType type, String eventName) {
|
||||
if (!globalEnabled) return false;
|
||||
if (!userOptIn) return false;
|
||||
if (disabledEvents.contains(eventName)) return false;
|
||||
|
||||
switch (type) {
|
||||
case EventType.error:
|
||||
case EventType.crash:
|
||||
return errorReportEnabled;
|
||||
case EventType.performance:
|
||||
return performanceEnabled;
|
||||
case EventType.userAction:
|
||||
return userActionEnabled;
|
||||
case EventType.pageView:
|
||||
return pageViewEnabled;
|
||||
case EventType.apiCall:
|
||||
return performanceEnabled;
|
||||
case EventType.session:
|
||||
return sessionEnabled;
|
||||
case EventType.presence:
|
||||
return presenceConfig?.enabled ?? true;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setUserOptIn(bool optIn) async {
|
||||
userOptIn = optIn;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('telemetry_user_opt_in', optIn);
|
||||
debugPrint('User opt-in: $optIn');
|
||||
}
|
||||
|
||||
Future<void> loadUserOptIn() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
userOptIn = prefs.getBool('telemetry_user_opt_in') ?? true;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// 事件级别
|
||||
enum EventLevel {
|
||||
debug,
|
||||
info,
|
||||
warning,
|
||||
error,
|
||||
fatal,
|
||||
}
|
||||
|
||||
/// 事件类型
|
||||
enum EventType {
|
||||
/// 页面访问
|
||||
pageView,
|
||||
|
||||
/// 用户行为
|
||||
userAction,
|
||||
|
||||
/// API请求
|
||||
apiCall,
|
||||
|
||||
/// 性能指标
|
||||
performance,
|
||||
|
||||
/// 错误异常
|
||||
error,
|
||||
|
||||
/// 崩溃
|
||||
crash,
|
||||
|
||||
/// 会话事件 (app_session_start, app_session_end)
|
||||
session,
|
||||
|
||||
/// 在线状态 (心跳相关)
|
||||
presence,
|
||||
}
|
||||
|
||||
/// 遥测事件模型
|
||||
class TelemetryEvent extends Equatable {
|
||||
/// 事件ID (UUID)
|
||||
final String eventId;
|
||||
|
||||
/// 事件类型
|
||||
final EventType type;
|
||||
|
||||
/// 事件级别
|
||||
final EventLevel level;
|
||||
|
||||
/// 事件名称: 'app_session_start', 'open_planting_page'
|
||||
final String name;
|
||||
|
||||
/// 事件参数
|
||||
final Map<String, dynamic>? properties;
|
||||
|
||||
/// 事件时间戳
|
||||
final DateTime timestamp;
|
||||
|
||||
/// 用户ID(登录后设置)
|
||||
final String? userId;
|
||||
|
||||
/// 会话ID
|
||||
final String? sessionId;
|
||||
|
||||
/// 安装ID(设备唯一标识)
|
||||
final String installId;
|
||||
|
||||
/// 关联设备信息ID
|
||||
final String deviceContextId;
|
||||
|
||||
const TelemetryEvent({
|
||||
required this.eventId,
|
||||
required this.type,
|
||||
required this.level,
|
||||
required this.name,
|
||||
this.properties,
|
||||
required this.timestamp,
|
||||
this.userId,
|
||||
this.sessionId,
|
||||
required this.installId,
|
||||
required this.deviceContextId,
|
||||
});
|
||||
|
||||
factory TelemetryEvent.fromJson(Map<String, dynamic> json) {
|
||||
return TelemetryEvent(
|
||||
eventId: json['eventId'] as String,
|
||||
type: EventType.values.firstWhere(
|
||||
(e) => e.name == json['type'],
|
||||
orElse: () => EventType.userAction,
|
||||
),
|
||||
level: EventLevel.values.firstWhere(
|
||||
(e) => e.name == json['level'],
|
||||
orElse: () => EventLevel.info,
|
||||
),
|
||||
name: json['name'] as String,
|
||||
properties: json['properties'] as Map<String, dynamic>?,
|
||||
timestamp: DateTime.parse(json['timestamp'] as String),
|
||||
userId: json['userId'] as String?,
|
||||
sessionId: json['sessionId'] as String?,
|
||||
installId: json['installId'] as String,
|
||||
deviceContextId: json['deviceContextId'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
/// 转换为本地存储 JSON 格式
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'eventId': eventId,
|
||||
'type': type.name,
|
||||
'level': level.name,
|
||||
'name': name,
|
||||
'properties': properties,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
'userId': userId,
|
||||
'sessionId': sessionId,
|
||||
'installId': installId,
|
||||
'deviceContextId': deviceContextId,
|
||||
};
|
||||
}
|
||||
|
||||
/// 转换为服务端 API 格式
|
||||
Map<String, dynamic> toServerJson() {
|
||||
return {
|
||||
'eventName': name,
|
||||
'userId': userId,
|
||||
'installId': installId,
|
||||
'clientTs': timestamp.millisecondsSinceEpoch ~/ 1000,
|
||||
'properties': {
|
||||
...?properties,
|
||||
'eventId': eventId,
|
||||
'type': type.name,
|
||||
'level': level.name,
|
||||
'sessionId': sessionId,
|
||||
'deviceContextId': deviceContextId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
TelemetryEvent copyWith({
|
||||
String? eventId,
|
||||
EventType? type,
|
||||
EventLevel? level,
|
||||
String? name,
|
||||
Map<String, dynamic>? properties,
|
||||
DateTime? timestamp,
|
||||
String? userId,
|
||||
String? sessionId,
|
||||
String? installId,
|
||||
String? deviceContextId,
|
||||
}) {
|
||||
return TelemetryEvent(
|
||||
eventId: eventId ?? this.eventId,
|
||||
type: type ?? this.type,
|
||||
level: level ?? this.level,
|
||||
name: name ?? this.name,
|
||||
properties: properties ?? this.properties,
|
||||
timestamp: timestamp ?? this.timestamp,
|
||||
userId: userId ?? this.userId,
|
||||
sessionId: sessionId ?? this.sessionId,
|
||||
installId: installId ?? this.installId,
|
||||
deviceContextId: deviceContextId ?? this.deviceContextId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
eventId,
|
||||
type,
|
||||
level,
|
||||
name,
|
||||
properties,
|
||||
timestamp,
|
||||
userId,
|
||||
sessionId,
|
||||
installId,
|
||||
deviceContextId,
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import '../session/session_manager.dart';
|
||||
import '../session/session_events.dart';
|
||||
import 'presence_config.dart';
|
||||
|
||||
/// 心跳服务
|
||||
class HeartbeatService {
|
||||
static HeartbeatService? _instance;
|
||||
|
||||
HeartbeatService._();
|
||||
|
||||
factory HeartbeatService() {
|
||||
_instance ??= HeartbeatService._();
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
PresenceConfig _config = PresenceConfig.defaultConfig;
|
||||
|
||||
Timer? _heartbeatTimer;
|
||||
|
||||
bool _isRunning = false;
|
||||
bool get isRunning => _isRunning;
|
||||
|
||||
DateTime? _lastHeartbeatAt;
|
||||
DateTime? get lastHeartbeatAt => _lastHeartbeatAt;
|
||||
|
||||
int _heartbeatCount = 0;
|
||||
int get heartbeatCount => _heartbeatCount;
|
||||
|
||||
// 回调函数,用于获取运行时数据
|
||||
String Function()? getInstallId;
|
||||
String? Function()? getUserId;
|
||||
String Function()? getAppVersion;
|
||||
Map<String, String> Function()? getAuthHeaders;
|
||||
|
||||
late Dio _dio;
|
||||
|
||||
bool _isInitialized = false;
|
||||
|
||||
void initialize({
|
||||
required String apiBaseUrl,
|
||||
PresenceConfig? config,
|
||||
required String Function() getInstallId,
|
||||
required String? Function() getUserId,
|
||||
required String Function() getAppVersion,
|
||||
Map<String, String> Function()? getAuthHeaders,
|
||||
}) {
|
||||
if (_isInitialized) return;
|
||||
|
||||
_apiBaseUrl = apiBaseUrl;
|
||||
_config = config ?? PresenceConfig.defaultConfig;
|
||||
this.getInstallId = getInstallId;
|
||||
this.getUserId = getUserId;
|
||||
this.getAppVersion = getAppVersion;
|
||||
this.getAuthHeaders = getAuthHeaders;
|
||||
|
||||
_dio = Dio(BaseOptions(
|
||||
baseUrl: apiBaseUrl,
|
||||
connectTimeout: const Duration(seconds: 5),
|
||||
receiveTimeout: const Duration(seconds: 5),
|
||||
));
|
||||
|
||||
final sessionManager = SessionManager();
|
||||
sessionManager.onSessionStart = _onSessionStart;
|
||||
sessionManager.onSessionEnd = _onSessionEnd;
|
||||
|
||||
if (sessionManager.state == SessionState.foreground) {
|
||||
_startHeartbeat();
|
||||
}
|
||||
|
||||
_isInitialized = true;
|
||||
debugPrint('[Heartbeat] Initialized, interval: ${_config.heartbeatIntervalSeconds}s');
|
||||
}
|
||||
|
||||
void updateConfig(PresenceConfig config) {
|
||||
final wasRunning = _isRunning;
|
||||
|
||||
if (wasRunning) {
|
||||
_stopHeartbeat();
|
||||
}
|
||||
|
||||
_config = config;
|
||||
|
||||
if (wasRunning && _config.enabled) {
|
||||
_startHeartbeat();
|
||||
}
|
||||
|
||||
debugPrint('[Heartbeat] Config updated');
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_stopHeartbeat();
|
||||
_isInitialized = false;
|
||||
_instance = null;
|
||||
debugPrint('[Heartbeat] Disposed');
|
||||
}
|
||||
|
||||
void _onSessionStart() {
|
||||
if (_config.enabled) {
|
||||
_startHeartbeat();
|
||||
}
|
||||
}
|
||||
|
||||
void _onSessionEnd() {
|
||||
_stopHeartbeat();
|
||||
}
|
||||
|
||||
void _startHeartbeat() {
|
||||
if (_isRunning) return;
|
||||
if (!_config.enabled) return;
|
||||
|
||||
_isRunning = true;
|
||||
_heartbeatCount = 0;
|
||||
|
||||
_sendHeartbeat();
|
||||
|
||||
_heartbeatTimer = Timer.periodic(
|
||||
Duration(seconds: _config.heartbeatIntervalSeconds),
|
||||
(_) => _sendHeartbeat(),
|
||||
);
|
||||
|
||||
debugPrint('[Heartbeat] Started');
|
||||
}
|
||||
|
||||
void _stopHeartbeat() {
|
||||
_heartbeatTimer?.cancel();
|
||||
_heartbeatTimer = null;
|
||||
_isRunning = false;
|
||||
|
||||
debugPrint('[Heartbeat] Stopped (count: $_heartbeatCount)');
|
||||
}
|
||||
|
||||
Future<void> _sendHeartbeat() async {
|
||||
if (_config.requiresAuth && (getUserId?.call() == null)) {
|
||||
debugPrint('[Heartbeat] Skipped: user not logged in');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
'/api/v1/presence/heartbeat',
|
||||
data: {
|
||||
'installId': getInstallId?.call() ?? '',
|
||||
'appVersion': getAppVersion?.call() ?? '',
|
||||
'clientTs': DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
},
|
||||
options: Options(
|
||||
headers: getAuthHeaders?.call(),
|
||||
),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
_lastHeartbeatAt = DateTime.now();
|
||||
_heartbeatCount++;
|
||||
debugPrint('[Heartbeat] Sent #$_heartbeatCount');
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
debugPrint('[Heartbeat] Failed (DioException): ${e.message}');
|
||||
} catch (e) {
|
||||
debugPrint('[Heartbeat] Failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<void> forceHeartbeat() async {
|
||||
await _sendHeartbeat();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
/// 心跳配置
|
||||
class PresenceConfig {
|
||||
final int heartbeatIntervalSeconds;
|
||||
final bool requiresAuth;
|
||||
final bool enabled;
|
||||
|
||||
const PresenceConfig({
|
||||
this.heartbeatIntervalSeconds = 60,
|
||||
this.requiresAuth = true,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
static const PresenceConfig defaultConfig = PresenceConfig();
|
||||
|
||||
factory PresenceConfig.fromJson(Map<String, dynamic> json) {
|
||||
return PresenceConfig(
|
||||
heartbeatIntervalSeconds: json['heartbeat_interval_seconds'] ?? 60,
|
||||
requiresAuth: json['requires_auth'] ?? true,
|
||||
enabled: json['presence_enabled'] ?? json['enabled'] ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'heartbeat_interval_seconds': heartbeatIntervalSeconds,
|
||||
'requires_auth': requiresAuth,
|
||||
'presence_enabled': enabled,
|
||||
};
|
||||
}
|
||||
|
||||
PresenceConfig copyWith({
|
||||
int? heartbeatIntervalSeconds,
|
||||
bool? requiresAuth,
|
||||
bool? enabled,
|
||||
}) {
|
||||
return PresenceConfig(
|
||||
heartbeatIntervalSeconds:
|
||||
heartbeatIntervalSeconds ?? this.heartbeatIntervalSeconds,
|
||||
requiresAuth: requiresAuth ?? this.requiresAuth,
|
||||
enabled: enabled ?? this.enabled,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
/// 会话相关的事件名常量
|
||||
class SessionEvents {
|
||||
static const String sessionStart = 'app_session_start';
|
||||
static const String sessionEnd = 'app_session_end';
|
||||
static const String heartbeat = 'presence_heartbeat';
|
||||
|
||||
SessionEvents._();
|
||||
}
|
||||
|
||||
/// 会话状态
|
||||
enum SessionState {
|
||||
foreground,
|
||||
background,
|
||||
unknown,
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
import 'package:flutter/widgets.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../telemetry_service.dart';
|
||||
import '../models/telemetry_event.dart';
|
||||
import 'session_events.dart';
|
||||
|
||||
/// 会话管理器
|
||||
class SessionManager with WidgetsBindingObserver {
|
||||
static SessionManager? _instance;
|
||||
|
||||
SessionManager._();
|
||||
|
||||
factory SessionManager() {
|
||||
_instance ??= SessionManager._();
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
String? _currentSessionId;
|
||||
String? get currentSessionId => _currentSessionId;
|
||||
|
||||
SessionState _state = SessionState.unknown;
|
||||
SessionState get state => _state;
|
||||
|
||||
DateTime? _sessionStartTime;
|
||||
|
||||
VoidCallback? onSessionStart;
|
||||
VoidCallback? onSessionEnd;
|
||||
|
||||
TelemetryService? _telemetryService;
|
||||
|
||||
bool _isInitialized = false;
|
||||
|
||||
void initialize(TelemetryService telemetryService) {
|
||||
if (_isInitialized) return;
|
||||
|
||||
_telemetryService = telemetryService;
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
_handleForeground();
|
||||
|
||||
_isInitialized = true;
|
||||
debugPrint('[Session] Manager initialized');
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_isInitialized = false;
|
||||
_instance = null;
|
||||
debugPrint('[Session] Manager disposed');
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
switch (state) {
|
||||
case AppLifecycleState.resumed:
|
||||
_handleForeground();
|
||||
break;
|
||||
case AppLifecycleState.paused:
|
||||
_handleBackground();
|
||||
break;
|
||||
case AppLifecycleState.inactive:
|
||||
case AppLifecycleState.detached:
|
||||
case AppLifecycleState.hidden:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleForeground() {
|
||||
if (_state == SessionState.foreground) return;
|
||||
|
||||
_state = SessionState.foreground;
|
||||
_startNewSession();
|
||||
}
|
||||
|
||||
void _handleBackground() {
|
||||
if (_state == SessionState.background) return;
|
||||
|
||||
_state = SessionState.background;
|
||||
_endCurrentSession();
|
||||
}
|
||||
|
||||
void _startNewSession() {
|
||||
_currentSessionId = const Uuid().v4();
|
||||
_sessionStartTime = DateTime.now();
|
||||
|
||||
_telemetryService?.logEvent(
|
||||
SessionEvents.sessionStart,
|
||||
type: EventType.session,
|
||||
level: EventLevel.info,
|
||||
properties: {
|
||||
'session_id': _currentSessionId,
|
||||
},
|
||||
);
|
||||
|
||||
onSessionStart?.call();
|
||||
|
||||
debugPrint('[Session] Started: $_currentSessionId');
|
||||
}
|
||||
|
||||
void _endCurrentSession() {
|
||||
if (_currentSessionId == null) return;
|
||||
|
||||
final duration = _sessionStartTime != null
|
||||
? DateTime.now().difference(_sessionStartTime!).inSeconds
|
||||
: 0;
|
||||
|
||||
_telemetryService?.logEvent(
|
||||
SessionEvents.sessionEnd,
|
||||
type: EventType.session,
|
||||
level: EventLevel.info,
|
||||
properties: {
|
||||
'session_id': _currentSessionId,
|
||||
'duration_seconds': duration,
|
||||
},
|
||||
);
|
||||
|
||||
onSessionEnd?.call();
|
||||
|
||||
debugPrint('[Session] Ended: $_currentSessionId (${duration}s)');
|
||||
|
||||
_currentSessionId = null;
|
||||
_sessionStartTime = null;
|
||||
}
|
||||
|
||||
int get sessionDurationSeconds {
|
||||
if (_sessionStartTime == null) return 0;
|
||||
return DateTime.now().difference(_sessionStartTime!).inSeconds;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
import 'dart:convert';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/telemetry_event.dart';
|
||||
|
||||
/// 遥测本地存储
|
||||
class TelemetryStorage {
|
||||
static const String _keyEventQueue = 'telemetry_event_queue';
|
||||
static const String _keyDeviceContext = 'telemetry_device_context';
|
||||
static const String _keyInstallId = 'telemetry_install_id';
|
||||
static const int _maxQueueSize = 500;
|
||||
|
||||
late SharedPreferences _prefs;
|
||||
bool _isInitialized = false;
|
||||
|
||||
Future<void> init() async {
|
||||
if (_isInitialized) return;
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
_isInitialized = true;
|
||||
}
|
||||
|
||||
Future<void> saveDeviceContext(Map<String, dynamic> context) async {
|
||||
await _prefs.setString(_keyDeviceContext, jsonEncode(context));
|
||||
}
|
||||
|
||||
Map<String, dynamic>? getDeviceContext() {
|
||||
final str = _prefs.getString(_keyDeviceContext);
|
||||
if (str == null) return null;
|
||||
return jsonDecode(str) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
Future<void> saveInstallId(String installId) async {
|
||||
await _prefs.setString(_keyInstallId, installId);
|
||||
}
|
||||
|
||||
String? getInstallId() {
|
||||
return _prefs.getString(_keyInstallId);
|
||||
}
|
||||
|
||||
Future<void> enqueueEvent(TelemetryEvent event) async {
|
||||
final queue = _getEventQueue();
|
||||
|
||||
if (queue.length >= _maxQueueSize) {
|
||||
queue.removeAt(0);
|
||||
debugPrint('Telemetry queue full, removed oldest event');
|
||||
}
|
||||
|
||||
queue.add(event.toJson());
|
||||
await _saveEventQueue(queue);
|
||||
}
|
||||
|
||||
Future<void> enqueueEvents(List<TelemetryEvent> events) async {
|
||||
final queue = _getEventQueue();
|
||||
|
||||
for (var event in events) {
|
||||
if (queue.length >= _maxQueueSize) break;
|
||||
queue.add(event.toJson());
|
||||
}
|
||||
|
||||
await _saveEventQueue(queue);
|
||||
}
|
||||
|
||||
List<TelemetryEvent> dequeueEvents(int limit) {
|
||||
final queue = _getEventQueue();
|
||||
final count = queue.length > limit ? limit : queue.length;
|
||||
|
||||
final events = queue
|
||||
.take(count)
|
||||
.map((json) => TelemetryEvent.fromJson(json))
|
||||
.toList();
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
Future<void> removeEvents(int count) async {
|
||||
final queue = _getEventQueue();
|
||||
if (count >= queue.length) {
|
||||
await clearEventQueue();
|
||||
} else {
|
||||
queue.removeRange(0, count);
|
||||
await _saveEventQueue(queue);
|
||||
}
|
||||
}
|
||||
|
||||
int getQueueSize() {
|
||||
return _getEventQueue().length;
|
||||
}
|
||||
|
||||
Future<void> clearEventQueue() async {
|
||||
await _prefs.remove(_keyEventQueue);
|
||||
}
|
||||
|
||||
Future<void> clearUserData() async {
|
||||
if (!_isInitialized) {
|
||||
await init();
|
||||
}
|
||||
await _prefs.remove(_keyEventQueue);
|
||||
debugPrint('TelemetryStorage: cleared user telemetry data');
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _getEventQueue() {
|
||||
final str = _prefs.getString(_keyEventQueue);
|
||||
if (str == null) return [];
|
||||
|
||||
try {
|
||||
final List<dynamic> list = jsonDecode(str);
|
||||
return list.cast<Map<String, dynamic>>();
|
||||
} catch (e) {
|
||||
debugPrint('Failed to parse event queue: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveEventQueue(List<Map<String, dynamic>> queue) async {
|
||||
await _prefs.setString(_keyEventQueue, jsonEncode(queue));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
/// 遥测模块导出文件
|
||||
library telemetry;
|
||||
|
||||
export 'models/telemetry_event.dart';
|
||||
export 'models/device_context.dart';
|
||||
export 'models/telemetry_config.dart';
|
||||
export 'collectors/device_info_collector.dart';
|
||||
export 'storage/telemetry_storage.dart';
|
||||
export 'uploader/telemetry_uploader.dart';
|
||||
export 'session/session_events.dart';
|
||||
export 'session/session_manager.dart';
|
||||
export 'presence/presence_config.dart';
|
||||
export 'presence/heartbeat_service.dart';
|
||||
export 'telemetry_service.dart';
|
||||
|
|
@ -0,0 +1,328 @@
|
|||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'models/telemetry_event.dart';
|
||||
import 'models/device_context.dart';
|
||||
import 'models/telemetry_config.dart';
|
||||
import 'collectors/device_info_collector.dart';
|
||||
import 'storage/telemetry_storage.dart';
|
||||
import 'uploader/telemetry_uploader.dart';
|
||||
import 'session/session_manager.dart';
|
||||
import 'presence/heartbeat_service.dart';
|
||||
import 'presence/presence_config.dart';
|
||||
|
||||
/// 遥测服务 - TelemetryService
|
||||
///
|
||||
/// 功能概述:
|
||||
/// 收集应用使用数据,用于统计 DAU(日活跃用户)和在线用户数。
|
||||
/// 支持事件上报、会话追踪、心跳检测等功能。
|
||||
///
|
||||
/// 主要功能:
|
||||
/// 1. 事件记录 - 记录用户行为、页面访问、API调用、错误等
|
||||
/// 2. 会话管理 - 追踪应用前后台切换,计算会话时长
|
||||
/// 3. 心跳服务 - 定期上报在线状态,用于统计在线用户
|
||||
/// 4. 设备信息 - 收集设备型号、系统版本、应用版本等
|
||||
///
|
||||
/// 使用方式:
|
||||
/// ```dart
|
||||
/// // 初始化
|
||||
/// await TelemetryService().initialize(
|
||||
/// apiBaseUrl: 'https://api.example.com',
|
||||
/// context: context,
|
||||
/// );
|
||||
///
|
||||
/// // 记录事件
|
||||
/// TelemetryService().logPageView('home');
|
||||
/// TelemetryService().logUserAction('button_click');
|
||||
/// ```
|
||||
///
|
||||
/// 隐私说明:
|
||||
/// - 支持用户选择是否开启遥测
|
||||
/// - 支持远程配置开关
|
||||
/// - 不收集用户敏感信息
|
||||
class TelemetryService {
|
||||
static TelemetryService? _instance;
|
||||
TelemetryService._();
|
||||
|
||||
factory TelemetryService() {
|
||||
_instance ??= TelemetryService._();
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
final _storage = TelemetryStorage();
|
||||
late TelemetryUploader _uploader;
|
||||
|
||||
DeviceContext? _deviceContext;
|
||||
|
||||
late String _installId;
|
||||
String get installId => _installId;
|
||||
|
||||
String? _userId;
|
||||
String? get userId => _userId;
|
||||
|
||||
bool _isInitialized = false;
|
||||
bool get isInitialized => _isInitialized;
|
||||
|
||||
late SessionManager _sessionManager;
|
||||
late HeartbeatService _heartbeatService;
|
||||
|
||||
Timer? _configSyncTimer;
|
||||
|
||||
Future<void> initialize({
|
||||
required String apiBaseUrl,
|
||||
required BuildContext context,
|
||||
String? userId,
|
||||
Duration configSyncInterval = const Duration(hours: 1),
|
||||
PresenceConfig? presenceConfig,
|
||||
}) async {
|
||||
if (_isInitialized) return;
|
||||
|
||||
await _storage.init();
|
||||
await _initInstallId();
|
||||
await TelemetryConfig().loadUserOptIn();
|
||||
|
||||
TelemetryConfig().syncFromRemote(apiBaseUrl).catchError((e) {
|
||||
debugPrint('[Telemetry] Remote config sync failed (non-blocking): $e');
|
||||
});
|
||||
|
||||
_deviceContext = await DeviceInfoCollector().collect(context);
|
||||
await _storage.saveDeviceContext(_deviceContext!.toJson());
|
||||
|
||||
_userId = userId;
|
||||
|
||||
_uploader = TelemetryUploader(
|
||||
apiBaseUrl: apiBaseUrl,
|
||||
storage: _storage,
|
||||
getAuthHeaders: _getAuthHeaders,
|
||||
);
|
||||
|
||||
if (TelemetryConfig().globalEnabled) {
|
||||
_uploader.startPeriodicUpload();
|
||||
}
|
||||
|
||||
_configSyncTimer = Timer.periodic(configSyncInterval, (_) async {
|
||||
await TelemetryConfig().syncFromRemote(apiBaseUrl);
|
||||
|
||||
if (TelemetryConfig().globalEnabled) {
|
||||
_uploader.startPeriodicUpload();
|
||||
} else {
|
||||
_uploader.stopPeriodicUpload();
|
||||
}
|
||||
|
||||
if (TelemetryConfig().presenceConfig != null) {
|
||||
_heartbeatService.updateConfig(TelemetryConfig().presenceConfig!);
|
||||
}
|
||||
});
|
||||
|
||||
_sessionManager = SessionManager();
|
||||
_sessionManager.initialize(this);
|
||||
|
||||
_heartbeatService = HeartbeatService();
|
||||
_heartbeatService.initialize(
|
||||
apiBaseUrl: apiBaseUrl,
|
||||
config: presenceConfig ?? TelemetryConfig().presenceConfig,
|
||||
getInstallId: () => _installId,
|
||||
getUserId: () => _userId,
|
||||
getAppVersion: () => _deviceContext?.appVersion ?? 'unknown',
|
||||
getAuthHeaders: _getAuthHeaders,
|
||||
);
|
||||
|
||||
_isInitialized = true;
|
||||
debugPrint('TelemetryService initialized');
|
||||
debugPrint(' InstallId: $_installId');
|
||||
debugPrint(' UserId: $_userId');
|
||||
}
|
||||
|
||||
Future<void> _initInstallId() async {
|
||||
final storedId = _storage.getInstallId();
|
||||
|
||||
if (storedId != null) {
|
||||
_installId = storedId;
|
||||
} else {
|
||||
_installId = const Uuid().v4();
|
||||
await _storage.saveInstallId(_installId);
|
||||
}
|
||||
|
||||
debugPrint('[Telemetry] Install ID: $_installId');
|
||||
}
|
||||
|
||||
Map<String, String> _getAuthHeaders() {
|
||||
return {};
|
||||
}
|
||||
|
||||
void logEvent(
|
||||
String eventName, {
|
||||
EventType type = EventType.userAction,
|
||||
EventLevel level = EventLevel.info,
|
||||
Map<String, dynamic>? properties,
|
||||
}) {
|
||||
if (!_isInitialized) {
|
||||
debugPrint('TelemetryService not initialized, event ignored');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TelemetryConfig().shouldLog(type, eventName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_needsSampling(type)) {
|
||||
if (Random().nextDouble() > TelemetryConfig().samplingRate) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
final event = TelemetryEvent(
|
||||
eventId: const Uuid().v4(),
|
||||
type: type,
|
||||
level: level,
|
||||
name: eventName,
|
||||
properties: properties,
|
||||
timestamp: DateTime.now(),
|
||||
userId: _userId,
|
||||
sessionId: _sessionManager.currentSessionId,
|
||||
installId: _installId,
|
||||
deviceContextId: _deviceContext!.androidId,
|
||||
);
|
||||
|
||||
_storage.enqueueEvent(event);
|
||||
_uploader.uploadIfNeeded();
|
||||
}
|
||||
|
||||
bool _needsSampling(EventType type) {
|
||||
return type != EventType.error &&
|
||||
type != EventType.crash &&
|
||||
type != EventType.session;
|
||||
}
|
||||
|
||||
void logPageView(String pageName, {Map<String, dynamic>? extra}) {
|
||||
logEvent(
|
||||
'page_view',
|
||||
type: EventType.pageView,
|
||||
properties: {'page': pageName, ...?extra},
|
||||
);
|
||||
}
|
||||
|
||||
void logUserAction(String action, {Map<String, dynamic>? properties}) {
|
||||
logEvent(
|
||||
action,
|
||||
type: EventType.userAction,
|
||||
properties: properties,
|
||||
);
|
||||
}
|
||||
|
||||
void logError(
|
||||
String errorMessage, {
|
||||
Object? error,
|
||||
StackTrace? stackTrace,
|
||||
Map<String, dynamic>? extra,
|
||||
}) {
|
||||
logEvent(
|
||||
'error_occurred',
|
||||
type: EventType.error,
|
||||
level: EventLevel.error,
|
||||
properties: {
|
||||
'message': errorMessage,
|
||||
'error': error?.toString(),
|
||||
'stack_trace': stackTrace?.toString(),
|
||||
...?extra,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void logApiCall({
|
||||
required String url,
|
||||
required String method,
|
||||
required int statusCode,
|
||||
required int durationMs,
|
||||
String? error,
|
||||
}) {
|
||||
logEvent(
|
||||
'api_call',
|
||||
type: EventType.apiCall,
|
||||
level: error != null ? EventLevel.error : EventLevel.info,
|
||||
properties: {
|
||||
'url': url,
|
||||
'method': method,
|
||||
'status_code': statusCode,
|
||||
'duration_ms': durationMs,
|
||||
'error': error,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void logPerformance(
|
||||
String metricName, {
|
||||
required int durationMs,
|
||||
Map<String, dynamic>? extra,
|
||||
}) {
|
||||
logEvent(
|
||||
metricName,
|
||||
type: EventType.performance,
|
||||
properties: {'duration_ms': durationMs, ...?extra},
|
||||
);
|
||||
}
|
||||
|
||||
void setUserId(String? userId) {
|
||||
_userId = userId;
|
||||
debugPrint('[Telemetry] User ID set: $userId');
|
||||
}
|
||||
|
||||
void clearUserId() {
|
||||
_userId = null;
|
||||
debugPrint('[Telemetry] User ID cleared');
|
||||
}
|
||||
|
||||
Future<void> pauseForLogout() async {
|
||||
_uploader.stopPeriodicUpload();
|
||||
await _storage.clearEventQueue();
|
||||
_userId = null;
|
||||
debugPrint('[Telemetry] Paused for logout');
|
||||
}
|
||||
|
||||
void resumeAfterLogin() {
|
||||
if (TelemetryConfig().globalEnabled) {
|
||||
_uploader.startPeriodicUpload();
|
||||
debugPrint('[Telemetry] Resumed after login');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setUserOptIn(bool optIn) async {
|
||||
await TelemetryConfig().setUserOptIn(optIn);
|
||||
|
||||
if (!optIn) {
|
||||
_uploader.stopPeriodicUpload();
|
||||
await _storage.clearEventQueue();
|
||||
debugPrint('Telemetry disabled by user');
|
||||
} else {
|
||||
if (TelemetryConfig().globalEnabled) {
|
||||
_uploader.startPeriodicUpload();
|
||||
}
|
||||
debugPrint('Telemetry enabled by user');
|
||||
}
|
||||
}
|
||||
|
||||
String? get currentSessionId => _sessionManager.currentSessionId;
|
||||
int get sessionDurationSeconds => _sessionManager.sessionDurationSeconds;
|
||||
bool get isHeartbeatRunning => _heartbeatService.isRunning;
|
||||
int get heartbeatCount => _heartbeatService.heartbeatCount;
|
||||
|
||||
void updatePresenceConfig(PresenceConfig config) {
|
||||
_heartbeatService.updateConfig(config);
|
||||
}
|
||||
|
||||
DeviceContext? get deviceContext => _deviceContext;
|
||||
|
||||
Future<void> dispose() async {
|
||||
_configSyncTimer?.cancel();
|
||||
_sessionManager.dispose();
|
||||
_heartbeatService.dispose();
|
||||
await _uploader.forceUploadAll();
|
||||
_isInitialized = false;
|
||||
debugPrint('TelemetryService disposed');
|
||||
}
|
||||
|
||||
static void reset() {
|
||||
_instance = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
import 'dart:async';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../storage/telemetry_storage.dart';
|
||||
|
||||
/// 遥测上传器
|
||||
class TelemetryUploader {
|
||||
final String apiBaseUrl;
|
||||
final TelemetryStorage storage;
|
||||
final Dio _dio;
|
||||
|
||||
Timer? _uploadTimer;
|
||||
bool _isUploading = false;
|
||||
|
||||
Map<String, String> Function()? getAuthHeaders;
|
||||
|
||||
TelemetryUploader({
|
||||
required this.apiBaseUrl,
|
||||
required this.storage,
|
||||
this.getAuthHeaders,
|
||||
}) : _dio = Dio(BaseOptions(
|
||||
baseUrl: apiBaseUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 10),
|
||||
));
|
||||
|
||||
void startPeriodicUpload({
|
||||
Duration interval = const Duration(seconds: 30),
|
||||
int batchSize = 20,
|
||||
}) {
|
||||
_uploadTimer?.cancel();
|
||||
_uploadTimer = Timer.periodic(interval, (_) {
|
||||
uploadIfNeeded(batchSize: batchSize);
|
||||
});
|
||||
debugPrint('Telemetry uploader started (interval: ${interval.inSeconds}s)');
|
||||
}
|
||||
|
||||
void stopPeriodicUpload() {
|
||||
_uploadTimer?.cancel();
|
||||
_uploadTimer = null;
|
||||
debugPrint('Telemetry uploader stopped');
|
||||
}
|
||||
|
||||
Future<void> uploadIfNeeded({int batchSize = 20}) async {
|
||||
if (_isUploading) return;
|
||||
|
||||
final queueSize = storage.getQueueSize();
|
||||
if (queueSize < 10) return;
|
||||
|
||||
await uploadBatch(batchSize: batchSize);
|
||||
}
|
||||
|
||||
Future<bool> uploadBatch({int batchSize = 20}) async {
|
||||
if (_isUploading) return false;
|
||||
|
||||
_isUploading = true;
|
||||
try {
|
||||
final events = storage.dequeueEvents(batchSize);
|
||||
if (events.isEmpty) return true;
|
||||
|
||||
final response = await _dio.post(
|
||||
'/api/v1/analytics/events',
|
||||
data: {
|
||||
'events': events.map((e) => e.toServerJson()).toList(),
|
||||
},
|
||||
options: Options(
|
||||
headers: getAuthHeaders?.call(),
|
||||
),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
await storage.removeEvents(events.length);
|
||||
debugPrint('Uploaded ${events.length} telemetry events');
|
||||
return true;
|
||||
} else {
|
||||
debugPrint('Upload failed: ${response.statusCode}');
|
||||
return false;
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
debugPrint('Upload error (DioException): ${e.message}');
|
||||
return false;
|
||||
} catch (e) {
|
||||
debugPrint('Upload error: $e');
|
||||
return false;
|
||||
} finally {
|
||||
_isUploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> forceUploadAll() async {
|
||||
stopPeriodicUpload();
|
||||
|
||||
int retries = 0;
|
||||
while (storage.getQueueSize() > 0 && retries < 3) {
|
||||
final success = await uploadBatch(batchSize: 50);
|
||||
if (!success) {
|
||||
retries++;
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
}
|
||||
}
|
||||
|
||||
if (storage.getQueueSize() > 0) {
|
||||
debugPrint('${storage.getQueueSize()} events remaining in queue');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import 'dart:io';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
/// APK 安装器
|
||||
/// 负责调用原生代码安装 APK 文件
|
||||
class ApkInstaller {
|
||||
static const MethodChannel _channel =
|
||||
MethodChannel('com.durianqueen.mining/apk_installer');
|
||||
|
||||
/// 安装 APK
|
||||
static Future<bool> installApk(File apkFile) async {
|
||||
try {
|
||||
if (!await apkFile.exists()) {
|
||||
debugPrint('APK file not found');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Android 8.0+ 需要请求安装权限
|
||||
if (Platform.isAndroid) {
|
||||
final hasPermission = await _requestInstallPermission();
|
||||
if (!hasPermission) {
|
||||
debugPrint('Install permission denied');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('Installing APK: ${apkFile.path}');
|
||||
final result = await _channel.invokeMethod('installApk', {
|
||||
'apkPath': apkFile.path,
|
||||
});
|
||||
|
||||
debugPrint('Installation triggered: $result');
|
||||
return result == true;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('Install failed (PlatformException): ${e.message}');
|
||||
return false;
|
||||
} catch (e) {
|
||||
debugPrint('Install failed: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 请求安装权限(Android 8.0+)
|
||||
static Future<bool> _requestInstallPermission() async {
|
||||
if (await Permission.requestInstallPackages.isGranted) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final status = await Permission.requestInstallPackages.request();
|
||||
return status.isGranted;
|
||||
}
|
||||
|
||||
/// 检查是否有安装权限
|
||||
static Future<bool> hasInstallPermission() async {
|
||||
return await Permission.requestInstallPackages.isGranted;
|
||||
}
|
||||
|
||||
/// 打开应用设置页面(用于手动授权)
|
||||
static Future<void> openSettings() async {
|
||||
await openAppSettings();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
import 'dart:io';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
/// 应用市场检测器
|
||||
/// 检测应用安装来源,决定升级策略
|
||||
class AppMarketDetector {
|
||||
static const MethodChannel _channel =
|
||||
MethodChannel('com.durianqueen.mining/app_market');
|
||||
|
||||
/// 常见应用市场包名
|
||||
static const List<String> _marketPackages = [
|
||||
'com.android.vending', // Google Play
|
||||
'com.huawei.appmarket', // 华为应用市场
|
||||
'com.xiaomi.market', // 小米应用商店
|
||||
'com.oppo.market', // OPPO 软件商店
|
||||
'com.bbk.appstore', // vivo 应用商店
|
||||
'com.tencent.android.qqdownloader', // 应用宝
|
||||
'com.qihoo.appstore', // 360 手机助手
|
||||
'com.baidu.appsearch', // 百度手机助手
|
||||
'com.wandoujia.phoenix2', // 豌豆荚
|
||||
'com.dragon.android.pandaspace', // 91 助手
|
||||
'com.sec.android.app.samsungapps', // 三星应用商店
|
||||
];
|
||||
|
||||
/// 获取安装来源
|
||||
static Future<String?> getInstallerPackageName() async {
|
||||
if (!Platform.isAndroid) return null;
|
||||
|
||||
try {
|
||||
final installer =
|
||||
await _channel.invokeMethod<String>('getInstallerPackageName');
|
||||
return installer;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('Get installer failed (PlatformException): ${e.message}');
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('Get installer failed: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断是否来自应用市场
|
||||
static Future<bool> isFromAppMarket() async {
|
||||
final installer = await getInstallerPackageName();
|
||||
|
||||
if (installer == null || installer.isEmpty) {
|
||||
return false; // 直接安装(如 adb install)
|
||||
}
|
||||
|
||||
return _marketPackages.contains(installer);
|
||||
}
|
||||
|
||||
/// 判断是否来自 Google Play
|
||||
static Future<bool> isFromGooglePlay() async {
|
||||
final installer = await getInstallerPackageName();
|
||||
return installer == 'com.android.vending';
|
||||
}
|
||||
|
||||
/// 判断是否来自国内应用市场
|
||||
static Future<bool> isFromChineseMarket() async {
|
||||
final installer = await getInstallerPackageName();
|
||||
|
||||
if (installer == null || installer.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 排除 Google Play
|
||||
if (installer == 'com.android.vending') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return _marketPackages.contains(installer);
|
||||
}
|
||||
|
||||
/// 获取安装来源名称(用于显示)
|
||||
static Future<String> getInstallerName() async {
|
||||
final installer = await getInstallerPackageName();
|
||||
|
||||
if (installer == null || installer.isEmpty) {
|
||||
return '直接安装';
|
||||
}
|
||||
|
||||
switch (installer) {
|
||||
case 'com.android.vending':
|
||||
return 'Google Play';
|
||||
case 'com.huawei.appmarket':
|
||||
return '华为应用市场';
|
||||
case 'com.xiaomi.market':
|
||||
return '小米应用商店';
|
||||
case 'com.oppo.market':
|
||||
return 'OPPO 软件商店';
|
||||
case 'com.bbk.appstore':
|
||||
return 'vivo 应用商店';
|
||||
case 'com.tencent.android.qqdownloader':
|
||||
return '应用宝';
|
||||
case 'com.qihoo.appstore':
|
||||
return '360 手机助手';
|
||||
case 'com.baidu.appsearch':
|
||||
return '百度手机助手';
|
||||
case 'com.wandoujia.phoenix2':
|
||||
return '豌豆荚';
|
||||
case 'com.sec.android.app.samsungapps':
|
||||
return '三星应用商店';
|
||||
default:
|
||||
return installer;
|
||||
}
|
||||
}
|
||||
|
||||
/// 打开应用市场详情页
|
||||
static Future<bool> openAppMarketDetail(String packageName) async {
|
||||
final marketUri = Uri.parse('market://details?id=$packageName');
|
||||
|
||||
if (await canLaunchUrl(marketUri)) {
|
||||
await launchUrl(marketUri, mode: LaunchMode.externalApplication);
|
||||
return true;
|
||||
} else {
|
||||
// 回退到 Google Play 网页版
|
||||
final webUri =
|
||||
Uri.parse('https://play.google.com/store/apps/details?id=$packageName');
|
||||
if (await canLaunchUrl(webUri)) {
|
||||
await launchUrl(webUri, mode: LaunchMode.externalApplication);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 打开 Google Play 详情页
|
||||
static Future<bool> openGooglePlay(String packageName) async {
|
||||
final uri = Uri.parse(
|
||||
'https://play.google.com/store/apps/details?id=$packageName');
|
||||
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,275 @@
|
|||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/version_info.dart';
|
||||
import '../download_manager.dart';
|
||||
import '../update_service.dart';
|
||||
|
||||
/// 自托管更新弹窗
|
||||
/// 用于展示更新信息和下载进度
|
||||
class SelfHostedUpdater extends StatefulWidget {
|
||||
final VersionInfo versionInfo;
|
||||
final bool isForceUpdate;
|
||||
final VoidCallback? onDismiss;
|
||||
final VoidCallback? onInstalled;
|
||||
|
||||
const SelfHostedUpdater({
|
||||
super.key,
|
||||
required this.versionInfo,
|
||||
this.isForceUpdate = false,
|
||||
this.onDismiss,
|
||||
this.onInstalled,
|
||||
});
|
||||
|
||||
/// 显示更新弹窗
|
||||
static Future<void> show(
|
||||
BuildContext context, {
|
||||
required VersionInfo versionInfo,
|
||||
bool isForceUpdate = false,
|
||||
VoidCallback? onDismiss,
|
||||
VoidCallback? onInstalled,
|
||||
}) async {
|
||||
await showDialog(
|
||||
context: context,
|
||||
barrierDismissible: !isForceUpdate,
|
||||
builder: (context) => SelfHostedUpdater(
|
||||
versionInfo: versionInfo,
|
||||
isForceUpdate: isForceUpdate,
|
||||
onDismiss: onDismiss,
|
||||
onInstalled: onInstalled,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<SelfHostedUpdater> createState() => _SelfHostedUpdaterState();
|
||||
}
|
||||
|
||||
class _SelfHostedUpdaterState extends State<SelfHostedUpdater> {
|
||||
DownloadProgress _progress = DownloadProgress.initial();
|
||||
File? _downloadedFile;
|
||||
bool _isDownloading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_listenToProgress();
|
||||
}
|
||||
|
||||
void _listenToProgress() {
|
||||
UpdateService.instance.downloadProgressStream?.listen((progress) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_progress = progress;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _startDownload() async {
|
||||
setState(() {
|
||||
_isDownloading = true;
|
||||
_progress = DownloadProgress.initial().copyWith(
|
||||
status: DownloadStatus.downloading,
|
||||
);
|
||||
});
|
||||
|
||||
_listenToProgress();
|
||||
|
||||
final file = await UpdateService.instance.downloadUpdate(widget.versionInfo);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_downloadedFile = file;
|
||||
_isDownloading = false;
|
||||
});
|
||||
|
||||
if (file != null) {
|
||||
_installApk(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _installApk(File file) async {
|
||||
final success = await UpdateService.instance.installUpdate(file);
|
||||
if (success) {
|
||||
widget.onInstalled?.call();
|
||||
}
|
||||
}
|
||||
|
||||
void _cancelDownload() {
|
||||
UpdateService.instance.cancelDownload();
|
||||
setState(() {
|
||||
_isDownloading = false;
|
||||
_progress = DownloadProgress.initial();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 使用 PopScope 替代已废弃的 WillPopScope
|
||||
// canPop: false 表示禁止返回(强制更新时)
|
||||
return PopScope(
|
||||
canPop: !widget.isForceUpdate,
|
||||
child: AlertDialog(
|
||||
title: const Text('发现新版本'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 版本信息
|
||||
_buildVersionInfo(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 更新日志
|
||||
_buildChangelog(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 下载进度
|
||||
if (_isDownloading || _progress.status != DownloadStatus.idle)
|
||||
_buildDownloadProgress(),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: _buildActions(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildVersionInfo() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'版本 ${widget.versionInfo.versionName}',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (widget.isForceUpdate)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text(
|
||||
'强制更新',
|
||||
style: TextStyle(color: Colors.white, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'文件大小: ${widget.versionInfo.formattedFileSize}',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChangelog() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'更新内容',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxHeight: 150),
|
||||
child: SingleChildScrollView(
|
||||
child: Text(
|
||||
widget.versionInfo.changelog,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDownloadProgress() {
|
||||
String statusText;
|
||||
switch (_progress.status) {
|
||||
case DownloadStatus.downloading:
|
||||
statusText = '下载中 ${_progress.progressText}';
|
||||
break;
|
||||
case DownloadStatus.verifying:
|
||||
statusText = '验证文件中...';
|
||||
break;
|
||||
case DownloadStatus.completed:
|
||||
statusText = '下载完成';
|
||||
break;
|
||||
case DownloadStatus.failed:
|
||||
statusText = '下载失败: ${_progress.error ?? "未知错误"}';
|
||||
break;
|
||||
default:
|
||||
statusText = '准备下载...';
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(statusText, style: const TextStyle(fontSize: 13)),
|
||||
const SizedBox(height: 8),
|
||||
LinearProgressIndicator(
|
||||
value: _progress.status == DownloadStatus.verifying
|
||||
? null
|
||||
: _progress.progress,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildActions() {
|
||||
if (_progress.status == DownloadStatus.completed && _downloadedFile != null) {
|
||||
return [
|
||||
TextButton(
|
||||
onPressed: () => _installApk(_downloadedFile!),
|
||||
child: const Text('立即安装'),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (_isDownloading) {
|
||||
return [
|
||||
TextButton(
|
||||
onPressed: _cancelDownload,
|
||||
child: const Text('取消'),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
if (!widget.isForceUpdate)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
widget.onDismiss?.call();
|
||||
},
|
||||
child: const Text('稍后再说'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: _startDownload,
|
||||
child: const Text('立即更新'),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,238 @@
|
|||
import 'dart:io';
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'models/version_info.dart';
|
||||
|
||||
/// 下载状态
|
||||
enum DownloadStatus {
|
||||
idle,
|
||||
downloading,
|
||||
paused,
|
||||
completed,
|
||||
failed,
|
||||
verifying,
|
||||
}
|
||||
|
||||
/// 下载进度
|
||||
class DownloadProgress {
|
||||
final int downloaded;
|
||||
final int total;
|
||||
final DownloadStatus status;
|
||||
final String? error;
|
||||
|
||||
const DownloadProgress({
|
||||
required this.downloaded,
|
||||
required this.total,
|
||||
required this.status,
|
||||
this.error,
|
||||
});
|
||||
|
||||
double get progress => total > 0 ? downloaded / total : 0;
|
||||
String get progressText => '${(progress * 100).toStringAsFixed(1)}%';
|
||||
|
||||
factory DownloadProgress.initial() {
|
||||
return const DownloadProgress(
|
||||
downloaded: 0,
|
||||
total: 0,
|
||||
status: DownloadStatus.idle,
|
||||
);
|
||||
}
|
||||
|
||||
DownloadProgress copyWith({
|
||||
int? downloaded,
|
||||
int? total,
|
||||
DownloadStatus? status,
|
||||
String? error,
|
||||
}) {
|
||||
return DownloadProgress(
|
||||
downloaded: downloaded ?? this.downloaded,
|
||||
total: total ?? this.total,
|
||||
status: status ?? this.status,
|
||||
error: error ?? this.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 下载管理器
|
||||
/// 负责下载 APK 文件,支持断点续传
|
||||
class DownloadManager {
|
||||
final VersionInfo versionInfo;
|
||||
final _progressController = StreamController<DownloadProgress>.broadcast();
|
||||
|
||||
http.Client? _httpClient;
|
||||
File? _downloadFile;
|
||||
bool _isCancelled = false;
|
||||
|
||||
DownloadManager({required this.versionInfo});
|
||||
|
||||
Stream<DownloadProgress> get progressStream => _progressController.stream;
|
||||
|
||||
/// 获取下载目录
|
||||
Future<Directory> _getDownloadDir() async {
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final downloadDir = Directory('${appDir.path}/updates');
|
||||
if (!await downloadDir.exists()) {
|
||||
await downloadDir.create(recursive: true);
|
||||
}
|
||||
return downloadDir;
|
||||
}
|
||||
|
||||
/// 获取下载文件路径
|
||||
Future<File> _getDownloadFile() async {
|
||||
final downloadDir = await _getDownloadDir();
|
||||
final fileName = 'mining_app_${versionInfo.versionName}.apk';
|
||||
return File('${downloadDir.path}/$fileName');
|
||||
}
|
||||
|
||||
/// 开始下载
|
||||
Future<File?> startDownload() async {
|
||||
_isCancelled = false;
|
||||
_httpClient = http.Client();
|
||||
|
||||
try {
|
||||
_downloadFile = await _getDownloadFile();
|
||||
|
||||
// 检查是否有已下载的部分
|
||||
int downloadedBytes = 0;
|
||||
if (await _downloadFile!.exists()) {
|
||||
downloadedBytes = await _downloadFile!.length();
|
||||
|
||||
// 如果已经完整下载,验证文件
|
||||
if (downloadedBytes == versionInfo.fileSize) {
|
||||
_progressController.add(DownloadProgress(
|
||||
downloaded: downloadedBytes,
|
||||
total: versionInfo.fileSize,
|
||||
status: DownloadStatus.verifying,
|
||||
));
|
||||
|
||||
if (await _verifyFile(_downloadFile!)) {
|
||||
_progressController.add(DownloadProgress(
|
||||
downloaded: downloadedBytes,
|
||||
total: versionInfo.fileSize,
|
||||
status: DownloadStatus.completed,
|
||||
));
|
||||
return _downloadFile;
|
||||
} else {
|
||||
// 文件损坏,删除重新下载
|
||||
await _downloadFile!.delete();
|
||||
downloadedBytes = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('Starting download from byte $downloadedBytes');
|
||||
_progressController.add(DownloadProgress(
|
||||
downloaded: downloadedBytes,
|
||||
total: versionInfo.fileSize,
|
||||
status: DownloadStatus.downloading,
|
||||
));
|
||||
|
||||
// 创建请求,支持断点续传
|
||||
final request = http.Request('GET', Uri.parse(versionInfo.downloadUrl));
|
||||
if (downloadedBytes > 0) {
|
||||
request.headers['Range'] = 'bytes=$downloadedBytes-';
|
||||
}
|
||||
|
||||
final response = await _httpClient!.send(request);
|
||||
|
||||
if (response.statusCode != 200 && response.statusCode != 206) {
|
||||
throw Exception('Download failed: HTTP ${response.statusCode}');
|
||||
}
|
||||
|
||||
// 打开文件进行写入
|
||||
final fileSink = _downloadFile!.openWrite(mode: FileMode.append);
|
||||
|
||||
try {
|
||||
await for (final chunk in response.stream) {
|
||||
if (_isCancelled) {
|
||||
throw Exception('Download cancelled');
|
||||
}
|
||||
|
||||
fileSink.add(chunk);
|
||||
downloadedBytes += chunk.length;
|
||||
|
||||
_progressController.add(DownloadProgress(
|
||||
downloaded: downloadedBytes,
|
||||
total: versionInfo.fileSize,
|
||||
status: DownloadStatus.downloading,
|
||||
));
|
||||
}
|
||||
} finally {
|
||||
await fileSink.close();
|
||||
}
|
||||
|
||||
// 验证文件
|
||||
_progressController.add(DownloadProgress(
|
||||
downloaded: downloadedBytes,
|
||||
total: versionInfo.fileSize,
|
||||
status: DownloadStatus.verifying,
|
||||
));
|
||||
|
||||
if (await _verifyFile(_downloadFile!)) {
|
||||
_progressController.add(DownloadProgress(
|
||||
downloaded: downloadedBytes,
|
||||
total: versionInfo.fileSize,
|
||||
status: DownloadStatus.completed,
|
||||
));
|
||||
return _downloadFile;
|
||||
} else {
|
||||
await _downloadFile!.delete();
|
||||
throw Exception('File verification failed');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Download error: $e');
|
||||
_progressController.add(DownloadProgress(
|
||||
downloaded: 0,
|
||||
total: versionInfo.fileSize,
|
||||
status: DownloadStatus.failed,
|
||||
error: e.toString(),
|
||||
));
|
||||
return null;
|
||||
} finally {
|
||||
_httpClient?.close();
|
||||
_httpClient = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 取消下载
|
||||
void cancelDownload() {
|
||||
_isCancelled = true;
|
||||
_httpClient?.close();
|
||||
}
|
||||
|
||||
/// 验证文件 SHA256
|
||||
Future<bool> _verifyFile(File file) async {
|
||||
try {
|
||||
final bytes = await file.readAsBytes();
|
||||
final digest = sha256.convert(bytes);
|
||||
final actualHash = digest.toString();
|
||||
|
||||
debugPrint('Expected hash: ${versionInfo.fileSha256}');
|
||||
debugPrint('Actual hash: $actualHash');
|
||||
|
||||
return actualHash.toLowerCase() == versionInfo.fileSha256.toLowerCase();
|
||||
} catch (e) {
|
||||
debugPrint('Verify error: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理下载文件
|
||||
Future<void> cleanup() async {
|
||||
try {
|
||||
if (_downloadFile != null && await _downloadFile!.exists()) {
|
||||
await _downloadFile!.delete();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Cleanup error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
cancelDownload();
|
||||
_progressController.close();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
/// 更新配置
|
||||
class UpdateConfig {
|
||||
/// 检查更新的 API 地址
|
||||
final String checkUpdateUrl;
|
||||
|
||||
/// 应用包名
|
||||
final String packageName;
|
||||
|
||||
/// 更新检查间隔(分钟)
|
||||
final int checkIntervalMinutes;
|
||||
|
||||
/// 是否启用自动检查
|
||||
final bool autoCheck;
|
||||
|
||||
/// 是否在 WiFi 下自动下载
|
||||
final bool autoDownloadOnWifi;
|
||||
|
||||
const UpdateConfig({
|
||||
required this.checkUpdateUrl,
|
||||
required this.packageName,
|
||||
this.checkIntervalMinutes = 60,
|
||||
this.autoCheck = true,
|
||||
this.autoDownloadOnWifi = false,
|
||||
});
|
||||
|
||||
/// Mining App 默认配置
|
||||
static const mining = UpdateConfig(
|
||||
checkUpdateUrl: 'https://rwaapi.szaiai.com/mining-admin/api/app/version/check',
|
||||
packageName: 'com.durianqueen.mining',
|
||||
checkIntervalMinutes: 60,
|
||||
autoCheck: true,
|
||||
);
|
||||
|
||||
UpdateConfig copyWith({
|
||||
String? checkUpdateUrl,
|
||||
String? packageName,
|
||||
int? checkIntervalMinutes,
|
||||
bool? autoCheck,
|
||||
bool? autoDownloadOnWifi,
|
||||
}) {
|
||||
return UpdateConfig(
|
||||
checkUpdateUrl: checkUpdateUrl ?? this.checkUpdateUrl,
|
||||
packageName: packageName ?? this.packageName,
|
||||
checkIntervalMinutes: checkIntervalMinutes ?? this.checkIntervalMinutes,
|
||||
autoCheck: autoCheck ?? this.autoCheck,
|
||||
autoDownloadOnWifi: autoDownloadOnWifi ?? this.autoDownloadOnWifi,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
/// 版本信息
|
||||
class VersionInfo {
|
||||
/// 版本代码(数字)
|
||||
final int versionCode;
|
||||
|
||||
/// 版本名称(如 1.2.3)
|
||||
final String versionName;
|
||||
|
||||
/// 构建号
|
||||
final String buildNumber;
|
||||
|
||||
/// 下载地址
|
||||
final String downloadUrl;
|
||||
|
||||
/// 文件大小(字节)
|
||||
final int fileSize;
|
||||
|
||||
/// SHA256 校验和
|
||||
final String fileSha256;
|
||||
|
||||
/// 更新日志
|
||||
final String changelog;
|
||||
|
||||
/// 是否强制更新
|
||||
final bool isForceUpdate;
|
||||
|
||||
/// 最低支持的系统版本
|
||||
final String? minOsVersion;
|
||||
|
||||
/// 发布日期
|
||||
final DateTime? releaseDate;
|
||||
|
||||
const VersionInfo({
|
||||
required this.versionCode,
|
||||
required this.versionName,
|
||||
required this.buildNumber,
|
||||
required this.downloadUrl,
|
||||
required this.fileSize,
|
||||
required this.fileSha256,
|
||||
required this.changelog,
|
||||
this.isForceUpdate = false,
|
||||
this.minOsVersion,
|
||||
this.releaseDate,
|
||||
});
|
||||
|
||||
factory VersionInfo.fromJson(Map<String, dynamic> json) {
|
||||
return VersionInfo(
|
||||
versionCode: json['versionCode'] as int,
|
||||
versionName: json['versionName'] as String,
|
||||
buildNumber: json['buildNumber'] as String,
|
||||
downloadUrl: json['downloadUrl'] as String,
|
||||
fileSize: json['fileSize'] is String
|
||||
? int.parse(json['fileSize'])
|
||||
: json['fileSize'] as int,
|
||||
fileSha256: json['fileSha256'] as String,
|
||||
changelog: json['changelog'] as String,
|
||||
isForceUpdate: json['isForceUpdate'] as bool? ?? false,
|
||||
minOsVersion: json['minOsVersion'] as String?,
|
||||
releaseDate: json['releaseDate'] != null
|
||||
? DateTime.tryParse(json['releaseDate'] as String)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'versionCode': versionCode,
|
||||
'versionName': versionName,
|
||||
'buildNumber': buildNumber,
|
||||
'downloadUrl': downloadUrl,
|
||||
'fileSize': fileSize,
|
||||
'fileSha256': fileSha256,
|
||||
'changelog': changelog,
|
||||
'isForceUpdate': isForceUpdate,
|
||||
'minOsVersion': minOsVersion,
|
||||
'releaseDate': releaseDate?.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
/// 格式化文件大小
|
||||
String get formattedFileSize {
|
||||
if (fileSize < 1024) {
|
||||
return '$fileSize B';
|
||||
} else if (fileSize < 1024 * 1024) {
|
||||
return '${(fileSize / 1024).toStringAsFixed(1)} KB';
|
||||
} else if (fileSize < 1024 * 1024 * 1024) {
|
||||
return '${(fileSize / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
} else {
|
||||
return '${(fileSize / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新检查结果
|
||||
class UpdateCheckResult {
|
||||
/// 是否有更新
|
||||
final bool hasUpdate;
|
||||
|
||||
/// 新版本信息
|
||||
final VersionInfo? versionInfo;
|
||||
|
||||
/// 错误信息
|
||||
final String? error;
|
||||
|
||||
const UpdateCheckResult({
|
||||
required this.hasUpdate,
|
||||
this.versionInfo,
|
||||
this.error,
|
||||
});
|
||||
|
||||
factory UpdateCheckResult.noUpdate() {
|
||||
return const UpdateCheckResult(hasUpdate: false);
|
||||
}
|
||||
|
||||
factory UpdateCheckResult.hasUpdate(VersionInfo info) {
|
||||
return UpdateCheckResult(hasUpdate: true, versionInfo: info);
|
||||
}
|
||||
|
||||
factory UpdateCheckResult.error(String message) {
|
||||
return UpdateCheckResult(hasUpdate: false, error: message);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
import 'dart:io';
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'models/update_config.dart';
|
||||
import 'models/version_info.dart';
|
||||
import 'version_checker.dart';
|
||||
import 'download_manager.dart';
|
||||
import 'apk_installer.dart';
|
||||
import 'app_market_detector.dart';
|
||||
|
||||
/// 应用更新服务 - UpdateService
|
||||
///
|
||||
/// 功能概述:
|
||||
/// 统一管理应用的版本检查、APK下载、安装更新流程。
|
||||
/// 支持自托管更新服务器,与 mining-admin-service 后端配合使用。
|
||||
///
|
||||
/// 主要功能:
|
||||
/// 1. 版本检查 - 通过 VersionChecker 与服务器通信获取最新版本信息
|
||||
/// 2. APK下载 - 支持断点续传、SHA256校验
|
||||
/// 3. APK安装 - 通过原生 MethodChannel 调用系统安装器
|
||||
/// 4. 应用市场检测 - 判断是否从应用市场安装,支持引导跳转
|
||||
///
|
||||
/// 使用方式:
|
||||
/// ```dart
|
||||
/// // 初始化
|
||||
/// UpdateService.instance.initialize(config: UpdateConfig.mining);
|
||||
///
|
||||
/// // 检查更新
|
||||
/// final result = await UpdateService.instance.checkForUpdate();
|
||||
/// if (result.hasUpdate) {
|
||||
/// // 显示更新弹窗
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// 后端API:
|
||||
/// - GET /api/v2/versions/latest?platform=ANDROID - 获取最新版本
|
||||
/// - GET /api/v2/versions/:id/download - 下载APK文件
|
||||
class UpdateService {
|
||||
static UpdateService? _instance;
|
||||
static UpdateService get instance => _instance ??= UpdateService._();
|
||||
|
||||
UpdateService._();
|
||||
|
||||
static const String _lastCheckKey = 'update_last_check_time';
|
||||
|
||||
UpdateConfig _config = UpdateConfig.mining;
|
||||
VersionChecker? _versionChecker;
|
||||
DownloadManager? _downloadManager;
|
||||
|
||||
final _updateAvailableController = StreamController<VersionInfo?>.broadcast();
|
||||
Stream<VersionInfo?> get updateAvailableStream => _updateAvailableController.stream;
|
||||
|
||||
VersionInfo? _latestVersion;
|
||||
VersionInfo? get latestVersion => _latestVersion;
|
||||
|
||||
bool _isChecking = false;
|
||||
bool get isChecking => _isChecking;
|
||||
|
||||
/// 初始化更新服务
|
||||
void initialize({UpdateConfig? config}) {
|
||||
_config = config ?? UpdateConfig.mining;
|
||||
_versionChecker = VersionChecker(config: _config);
|
||||
debugPrint('UpdateService initialized with config: ${_config.checkUpdateUrl}');
|
||||
}
|
||||
|
||||
/// 检查更新
|
||||
Future<UpdateCheckResult> checkForUpdate({bool force = false}) async {
|
||||
if (_isChecking) {
|
||||
return UpdateCheckResult.error('Already checking');
|
||||
}
|
||||
|
||||
if (_versionChecker == null) {
|
||||
initialize();
|
||||
}
|
||||
|
||||
// 检查是否需要检查更新
|
||||
if (!force && !await _shouldCheck()) {
|
||||
if (_latestVersion != null) {
|
||||
return UpdateCheckResult.hasUpdate(_latestVersion!);
|
||||
}
|
||||
return UpdateCheckResult.noUpdate();
|
||||
}
|
||||
|
||||
_isChecking = true;
|
||||
|
||||
try {
|
||||
final result = await _versionChecker!.checkForUpdate();
|
||||
|
||||
if (result.hasUpdate && result.versionInfo != null) {
|
||||
_latestVersion = result.versionInfo;
|
||||
_updateAvailableController.add(_latestVersion);
|
||||
} else {
|
||||
_latestVersion = null;
|
||||
_updateAvailableController.add(null);
|
||||
}
|
||||
|
||||
// 保存检查时间
|
||||
await _saveLastCheckTime();
|
||||
|
||||
return result;
|
||||
} finally {
|
||||
_isChecking = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否应该检查更新
|
||||
Future<bool> _shouldCheck() async {
|
||||
if (!_config.autoCheck) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final lastCheck = prefs.getInt(_lastCheckKey) ?? 0;
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
final interval = _config.checkIntervalMinutes * 60 * 1000;
|
||||
|
||||
return (now - lastCheck) >= interval;
|
||||
}
|
||||
|
||||
/// 保存最后检查时间
|
||||
Future<void> _saveLastCheckTime() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(_lastCheckKey, DateTime.now().millisecondsSinceEpoch);
|
||||
}
|
||||
|
||||
/// 下载更新
|
||||
Future<File?> downloadUpdate(VersionInfo versionInfo) async {
|
||||
_downloadManager?.dispose();
|
||||
_downloadManager = DownloadManager(versionInfo: versionInfo);
|
||||
return await _downloadManager!.startDownload();
|
||||
}
|
||||
|
||||
/// 获取下载进度流
|
||||
Stream<DownloadProgress>? get downloadProgressStream =>
|
||||
_downloadManager?.progressStream;
|
||||
|
||||
/// 取消下载
|
||||
void cancelDownload() {
|
||||
_downloadManager?.cancelDownload();
|
||||
}
|
||||
|
||||
/// 安装更新
|
||||
Future<bool> installUpdate(File apkFile) async {
|
||||
return await ApkInstaller.installApk(apkFile);
|
||||
}
|
||||
|
||||
/// 检查是否来自应用市场
|
||||
Future<bool> isFromAppMarket() async {
|
||||
return await AppMarketDetector.isFromAppMarket();
|
||||
}
|
||||
|
||||
/// 打开应用市场
|
||||
Future<bool> openAppMarket() async {
|
||||
return await AppMarketDetector.openAppMarketDetail(_config.packageName);
|
||||
}
|
||||
|
||||
/// 获取安装来源名称
|
||||
Future<String> getInstallerName() async {
|
||||
return await AppMarketDetector.getInstallerName();
|
||||
}
|
||||
|
||||
/// 清除缓存的更新文件
|
||||
Future<void> clearCache() async {
|
||||
await _downloadManager?.cleanup();
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_downloadManager?.dispose();
|
||||
_updateAvailableController.close();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
/// 升级模块导出文件
|
||||
library updater;
|
||||
|
||||
export 'models/update_config.dart';
|
||||
export 'models/version_info.dart';
|
||||
export 'version_checker.dart';
|
||||
export 'download_manager.dart';
|
||||
export 'apk_installer.dart';
|
||||
export 'app_market_detector.dart';
|
||||
export 'update_service.dart';
|
||||
export 'channels/self_hosted_updater.dart';
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'models/update_config.dart';
|
||||
import 'models/version_info.dart';
|
||||
|
||||
/// 版本检查器
|
||||
/// 负责与服务器通信检查是否有新版本
|
||||
class VersionChecker {
|
||||
final UpdateConfig config;
|
||||
PackageInfo? _packageInfo;
|
||||
|
||||
VersionChecker({required this.config});
|
||||
|
||||
/// 获取当前应用版本信息
|
||||
Future<PackageInfo> getPackageInfo() async {
|
||||
_packageInfo ??= await PackageInfo.fromPlatform();
|
||||
return _packageInfo!;
|
||||
}
|
||||
|
||||
/// 获取当前版本代码
|
||||
Future<int> getCurrentVersionCode() async {
|
||||
final info = await getPackageInfo();
|
||||
return int.tryParse(info.buildNumber) ?? 0;
|
||||
}
|
||||
|
||||
/// 获取当前版本名称
|
||||
Future<String> getCurrentVersionName() async {
|
||||
final info = await getPackageInfo();
|
||||
return info.version;
|
||||
}
|
||||
|
||||
/// 检查是否有新版本
|
||||
Future<UpdateCheckResult> checkForUpdate() async {
|
||||
try {
|
||||
final currentVersionCode = await getCurrentVersionCode();
|
||||
final platform = Platform.isAndroid ? 'ANDROID' : 'IOS';
|
||||
|
||||
debugPrint('Checking for update: platform=$platform, currentVersion=$currentVersionCode');
|
||||
|
||||
final uri = Uri.parse(config.checkUpdateUrl).replace(
|
||||
queryParameters: {
|
||||
'platform': platform,
|
||||
'versionCode': currentVersionCode.toString(),
|
||||
},
|
||||
);
|
||||
|
||||
final response = await http.get(
|
||||
uri,
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
).timeout(const Duration(seconds: 30));
|
||||
|
||||
debugPrint('Update check response: ${response.statusCode}');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final json = jsonDecode(response.body);
|
||||
|
||||
// 检查 API 响应格式
|
||||
if (json['hasUpdate'] == true && json['latestVersion'] != null) {
|
||||
final versionInfo = VersionInfo.fromJson(json['latestVersion']);
|
||||
debugPrint('New version available: ${versionInfo.versionName}');
|
||||
return UpdateCheckResult.hasUpdate(versionInfo);
|
||||
}
|
||||
|
||||
debugPrint('No update available');
|
||||
return UpdateCheckResult.noUpdate();
|
||||
} else if (response.statusCode == 404) {
|
||||
// 未找到更新信息,视为无更新
|
||||
debugPrint('No version info found (404)');
|
||||
return UpdateCheckResult.noUpdate();
|
||||
} else {
|
||||
final errorMsg = 'Server error: ${response.statusCode}';
|
||||
debugPrint(errorMsg);
|
||||
return UpdateCheckResult.error(errorMsg);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Update check failed: $e');
|
||||
return UpdateCheckResult.error('检查更新失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 比较版本号
|
||||
static int compareVersions(String v1, String v2) {
|
||||
final parts1 = v1.split('.').map((e) => int.tryParse(e) ?? 0).toList();
|
||||
final parts2 = v2.split('.').map((e) => int.tryParse(e) ?? 0).toList();
|
||||
|
||||
final maxLength = parts1.length > parts2.length ? parts1.length : parts2.length;
|
||||
|
||||
for (var i = 0; i < maxLength; i++) {
|
||||
final p1 = i < parts1.length ? parts1[i] : 0;
|
||||
final p2 = i < parts2.length ? parts2[i] : 0;
|
||||
|
||||
if (p1 < p2) return -1;
|
||||
if (p1 > p2) return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import 'core/router/app_router.dart';
|
|||
import 'core/constants/app_colors.dart';
|
||||
import 'core/network/api_client.dart';
|
||||
import 'core/router/routes.dart';
|
||||
import 'core/updater/updater.dart';
|
||||
import 'presentation/providers/user_providers.dart';
|
||||
import 'presentation/providers/settings_providers.dart';
|
||||
|
||||
|
|
@ -18,6 +19,9 @@ void main() async {
|
|||
// 初始化依赖注入
|
||||
await configureDependencies();
|
||||
|
||||
// 初始化更新服务
|
||||
UpdateService.instance.initialize();
|
||||
|
||||
runApp(const ProviderScope(child: MiningApp()));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter/services.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import '../../../core/updater/updater.dart';
|
||||
|
||||
/// 关于我们页面
|
||||
class AboutPage extends StatefulWidget {
|
||||
|
|
@ -19,6 +20,7 @@ class _AboutPageState extends State<AboutPage> {
|
|||
|
||||
String _version = '';
|
||||
String _buildNumber = '';
|
||||
bool _isCheckingUpdate = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -331,6 +333,42 @@ class _AboutPageState extends State<AboutPage> {
|
|||
);
|
||||
}
|
||||
|
||||
Future<void> _checkForUpdate() async {
|
||||
if (_isCheckingUpdate) return;
|
||||
|
||||
setState(() {
|
||||
_isCheckingUpdate = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final result = await UpdateService.instance.checkForUpdate(force: true);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (result.hasUpdate && result.versionInfo != null) {
|
||||
SelfHostedUpdater.show(
|
||||
context,
|
||||
versionInfo: result.versionInfo!,
|
||||
isForceUpdate: result.versionInfo!.isForceUpdate,
|
||||
);
|
||||
} else if (result.error != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(result.error!)),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('已是最新版本')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isCheckingUpdate = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildLegalSection() {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
|
|
@ -344,6 +382,18 @@ class _AboutPageState extends State<AboutPage> {
|
|||
children: [
|
||||
_buildSectionTitle('法律条款'),
|
||||
const SizedBox(height: 16),
|
||||
_buildLegalItem(
|
||||
title: '检查更新',
|
||||
trailing: _isCheckingUpdate
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: null,
|
||||
onTap: _checkForUpdate,
|
||||
),
|
||||
const Divider(height: 24),
|
||||
_buildLegalItem(
|
||||
title: '用户协议',
|
||||
onTap: () {
|
||||
|
|
@ -365,6 +415,7 @@ class _AboutPageState extends State<AboutPage> {
|
|||
Widget _buildLegalItem({
|
||||
required String title,
|
||||
required VoidCallback onTap,
|
||||
Widget? trailing,
|
||||
}) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
|
|
@ -382,7 +433,7 @@ class _AboutPageState extends State<AboutPage> {
|
|||
),
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right, size: 20, color: _grayText),
|
||||
if (trailing != null) trailing else const Icon(Icons.chevron_right, size: 20, color: _grayText),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -48,6 +48,17 @@ dependencies:
|
|||
logger: ^2.0.0
|
||||
package_info_plus: ^8.0.0
|
||||
|
||||
# 升级模块
|
||||
url_launcher: ^6.2.0
|
||||
permission_handler: ^11.0.0
|
||||
path_provider: ^2.1.0
|
||||
crypto: ^3.0.0
|
||||
http: ^1.1.0
|
||||
|
||||
# 遥测模块
|
||||
uuid: ^4.2.0
|
||||
device_info_plus: ^10.0.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
# API Configuration
|
||||
NEXT_PUBLIC_API_URL=https://rwaapi.szaiai.com
|
||||
NEXT_PUBLIC_MINING_API_URL=https://rwaapi.szaiai.com/mining-admin
|
||||
|
||||
# Application Info
|
||||
NEXT_PUBLIC_APP_NAME=RWADurian Mobile Upgrade
|
||||
|
|
|
|||
|
|
@ -1,15 +1,42 @@
|
|||
/**
|
||||
* 移动端版本管理首页
|
||||
*
|
||||
* 功能概述:
|
||||
* 统一管理榴莲 App 和股行 App 的版本发布。
|
||||
* 支持在同一界面切换管理不同应用的版本。
|
||||
*
|
||||
* 支持的应用:
|
||||
* - 榴莲 App (mobile): 连接 admin-service 后端
|
||||
* - 股行 App (mining): 连接 mining-admin-service 后端
|
||||
*
|
||||
* 主要功能:
|
||||
* 1. 应用切换 - 在两个应用间切换版本管理
|
||||
* 2. 平台筛选 - 按 Android/iOS 筛选版本
|
||||
* 3. 版本上传 - 上传新版本 APK/IPA
|
||||
* 4. 版本管理 - 编辑、删除、启用/禁用版本
|
||||
*/
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useVersions, useVersionActions } from '@/application'
|
||||
import { Platform } from '@/domain'
|
||||
import { AppType } from '@/infrastructure'
|
||||
import { VersionCard } from '@/presentation/components/version-card'
|
||||
import { UploadModal } from '@/presentation/components/upload-modal'
|
||||
import { EditModal } from '@/presentation/components/edit-modal'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
/**
|
||||
* 应用标签配置
|
||||
* 定义每个应用在UI中显示的名称和描述
|
||||
*/
|
||||
const APP_LABELS: Record<AppType, { name: string; description: string }> = {
|
||||
mobile: { name: '榴莲 App', description: '管理榴莲 App 的版本更新' },
|
||||
mining: { name: '股行 App', description: '管理股行 App 的版本更新' },
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const { versions, isLoading, error, refetch, setFilter } = useVersions()
|
||||
const { versions, isLoading, error, appType, refetch, setFilter, setAppType } = useVersions()
|
||||
const { deleteVersion, toggleVersion } = useVersionActions()
|
||||
|
||||
const [platformFilter, setPlatformFilter] = useState<Platform | 'all'>('all')
|
||||
|
|
@ -24,6 +51,11 @@ export default function HomePage() {
|
|||
refetch()
|
||||
}, [refetch])
|
||||
|
||||
const handleAppTypeChange = (newAppType: AppType) => {
|
||||
setAppType(newAppType)
|
||||
setPlatformFilter('all')
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('确定要删除这个版本吗?此操作不可恢复。')) {
|
||||
return
|
||||
|
|
@ -58,14 +90,49 @@ export default function HomePage() {
|
|||
}
|
||||
|
||||
const filteredVersions = versions
|
||||
const currentAppInfo = APP_LABELS[appType]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* App Selector */}
|
||||
<div className="card bg-gradient-to-r from-blue-50 to-indigo-50 border-blue-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="label font-semibold text-gray-700">应用选择:</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleAppTypeChange('mobile')}
|
||||
className={`px-5 py-2.5 rounded-lg text-sm font-medium transition-all shadow-sm ${
|
||||
appType === 'mobile'
|
||||
? 'bg-blue-600 text-white shadow-blue-200'
|
||||
: 'bg-white text-gray-700 hover:bg-gray-50 border border-gray-200'
|
||||
}`}
|
||||
>
|
||||
榴莲 App
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAppTypeChange('mining')}
|
||||
className={`px-5 py-2.5 rounded-lg text-sm font-medium transition-all shadow-sm ${
|
||||
appType === 'mining'
|
||||
? 'bg-orange-500 text-white shadow-orange-200'
|
||||
: 'bg-white text-gray-700 hover:bg-gray-50 border border-gray-200'
|
||||
}`}
|
||||
>
|
||||
股行 App
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
当前管理: <span className="font-medium text-gray-700">{currentAppInfo.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">版本管理</h2>
|
||||
<p className="text-gray-600 mt-1">管理移动应用的版本更新</p>
|
||||
<p className="text-gray-600 mt-1">{currentAppInfo.description}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useEffect } from 'react'
|
||||
import { useVersionStore } from '../stores/version-store'
|
||||
import { AppType } from '@/infrastructure'
|
||||
|
||||
export function useVersions() {
|
||||
const {
|
||||
|
|
@ -7,22 +8,26 @@ export function useVersions() {
|
|||
isLoading,
|
||||
error,
|
||||
filter,
|
||||
appType,
|
||||
fetchVersions,
|
||||
setFilter,
|
||||
setAppType,
|
||||
clearError,
|
||||
} = useVersionStore()
|
||||
|
||||
useEffect(() => {
|
||||
fetchVersions()
|
||||
}, [filter.platform, filter.includeDisabled])
|
||||
}, [filter.platform, filter.includeDisabled, appType])
|
||||
|
||||
return {
|
||||
versions,
|
||||
isLoading,
|
||||
error,
|
||||
filter,
|
||||
appType,
|
||||
refetch: fetchVersions,
|
||||
setFilter,
|
||||
setAppType,
|
||||
clearError,
|
||||
}
|
||||
}
|
||||
|
|
@ -58,6 +63,8 @@ export function useVersionActions() {
|
|||
const {
|
||||
fetchVersions,
|
||||
setFilter,
|
||||
setAppType,
|
||||
appType,
|
||||
createVersion,
|
||||
updateVersion,
|
||||
deleteVersion,
|
||||
|
|
@ -71,6 +78,8 @@ export function useVersionActions() {
|
|||
return {
|
||||
loadVersions: fetchVersions,
|
||||
setFilter,
|
||||
setAppType,
|
||||
appType,
|
||||
createVersion,
|
||||
updateVersion,
|
||||
deleteVersion,
|
||||
|
|
@ -81,3 +90,8 @@ export function useVersionActions() {
|
|||
clearError,
|
||||
}
|
||||
}
|
||||
|
||||
export function useAppType() {
|
||||
const { appType, setAppType } = useVersionStore()
|
||||
return { appType, setAppType }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,19 +6,21 @@ import {
|
|||
UploadVersionInput,
|
||||
Platform,
|
||||
} from '@/domain'
|
||||
import { versionRepository } from '@/infrastructure'
|
||||
import { getVersionRepository, AppType } from '@/infrastructure'
|
||||
|
||||
interface VersionState {
|
||||
versions: AppVersion[]
|
||||
selectedVersion: AppVersion | null
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
appType: AppType
|
||||
filter: {
|
||||
platform?: Platform
|
||||
includeDisabled: boolean
|
||||
}
|
||||
|
||||
// Actions
|
||||
setAppType: (appType: AppType) => void
|
||||
fetchVersions: () => Promise<void>
|
||||
fetchVersionById: (id: string) => Promise<void>
|
||||
createVersion: (input: CreateVersionInput) => Promise<AppVersion>
|
||||
|
|
@ -36,16 +38,22 @@ export const useVersionStore = create<VersionState>((set, get) => ({
|
|||
selectedVersion: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
appType: 'mobile',
|
||||
filter: {
|
||||
platform: undefined,
|
||||
includeDisabled: true,
|
||||
},
|
||||
|
||||
setAppType: (appType: AppType) => {
|
||||
set({ appType, versions: [], selectedVersion: null, error: null })
|
||||
},
|
||||
|
||||
fetchVersions: async () => {
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const { filter } = get()
|
||||
const versions = await versionRepository.list(filter)
|
||||
const { filter, appType } = get()
|
||||
const repository = getVersionRepository(appType)
|
||||
const versions = await repository.list(filter)
|
||||
set({ versions, isLoading: false })
|
||||
} catch (err) {
|
||||
set({
|
||||
|
|
@ -58,7 +66,9 @@ export const useVersionStore = create<VersionState>((set, get) => ({
|
|||
fetchVersionById: async (id: string) => {
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const version = await versionRepository.getById(id)
|
||||
const { appType } = get()
|
||||
const repository = getVersionRepository(appType)
|
||||
const version = await repository.getById(id)
|
||||
set({ selectedVersion: version, isLoading: false })
|
||||
} catch (err) {
|
||||
set({
|
||||
|
|
@ -71,7 +81,9 @@ export const useVersionStore = create<VersionState>((set, get) => ({
|
|||
createVersion: async (input: CreateVersionInput) => {
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const version = await versionRepository.create(input)
|
||||
const { appType } = get()
|
||||
const repository = getVersionRepository(appType)
|
||||
const version = await repository.create(input)
|
||||
const { versions } = get()
|
||||
set({ versions: [version, ...versions], isLoading: false })
|
||||
return version
|
||||
|
|
@ -87,7 +99,9 @@ export const useVersionStore = create<VersionState>((set, get) => ({
|
|||
updateVersion: async (id: string, input: UpdateVersionInput) => {
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const version = await versionRepository.update(id, input)
|
||||
const { appType } = get()
|
||||
const repository = getVersionRepository(appType)
|
||||
const version = await repository.update(id, input)
|
||||
const { versions } = get()
|
||||
set({
|
||||
versions: versions.map((v) => (v.id === id ? version : v)),
|
||||
|
|
@ -107,7 +121,9 @@ export const useVersionStore = create<VersionState>((set, get) => ({
|
|||
deleteVersion: async (id: string) => {
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
await versionRepository.delete(id)
|
||||
const { appType } = get()
|
||||
const repository = getVersionRepository(appType)
|
||||
await repository.delete(id)
|
||||
const { versions, selectedVersion } = get()
|
||||
set({
|
||||
versions: versions.filter((v) => v.id !== id),
|
||||
|
|
@ -126,7 +142,9 @@ export const useVersionStore = create<VersionState>((set, get) => ({
|
|||
toggleVersion: async (id: string, isEnabled: boolean) => {
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
await versionRepository.toggle(id, isEnabled)
|
||||
const { appType } = get()
|
||||
const repository = getVersionRepository(appType)
|
||||
await repository.toggle(id, isEnabled)
|
||||
const { versions } = get()
|
||||
set({
|
||||
versions: versions.map((v) =>
|
||||
|
|
@ -146,7 +164,9 @@ export const useVersionStore = create<VersionState>((set, get) => ({
|
|||
uploadVersion: async (input: UploadVersionInput) => {
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const version = await versionRepository.upload(input)
|
||||
const { appType } = get()
|
||||
const repository = getVersionRepository(appType)
|
||||
const version = await repository.upload(input)
|
||||
const { versions } = get()
|
||||
set({ versions: [version, ...versions], isLoading: false })
|
||||
return version
|
||||
|
|
|
|||
|
|
@ -1,3 +1,26 @@
|
|||
/**
|
||||
* API客户端模块 - 多后端支持
|
||||
*
|
||||
* 功能概述:
|
||||
* 为前端提供统一的HTTP客户端,支持连接多个后端服务。
|
||||
* 当前支持两个应用的版本管理:
|
||||
* - mobile (榴莲 App): 连接 admin-service 后端
|
||||
* - mining (股行 App): 连接 mining-admin-service 后端
|
||||
*
|
||||
* 配置方式:
|
||||
* 通过环境变量配置后端地址:
|
||||
* - NEXT_PUBLIC_API_URL: 榴莲App后端地址
|
||||
* - NEXT_PUBLIC_MINING_API_URL: 股行App后端地址
|
||||
*
|
||||
* 使用方式:
|
||||
* ```typescript
|
||||
* // 直接使用预创建的客户端
|
||||
* import { apiClient, miningApiClient } from '@/infrastructure'
|
||||
*
|
||||
* // 或通过工厂函数创建
|
||||
* const client = createApiClient('mining')
|
||||
* ```
|
||||
*/
|
||||
import axios, { AxiosInstance, AxiosError } from 'axios'
|
||||
|
||||
export class ApiError extends Error {
|
||||
|
|
@ -11,9 +34,44 @@ export class ApiError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
export function createApiClient(): AxiosInstance {
|
||||
const client = axios.create({
|
||||
/**
|
||||
* 应用类型
|
||||
* - mobile: 榴莲 App (使用 admin-service 后端)
|
||||
* - mining: 股行 App (使用 mining-admin-service 后端)
|
||||
*/
|
||||
export type AppType = 'mobile' | 'mining'
|
||||
|
||||
export interface ApiConfig {
|
||||
baseURL: string
|
||||
apiPrefix: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 各应用的API配置
|
||||
*
|
||||
* 注意:
|
||||
* - mobile 使用 /api/v1/versions 前缀 (admin-service)
|
||||
* - mining 使用 /api/v2/versions 前缀 (mining-admin-service)
|
||||
* - API前缀不同是因为两个后端是独立的服务
|
||||
*/
|
||||
const APP_CONFIGS: Record<AppType, ApiConfig> = {
|
||||
// 榴莲 App - 连接原有的 admin-service 后端
|
||||
mobile: {
|
||||
baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3010',
|
||||
apiPrefix: '/api/v1/versions',
|
||||
},
|
||||
// 股行 App - 连接新的 mining-admin-service 后端
|
||||
mining: {
|
||||
baseURL: process.env.NEXT_PUBLIC_MINING_API_URL || 'http://localhost:3023',
|
||||
apiPrefix: '/api/v2/versions',
|
||||
},
|
||||
}
|
||||
|
||||
export function createApiClient(appType: AppType = 'mobile'): AxiosInstance {
|
||||
const config = APP_CONFIGS[appType]
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: config.baseURL,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -38,4 +96,9 @@ export function createApiClient(): AxiosInstance {
|
|||
return client
|
||||
}
|
||||
|
||||
export const apiClient = createApiClient()
|
||||
export function getApiPrefix(appType: AppType): string {
|
||||
return APP_CONFIGS[appType].apiPrefix
|
||||
}
|
||||
|
||||
export const apiClient = createApiClient('mobile')
|
||||
export const miningApiClient = createApiClient('mining')
|
||||
|
|
|
|||
|
|
@ -9,13 +9,20 @@ import {
|
|||
ParsedPackageInfo,
|
||||
Platform,
|
||||
} from '@/domain'
|
||||
import { apiClient } from '../http/api-client'
|
||||
import { apiClient, miningApiClient, AppType, getApiPrefix } from '../http/api-client'
|
||||
|
||||
export class VersionRepositoryImpl implements IVersionRepository {
|
||||
private client: AxiosInstance
|
||||
private apiPrefix: string
|
||||
|
||||
constructor(client?: AxiosInstance) {
|
||||
this.client = client || apiClient
|
||||
constructor(appType: AppType = 'mobile', client?: AxiosInstance) {
|
||||
if (client) {
|
||||
this.client = client
|
||||
this.apiPrefix = '/api/v1/versions'
|
||||
} else {
|
||||
this.client = appType === 'mining' ? miningApiClient : apiClient
|
||||
this.apiPrefix = getApiPrefix(appType)
|
||||
}
|
||||
}
|
||||
|
||||
async list(filter?: VersionListFilter): Promise<AppVersion[]> {
|
||||
|
|
@ -28,35 +35,35 @@ export class VersionRepositoryImpl implements IVersionRepository {
|
|||
}
|
||||
|
||||
const response = await this.client.get<AppVersion[]>(
|
||||
`/api/v1/versions?${params.toString()}`
|
||||
`${this.apiPrefix}?${params.toString()}`
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<AppVersion> {
|
||||
const response = await this.client.get<AppVersion>(`/api/v1/versions/${id}`)
|
||||
const response = await this.client.get<AppVersion>(`${this.apiPrefix}/${id}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async create(input: CreateVersionInput): Promise<AppVersion> {
|
||||
const response = await this.client.post<AppVersion>('/api/v1/versions', input)
|
||||
const response = await this.client.post<AppVersion>(this.apiPrefix, input)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async update(id: string, input: UpdateVersionInput): Promise<AppVersion> {
|
||||
const response = await this.client.put<AppVersion>(
|
||||
`/api/v1/versions/${id}`,
|
||||
`${this.apiPrefix}/${id}`,
|
||||
input
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.client.delete(`/api/v1/versions/${id}`)
|
||||
await this.client.delete(`${this.apiPrefix}/${id}`)
|
||||
}
|
||||
|
||||
async toggle(id: string, isEnabled: boolean): Promise<void> {
|
||||
await this.client.patch(`/api/v1/versions/${id}/toggle`, { isEnabled })
|
||||
await this.client.patch(`${this.apiPrefix}/${id}/toggle`, { isEnabled })
|
||||
}
|
||||
|
||||
async upload(input: UploadVersionInput): Promise<AppVersion> {
|
||||
|
|
@ -79,7 +86,7 @@ export class VersionRepositoryImpl implements IVersionRepository {
|
|||
}
|
||||
|
||||
const response = await this.client.post<AppVersion>(
|
||||
'/api/v1/versions/upload',
|
||||
`${this.apiPrefix}/upload`,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
|
|
@ -98,7 +105,7 @@ export class VersionRepositoryImpl implements IVersionRepository {
|
|||
formData.append('platform', platform.toUpperCase())
|
||||
|
||||
const response = await this.client.post<ParsedPackageInfo>(
|
||||
'/api/v1/versions/parse',
|
||||
`${this.apiPrefix}/parse`,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
|
|
@ -111,4 +118,11 @@ export class VersionRepositoryImpl implements IVersionRepository {
|
|||
}
|
||||
}
|
||||
|
||||
export const versionRepository = new VersionRepositoryImpl()
|
||||
// Pre-created repository instances for both apps
|
||||
export const versionRepository = new VersionRepositoryImpl('mobile')
|
||||
export const miningVersionRepository = new VersionRepositoryImpl('mining')
|
||||
|
||||
// Factory function to get repository by app type
|
||||
export function getVersionRepository(appType: AppType): VersionRepositoryImpl {
|
||||
return appType === 'mining' ? miningVersionRepository : versionRepository
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue