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:
hailin 2026-01-28 20:03:26 -08:00
parent 219fb7bb69
commit 76d566d145
65 changed files with 4808 additions and 33 deletions

View File

@ -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")
}

View File

@ -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 {}

View File

@ -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(),
}
}
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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'

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -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()
}
}

View File

@ -0,0 +1,4 @@
export enum Platform {
ANDROID = 'ANDROID',
IOS = 'IOS',
}

View File

@ -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'

View File

@ -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>
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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'

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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 {}

View File

@ -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
}
}
}

View File

@ -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,
}
}
}

View File

@ -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 } })
}
}

View File

@ -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)
}
}

View File

@ -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);

View File

@ -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

View File

@ -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
}
}
}

View File

@ -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>

View File

@ -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;
}

View File

@ -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,
];
}

View File

@ -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;
}
}

View File

@ -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,
];
}

View File

@ -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();
}
}

View File

@ -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,
);
}
}

View File

@ -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,
}

View File

@ -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;
}
}

View File

@ -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));
}
}

View File

@ -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';

View File

@ -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;
}
}

View File

@ -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');
}
}
}

View File

@ -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();
}
}

View File

@ -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;
}
}

View File

@ -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('立即更新'),
),
];
}
}

View File

@ -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();
}
}

View File

@ -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,
);
}
}

View File

@ -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);
}
}

View File

@ -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();
}
}

View File

@ -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';

View File

@ -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;
}
}

View File

@ -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()));
}

View File

@ -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),
],
),
),

View File

@ -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

View File

@ -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

View File

@ -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)}

View File

@ -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 }
}

View File

@ -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

View File

@ -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')

View File

@ -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
}