diff --git a/backend/services/admin-service/deploy.sh b/backend/services/admin-service/deploy.sh index 42698acb..464ebaa1 100644 --- a/backend/services/admin-service/deploy.sh +++ b/backend/services/admin-service/deploy.sh @@ -82,7 +82,7 @@ case "$1" in log_success "$SERVICE_NAME started" log_info "Waiting for service to be healthy..." sleep 5 - $0 health + "$SCRIPT_DIR/deploy.sh" health ;; stop) @@ -95,8 +95,8 @@ case "$1" in restart) show_banner - $0 stop - $0 start + "$SCRIPT_DIR/deploy.sh" stop + "$SCRIPT_DIR/deploy.sh" start ;; up) @@ -257,7 +257,7 @@ case "$1" in clean) show_banner log_warn "Cleaning $SERVICE_NAME (removing containers)..." - $0 stop + "$SCRIPT_DIR/deploy.sh" stop log_success "$SERVICE_NAME cleaned" ;; diff --git a/backend/services/admin-service/docs/APP_UPGRADE_SERVICE.md b/backend/services/admin-service/docs/APP_UPGRADE_SERVICE.md new file mode 100644 index 00000000..d2665f81 --- /dev/null +++ b/backend/services/admin-service/docs/APP_UPGRADE_SERVICE.md @@ -0,0 +1,591 @@ +# 移动应用升级服务架构文档 + +## 1. 概述 + +本文档描述了 RWA Durian 移动应用升级服务的完整架构,包括后端 Admin Service 和前端 Flutter Mobile App 的协作方式。 + +### 1.1 系统架构图 + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 管理员操作流程 │ +│ 1. 构建新版APK → 2. 计算SHA256 → 3. 上传至文件服务器 → 4. 调用API创建版本 │ +└─────────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────────┐ +│ Admin Service (NestJS 后端) │ +│ • 存储版本元数据 (PostgreSQL) │ +│ • 提供版本检查API (公开) │ +│ • 管理员版本管理API (需认证) │ +└─────────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────────┐ +│ Mobile App (Flutter 前端) │ +│ • 启动时检查更新 │ +│ • 下载APK + SHA256校验 │ +│ • 触发系统安装 │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### 1.2 双渠道更新策略 + +| 渠道 | 机制 | 使用场景 | 流程 | +|------|------|---------|------| +| **Google Play** | Google Play Core Library | 正式发布、应用商店 | UpdateService → GooglePlayUpdater → InAppUpdate API | +| **Self-Hosted** | 自定义APK下载 | 侧载安装、企业分发、中国市场 | UpdateService → SelfHostedUpdater → VersionChecker → DownloadManager | + +应用通过 `AppMarketDetector.isFromAppMarket()` 检测安装来源,应用市场安装的版本会跳转到 Play Store,侧载版本使用自托管下载。 + +--- + +## 2. 后端架构 (Admin Service) + +### 2.1 API 端点 + +#### 公开端点 (无需认证) + +| 端点 | 方法 | 用途 | +|------|------|------| +| `/api/app/version/check` | GET | 移动端检查更新 (推荐) | +| `/api/v1/versions/check-update` | GET | 检查更新 (兼容端点) | +| `/api/v1/health` | GET | 健康检查 | +| `/uploads/:filename` | GET | 下载APK文件 | + +#### 管理员端点 (需要认证) + +| 端点 | 方法 | 用途 | +|------|------|------| +| `/api/v1/versions` | GET | 获取版本列表 | +| `/api/v1/versions` | POST | 创建新版本 (URL方式) | +| `/api/v1/versions/upload` | POST | 上传APK并创建版本 | +| `/api/v1/versions/:id` | GET | 获取单个版本详情 | +| `/api/v1/versions/:id` | PUT | 更新版本信息 | +| `/api/v1/versions/:id` | DELETE | 删除版本 | +| `/api/v1/versions/:id/toggle` | PATCH | 启用/禁用版本 | + +### 2.2 移动端检查更新 API (推荐) + +此端点专为移动端设计,返回格式与 Flutter 应用的 `VersionInfo` 模型完全兼容。 + +**请求:** +``` +GET /api/app/version/check?platform=android¤t_version=1.0.0¤t_version_code=100 +``` + +**响应 (有更新):** +```json +{ + "needUpdate": true, + "version": "1.0.1", + "versionCode": 101, + "downloadUrl": "https://api.example.com/uploads/android-1.0.1-xxx.apk", + "fileSize": 52428800, + "fileSizeFriendly": "50.0 MB", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "forceUpdate": false, + "updateLog": "1. 修复登录问题\n2. 性能优化", + "releaseDate": "2025-12-02T10:00:00.000Z" +} +``` + +**响应 (无更新):** +```json +{ + "needUpdate": false +} +``` + +### 2.3 管理员检查更新 API (兼容) + +**请求:** +``` +GET /api/v1/versions/check-update?platform=android¤tVersionCode=100 +``` + +**响应:** +```json +{ + "hasUpdate": true, + "isForceUpdate": false, + "latestVersion": { + "versionCode": 101, + "versionName": "1.0.1", + "downloadUrl": "https://cdn.example.com/app-v1.0.1.apk", + "fileSize": "52428800", + "fileSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "changelog": "1. 修复登录问题\n2. 性能优化", + "minOsVersion": "5.0", + "releaseDate": "2025-12-02T10:00:00Z" + } +} +``` + +### 2.3 创建版本 API + +**请求:** +``` +POST /api/v1/versions +Authorization: Bearer +Content-Type: application/json + +{ + "platform": "android", + "versionCode": 101, + "versionName": "1.0.1", + "buildNumber": "202512021200", + "downloadUrl": "https://cdn.example.com/app-v1.0.1.apk", + "fileSize": "52428800", + "fileSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "changelog": "1. 修复登录问题\n2. 性能优化", + "isForceUpdate": false, + "minOsVersion": "5.0", + "releaseDate": "2025-12-02T10:00:00Z" +} +``` + +**响应 (201 Created):** +```json +{ + "id": "uuid", + "platform": "android", + "versionCode": 101, + "versionName": "1.0.1", + "buildNumber": "202512021200", + "downloadUrl": "https://cdn.example.com/app-v1.0.1.apk", + "fileSize": "52428800", + "fileSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "changelog": "1. 修复登录问题\n2. 性能优化", + "isForceUpdate": false, + "isEnabled": true, + "minOsVersion": "5.0", + "releaseDate": "2025-12-02T10:00:00Z", + "createdAt": "2025-12-02T10:00:00Z", + "updatedAt": "2025-12-02T10:00:00Z" +} +``` + +### 2.4 数据验证规则 + +| 字段 | 类型 | 验证规则 | +|------|------|---------| +| `platform` | enum | `android` \| `ios` | +| `versionCode` | number | 1 ~ 2147483647 | +| `versionName` | string | 语义化版本格式: `X.Y.Z` | +| `buildNumber` | string | 非空字符串 | +| `downloadUrl` | string | 有效的 HTTP/HTTPS URL | +| `fileSize` | string | 0 ~ 2GB (以字节为单位) | +| `fileSha256` | string | 64位十六进制字符 | +| `changelog` | string | 非空字符串 | +| `minOsVersion` | string | 格式: `X.Y` 或 `X.Y.Z` | + +### 2.5 数据库表结构 + +```sql +CREATE TYPE "Platform" AS ENUM ('ANDROID', 'IOS'); + +CREATE TABLE "app_versions" ( + id UUID PRIMARY KEY, + platform Platform NOT NULL, + versionCode INTEGER NOT NULL, + versionName TEXT NOT NULL, + buildNumber TEXT NOT NULL, + downloadUrl TEXT NOT NULL, + fileSize BIGINT NOT NULL, + fileSha256 TEXT NOT NULL, + changelog TEXT NOT NULL, + minOsVersion TEXT, + isForceUpdate BOOLEAN DEFAULT false, + isEnabled BOOLEAN DEFAULT true, + releaseDate TIMESTAMP, + createdAt TIMESTAMP DEFAULT NOW(), + updatedAt TIMESTAMP, + createdBy TEXT NOT NULL, + updatedBy TEXT +); + +CREATE INDEX idx_app_versions_platform_enabled ON "app_versions"(platform, isEnabled); +CREATE INDEX idx_app_versions_platform_code ON "app_versions"(platform, versionCode); +``` + +--- + +## 3. 移动端架构 (Flutter) + +### 3.1 初始化配置 + +```dart +// lib/bootstrap.dart +const String _apiBaseUrl = 'https://api.rwadurian.com'; + +UpdateService().initialize( + UpdateConfig.selfHosted( + apiBaseUrl: _apiBaseUrl, + enabled: true, + checkIntervalSeconds: 86400, // 24小时 + ), +); +``` + +### 3.2 核心组件 + +| 组件 | 文件路径 | 职责 | +|------|---------|------| +| `UpdateService` | `lib/core/updater/update_service.dart` | 统一入口,管理更新流程 | +| `VersionChecker` | `lib/core/updater/version_checker.dart` | 与后端API通信,获取最新版本 | +| `DownloadManager` | `lib/core/updater/download_manager.dart` | 下载APK,验证SHA256 | +| `ApkInstaller` | `lib/core/updater/apk_installer.dart` | 调用系统安装APK | +| `SelfHostedUpdater` | `lib/core/updater/channels/self_hosted_updater.dart` | 自托管更新完整流程 | + +### 3.3 版本信息模型 + +```dart +class VersionInfo extends Equatable { + final String version; // "1.0.1" + final int versionCode; // 101 + final String downloadUrl; // APK下载URL + final int fileSize; // 字节数 + final String fileSizeFriendly; // "50.0 MB" + final String sha256; // SHA-256校验和 + final bool forceUpdate; // 强制更新标志 + final String? updateLog; // 更新日志 + final DateTime releaseDate; // 发布日期 +} +``` + +### 3.4 更新检查流程 + +``` +App启动 (SplashPage) + ↓ +UpdateService.checkForUpdate(context) + ↓ +VersionChecker.fetchLatestVersion() + ├─ 获取当前版本: PackageInfo.fromPlatform() + └─ 请求后端: GET /api/v1/versions/check-update + ↓ +比较 latestVersionCode > currentVersionCode ? + ↓ +显示更新对话框 + ├─ 普通更新: 显示 [稍后] [立即更新] + └─ 强制更新: 只显示 [立即更新], 禁止关闭对话框 +``` + +### 3.5 下载与安装流程 + +``` +用户点击"立即更新" + ↓ +显示下载进度对话框 + ↓ +DownloadManager.downloadApk() + ├─ 验证HTTPS URL + ├─ 下载到应用私有目录 + ├─ 显示下载进度 + └─ 验证SHA256校验和 + ↓ +ApkInstaller.installApk(apkFile) + ├─ 请求安装权限 (Android 8.0+) + └─ 调用系统安装界面 + ↓ +用户完成安装,应用重启 +``` + +--- + +## 4. 安全机制 + +| 安全措施 | 实现位置 | 说明 | +|---------|---------|------| +| **HTTPS强制** | DownloadManager | 只接受 `https://` 开头的URL | +| **SHA256校验** | DownloadManager | 下载后验证文件完整性 | +| **文件大小限制** | FileSize值对象 | 最大2GB | +| **版本号验证** | VersionCode值对象 | 1 ~ 2147483647 | +| **无外部存储权限** | 应用私有目录 | 使用 `getApplicationDocumentsDirectory()` | +| **安装权限请求** | ApkInstaller | Android 8.0+ 需要 `INSTALL_UNKNOWN_APPS` 权限 | +| **管理员认证** | Bearer Token | 创建/修改版本需要认证 | + +--- + +## 5. 管理员操作指南 + +### 5.1 发布新版本流程 + +```bash +# 1. 构建Release APK +cd android +./gradlew assembleRelease + +# 2. 计算SHA256校验和 +sha256sum app/build/outputs/apk/release/app-release.apk +# 输出: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 + +# 3. 上传APK到文件服务器 (选择以下任一方式) +# 阿里云OSS +aliyun oss cp app-release.apk oss://bucket/app-v1.0.1.apk + +# AWS S3 +aws s3 cp app-release.apk s3://bucket/app-v1.0.1.apk + +# 4. 调用API创建版本 +curl -X POST https://api.rwadurian.com/api/v1/versions \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "platform": "android", + "versionCode": 101, + "versionName": "1.0.1", + "buildNumber": "202512021200", + "downloadUrl": "https://cdn.example.com/app-v1.0.1.apk", + "fileSize": "52428800", + "fileSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "changelog": "1. 修复登录问题\n2. 性能优化", + "isForceUpdate": false, + "minOsVersion": "5.0" + }' +``` + +### 5.2 启用/禁用版本 + +```bash +# 禁用版本 +curl -X PATCH https://api.rwadurian.com/api/v1/versions/{id}/toggle \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"isEnabled": false}' + +# 启用版本 +curl -X PATCH https://api.rwadurian.com/api/v1/versions/{id}/toggle \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"isEnabled": true}' +``` + +### 5.3 设置强制更新 + +```bash +curl -X PUT https://api.rwadurian.com/api/v1/versions/{id} \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"isForceUpdate": true}' +``` + +--- + +## 6. 版本号约定 + +### 6.1 versionCode vs versionName + +| 属性 | 类型 | 用途 | 示例 | +|------|------|------|------| +| `versionCode` | 整数 | 用于比较新旧版本,必须递增 | 101, 102, 200 | +| `versionName` | 字符串 | 用户可见的版本号 | "1.0.1", "2.0.0" | + +### 6.2 版本号递增规则 + +``` +versionCode 计算公式建议: + major * 10000 + minor * 100 + patch + +示例: + 1.0.0 → 10000 + 1.0.1 → 10001 + 1.1.0 → 10100 + 2.0.0 → 20000 +``` + +### 6.3 Android 配置 + +```groovy +// android/app/build.gradle +android { + defaultConfig { + versionCode 10001 // 用于更新比较 + versionName "1.0.1" // 用户可见 + } +} +``` + +### 6.4 Flutter 配置 + +```yaml +# pubspec.yaml +version: 1.0.1+10001 +# 格式: versionName+versionCode +``` + +--- + +## 7. 错误处理 + +### 7.1 后端错误码 + +| HTTP状态码 | 错误类型 | 说明 | +|-----------|---------|------| +| 400 | Bad Request | 请求参数验证失败 | +| 401 | Unauthorized | 未提供或无效的认证令牌 | +| 404 | Not Found | 版本不存在 | +| 409 | Conflict | 版本号已存在 | +| 500 | Internal Server Error | 服务器内部错误 | + +### 7.2 移动端错误处理 + +```dart +try { + final versionInfo = await versionChecker.checkForUpdate(); + if (versionInfo != null) { + await showUpdateDialog(context, versionInfo); + } +} on DioException catch (e) { + // 网络错误,静默失败,不影响用户使用 + debugPrint('Update check failed: ${e.message}'); +} catch (e) { + debugPrint('Unexpected error: $e'); +} +``` + +--- + +## 8. 文件结构 + +### 8.1 后端 (Admin Service) + +``` +src/ +├── api/ +│ ├── controllers/ +│ │ └── version.controller.ts +│ └── dto/ +│ ├── request/ +│ │ ├── check-update.dto.ts +│ │ ├── create-version.dto.ts +│ │ └── update-version.dto.ts +│ └── response/ +│ └── version.dto.ts +├── application/ +│ ├── commands/ +│ │ ├── create-version/ +│ │ ├── update-version/ +│ │ └── delete-version/ +│ └── queries/ +│ ├── check-update/ +│ ├── get-version/ +│ └── list-versions/ +├── domain/ +│ ├── entities/ +│ │ └── app-version.entity.ts +│ ├── enums/ +│ │ └── platform.enum.ts +│ ├── repositories/ +│ │ └── app-version.repository.ts +│ └── value-objects/ +│ ├── version-code.vo.ts +│ ├── version-name.vo.ts +│ └── ... (其他值对象) +└── infrastructure/ + └── persistence/ + ├── mappers/ + │ └── app-version.mapper.ts + └── repositories/ + └── app-version.repository.impl.ts +``` + +### 8.2 移动端 (Flutter) + +``` +lib/ +├── core/ +│ └── updater/ +│ ├── models/ +│ │ ├── version_info.dart +│ │ └── update_config.dart +│ ├── channels/ +│ │ ├── google_play_updater.dart +│ │ └── self_hosted_updater.dart +│ ├── update_service.dart +│ ├── version_checker.dart +│ ├── download_manager.dart +│ ├── apk_installer.dart +│ └── app_market_detector.dart +└── features/ + └── auth/ + └── presentation/ + └── pages/ + └── splash_page.dart +``` + +--- + +## 9. 测试 + +### 9.1 后端测试 + +```bash +# 单元测试 +npm run test:unit + +# 集成测试 +npm run test:integration + +# E2E测试 +npm run test:e2e +``` + +### 9.2 移动端测试 + +```bash +# 运行所有测试 +flutter test + +# 测试更新模块 +flutter test test/core/updater/ +``` + +### 9.3 手动测试流程 + +1. **创建测试版本** + ```bash + curl -X POST http://localhost:3010/api/v1/versions ... + ``` + +2. **验证检查更新** + ```bash + curl "http://localhost:3010/api/v1/versions/check-update?platform=android¤tVersionCode=100" + ``` + +3. **在模拟器中测试** + - 安装旧版本APK + - 启动应用,观察更新对话框 + - 测试下载和安装流程 + +--- + +## 10. 常见问题 + +### Q1: 如何实现灰度发布? + +目前系统不支持灰度发布。可通过以下方式扩展: +- 添加 `rolloutPercentage` 字段 +- 基于设备ID哈希值判断是否推送更新 + +### Q2: 如何支持多语言更新日志? + +可将 `changelog` 字段改为 JSON 格式: +```json +{ + "zh": "1. 修复问题\n2. 性能优化", + "en": "1. Bug fixes\n2. Performance improvements" +} +``` + +### Q3: 下载失败怎么办? + +移动端实现了以下容错机制: +- 自动重试 (最多3次) +- 断点续传支持 +- 失败时提示用户手动下载 + +### Q4: 如何处理大文件下载? + +- 使用CDN加速 +- 支持分片下载 +- 后台下载 + 通知栏进度 diff --git a/backend/services/admin-service/package-lock.json b/backend/services/admin-service/package-lock.json index d7928656..9f803e0e 100644 --- a/backend/services/admin-service/package-lock.json +++ b/backend/services/admin-service/package-lock.json @@ -15,8 +15,10 @@ "@nestjs/jwt": "^10.2.0", "@nestjs/passport": "^10.0.0", "@nestjs/platform-express": "^10.0.0", + "@nestjs/serve-static": "^4.0.2", "@nestjs/swagger": "^7.1.17", "@prisma/client": "^5.7.0", + "@types/multer": "^2.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "ioredis": "^5.3.2", @@ -241,6 +243,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1902,6 +1905,39 @@ "dev": true, "license": "MIT" }, + "node_modules/@nestjs/serve-static": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@nestjs/serve-static/-/serve-static-4.0.2.tgz", + "integrity": "sha512-cT0vdWN5ar7jDI2NKbhf4LcwJzU4vS5sVpMkVrHuyLcltbrz6JdGi1TfIMMatP2pNiq5Ie/uUdPSFDVaZX/URQ==", + "license": "MIT", + "dependencies": { + "path-to-regexp": "0.2.5" + }, + "peerDependencies": { + "@fastify/static": "^6.5.0 || ^7.0.0", + "@nestjs/common": "^9.0.0 || ^10.0.0", + "@nestjs/core": "^9.0.0 || ^10.0.0", + "express": "^4.18.1", + "fastify": "^4.7.0" + }, + "peerDependenciesMeta": { + "@fastify/static": { + "optional": true + }, + "express": { + "optional": true + }, + "fastify": { + "optional": true + } + } + }, + "node_modules/@nestjs/serve-static/node_modules/path-to-regexp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.2.5.tgz", + "integrity": "sha512-l6qtdDPIkmAmzEO6egquYDfqQGPMRNGjYtrU13HAXb3YSRrt7HSb1sJY0pKp6o2bAa86tSB6iwaW2JbthPKr7Q==", + "license": "MIT" + }, "node_modules/@nestjs/swagger": { "version": "7.4.2", "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-7.4.2.tgz", @@ -2262,7 +2298,6 @@ "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "dev": true, "license": "MIT", "dependencies": { "@types/connect": "*", @@ -2273,7 +2308,6 @@ "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -2320,7 +2354,6 @@ "version": "4.17.25", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", - "dev": true, "license": "MIT", "dependencies": { "@types/body-parser": "*", @@ -2333,7 +2366,6 @@ "version": "4.19.7", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.7.tgz", "integrity": "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -2356,7 +2388,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "dev": true, "license": "MIT" }, "node_modules/@types/istanbul-lib-coverage": { @@ -2424,9 +2455,17 @@ "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true, "license": "MIT" }, + "node_modules/@types/multer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz", + "integrity": "sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==", + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "20.19.25", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", @@ -2474,14 +2513,12 @@ "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", - "dev": true, "license": "MIT" }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, "license": "MIT" }, "node_modules/@types/semver": { @@ -2495,7 +2532,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -2505,7 +2541,6 @@ "version": "1.15.10", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", - "dev": true, "license": "MIT", "dependencies": { "@types/http-errors": "*", @@ -2517,7 +2552,6 @@ "version": "0.17.6", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", - "dev": true, "license": "MIT", "dependencies": { "@types/mime": "^1", @@ -4879,6 +4913,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", diff --git a/backend/services/admin-service/package.json b/backend/services/admin-service/package.json index 6a357ae8..d5f4d922 100644 --- a/backend/services/admin-service/package.json +++ b/backend/services/admin-service/package.json @@ -35,8 +35,10 @@ "@nestjs/jwt": "^10.2.0", "@nestjs/passport": "^10.0.0", "@nestjs/platform-express": "^10.0.0", + "@nestjs/serve-static": "^4.0.2", "@nestjs/swagger": "^7.1.17", "@prisma/client": "^5.7.0", + "@types/multer": "^2.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "ioredis": "^5.3.2", @@ -49,25 +51,25 @@ "@nestjs/schematics": "^10.0.0", "@nestjs/testing": "^10.0.0", "@types/express": "^4.17.17", + "@types/jest": "^29.5.2", "@types/node": "^20.3.1", "@types/passport-jwt": "^3.0.13", + "@types/supertest": "^6.0.0", "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", "eslint": "^8.42.0", "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.0", + "jest": "^29.5.0", "prettier": "^3.0.0", "prisma": "^5.7.0", "source-map-support": "^0.5.21", + "supertest": "^6.3.3", + "ts-jest": "^29.1.0", "ts-loader": "^9.4.3", "ts-node": "^10.9.1", "tsconfig-paths": "^4.2.0", - "typescript": "^5.1.3", - "@types/jest": "^29.5.2", - "@types/supertest": "^6.0.0", - "jest": "^29.5.0", - "supertest": "^6.3.3", - "ts-jest": "^29.1.0" + "typescript": "^5.1.3" }, "jest": { "moduleFileExtensions": [ @@ -98,4 +100,4 @@ "^src/(.*)$": "/src/" } } -} \ No newline at end of file +} diff --git a/backend/services/admin-service/src/api/controllers/mobile-version.controller.ts b/backend/services/admin-service/src/api/controllers/mobile-version.controller.ts new file mode 100644 index 00000000..e3fa6c96 --- /dev/null +++ b/backend/services/admin-service/src/api/controllers/mobile-version.controller.ts @@ -0,0 +1,67 @@ +import { Controller, Get, Query } from '@nestjs/common' +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger' +import { MobileCheckUpdateDto } from '../dto/request/mobile-check-update.dto' +import { MobileUpdateCheckResultDto } from '../dto/response/version.dto' +import { CheckUpdateHandler } from '@/application/queries/check-update/check-update.handler' +import { CheckUpdateQuery } from '@/application/queries/check-update/check-update.query' +import { Platform } from '@/domain/enums/platform.enum' + +/** + * 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 checkUpdateHandler: CheckUpdateHandler) {} + + /** + * Format file size to human-readable string + */ + private formatFileSize(bytes: bigint): string { + const bytesNum = Number(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` + } + + /** + * Convert platform string to enum + */ + private getPlatform(platform: string): Platform { + const normalized = platform.toLowerCase() + if (normalized === 'ios') return Platform.IOS + return Platform.ANDROID + } + + @Get('check') + @ApiOperation({ summary: '检查更新 (移动端专用)' }) + @ApiResponse({ status: 200, type: MobileUpdateCheckResultDto }) + async checkUpdate(@Query() dto: MobileCheckUpdateDto): Promise { + const platform = this.getPlatform(dto.platform) + const query = new CheckUpdateQuery(platform, dto.current_version_code) + const result = await this.checkUpdateHandler.execute(query) + + if (!result.hasUpdate || !result.latestVersion) { + return { + needUpdate: false, + } + } + + const fileSize = BigInt(result.latestVersion.fileSize) + + return { + needUpdate: true, + version: result.latestVersion.versionName, + versionCode: result.latestVersion.versionCode, + downloadUrl: result.latestVersion.downloadUrl, + fileSize: Number(fileSize), + fileSizeFriendly: this.formatFileSize(fileSize), + sha256: result.latestVersion.fileSha256, + forceUpdate: result.isForceUpdate, + updateLog: result.latestVersion.changelog, + releaseDate: result.latestVersion.releaseDate?.toISOString() ?? new Date().toISOString(), + } + } +} diff --git a/backend/services/admin-service/src/api/controllers/version.controller.ts b/backend/services/admin-service/src/api/controllers/version.controller.ts index 3acf3476..30180103 100644 --- a/backend/services/admin-service/src/api/controllers/version.controller.ts +++ b/backend/services/admin-service/src/api/controllers/version.controller.ts @@ -1,21 +1,86 @@ -import { Controller, Get, Post, Body, Param, Query, UseGuards } from '@nestjs/common' -import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger' +import { + Controller, + Get, + Post, + Put, + Delete, + Patch, + Body, + Param, + Query, + HttpCode, + HttpStatus, + UseInterceptors, + UploadedFile, + ParseFilePipe, + MaxFileSizeValidator, + FileTypeValidator, + BadRequestException, +} from '@nestjs/common' +import { FileInterceptor } from '@nestjs/platform-express' +import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery, ApiConsumes, ApiBody } from '@nestjs/swagger' import { CheckUpdateDto } from '../dto/request/check-update.dto' import { CreateVersionDto } from '../dto/request/create-version.dto' +import { UpdateVersionDto } from '../dto/request/update-version.dto' +import { ToggleVersionDto } from '../dto/request/toggle-version.dto' +import { UploadVersionDto } from '../dto/request/upload-version.dto' import { UpdateCheckResultDto, VersionDto } from '../dto/response/version.dto' import { CheckUpdateHandler } from '@/application/queries/check-update/check-update.handler' import { CheckUpdateQuery } from '@/application/queries/check-update/check-update.query' +import { ListVersionsHandler } from '@/application/queries/list-versions/list-versions.handler' +import { ListVersionsQuery } from '@/application/queries/list-versions/list-versions.query' +import { GetVersionHandler } from '@/application/queries/get-version/get-version.handler' +import { GetVersionQuery } from '@/application/queries/get-version/get-version.query' import { CreateVersionHandler } from '@/application/commands/create-version/create-version.handler' import { CreateVersionCommand } from '@/application/commands/create-version/create-version.command' +import { UpdateVersionHandler } from '@/application/commands/update-version/update-version.handler' +import { UpdateVersionCommand } from '@/application/commands/update-version/update-version.command' +import { DeleteVersionHandler } from '@/application/commands/delete-version/delete-version.handler' +import { DeleteVersionCommand } from '@/application/commands/delete-version/delete-version.command' +import { ToggleVersionHandler } from '@/application/commands/toggle-version/toggle-version.handler' +import { ToggleVersionCommand } from '@/application/commands/toggle-version/toggle-version.command' +import { UploadVersionHandler } from '@/application/commands/upload-version/upload-version.handler' +import { UploadVersionCommand } from '@/application/commands/upload-version/upload-version.command' +import { Platform } from '@/domain/enums/platform.enum' +import { AppVersion } from '@/domain/entities/app-version.entity' + +// Maximum file size: 500MB +const MAX_FILE_SIZE = 500 * 1024 * 1024 @ApiTags('Version Management') @Controller('versions') export class VersionController { constructor( private readonly checkUpdateHandler: CheckUpdateHandler, + private readonly listVersionsHandler: ListVersionsHandler, + private readonly getVersionHandler: GetVersionHandler, private readonly createVersionHandler: CreateVersionHandler, + private readonly updateVersionHandler: UpdateVersionHandler, + private readonly deleteVersionHandler: DeleteVersionHandler, + private readonly toggleVersionHandler: ToggleVersionHandler, + private readonly uploadVersionHandler: UploadVersionHandler, ) {} + private toVersionDto(version: AppVersion): VersionDto { + 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('check-update') @ApiOperation({ summary: '检查更新 (供移动端调用)' }) @ApiResponse({ status: 200, type: UpdateCheckResultDto }) @@ -35,6 +100,32 @@ export class VersionController { } } + @Get() + @ApiOperation({ summary: '获取版本列表 (管理员)' }) + @ApiBearerAuth() + @ApiQuery({ name: 'platform', required: false, enum: Platform, description: '筛选平台' }) + @ApiQuery({ name: 'includeDisabled', required: false, type: Boolean, description: '是否包含已禁用版本' }) + @ApiResponse({ status: 200, type: [VersionDto] }) + async listVersions( + @Query('platform') platform?: Platform, + @Query('includeDisabled') includeDisabled?: string, + ): Promise { + const query = new ListVersionsQuery(platform, includeDisabled === 'true') + const versions = await this.listVersionsHandler.execute(query) + return versions.map((v) => this.toVersionDto(v)) + } + + @Get(':id') + @ApiOperation({ summary: '获取版本详情 (管理员)' }) + @ApiBearerAuth() + @ApiResponse({ status: 200, type: VersionDto }) + @ApiResponse({ status: 404, description: '版本不存在' }) + async getVersion(@Param('id') id: string): Promise { + const query = new GetVersionQuery(id) + const version = await this.getVersionHandler.execute(query) + return this.toVersionDto(version) + } + @Post() @ApiOperation({ summary: '创建新版本 (管理员)' }) @ApiBearerAuth() @@ -56,23 +147,143 @@ export class VersionController { ) const version = await this.createVersionHandler.execute(command) + return this.toVersionDto(version) + } - 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, + @Put(':id') + @ApiOperation({ summary: '更新版本 (管理员)' }) + @ApiBearerAuth() + @ApiResponse({ status: 200, type: VersionDto }) + @ApiResponse({ status: 404, description: '版本不存在' }) + async updateVersion(@Param('id') id: string, @Body() dto: UpdateVersionDto): Promise { + const command = new UpdateVersionCommand( + id, + dto.downloadUrl, + dto.fileSize ? BigInt(dto.fileSize) : undefined, + dto.fileSha256, + dto.changelog, + dto.isForceUpdate, + dto.minOsVersion, + dto.releaseDate ? new Date(dto.releaseDate) : dto.releaseDate === null ? null : undefined, + 'admin', // TODO: Get from JWT token + ) + + const version = await this.updateVersionHandler.execute(command) + 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 { + const command = new DeleteVersionCommand(id) + await this.deleteVersionHandler.execute(command) + } + + @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 }> { + const command = new ToggleVersionCommand(id, dto.isEnabled) + await this.toggleVersionHandler.execute(command) + return { success: true } + } + + @Post('upload') + @UseInterceptors(FileInterceptor('file')) + @ApiOperation({ summary: '上传APK/IPA并创建版本 (管理员)' }) + @ApiBearerAuth() + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + required: ['file', 'platform', 'versionCode', 'versionName', 'buildNumber', 'changelog'], + properties: { + file: { + type: 'string', + format: 'binary', + description: 'APK或IPA安装包文件', + }, + platform: { + type: 'string', + enum: ['android', 'ios'], + description: '平台', + }, + versionCode: { + type: 'integer', + description: '版本号', + minimum: 1, + }, + versionName: { + type: 'string', + description: '版本名称 (格式: x.y.z)', + }, + 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: VersionDto }) + @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 { + // Validate file extension + 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 command = new UploadVersionCommand( + dto.platform, + dto.versionCode, + dto.versionName, + dto.buildNumber, + dto.changelog, + dto.isForceUpdate ?? false, + dto.minOsVersion ?? null, + dto.releaseDate ? new Date(dto.releaseDate) : null, + file.buffer, + file.originalname, + 'admin', // TODO: Get from JWT token + ) + + const version = await this.uploadVersionHandler.execute(command) + return this.toVersionDto(version) } } diff --git a/backend/services/admin-service/src/api/dto/request/mobile-check-update.dto.ts b/backend/services/admin-service/src/api/dto/request/mobile-check-update.dto.ts new file mode 100644 index 00000000..2aca9696 --- /dev/null +++ b/backend/services/admin-service/src/api/dto/request/mobile-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 MobileCheckUpdateDto { + @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: 110 }) + @Type(() => Number) + @IsInt() + @Min(0) + current_version_code: number +} diff --git a/backend/services/admin-service/src/api/dto/request/toggle-version.dto.ts b/backend/services/admin-service/src/api/dto/request/toggle-version.dto.ts new file mode 100644 index 00000000..1802eb48 --- /dev/null +++ b/backend/services/admin-service/src/api/dto/request/toggle-version.dto.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger' +import { IsBoolean } from 'class-validator' + +export class ToggleVersionDto { + @ApiProperty({ description: '是否启用' }) + @IsBoolean() + isEnabled: boolean +} diff --git a/backend/services/admin-service/src/api/dto/request/update-version.dto.ts b/backend/services/admin-service/src/api/dto/request/update-version.dto.ts new file mode 100644 index 00000000..9ea9258d --- /dev/null +++ b/backend/services/admin-service/src/api/dto/request/update-version.dto.ts @@ -0,0 +1,39 @@ +import { ApiPropertyOptional } from '@nestjs/swagger' +import { IsString, IsBoolean, IsUrl, IsOptional, IsDateString } from 'class-validator' + +export class UpdateVersionDto { + @ApiPropertyOptional({ description: 'APK/IPA下载地址', example: 'https://example.com/app-v1.0.1.apk' }) + @IsOptional() + @IsUrl() + downloadUrl?: string + + @ApiPropertyOptional({ description: '文件大小(字节)', example: '52428800' }) + @IsOptional() + @IsString() + fileSize?: string + + @ApiPropertyOptional({ description: '文件SHA-256校验和' }) + @IsOptional() + @IsString() + fileSha256?: string + + @ApiPropertyOptional({ description: '更新日志' }) + @IsOptional() + @IsString() + changelog?: string + + @ApiPropertyOptional({ description: '是否强制更新' }) + @IsOptional() + @IsBoolean() + isForceUpdate?: boolean + + @ApiPropertyOptional({ description: '最低操作系统版本', example: '10.0' }) + @IsOptional() + @IsString() + minOsVersion?: string | null + + @ApiPropertyOptional({ description: '发布日期', example: '2025-12-02T10:00:00Z' }) + @IsOptional() + @IsDateString() + releaseDate?: string | null +} diff --git a/backend/services/admin-service/src/api/dto/request/upload-version.dto.ts b/backend/services/admin-service/src/api/dto/request/upload-version.dto.ts new file mode 100644 index 00000000..43556162 --- /dev/null +++ b/backend/services/admin-service/src/api/dto/request/upload-version.dto.ts @@ -0,0 +1,45 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' +import { IsString, IsInt, IsBoolean, IsOptional, IsDateString, Min, Matches } from 'class-validator' +import { Type, Transform } from 'class-transformer' +import { Platform } from '@/domain/enums/platform.enum' + +export class UploadVersionDto { + @ApiProperty({ enum: Platform, description: '平台 (android/ios)' }) + @IsString() + platform: Platform + + @ApiProperty({ description: '版本号', example: 100, minimum: 1 }) + @Type(() => Number) + @IsInt() + @Min(1) + versionCode: number + + @ApiProperty({ description: '版本名称', example: '1.0.0' }) + @IsString() + @Matches(/^\d+\.\d+\.\d+$/, { message: 'versionName must be in format x.y.z' }) + versionName: string + + @ApiProperty({ description: '构建号', example: '100' }) + @IsString() + buildNumber: string + + @ApiProperty({ description: '更新日志' }) + @IsString() + changelog: string + + @ApiPropertyOptional({ description: '是否强制更新', default: false }) + @IsOptional() + @Transform(({ value }) => value === 'true' || value === true) + @IsBoolean() + isForceUpdate?: boolean + + @ApiPropertyOptional({ description: '最低操作系统版本', example: '10.0' }) + @IsOptional() + @IsString() + minOsVersion?: string + + @ApiPropertyOptional({ description: '发布日期', example: '2025-12-02T10:00:00Z' }) + @IsOptional() + @IsDateString() + releaseDate?: string +} diff --git a/backend/services/admin-service/src/api/dto/response/version.dto.ts b/backend/services/admin-service/src/api/dto/response/version.dto.ts index fd5b158e..0ca9a231 100644 --- a/backend/services/admin-service/src/api/dto/response/version.dto.ts +++ b/backend/services/admin-service/src/api/dto/response/version.dto.ts @@ -67,3 +67,36 @@ export class UpdateCheckResultDto { releaseDate: Date | null } | null } + +// Mobile app compatible response format +export class MobileUpdateCheckResultDto { + @ApiProperty({ description: '是否需要更新' }) + needUpdate: boolean + + @ApiPropertyOptional({ description: '版本号 (语义化版本)' }) + version?: string + + @ApiPropertyOptional({ description: '版本代码 (用于比较)' }) + versionCode?: number + + @ApiPropertyOptional({ description: 'APK下载地址' }) + downloadUrl?: string + + @ApiPropertyOptional({ description: '文件大小(字节)' }) + fileSize?: number + + @ApiPropertyOptional({ description: '友好的文件大小显示' }) + fileSizeFriendly?: string + + @ApiPropertyOptional({ description: 'SHA-256校验和' }) + sha256?: string + + @ApiPropertyOptional({ description: '是否强制更新' }) + forceUpdate?: boolean + + @ApiPropertyOptional({ description: '更新日志' }) + updateLog?: string + + @ApiPropertyOptional({ description: '发布日期' }) + releaseDate?: string +} diff --git a/backend/services/admin-service/src/app.module.ts b/backend/services/admin-service/src/app.module.ts index 3834edd5..152c2ddf 100644 --- a/backend/services/admin-service/src/app.module.ts +++ b/backend/services/admin-service/src/app.module.ts @@ -1,13 +1,23 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; +import { ServeStaticModule } from '@nestjs/serve-static'; +import { join } from 'path'; import { configurations } from './config'; import { PrismaService } from './infrastructure/persistence/prisma/prisma.service'; import { AppVersionMapper } from './infrastructure/persistence/mappers/app-version.mapper'; import { AppVersionRepositoryImpl } from './infrastructure/persistence/repositories/app-version.repository.impl'; import { APP_VERSION_REPOSITORY } from './domain/repositories/app-version.repository'; +import { FileStorageService } from './infrastructure/storage/file-storage.service'; import { CheckUpdateHandler } from './application/queries/check-update/check-update.handler'; +import { ListVersionsHandler } from './application/queries/list-versions/list-versions.handler'; +import { GetVersionHandler } from './application/queries/get-version/get-version.handler'; import { CreateVersionHandler } from './application/commands/create-version/create-version.handler'; +import { UpdateVersionHandler } from './application/commands/update-version/update-version.handler'; +import { DeleteVersionHandler } from './application/commands/delete-version/delete-version.handler'; +import { ToggleVersionHandler } from './application/commands/toggle-version/toggle-version.handler'; +import { UploadVersionHandler } from './application/commands/upload-version/upload-version.handler'; import { VersionController } from './api/controllers/version.controller'; +import { MobileVersionController } from './api/controllers/mobile-version.controller'; import { HealthController } from './api/controllers/health.controller'; @Module({ @@ -16,17 +26,31 @@ import { HealthController } from './api/controllers/health.controller'; isGlobal: true, load: configurations, }), + // Serve static files from uploads directory + ServeStaticModule.forRoot({ + rootPath: join(process.cwd(), process.env.UPLOAD_DIR || 'uploads'), + serveRoot: '/uploads', + }), ], - controllers: [VersionController, HealthController], + controllers: [VersionController, MobileVersionController, HealthController], providers: [ PrismaService, AppVersionMapper, + FileStorageService, { provide: APP_VERSION_REPOSITORY, useClass: AppVersionRepositoryImpl, }, + // Query Handlers CheckUpdateHandler, + ListVersionsHandler, + GetVersionHandler, + // Command Handlers CreateVersionHandler, + UpdateVersionHandler, + DeleteVersionHandler, + ToggleVersionHandler, + UploadVersionHandler, ], }) export class AppModule {} diff --git a/backend/services/admin-service/src/application/commands/delete-version/delete-version.command.ts b/backend/services/admin-service/src/application/commands/delete-version/delete-version.command.ts new file mode 100644 index 00000000..f58b0204 --- /dev/null +++ b/backend/services/admin-service/src/application/commands/delete-version/delete-version.command.ts @@ -0,0 +1,3 @@ +export class DeleteVersionCommand { + constructor(public readonly id: string) {} +} diff --git a/backend/services/admin-service/src/application/commands/delete-version/delete-version.handler.ts b/backend/services/admin-service/src/application/commands/delete-version/delete-version.handler.ts new file mode 100644 index 00000000..19bd8960 --- /dev/null +++ b/backend/services/admin-service/src/application/commands/delete-version/delete-version.handler.ts @@ -0,0 +1,21 @@ +import { Inject, Injectable, NotFoundException } from '@nestjs/common' +import { DeleteVersionCommand } from './delete-version.command' +import { AppVersionRepository, APP_VERSION_REPOSITORY } from '@/domain/repositories/app-version.repository' + +@Injectable() +export class DeleteVersionHandler { + constructor( + @Inject(APP_VERSION_REPOSITORY) + private readonly appVersionRepository: AppVersionRepository, + ) {} + + async execute(command: DeleteVersionCommand): Promise { + const version = await this.appVersionRepository.findById(command.id) + + if (!version) { + throw new NotFoundException(`Version with ID ${command.id} not found`) + } + + await this.appVersionRepository.delete(command.id) + } +} diff --git a/backend/services/admin-service/src/application/commands/toggle-version/toggle-version.command.ts b/backend/services/admin-service/src/application/commands/toggle-version/toggle-version.command.ts new file mode 100644 index 00000000..3e99b0b6 --- /dev/null +++ b/backend/services/admin-service/src/application/commands/toggle-version/toggle-version.command.ts @@ -0,0 +1,6 @@ +export class ToggleVersionCommand { + constructor( + public readonly id: string, + public readonly isEnabled: boolean, + ) {} +} diff --git a/backend/services/admin-service/src/application/commands/toggle-version/toggle-version.handler.ts b/backend/services/admin-service/src/application/commands/toggle-version/toggle-version.handler.ts new file mode 100644 index 00000000..9cece5ae --- /dev/null +++ b/backend/services/admin-service/src/application/commands/toggle-version/toggle-version.handler.ts @@ -0,0 +1,21 @@ +import { Inject, Injectable, NotFoundException } from '@nestjs/common' +import { ToggleVersionCommand } from './toggle-version.command' +import { AppVersionRepository, APP_VERSION_REPOSITORY } from '@/domain/repositories/app-version.repository' + +@Injectable() +export class ToggleVersionHandler { + constructor( + @Inject(APP_VERSION_REPOSITORY) + private readonly appVersionRepository: AppVersionRepository, + ) {} + + async execute(command: ToggleVersionCommand): Promise { + const version = await this.appVersionRepository.findById(command.id) + + if (!version) { + throw new NotFoundException(`Version with ID ${command.id} not found`) + } + + await this.appVersionRepository.toggleEnabled(command.id, command.isEnabled) + } +} diff --git a/backend/services/admin-service/src/application/commands/update-version/update-version.command.ts b/backend/services/admin-service/src/application/commands/update-version/update-version.command.ts new file mode 100644 index 00000000..179240e6 --- /dev/null +++ b/backend/services/admin-service/src/application/commands/update-version/update-version.command.ts @@ -0,0 +1,13 @@ +export class UpdateVersionCommand { + constructor( + public readonly id: string, + public readonly downloadUrl?: string, + public readonly fileSize?: bigint, + public readonly fileSha256?: string, + public readonly changelog?: string, + public readonly isForceUpdate?: boolean, + public readonly minOsVersion?: string | null, + public readonly releaseDate?: Date | null, + public readonly updatedBy: string = 'admin', + ) {} +} diff --git a/backend/services/admin-service/src/application/commands/update-version/update-version.handler.ts b/backend/services/admin-service/src/application/commands/update-version/update-version.handler.ts new file mode 100644 index 00000000..63e94573 --- /dev/null +++ b/backend/services/admin-service/src/application/commands/update-version/update-version.handler.ts @@ -0,0 +1,32 @@ +import { Inject, Injectable, NotFoundException } from '@nestjs/common' +import { UpdateVersionCommand } from './update-version.command' +import { AppVersionRepository, APP_VERSION_REPOSITORY } from '@/domain/repositories/app-version.repository' +import { AppVersion } from '@/domain/entities/app-version.entity' + +@Injectable() +export class UpdateVersionHandler { + constructor( + @Inject(APP_VERSION_REPOSITORY) + private readonly appVersionRepository: AppVersionRepository, + ) {} + + async execute(command: UpdateVersionCommand): Promise { + const version = await this.appVersionRepository.findById(command.id) + + if (!version) { + throw new NotFoundException(`Version with ID ${command.id} not found`) + } + + // Apply updates through domain methods + if (command.isForceUpdate !== undefined) { + version.setForceUpdate(command.isForceUpdate, command.updatedBy) + } + + if (command.releaseDate !== undefined) { + version.setReleaseDate(command.releaseDate, command.updatedBy) + } + + // For other fields, we need to use the repository update method + return await this.appVersionRepository.update(command.id, version) + } +} diff --git a/backend/services/admin-service/src/application/commands/upload-version/upload-version.command.ts b/backend/services/admin-service/src/application/commands/upload-version/upload-version.command.ts new file mode 100644 index 00000000..a997e0a6 --- /dev/null +++ b/backend/services/admin-service/src/application/commands/upload-version/upload-version.command.ts @@ -0,0 +1,17 @@ +import { Platform } from '@/domain/enums/platform.enum' + +export class UploadVersionCommand { + constructor( + public readonly platform: Platform, + public readonly versionCode: number, + public readonly versionName: string, + public readonly buildNumber: string, + public readonly changelog: string, + public readonly isForceUpdate: boolean, + public readonly minOsVersion: string | null, + public readonly releaseDate: Date | null, + public readonly fileBuffer: Buffer, + public readonly originalFilename: string, + public readonly createdBy: string, + ) {} +} diff --git a/backend/services/admin-service/src/application/commands/upload-version/upload-version.handler.ts b/backend/services/admin-service/src/application/commands/upload-version/upload-version.handler.ts new file mode 100644 index 00000000..43161366 --- /dev/null +++ b/backend/services/admin-service/src/application/commands/upload-version/upload-version.handler.ts @@ -0,0 +1,61 @@ +import { Inject, Injectable } from '@nestjs/common' +import { UploadVersionCommand } from './upload-version.command' +import { AppVersionRepository, APP_VERSION_REPOSITORY } from '@/domain/repositories/app-version.repository' +import { AppVersion } from '@/domain/entities/app-version.entity' +import { VersionCode } from '@/domain/value-objects/version-code.vo' +import { VersionName } from '@/domain/value-objects/version-name.vo' +import { BuildNumber } from '@/domain/value-objects/build-number.vo' +import { DownloadUrl } from '@/domain/value-objects/download-url.vo' +import { FileSize } from '@/domain/value-objects/file-size.vo' +import { FileSha256 } from '@/domain/value-objects/file-sha256.vo' +import { Changelog } from '@/domain/value-objects/changelog.vo' +import { MinOsVersion } from '@/domain/value-objects/min-os-version.vo' +import { FileStorageService } from '@/infrastructure/storage/file-storage.service' + +@Injectable() +export class UploadVersionHandler { + constructor( + @Inject(APP_VERSION_REPOSITORY) + private readonly appVersionRepository: AppVersionRepository, + private readonly fileStorageService: FileStorageService, + ) {} + + async execute(command: UploadVersionCommand): Promise { + // Save the uploaded file + const uploadResult = await this.fileStorageService.saveFile( + command.fileBuffer, + command.originalFilename, + command.platform, + command.versionName, + ) + + // Create value objects + const versionCode = VersionCode.create(command.versionCode) + const versionName = VersionName.create(command.versionName) + const buildNumber = BuildNumber.create(command.buildNumber) + const downloadUrl = DownloadUrl.create(uploadResult.url) + const fileSize = FileSize.create(BigInt(uploadResult.size)) + const fileSha256 = FileSha256.create(uploadResult.sha256) + const changelog = Changelog.create(command.changelog) + const minOsVersion = command.minOsVersion ? MinOsVersion.create(command.minOsVersion) : null + + // Create the app version entity + const appVersion = AppVersion.create({ + platform: command.platform, + versionCode, + versionName, + buildNumber, + downloadUrl, + fileSize, + fileSha256, + changelog, + isForceUpdate: command.isForceUpdate, + minOsVersion, + releaseDate: command.releaseDate, + createdBy: command.createdBy, + }) + + // Save to database + return await this.appVersionRepository.save(appVersion) + } +} diff --git a/backend/services/admin-service/src/application/queries/get-version/get-version.handler.ts b/backend/services/admin-service/src/application/queries/get-version/get-version.handler.ts new file mode 100644 index 00000000..aed5bfff --- /dev/null +++ b/backend/services/admin-service/src/application/queries/get-version/get-version.handler.ts @@ -0,0 +1,22 @@ +import { Inject, Injectable, NotFoundException } from '@nestjs/common' +import { GetVersionQuery } from './get-version.query' +import { AppVersionRepository, APP_VERSION_REPOSITORY } from '@/domain/repositories/app-version.repository' +import { AppVersion } from '@/domain/entities/app-version.entity' + +@Injectable() +export class GetVersionHandler { + constructor( + @Inject(APP_VERSION_REPOSITORY) + private readonly appVersionRepository: AppVersionRepository, + ) {} + + async execute(query: GetVersionQuery): Promise { + const version = await this.appVersionRepository.findById(query.id) + + if (!version) { + throw new NotFoundException(`Version with ID ${query.id} not found`) + } + + return version + } +} diff --git a/backend/services/admin-service/src/application/queries/get-version/get-version.query.ts b/backend/services/admin-service/src/application/queries/get-version/get-version.query.ts new file mode 100644 index 00000000..ca1a24e7 --- /dev/null +++ b/backend/services/admin-service/src/application/queries/get-version/get-version.query.ts @@ -0,0 +1,3 @@ +export class GetVersionQuery { + constructor(public readonly id: string) {} +} diff --git a/backend/services/admin-service/src/application/queries/list-versions/list-versions.handler.ts b/backend/services/admin-service/src/application/queries/list-versions/list-versions.handler.ts new file mode 100644 index 00000000..3d0547e5 --- /dev/null +++ b/backend/services/admin-service/src/application/queries/list-versions/list-versions.handler.ts @@ -0,0 +1,31 @@ +import { Inject, Injectable } from '@nestjs/common' +import { ListVersionsQuery } from './list-versions.query' +import { AppVersionRepository, APP_VERSION_REPOSITORY } from '@/domain/repositories/app-version.repository' +import { AppVersion } from '@/domain/entities/app-version.entity' +import { Platform } from '@/domain/enums/platform.enum' + +@Injectable() +export class ListVersionsHandler { + constructor( + @Inject(APP_VERSION_REPOSITORY) + private readonly appVersionRepository: AppVersionRepository, + ) {} + + async execute(query: ListVersionsQuery): Promise { + if (query.platform) { + return await this.appVersionRepository.findAllByPlatform(query.platform, query.includeDisabled) + } + + // If no platform specified, get all versions from both platforms + const androidVersions = await this.appVersionRepository.findAllByPlatform( + Platform.ANDROID, + query.includeDisabled, + ) + const iosVersions = await this.appVersionRepository.findAllByPlatform(Platform.IOS, query.includeDisabled) + + // Combine and sort by createdAt descending + return [...androidVersions, ...iosVersions].sort( + (a, b) => b.createdAt.getTime() - a.createdAt.getTime(), + ) + } +} diff --git a/backend/services/admin-service/src/application/queries/list-versions/list-versions.query.ts b/backend/services/admin-service/src/application/queries/list-versions/list-versions.query.ts new file mode 100644 index 00000000..143bae9d --- /dev/null +++ b/backend/services/admin-service/src/application/queries/list-versions/list-versions.query.ts @@ -0,0 +1,8 @@ +import { Platform } from '@/domain/enums/platform.enum' + +export class ListVersionsQuery { + constructor( + public readonly platform?: Platform, + public readonly includeDisabled: boolean = false, + ) {} +} diff --git a/backend/services/admin-service/src/infrastructure/storage/file-storage.service.ts b/backend/services/admin-service/src/infrastructure/storage/file-storage.service.ts new file mode 100644 index 00000000..9223515e --- /dev/null +++ b/backend/services/admin-service/src/infrastructure/storage/file-storage.service.ts @@ -0,0 +1,92 @@ +import { Injectable, Logger } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import * as fs from 'fs/promises' +import * as path from 'path' +import * as crypto from 'crypto' + +export interface FileUploadResult { + filename: string + originalName: string + path: string + size: number + sha256: string + url: 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('BASE_URL') || 'http://localhost:3000' + } + + async ensureUploadDir(): Promise { + try { + await fs.access(this.uploadDir) + } catch { + await fs.mkdir(this.uploadDir, { recursive: true }) + this.logger.log(`Created upload directory: ${this.uploadDir}`) + } + } + + async saveFile( + buffer: Buffer, + originalName: string, + platform: string, + versionName: string, + ): Promise { + await this.ensureUploadDir() + + // Generate unique filename with platform and version info + const ext = path.extname(originalName) + const timestamp = Date.now() + const randomSuffix = crypto.randomBytes(4).toString('hex') + const filename = `${platform}-${versionName}-${timestamp}-${randomSuffix}${ext}` + + // Calculate SHA256 hash + const sha256 = crypto.createHash('sha256').update(buffer).digest('hex') + + // Save file + const filePath = path.join(this.uploadDir, filename) + await fs.writeFile(filePath, buffer) + + this.logger.log(`Saved file: ${filename} (${buffer.length} bytes, SHA256: ${sha256})`) + + return { + filename, + originalName, + path: filePath, + size: buffer.length, + sha256, + url: `${this.baseUrl}/uploads/${filename}`, + } + } + + async deleteFile(filename: string): Promise { + const filePath = path.join(this.uploadDir, filename) + try { + await fs.unlink(filePath) + this.logger.log(`Deleted file: ${filename}`) + } catch (error) { + this.logger.warn(`Failed to delete file: ${filename}`, error) + } + } + + async getFileInfo(filename: string): Promise<{ size: number; sha256: string } | null> { + const filePath = path.join(this.uploadDir, filename) + try { + const buffer = await fs.readFile(filePath) + const sha256 = crypto.createHash('sha256').update(buffer).digest('hex') + return { + size: buffer.length, + sha256, + } + } catch { + return null + } + } +}