From 76d566d1455af72c60886031dd63a5e2fec4e1f5 Mon Sep 17 00:00:00 2001 From: hailin Date: Wed, 28 Jan 2026 20:03:26 -0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=9B=86=E6=88=90=20mining-app=20?= =?UTF-8?q?=E5=8D=87=E7=BA=A7=E5=92=8C=E9=81=A5=E6=B5=8B=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=EF=BC=8C=E6=89=A9=E5=B1=95=20mobile-upgrade=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=A4=9A=E5=BA=94=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- .../mining-admin-service/prisma/schema.prisma | 35 ++ .../src/api/api.module.ts | 14 +- .../controllers/mobile-version.controller.ts | 50 +++ .../src/api/controllers/version.controller.ts | 295 ++++++++++++++++ .../src/api/dto/version/check-update.dto.ts | 21 ++ .../src/api/dto/version/create-version.dto.ts | 55 +++ .../src/api/dto/version/index.ts | 6 + .../src/api/dto/version/toggle-version.dto.ts | 10 + .../src/api/dto/version/update-version.dto.ts | 41 +++ .../src/api/dto/version/upload-version.dto.ts | 47 +++ .../api/dto/version/version-response.dto.ts | 80 +++++ .../src/application/application.module.ts | 3 + .../application/services/version.service.ts | 263 ++++++++++++++ .../entities/app-version.entity.ts | 234 +++++++++++++ .../version-management/enums/platform.enum.ts | 4 + .../src/domain/version-management/index.ts | 4 + .../repositories/app-version.repository.ts | 50 +++ .../value-objects/build-number.vo.ts | 18 + .../value-objects/changelog.vo.ts | 16 + .../value-objects/download-url.vo.ts | 24 ++ .../value-objects/file-sha256.vo.ts | 24 ++ .../value-objects/file-size.vo.ts | 32 ++ .../version-management/value-objects/index.ts | 8 + .../value-objects/min-os-version.vo.ts | 18 + .../value-objects/version-code.vo.ts | 22 ++ .../value-objects/version-name.vo.ts | 23 ++ .../infrastructure/infrastructure.module.ts | 24 +- .../parsers/package-parser.service.ts | 86 +++++ .../persistence/mappers/app-version.mapper.ts | 61 ++++ .../app-version.repository.impl.ts | 101 ++++++ .../storage/file-storage.service.ts | 75 ++++ .../services/mining-admin-service/src/main.ts | 16 +- .../android/app/src/main/AndroidManifest.xml | 16 + .../com/rwadurian/mining_app/MainActivity.kt | 90 ++++- .../app/src/main/res/xml/file_paths.xml | 8 + .../collectors/device_info_collector.dart | 110 ++++++ .../core/telemetry/models/device_context.dart | 162 +++++++++ .../telemetry/models/telemetry_config.dart | 118 +++++++ .../telemetry/models/telemetry_event.dart | 178 ++++++++++ .../telemetry/presence/heartbeat_service.dart | 170 +++++++++ .../telemetry/presence/presence_config.dart | 43 +++ .../telemetry/session/session_events.dart | 15 + .../telemetry/session/session_manager.dart | 129 +++++++ .../telemetry/storage/telemetry_storage.dart | 117 +++++++ .../lib/core/telemetry/telemetry.dart | 14 + .../lib/core/telemetry/telemetry_service.dart | 328 ++++++++++++++++++ .../uploader/telemetry_uploader.dart | 106 ++++++ .../lib/core/updater/apk_installer.dart | 64 ++++ .../lib/core/updater/app_market_detector.dart | 141 ++++++++ .../updater/channels/self_hosted_updater.dart | 275 +++++++++++++++ .../lib/core/updater/download_manager.dart | 238 +++++++++++++ .../core/updater/models/update_config.dart | 49 +++ .../lib/core/updater/models/version_info.dart | 122 +++++++ .../lib/core/updater/update_service.dart | 172 +++++++++ .../mining-app/lib/core/updater/updater.dart | 11 + .../lib/core/updater/version_checker.dart | 104 ++++++ frontend/mining-app/lib/main.dart | 4 + .../pages/profile/about_page.dart | 53 ++- frontend/mining-app/pubspec.yaml | 11 + frontend/mobile-upgrade/.env.production | 1 + frontend/mobile-upgrade/src/app/page.tsx | 71 +++- .../src/application/hooks/use-versions.ts | 16 +- .../src/application/stores/version-store.ts | 38 +- .../src/infrastructure/http/api-client.ts | 69 +++- .../repositories/version-repository-impl.ts | 38 +- 65 files changed, 4808 insertions(+), 33 deletions(-) create mode 100644 backend/services/mining-admin-service/src/api/controllers/mobile-version.controller.ts create mode 100644 backend/services/mining-admin-service/src/api/controllers/version.controller.ts create mode 100644 backend/services/mining-admin-service/src/api/dto/version/check-update.dto.ts create mode 100644 backend/services/mining-admin-service/src/api/dto/version/create-version.dto.ts create mode 100644 backend/services/mining-admin-service/src/api/dto/version/index.ts create mode 100644 backend/services/mining-admin-service/src/api/dto/version/toggle-version.dto.ts create mode 100644 backend/services/mining-admin-service/src/api/dto/version/update-version.dto.ts create mode 100644 backend/services/mining-admin-service/src/api/dto/version/upload-version.dto.ts create mode 100644 backend/services/mining-admin-service/src/api/dto/version/version-response.dto.ts create mode 100644 backend/services/mining-admin-service/src/application/services/version.service.ts create mode 100644 backend/services/mining-admin-service/src/domain/version-management/entities/app-version.entity.ts create mode 100644 backend/services/mining-admin-service/src/domain/version-management/enums/platform.enum.ts create mode 100644 backend/services/mining-admin-service/src/domain/version-management/index.ts create mode 100644 backend/services/mining-admin-service/src/domain/version-management/repositories/app-version.repository.ts create mode 100644 backend/services/mining-admin-service/src/domain/version-management/value-objects/build-number.vo.ts create mode 100644 backend/services/mining-admin-service/src/domain/version-management/value-objects/changelog.vo.ts create mode 100644 backend/services/mining-admin-service/src/domain/version-management/value-objects/download-url.vo.ts create mode 100644 backend/services/mining-admin-service/src/domain/version-management/value-objects/file-sha256.vo.ts create mode 100644 backend/services/mining-admin-service/src/domain/version-management/value-objects/file-size.vo.ts create mode 100644 backend/services/mining-admin-service/src/domain/version-management/value-objects/index.ts create mode 100644 backend/services/mining-admin-service/src/domain/version-management/value-objects/min-os-version.vo.ts create mode 100644 backend/services/mining-admin-service/src/domain/version-management/value-objects/version-code.vo.ts create mode 100644 backend/services/mining-admin-service/src/domain/version-management/value-objects/version-name.vo.ts create mode 100644 backend/services/mining-admin-service/src/infrastructure/parsers/package-parser.service.ts create mode 100644 backend/services/mining-admin-service/src/infrastructure/persistence/mappers/app-version.mapper.ts create mode 100644 backend/services/mining-admin-service/src/infrastructure/persistence/repositories/app-version.repository.impl.ts create mode 100644 backend/services/mining-admin-service/src/infrastructure/storage/file-storage.service.ts create mode 100644 frontend/mining-app/android/app/src/main/res/xml/file_paths.xml create mode 100644 frontend/mining-app/lib/core/telemetry/collectors/device_info_collector.dart create mode 100644 frontend/mining-app/lib/core/telemetry/models/device_context.dart create mode 100644 frontend/mining-app/lib/core/telemetry/models/telemetry_config.dart create mode 100644 frontend/mining-app/lib/core/telemetry/models/telemetry_event.dart create mode 100644 frontend/mining-app/lib/core/telemetry/presence/heartbeat_service.dart create mode 100644 frontend/mining-app/lib/core/telemetry/presence/presence_config.dart create mode 100644 frontend/mining-app/lib/core/telemetry/session/session_events.dart create mode 100644 frontend/mining-app/lib/core/telemetry/session/session_manager.dart create mode 100644 frontend/mining-app/lib/core/telemetry/storage/telemetry_storage.dart create mode 100644 frontend/mining-app/lib/core/telemetry/telemetry.dart create mode 100644 frontend/mining-app/lib/core/telemetry/telemetry_service.dart create mode 100644 frontend/mining-app/lib/core/telemetry/uploader/telemetry_uploader.dart create mode 100644 frontend/mining-app/lib/core/updater/apk_installer.dart create mode 100644 frontend/mining-app/lib/core/updater/app_market_detector.dart create mode 100644 frontend/mining-app/lib/core/updater/channels/self_hosted_updater.dart create mode 100644 frontend/mining-app/lib/core/updater/download_manager.dart create mode 100644 frontend/mining-app/lib/core/updater/models/update_config.dart create mode 100644 frontend/mining-app/lib/core/updater/models/version_info.dart create mode 100644 frontend/mining-app/lib/core/updater/update_service.dart create mode 100644 frontend/mining-app/lib/core/updater/updater.dart create mode 100644 frontend/mining-app/lib/core/updater/version_checker.dart diff --git a/backend/services/mining-admin-service/prisma/schema.prisma b/backend/services/mining-admin-service/prisma/schema.prisma index 0083602d..0beb28f0 100644 --- a/backend/services/mining-admin-service/prisma/schema.prisma +++ b/backend/services/mining-admin-service/prisma/schema.prisma @@ -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") +} diff --git a/backend/services/mining-admin-service/src/api/api.module.ts b/backend/services/mining-admin-service/src/api/api.module.ts index e97efc09..25c32319 100644 --- a/backend/services/mining-admin-service/src/api/api.module.ts +++ b/backend/services/mining-admin-service/src/api/api.module.ts @@ -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 {} diff --git a/backend/services/mining-admin-service/src/api/controllers/mobile-version.controller.ts b/backend/services/mining-admin-service/src/api/controllers/mobile-version.controller.ts new file mode 100644 index 00000000..ecd2cdeb --- /dev/null +++ b/backend/services/mining-admin-service/src/api/controllers/mobile-version.controller.ts @@ -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 { + 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(), + } + } +} diff --git a/backend/services/mining-admin-service/src/api/controllers/version.controller.ts b/backend/services/mining-admin-service/src/api/controllers/version.controller.ts new file mode 100644 index 00000000..85017a07 --- /dev/null +++ b/backend/services/mining-admin-service/src/api/controllers/version.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + // 验证文件扩展名 + 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) + } +} diff --git a/backend/services/mining-admin-service/src/api/dto/version/check-update.dto.ts b/backend/services/mining-admin-service/src/api/dto/version/check-update.dto.ts new file mode 100644 index 00000000..f0f305bd --- /dev/null +++ b/backend/services/mining-admin-service/src/api/dto/version/check-update.dto.ts @@ -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 +} diff --git a/backend/services/mining-admin-service/src/api/dto/version/create-version.dto.ts b/backend/services/mining-admin-service/src/api/dto/version/create-version.dto.ts new file mode 100644 index 00000000..6e8c3303 --- /dev/null +++ b/backend/services/mining-admin-service/src/api/dto/version/create-version.dto.ts @@ -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 +} diff --git a/backend/services/mining-admin-service/src/api/dto/version/index.ts b/backend/services/mining-admin-service/src/api/dto/version/index.ts new file mode 100644 index 00000000..8fcb9b89 --- /dev/null +++ b/backend/services/mining-admin-service/src/api/dto/version/index.ts @@ -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' diff --git a/backend/services/mining-admin-service/src/api/dto/version/toggle-version.dto.ts b/backend/services/mining-admin-service/src/api/dto/version/toggle-version.dto.ts new file mode 100644 index 00000000..b9a0ec5b --- /dev/null +++ b/backend/services/mining-admin-service/src/api/dto/version/toggle-version.dto.ts @@ -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 +} diff --git a/backend/services/mining-admin-service/src/api/dto/version/update-version.dto.ts b/backend/services/mining-admin-service/src/api/dto/version/update-version.dto.ts new file mode 100644 index 00000000..94245350 --- /dev/null +++ b/backend/services/mining-admin-service/src/api/dto/version/update-version.dto.ts @@ -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 +} diff --git a/backend/services/mining-admin-service/src/api/dto/version/upload-version.dto.ts b/backend/services/mining-admin-service/src/api/dto/version/upload-version.dto.ts new file mode 100644 index 00000000..17ac2051 --- /dev/null +++ b/backend/services/mining-admin-service/src/api/dto/version/upload-version.dto.ts @@ -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 +} diff --git a/backend/services/mining-admin-service/src/api/dto/version/version-response.dto.ts b/backend/services/mining-admin-service/src/api/dto/version/version-response.dto.ts new file mode 100644 index 00000000..5ac97468 --- /dev/null +++ b/backend/services/mining-admin-service/src/api/dto/version/version-response.dto.ts @@ -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 +} diff --git a/backend/services/mining-admin-service/src/application/application.module.ts b/backend/services/mining-admin-service/src/application/application.module.ts index 0b56daf4..3f603c0b 100644 --- a/backend/services/mining-admin-service/src/application/application.module.ts +++ b/backend/services/mining-admin-service/src/application/application.module.ts @@ -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 { diff --git a/backend/services/mining-admin-service/src/application/services/version.service.ts b/backend/services/mining-admin-service/src/application/services/version.service.ts new file mode 100644 index 00000000..de6a9269 --- /dev/null +++ b/backend/services/mining-admin-service/src/application/services/version.service.ts @@ -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 { + // 检查版本是否已存在 + 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 { + // 解析包信息 + 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 { + 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 { + if (platform) { + return this.appVersionRepository.findAllByPlatform(platform, includeDisabled) + } + return this.appVersionRepository.findAll(includeDisabled) + } + + async update(id: string, input: UpdateVersionInput): Promise { + 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 { + 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 { + await this.findById(id) // 确保存在 + await this.appVersionRepository.delete(id) + } + + async checkUpdate(platform: Platform, currentVersionCode: number): Promise { + 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) + } +} diff --git a/backend/services/mining-admin-service/src/domain/version-management/entities/app-version.entity.ts b/backend/services/mining-admin-service/src/domain/version-management/entities/app-version.entity.ts new file mode 100644 index 00000000..d2c68966 --- /dev/null +++ b/backend/services/mining-admin-service/src/domain/version-management/entities/app-version.entity.ts @@ -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() + } +} diff --git a/backend/services/mining-admin-service/src/domain/version-management/enums/platform.enum.ts b/backend/services/mining-admin-service/src/domain/version-management/enums/platform.enum.ts new file mode 100644 index 00000000..4054586a --- /dev/null +++ b/backend/services/mining-admin-service/src/domain/version-management/enums/platform.enum.ts @@ -0,0 +1,4 @@ +export enum Platform { + ANDROID = 'ANDROID', + IOS = 'IOS', +} diff --git a/backend/services/mining-admin-service/src/domain/version-management/index.ts b/backend/services/mining-admin-service/src/domain/version-management/index.ts new file mode 100644 index 00000000..55a2e08c --- /dev/null +++ b/backend/services/mining-admin-service/src/domain/version-management/index.ts @@ -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' diff --git a/backend/services/mining-admin-service/src/domain/version-management/repositories/app-version.repository.ts b/backend/services/mining-admin-service/src/domain/version-management/repositories/app-version.repository.ts new file mode 100644 index 00000000..85d470cc --- /dev/null +++ b/backend/services/mining-admin-service/src/domain/version-management/repositories/app-version.repository.ts @@ -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 + + /** + * 根据ID查找版本 + */ + findById(id: string): Promise + + /** + * 获取指定平台的最新版本 + */ + findLatestByPlatform(platform: Platform): Promise + + /** + * 获取指定平台所有版本列表 + */ + findAllByPlatform(platform: Platform, includeDisabled?: boolean): Promise + + /** + * 获取所有版本 + */ + findAll(includeDisabled?: boolean): Promise + + /** + * 根据平台和版本号查找 + */ + findByPlatformAndVersionCode( + platform: Platform, + versionCode: VersionCode, + ): Promise + + /** + * 更新版本信息 + */ + update(appVersion: AppVersion): Promise + + /** + * 删除版本 + */ + delete(id: string): Promise +} diff --git a/backend/services/mining-admin-service/src/domain/version-management/value-objects/build-number.vo.ts b/backend/services/mining-admin-service/src/domain/version-management/value-objects/build-number.vo.ts new file mode 100644 index 00000000..366100f3 --- /dev/null +++ b/backend/services/mining-admin-service/src/domain/version-management/value-objects/build-number.vo.ts @@ -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 + } +} diff --git a/backend/services/mining-admin-service/src/domain/version-management/value-objects/changelog.vo.ts b/backend/services/mining-admin-service/src/domain/version-management/value-objects/changelog.vo.ts new file mode 100644 index 00000000..3c6716d0 --- /dev/null +++ b/backend/services/mining-admin-service/src/domain/version-management/value-objects/changelog.vo.ts @@ -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 + } +} diff --git a/backend/services/mining-admin-service/src/domain/version-management/value-objects/download-url.vo.ts b/backend/services/mining-admin-service/src/domain/version-management/value-objects/download-url.vo.ts new file mode 100644 index 00000000..28cb8b30 --- /dev/null +++ b/backend/services/mining-admin-service/src/domain/version-management/value-objects/download-url.vo.ts @@ -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 + } +} diff --git a/backend/services/mining-admin-service/src/domain/version-management/value-objects/file-sha256.vo.ts b/backend/services/mining-admin-service/src/domain/version-management/value-objects/file-sha256.vo.ts new file mode 100644 index 00000000..c722f42a --- /dev/null +++ b/backend/services/mining-admin-service/src/domain/version-management/value-objects/file-sha256.vo.ts @@ -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 + } +} diff --git a/backend/services/mining-admin-service/src/domain/version-management/value-objects/file-size.vo.ts b/backend/services/mining-admin-service/src/domain/version-management/value-objects/file-size.vo.ts new file mode 100644 index 00000000..9bb7c22c --- /dev/null +++ b/backend/services/mining-admin-service/src/domain/version-management/value-objects/file-size.vo.ts @@ -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 + } +} diff --git a/backend/services/mining-admin-service/src/domain/version-management/value-objects/index.ts b/backend/services/mining-admin-service/src/domain/version-management/value-objects/index.ts new file mode 100644 index 00000000..7f513a7d --- /dev/null +++ b/backend/services/mining-admin-service/src/domain/version-management/value-objects/index.ts @@ -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' diff --git a/backend/services/mining-admin-service/src/domain/version-management/value-objects/min-os-version.vo.ts b/backend/services/mining-admin-service/src/domain/version-management/value-objects/min-os-version.vo.ts new file mode 100644 index 00000000..ba1419df --- /dev/null +++ b/backend/services/mining-admin-service/src/domain/version-management/value-objects/min-os-version.vo.ts @@ -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 + } +} diff --git a/backend/services/mining-admin-service/src/domain/version-management/value-objects/version-code.vo.ts b/backend/services/mining-admin-service/src/domain/version-management/value-objects/version-code.vo.ts new file mode 100644 index 00000000..e69813a2 --- /dev/null +++ b/backend/services/mining-admin-service/src/domain/version-management/value-objects/version-code.vo.ts @@ -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 + } +} diff --git a/backend/services/mining-admin-service/src/domain/version-management/value-objects/version-name.vo.ts b/backend/services/mining-admin-service/src/domain/version-management/value-objects/version-name.vo.ts new file mode 100644 index 00000000..349ecb3e --- /dev/null +++ b/backend/services/mining-admin-service/src/domain/version-management/value-objects/version-name.vo.ts @@ -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 + } +} diff --git a/backend/services/mining-admin-service/src/infrastructure/infrastructure.module.ts b/backend/services/mining-admin-service/src/infrastructure/infrastructure.module.ts index 31c1c077..7d985bec 100644 --- a/backend/services/mining-admin-service/src/infrastructure/infrastructure.module.ts +++ b/backend/services/mining-admin-service/src/infrastructure/infrastructure.module.ts @@ -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 {} diff --git a/backend/services/mining-admin-service/src/infrastructure/parsers/package-parser.service.ts b/backend/services/mining-admin-service/src/infrastructure/parsers/package-parser.service.ts new file mode 100644 index 00000000..53bbc16c --- /dev/null +++ b/backend/services/mining-admin-service/src/infrastructure/parsers/package-parser.service.ts @@ -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 { + 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 { + 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 { + 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 + + 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 + } + } +} diff --git a/backend/services/mining-admin-service/src/infrastructure/persistence/mappers/app-version.mapper.ts b/backend/services/mining-admin-service/src/infrastructure/persistence/mappers/app-version.mapper.ts new file mode 100644 index 00000000..4b8250c9 --- /dev/null +++ b/backend/services/mining-admin-service/src/infrastructure/persistence/mappers/app-version.mapper.ts @@ -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 { + 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, + } + } +} diff --git a/backend/services/mining-admin-service/src/infrastructure/persistence/repositories/app-version.repository.impl.ts b/backend/services/mining-admin-service/src/infrastructure/persistence/repositories/app-version.repository.impl.ts new file mode 100644 index 00000000..48752bc6 --- /dev/null +++ b/backend/services/mining-admin-service/src/infrastructure/persistence/repositories/app-version.repository.impl.ts @@ -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 { + 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 { + const found = await this.prisma.appVersion.findUnique({ where: { id } }) + return found ? this.mapper.toDomain(found) : null + } + + async findLatestByPlatform(platform: Platform): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + await this.prisma.appVersion.delete({ where: { id } }) + } +} diff --git a/backend/services/mining-admin-service/src/infrastructure/storage/file-storage.service.ts b/backend/services/mining-admin-service/src/infrastructure/storage/file-storage.service.ts new file mode 100644 index 00000000..d17af5a9 --- /dev/null +++ b/backend/services/mining-admin-service/src/infrastructure/storage/file-storage.service.ts @@ -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('UPLOAD_DIR', './uploads') + this.baseUrl = this.configService.get('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 { + // 生成唯一文件名 + 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 { + 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) + } +} diff --git a/backend/services/mining-admin-service/src/main.ts b/backend/services/mining-admin-service/src/main.ts index 6ff257af..77633ee3 100644 --- a/backend/services/mining-admin-service/src/main.ts +++ b/backend/services/mining-admin-service/src/main.ts @@ -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(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); diff --git a/frontend/mining-app/android/app/src/main/AndroidManifest.xml b/frontend/mining-app/android/app/src/main/AndroidManifest.xml index 5eefa95a..03201e5c 100644 --- a/frontend/mining-app/android/app/src/main/AndroidManifest.xml +++ b/frontend/mining-app/android/app/src/main/AndroidManifest.xml @@ -3,6 +3,11 @@ + + + + + + + + + +