feat(admin-service): 实现完整的移动端版本管理和升级服务
- 添加版本CRUD API:list, get, update, delete, toggle - 添加文件上传支持:APK/IPA文件上传并计算SHA256校验 - 新增移动端专用API endpoint: /api/app/version/check - 修复deploy.sh自调用权限问题(使用绝对路径) - 添加完整的技术文档 APP_UPGRADE_SERVICE.md 新增文件: - MobileVersionController: 移动端兼容的版本检查接口 - FileStorageService: 文件上传和存储服务 - CQRS handlers: ListVersions, GetVersion, UpdateVersion, DeleteVersion, ToggleVersion, UploadVersion 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
c3f4243e81
commit
8a57013596
|
|
@ -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"
|
||||
;;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <admin-token>
|
||||
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 <admin-token>" \
|
||||
-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 <admin-token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"isEnabled": false}'
|
||||
|
||||
# 启用版本
|
||||
curl -X PATCH https://api.rwadurian.com/api/v1/versions/{id}/toggle \
|
||||
-H "Authorization: Bearer <admin-token>" \
|
||||
-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 <admin-token>" \
|
||||
-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加速
|
||||
- 支持分片下载
|
||||
- 后台下载 + 通知栏进度
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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/(.*)$": "<rootDir>/src/"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<MobileUpdateCheckResultDto> {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<VersionDto[]> {
|
||||
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<VersionDto> {
|
||||
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<VersionDto> {
|
||||
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<void> {
|
||||
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<VersionDto> {
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { ApiProperty } from '@nestjs/swagger'
|
||||
import { IsBoolean } from 'class-validator'
|
||||
|
||||
export class ToggleVersionDto {
|
||||
@ApiProperty({ description: '是否启用' })
|
||||
@IsBoolean()
|
||||
isEnabled: boolean
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
export class DeleteVersionCommand {
|
||||
constructor(public readonly id: string) {}
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export class ToggleVersionCommand {
|
||||
constructor(
|
||||
public readonly id: string,
|
||||
public readonly isEnabled: boolean,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
) {}
|
||||
}
|
||||
|
|
@ -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<AppVersion> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -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<AppVersion> {
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<AppVersion> {
|
||||
const version = await this.appVersionRepository.findById(query.id)
|
||||
|
||||
if (!version) {
|
||||
throw new NotFoundException(`Version with ID ${query.id} not found`)
|
||||
}
|
||||
|
||||
return version
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export class GetVersionQuery {
|
||||
constructor(public readonly id: string) {}
|
||||
}
|
||||
|
|
@ -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<AppVersion[]> {
|
||||
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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { Platform } from '@/domain/enums/platform.enum'
|
||||
|
||||
export class ListVersionsQuery {
|
||||
constructor(
|
||||
public readonly platform?: Platform,
|
||||
public readonly includeDisabled: boolean = false,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -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<string>('UPLOAD_DIR') || './uploads'
|
||||
this.baseUrl = this.configService.get<string>('BASE_URL') || 'http://localhost:3000'
|
||||
}
|
||||
|
||||
async ensureUploadDir(): Promise<void> {
|
||||
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<FileUploadResult> {
|
||||
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<void> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue