docs(mobile-upgrade): 添加完整技术文档
新增以下文档: - ARCHITECTURE.md: Clean Architecture 分层架构详解 - API.md: API 端点和数据类型定义 - DEVELOPMENT.md: 开发环境配置和规范指南 - TESTING.md: 单元测试、集成测试、E2E测试方案 - DEPLOYMENT.md: Docker、PM2、Nginx 部署配置 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
6db948099c
commit
08485d361f
|
|
@ -0,0 +1,388 @@
|
|||
# Mobile Upgrade Admin - API 文档
|
||||
|
||||
## 概述
|
||||
|
||||
本文档描述 Mobile Upgrade Admin 前端与 Admin Service 后端的 API 交互。
|
||||
|
||||
## 基础配置
|
||||
|
||||
### 环境变量
|
||||
|
||||
```bash
|
||||
# .env.local
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3000
|
||||
```
|
||||
|
||||
### API 客户端配置
|
||||
|
||||
```typescript
|
||||
// src/infrastructure/http/api-client.ts
|
||||
export const apiClient = axios.create({
|
||||
baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000',
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## API 端点
|
||||
|
||||
### 版本管理 API
|
||||
|
||||
Base URL: `/api/v1/versions`
|
||||
|
||||
---
|
||||
|
||||
### 1. 获取版本列表
|
||||
|
||||
**端点**: `GET /api/v1/versions`
|
||||
|
||||
**查询参数**:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| platform | string | 否 | 平台筛选:`android` 或 `ios` |
|
||||
| includeDisabled | boolean | 否 | 是否包含已禁用版本,默认 `true` |
|
||||
|
||||
**请求示例**:
|
||||
|
||||
```typescript
|
||||
// 获取所有版本
|
||||
const response = await apiClient.get('/api/v1/versions')
|
||||
|
||||
// 获取 Android 版本
|
||||
const response = await apiClient.get('/api/v1/versions?platform=android')
|
||||
|
||||
// 只获取已启用版本
|
||||
const response = await apiClient.get('/api/v1/versions?includeDisabled=false')
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "uuid-1234",
|
||||
"platform": "android",
|
||||
"versionCode": 100,
|
||||
"versionName": "1.0.0",
|
||||
"buildNumber": "100",
|
||||
"downloadUrl": "https://example.com/app-1.0.0.apk",
|
||||
"fileSize": "52428800",
|
||||
"fileSha256": "abc123...",
|
||||
"changelog": "1. 新增功能A\n2. 修复问题B",
|
||||
"isForceUpdate": false,
|
||||
"isEnabled": true,
|
||||
"minOsVersion": "8.0",
|
||||
"releaseDate": "2024-01-01T00:00:00.000Z",
|
||||
"createdAt": "2024-01-01T00:00:00.000Z",
|
||||
"updatedAt": "2024-01-01T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 获取单个版本
|
||||
|
||||
**端点**: `GET /api/v1/versions/:id`
|
||||
|
||||
**路径参数**:
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | string | 版本 ID (UUID) |
|
||||
|
||||
**请求示例**:
|
||||
|
||||
```typescript
|
||||
const response = await apiClient.get('/api/v1/versions/uuid-1234')
|
||||
```
|
||||
|
||||
**响应**: 单个 `AppVersion` 对象
|
||||
|
||||
---
|
||||
|
||||
### 3. 创建版本
|
||||
|
||||
**端点**: `POST /api/v1/versions`
|
||||
|
||||
**请求体**:
|
||||
|
||||
```typescript
|
||||
interface CreateVersionInput {
|
||||
platform: 'android' | 'ios'
|
||||
versionCode: number
|
||||
versionName: string
|
||||
buildNumber: string
|
||||
downloadUrl: string
|
||||
fileSize: string
|
||||
fileSha256: string
|
||||
changelog: string
|
||||
isForceUpdate: boolean
|
||||
minOsVersion?: string
|
||||
releaseDate?: string
|
||||
}
|
||||
```
|
||||
|
||||
**请求示例**:
|
||||
|
||||
```typescript
|
||||
const response = await apiClient.post('/api/v1/versions', {
|
||||
platform: 'android',
|
||||
versionCode: 101,
|
||||
versionName: '1.0.1',
|
||||
buildNumber: '101',
|
||||
downloadUrl: 'https://example.com/app-1.0.1.apk',
|
||||
fileSize: '52428800',
|
||||
fileSha256: 'sha256hash...',
|
||||
changelog: '修复已知问题',
|
||||
isForceUpdate: false,
|
||||
minOsVersion: '8.0'
|
||||
})
|
||||
```
|
||||
|
||||
**响应**: 创建的 `AppVersion` 对象
|
||||
|
||||
---
|
||||
|
||||
### 4. 更新版本
|
||||
|
||||
**端点**: `PUT /api/v1/versions/:id`
|
||||
|
||||
**请求体**:
|
||||
|
||||
```typescript
|
||||
interface UpdateVersionInput {
|
||||
downloadUrl?: string
|
||||
fileSize?: string
|
||||
fileSha256?: string
|
||||
changelog?: string
|
||||
isForceUpdate?: boolean
|
||||
isEnabled?: boolean
|
||||
minOsVersion?: string | null
|
||||
releaseDate?: string | null
|
||||
}
|
||||
```
|
||||
|
||||
**请求示例**:
|
||||
|
||||
```typescript
|
||||
const response = await apiClient.put('/api/v1/versions/uuid-1234', {
|
||||
changelog: '更新的更新日志',
|
||||
isForceUpdate: true
|
||||
})
|
||||
```
|
||||
|
||||
**响应**: 更新后的 `AppVersion` 对象
|
||||
|
||||
---
|
||||
|
||||
### 5. 删除版本
|
||||
|
||||
**端点**: `DELETE /api/v1/versions/:id`
|
||||
|
||||
**请求示例**:
|
||||
|
||||
```typescript
|
||||
await apiClient.delete('/api/v1/versions/uuid-1234')
|
||||
```
|
||||
|
||||
**响应**: 无内容 (204)
|
||||
|
||||
---
|
||||
|
||||
### 6. 切换版本状态
|
||||
|
||||
**端点**: `PATCH /api/v1/versions/:id/toggle`
|
||||
|
||||
**请求体**:
|
||||
|
||||
```json
|
||||
{
|
||||
"isEnabled": true
|
||||
}
|
||||
```
|
||||
|
||||
**请求示例**:
|
||||
|
||||
```typescript
|
||||
await apiClient.patch('/api/v1/versions/uuid-1234/toggle', {
|
||||
isEnabled: false
|
||||
})
|
||||
```
|
||||
|
||||
**响应**: 无内容 (204)
|
||||
|
||||
---
|
||||
|
||||
### 7. 上传版本文件
|
||||
|
||||
**端点**: `POST /api/v1/versions/upload`
|
||||
|
||||
**请求类型**: `multipart/form-data`
|
||||
|
||||
**表单字段**:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| file | File | 是 | APK 或 IPA 文件 |
|
||||
| platform | string | 是 | `android` 或 `ios` |
|
||||
| versionName | string | 是 | 版本号,如 `1.0.0` |
|
||||
| buildNumber | string | 是 | 构建号,如 `100` |
|
||||
| changelog | string | 否 | 更新日志 |
|
||||
| isForceUpdate | boolean | 否 | 是否强制更新,默认 `false` |
|
||||
| minOsVersion | string | 否 | 最低系统版本 |
|
||||
| releaseDate | string | 否 | 发布日期 (ISO 8601) |
|
||||
|
||||
**请求示例**:
|
||||
|
||||
```typescript
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('platform', 'android')
|
||||
formData.append('versionName', '1.0.0')
|
||||
formData.append('buildNumber', '100')
|
||||
formData.append('changelog', '首次发布')
|
||||
formData.append('isForceUpdate', 'false')
|
||||
|
||||
const response = await apiClient.post('/api/v1/versions/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
timeout: 300000, // 5分钟超时,适用于大文件
|
||||
})
|
||||
```
|
||||
|
||||
**响应**: 创建的 `AppVersion` 对象
|
||||
|
||||
---
|
||||
|
||||
## 移动端检查更新 API
|
||||
|
||||
### 检查版本更新
|
||||
|
||||
**端点**: `GET /api/app/version/check`
|
||||
|
||||
> 注意:此端点不使用 `/api/v1` 前缀
|
||||
|
||||
**查询参数**:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| platform | string | 是 | `android` 或 `ios` |
|
||||
| currentVersion | string | 是 | 当前版本号,如 `1.0.0` |
|
||||
| currentBuild | string | 否 | 当前构建号 |
|
||||
|
||||
**请求示例**:
|
||||
|
||||
```bash
|
||||
GET /api/app/version/check?platform=android¤tVersion=1.0.0¤tBuild=100
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"hasUpdate": true,
|
||||
"isForceUpdate": false,
|
||||
"latestVersion": {
|
||||
"versionName": "1.1.0",
|
||||
"buildNumber": "110",
|
||||
"downloadUrl": "https://example.com/app-1.1.0.apk",
|
||||
"fileSize": "55000000",
|
||||
"changelog": "1. 新功能\n2. 性能优化",
|
||||
"minOsVersion": "8.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 错误响应格式
|
||||
|
||||
```json
|
||||
{
|
||||
"statusCode": 400,
|
||||
"message": "错误描述",
|
||||
"error": "Bad Request"
|
||||
}
|
||||
```
|
||||
|
||||
### 常见错误码
|
||||
|
||||
| 状态码 | 说明 |
|
||||
|--------|------|
|
||||
| 400 | 请求参数错误 |
|
||||
| 404 | 资源不存在 |
|
||||
| 409 | 资源冲突(如版本已存在) |
|
||||
| 413 | 文件过大 |
|
||||
| 500 | 服务器内部错误 |
|
||||
|
||||
### 前端错误处理
|
||||
|
||||
```typescript
|
||||
// API 客户端拦截器
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
const message = error.response?.data?.message || '请求失败'
|
||||
return Promise.reject(new Error(message))
|
||||
}
|
||||
)
|
||||
|
||||
// 组件中使用
|
||||
try {
|
||||
await deleteVersion(id)
|
||||
toast.success('删除成功')
|
||||
} catch (err) {
|
||||
toast.error(err.message || '操作失败')
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 数据类型定义
|
||||
|
||||
### AppVersion
|
||||
|
||||
```typescript
|
||||
interface AppVersion {
|
||||
id: string // UUID
|
||||
platform: 'android' | 'ios' // 平台
|
||||
versionCode: number // 版本代码(用于比较)
|
||||
versionName: string // 版本名称(显示用)
|
||||
buildNumber: string // 构建号
|
||||
downloadUrl: string // 下载地址
|
||||
fileSize: string // 文件大小(字节)
|
||||
fileSha256: string // 文件 SHA256 校验值
|
||||
changelog: string // 更新日志
|
||||
isForceUpdate: boolean // 是否强制更新
|
||||
isEnabled: boolean // 是否启用
|
||||
minOsVersion: string | null // 最低系统版本要求
|
||||
releaseDate: string | null // 发布日期
|
||||
createdAt: string // 创建时间
|
||||
updatedAt: string // 更新时间
|
||||
}
|
||||
```
|
||||
|
||||
### VersionListFilter
|
||||
|
||||
```typescript
|
||||
interface VersionListFilter {
|
||||
platform?: 'android' | 'ios'
|
||||
includeDisabled?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [架构文档](./ARCHITECTURE.md)
|
||||
- [开发指南](./DEVELOPMENT.md)
|
||||
- [测试指南](./TESTING.md)
|
||||
- [部署指南](./DEPLOYMENT.md)
|
||||
|
|
@ -0,0 +1,298 @@
|
|||
# Mobile Upgrade Admin - 架构文档
|
||||
|
||||
## 概述
|
||||
|
||||
Mobile Upgrade Admin 是一个用于管理移动应用版本升级的 Web 管理后台。本项目采用 **Clean Architecture(整洁架构)** 设计模式,实现了关注点分离、可测试性和可维护性。
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 分类 | 技术 | 版本 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 框架 | Next.js | 14.x | React 全栈框架,App Router |
|
||||
| 语言 | TypeScript | 5.x | 类型安全 |
|
||||
| 状态管理 | Zustand | 4.x | 轻量级状态管理 |
|
||||
| HTTP 客户端 | Axios | 1.x | API 请求 |
|
||||
| 样式 | Tailwind CSS | 3.x | 原子化 CSS |
|
||||
| 通知 | react-hot-toast | 2.x | Toast 通知 |
|
||||
|
||||
## 架构设计
|
||||
|
||||
### Clean Architecture 分层
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Presentation Layer │
|
||||
│ (UI Components, Pages) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Application Layer │
|
||||
│ (Stores, Hooks, Use Cases) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Domain Layer │
|
||||
│ (Entities, Repository Interfaces) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Infrastructure Layer │
|
||||
│ (API Client, Repository Implementations) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 目录结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── domain/ # 领域层 - 业务核心
|
||||
│ ├── entities/
|
||||
│ │ └── version.ts # 版本实体和类型定义
|
||||
│ ├── repositories/
|
||||
│ │ └── version-repository.ts # Repository 接口定义
|
||||
│ └── index.ts # 统一导出
|
||||
│
|
||||
├── infrastructure/ # 基础设施层 - 外部依赖
|
||||
│ ├── http/
|
||||
│ │ └── api-client.ts # Axios 客户端配置
|
||||
│ ├── repositories/
|
||||
│ │ └── version-repository-impl.ts # Repository 实现
|
||||
│ └── index.ts # 统一导出
|
||||
│
|
||||
├── application/ # 应用层 - 业务逻辑
|
||||
│ ├── stores/
|
||||
│ │ └── version-store.ts # Zustand 状态管理
|
||||
│ ├── hooks/
|
||||
│ │ └── use-versions.ts # React Hooks
|
||||
│ └── index.ts # 统一导出
|
||||
│
|
||||
├── presentation/ # 表示层 - UI 组件
|
||||
│ ├── components/
|
||||
│ │ ├── version-card.tsx # 版本卡片组件
|
||||
│ │ ├── upload-modal.tsx # 上传弹窗组件
|
||||
│ │ └── edit-modal.tsx # 编辑弹窗组件
|
||||
│ └── index.ts # 统一导出
|
||||
│
|
||||
└── app/ # Next.js App Router
|
||||
├── layout.tsx # 根布局
|
||||
├── page.tsx # 首页
|
||||
└── globals.css # 全局样式
|
||||
```
|
||||
|
||||
## 各层职责详解
|
||||
|
||||
### 1. Domain Layer(领域层)
|
||||
|
||||
**职责**:定义业务实体和接口,不依赖任何外部框架。
|
||||
|
||||
#### 实体定义 (`entities/version.ts`)
|
||||
|
||||
```typescript
|
||||
// 平台类型
|
||||
export type Platform = 'android' | 'ios'
|
||||
|
||||
// 应用版本实体
|
||||
export interface AppVersion {
|
||||
id: string
|
||||
platform: Platform
|
||||
versionCode: number
|
||||
versionName: string
|
||||
buildNumber: string
|
||||
downloadUrl: string
|
||||
fileSize: string
|
||||
fileSha256: string
|
||||
changelog: string
|
||||
isForceUpdate: boolean
|
||||
isEnabled: boolean
|
||||
minOsVersion: string | null
|
||||
releaseDate: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
// 输入/输出 DTO
|
||||
export interface CreateVersionInput { ... }
|
||||
export interface UpdateVersionInput { ... }
|
||||
export interface UploadVersionInput { ... }
|
||||
export interface VersionListFilter { ... }
|
||||
```
|
||||
|
||||
#### Repository 接口 (`repositories/version-repository.ts`)
|
||||
|
||||
```typescript
|
||||
export interface IVersionRepository {
|
||||
list(filter?: VersionListFilter): Promise<AppVersion[]>
|
||||
getById(id: string): Promise<AppVersion>
|
||||
create(input: CreateVersionInput): Promise<AppVersion>
|
||||
update(id: string, input: UpdateVersionInput): Promise<AppVersion>
|
||||
delete(id: string): Promise<void>
|
||||
toggle(id: string, isEnabled: boolean): Promise<void>
|
||||
upload(input: UploadVersionInput): Promise<AppVersion>
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Infrastructure Layer(基础设施层)
|
||||
|
||||
**职责**:实现与外部系统的交互(API、数据库等)。
|
||||
|
||||
#### API 客户端 (`http/api-client.ts`)
|
||||
|
||||
```typescript
|
||||
import axios from 'axios'
|
||||
|
||||
export const apiClient = axios.create({
|
||||
baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000',
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// 响应拦截器处理错误
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
const message = error.response?.data?.message || '请求失败'
|
||||
return Promise.reject(new Error(message))
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
#### Repository 实现 (`repositories/version-repository-impl.ts`)
|
||||
|
||||
```typescript
|
||||
export class VersionRepositoryImpl implements IVersionRepository {
|
||||
private client: AxiosInstance
|
||||
|
||||
constructor(client?: AxiosInstance) {
|
||||
this.client = client || apiClient
|
||||
}
|
||||
|
||||
async list(filter?: VersionListFilter): Promise<AppVersion[]> {
|
||||
const response = await this.client.get<AppVersion[]>('/api/v1/versions', { params: filter })
|
||||
return response.data
|
||||
}
|
||||
|
||||
async upload(input: UploadVersionInput): Promise<AppVersion> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', input.file)
|
||||
// ... 其他字段
|
||||
const response = await this.client.post<AppVersion>('/api/v1/versions/upload', formData)
|
||||
return response.data
|
||||
}
|
||||
// ... 其他方法
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Application Layer(应用层)
|
||||
|
||||
**职责**:协调业务逻辑,管理应用状态。
|
||||
|
||||
#### Zustand Store (`stores/version-store.ts`)
|
||||
|
||||
```typescript
|
||||
import { create } from 'zustand'
|
||||
|
||||
interface VersionState {
|
||||
// 状态
|
||||
versions: AppVersion[]
|
||||
selectedVersion: AppVersion | null
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
filter: { platform?: Platform; includeDisabled: boolean }
|
||||
|
||||
// Actions
|
||||
fetchVersions: () => Promise<void>
|
||||
createVersion: (input: CreateVersionInput) => Promise<AppVersion>
|
||||
updateVersion: (id: string, input: UpdateVersionInput) => Promise<AppVersion>
|
||||
deleteVersion: (id: string) => Promise<void>
|
||||
toggleVersion: (id: string, isEnabled: boolean) => Promise<void>
|
||||
uploadVersion: (input: UploadVersionInput) => Promise<AppVersion>
|
||||
}
|
||||
|
||||
export const useVersionStore = create<VersionState>((set, get) => ({
|
||||
// 实现...
|
||||
}))
|
||||
```
|
||||
|
||||
#### React Hooks (`hooks/use-versions.ts`)
|
||||
|
||||
```typescript
|
||||
// 获取版本列表
|
||||
export function useVersions() {
|
||||
const { versions, isLoading, error, fetchVersions, setFilter } = useVersionStore()
|
||||
|
||||
useEffect(() => {
|
||||
fetchVersions()
|
||||
}, [filter.platform])
|
||||
|
||||
return { versions, isLoading, error, refetch: fetchVersions, setFilter }
|
||||
}
|
||||
|
||||
// 获取单个版本
|
||||
export function useVersion(id: string | null) { ... }
|
||||
|
||||
// 版本操作
|
||||
export function useVersionActions() { ... }
|
||||
```
|
||||
|
||||
### 4. Presentation Layer(表示层)
|
||||
|
||||
**职责**:UI 渲染和用户交互。
|
||||
|
||||
#### 组件结构
|
||||
|
||||
```
|
||||
presentation/
|
||||
├── components/
|
||||
│ ├── version-card.tsx # 版本信息卡片
|
||||
│ ├── upload-modal.tsx # 上传新版本弹窗
|
||||
│ └── edit-modal.tsx # 编辑版本弹窗
|
||||
```
|
||||
|
||||
## 数据流
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ User │───▶│ Presentation│───▶│ Application │───▶│Infrastructure│
|
||||
│ Action │ │ Component │ │ Store │ │ Repository │
|
||||
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
|
||||
│ │ │
|
||||
│ │ ▼
|
||||
│ │ ┌─────────────┐
|
||||
│ │ │ Admin API │
|
||||
│ │ └─────────────┘
|
||||
│ │ │
|
||||
│ ◀───────────────────┘
|
||||
│ │
|
||||
◀───────────────────┘
|
||||
│
|
||||
┌─────────────┐
|
||||
│ UI Update │
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
## 依赖关系
|
||||
|
||||
```
|
||||
Presentation ──▶ Application ──▶ Domain ◀── Infrastructure
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
▼ ▼ ▼ ▼
|
||||
React Zustand 纯TS接口 Axios
|
||||
Next.js Hooks API实现
|
||||
```
|
||||
|
||||
**关键原则**:
|
||||
- Domain 层不依赖任何外部层
|
||||
- Infrastructure 依赖 Domain(实现接口)
|
||||
- Application 依赖 Domain(使用接口)
|
||||
- Presentation 依赖 Application(使用 Hooks/Store)
|
||||
|
||||
## 设计优势
|
||||
|
||||
1. **可测试性**:每层都可以独立测试,Infrastructure 可以被 Mock
|
||||
2. **可维护性**:关注点分离,修改一层不影响其他层
|
||||
3. **可扩展性**:新增功能只需在相应层添加代码
|
||||
4. **可替换性**:可以轻松替换技术实现(如 Axios → Fetch)
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [API 文档](./API.md)
|
||||
- [开发指南](./DEVELOPMENT.md)
|
||||
- [测试指南](./TESTING.md)
|
||||
- [部署指南](./DEPLOYMENT.md)
|
||||
|
|
@ -0,0 +1,569 @@
|
|||
# Mobile Upgrade Admin - 部署指南
|
||||
|
||||
## 部署概述
|
||||
|
||||
本文档描述 Mobile Upgrade Admin 前端应用的部署方案,包括本地构建、Docker 容器化和生产环境部署。
|
||||
|
||||
## 部署架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Nginx / Caddy │
|
||||
│ (Reverse Proxy) │
|
||||
└─────────────────┬───────────────────────┬───────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ Mobile Upgrade │ │ Admin Service │
|
||||
│ Frontend (3001)│ │ Backend (3000) │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
## 环境要求
|
||||
|
||||
| 环境 | 要求 |
|
||||
|------|------|
|
||||
| Node.js | >= 18.x |
|
||||
| npm | >= 9.x |
|
||||
| Docker | >= 20.x (可选) |
|
||||
| 内存 | >= 512MB |
|
||||
| 磁盘 | >= 500MB |
|
||||
|
||||
## 环境变量配置
|
||||
|
||||
### 开发环境
|
||||
|
||||
```bash
|
||||
# .env.local
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3000
|
||||
```
|
||||
|
||||
### 生产环境
|
||||
|
||||
```bash
|
||||
# .env.production
|
||||
NEXT_PUBLIC_API_URL=https://api.example.com
|
||||
```
|
||||
|
||||
| 变量 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| NEXT_PUBLIC_API_URL | Admin Service API 地址 | http://localhost:3000 |
|
||||
| PORT | 应用监听端口 | 3000 |
|
||||
|
||||
## 部署方式
|
||||
|
||||
### 方式一:本地构建部署
|
||||
|
||||
#### 1. 构建生产版本
|
||||
|
||||
```bash
|
||||
cd frontend/mobile-upgrade
|
||||
|
||||
# 安装依赖
|
||||
npm ci
|
||||
|
||||
# 构建
|
||||
npm run build
|
||||
```
|
||||
|
||||
构建产物在 `.next/` 目录。
|
||||
|
||||
#### 2. 启动生产服务
|
||||
|
||||
```bash
|
||||
# 使用 Node.js 直接运行
|
||||
npm run start
|
||||
|
||||
# 或指定端口
|
||||
PORT=3001 npm run start
|
||||
```
|
||||
|
||||
#### 3. 使用 PM2 管理
|
||||
|
||||
```bash
|
||||
# 安装 PM2
|
||||
npm install -g pm2
|
||||
|
||||
# 启动应用
|
||||
pm2 start npm --name "mobile-upgrade" -- start
|
||||
|
||||
# 查看状态
|
||||
pm2 status
|
||||
|
||||
# 查看日志
|
||||
pm2 logs mobile-upgrade
|
||||
|
||||
# 重启
|
||||
pm2 restart mobile-upgrade
|
||||
|
||||
# 停止
|
||||
pm2 stop mobile-upgrade
|
||||
```
|
||||
|
||||
PM2 配置文件:
|
||||
|
||||
```javascript
|
||||
// ecosystem.config.js
|
||||
module.exports = {
|
||||
apps: [{
|
||||
name: 'mobile-upgrade',
|
||||
script: 'npm',
|
||||
args: 'start',
|
||||
cwd: '/path/to/mobile-upgrade',
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
PORT: 3001,
|
||||
},
|
||||
instances: 1,
|
||||
autorestart: true,
|
||||
watch: false,
|
||||
max_memory_restart: '500M',
|
||||
}],
|
||||
}
|
||||
```
|
||||
|
||||
### 方式二:Docker 容器部署
|
||||
|
||||
#### 1. Dockerfile
|
||||
|
||||
```dockerfile
|
||||
# Dockerfile
|
||||
FROM node:18-alpine AS base
|
||||
|
||||
# 安装依赖阶段
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# 构建阶段
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# 设置环境变量
|
||||
ARG NEXT_PUBLIC_API_URL
|
||||
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# 运行阶段
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV production
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
# 复制构建产物
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT 3000
|
||||
ENV HOSTNAME "0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
```
|
||||
|
||||
#### 2. 更新 next.config.js
|
||||
|
||||
```javascript
|
||||
// next.config.js
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'standalone', // 启用独立输出模式
|
||||
// ... 其他配置
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
```
|
||||
|
||||
#### 3. 构建 Docker 镜像
|
||||
|
||||
```bash
|
||||
# 构建镜像
|
||||
docker build \
|
||||
--build-arg NEXT_PUBLIC_API_URL=https://api.example.com \
|
||||
-t mobile-upgrade:latest \
|
||||
.
|
||||
|
||||
# 运行容器
|
||||
docker run -d \
|
||||
--name mobile-upgrade \
|
||||
-p 3001:3000 \
|
||||
mobile-upgrade:latest
|
||||
```
|
||||
|
||||
#### 4. Docker Compose
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
mobile-upgrade:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://admin-service:3000}
|
||||
image: mobile-upgrade:latest
|
||||
container_name: mobile-upgrade
|
||||
ports:
|
||||
- "3001:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- rwa-network
|
||||
depends_on:
|
||||
- admin-service
|
||||
|
||||
networks:
|
||||
rwa-network:
|
||||
external: true
|
||||
```
|
||||
|
||||
启动命令:
|
||||
|
||||
```bash
|
||||
# 启动
|
||||
docker-compose up -d
|
||||
|
||||
# 查看日志
|
||||
docker-compose logs -f mobile-upgrade
|
||||
|
||||
# 停止
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
### 方式三:静态导出部署
|
||||
|
||||
适用于不需要 SSR 的场景。
|
||||
|
||||
#### 1. 配置静态导出
|
||||
|
||||
```javascript
|
||||
// next.config.js
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'export',
|
||||
trailingSlash: true,
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
```
|
||||
|
||||
#### 2. 构建静态文件
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
静态文件输出到 `out/` 目录。
|
||||
|
||||
#### 3. 使用 Nginx 部署
|
||||
|
||||
```nginx
|
||||
# nginx.conf
|
||||
server {
|
||||
listen 80;
|
||||
server_name upgrade.example.com;
|
||||
root /var/www/mobile-upgrade;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api {
|
||||
proxy_pass http://admin-service:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 反向代理配置
|
||||
|
||||
### Nginx 配置
|
||||
|
||||
```nginx
|
||||
# /etc/nginx/sites-available/mobile-upgrade
|
||||
upstream mobile_upgrade {
|
||||
server 127.0.0.1:3001;
|
||||
}
|
||||
|
||||
upstream admin_service {
|
||||
server 127.0.0.1:3000;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name upgrade.example.com;
|
||||
|
||||
# 前端应用
|
||||
location / {
|
||||
proxy_pass http://mobile_upgrade;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# API 代理
|
||||
location /api {
|
||||
proxy_pass http://admin_service;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 大文件上传支持
|
||||
client_max_body_size 200M;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 75s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Caddy 配置
|
||||
|
||||
```caddyfile
|
||||
# Caddyfile
|
||||
upgrade.example.com {
|
||||
reverse_proxy /api/* admin-service:3000
|
||||
reverse_proxy /* mobile-upgrade:3001
|
||||
}
|
||||
```
|
||||
|
||||
## 健康检查
|
||||
|
||||
### 应用健康检查
|
||||
|
||||
```typescript
|
||||
// src/app/api/health/route.ts
|
||||
export async function GET() {
|
||||
return Response.json({
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Docker 健康检查
|
||||
|
||||
```dockerfile
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
|
||||
```
|
||||
|
||||
### Kubernetes 探针
|
||||
|
||||
```yaml
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3000
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 30
|
||||
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
```
|
||||
|
||||
## CI/CD 集成
|
||||
|
||||
### GitHub Actions
|
||||
|
||||
```yaml
|
||||
# .github/workflows/deploy.yml
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'frontend/mobile-upgrade/**'
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/mobile-upgrade/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: frontend/mobile-upgrade
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
working-directory: frontend/mobile-upgrade
|
||||
run: npm run build
|
||||
env:
|
||||
NEXT_PUBLIC_API_URL: ${{ secrets.API_URL }}
|
||||
|
||||
- name: Build Docker image
|
||||
working-directory: frontend/mobile-upgrade
|
||||
run: |
|
||||
docker build \
|
||||
--build-arg NEXT_PUBLIC_API_URL=${{ secrets.API_URL }} \
|
||||
-t mobile-upgrade:${{ github.sha }} \
|
||||
.
|
||||
|
||||
- name: Push to registry
|
||||
run: |
|
||||
docker tag mobile-upgrade:${{ github.sha }} registry.example.com/mobile-upgrade:${{ github.sha }}
|
||||
docker push registry.example.com/mobile-upgrade:${{ github.sha }}
|
||||
|
||||
- name: Deploy to server
|
||||
uses: appleboy/ssh-action@master
|
||||
with:
|
||||
host: ${{ secrets.SERVER_HOST }}
|
||||
username: ${{ secrets.SERVER_USER }}
|
||||
key: ${{ secrets.SERVER_SSH_KEY }}
|
||||
script: |
|
||||
docker pull registry.example.com/mobile-upgrade:${{ github.sha }}
|
||||
docker stop mobile-upgrade || true
|
||||
docker rm mobile-upgrade || true
|
||||
docker run -d \
|
||||
--name mobile-upgrade \
|
||||
-p 3001:3000 \
|
||||
--restart unless-stopped \
|
||||
registry.example.com/mobile-upgrade:${{ github.sha }}
|
||||
```
|
||||
|
||||
## 监控和日志
|
||||
|
||||
### 日志收集
|
||||
|
||||
```bash
|
||||
# Docker 日志
|
||||
docker logs -f mobile-upgrade
|
||||
|
||||
# PM2 日志
|
||||
pm2 logs mobile-upgrade
|
||||
|
||||
# 日志文件位置
|
||||
/var/log/mobile-upgrade/access.log
|
||||
/var/log/mobile-upgrade/error.log
|
||||
```
|
||||
|
||||
### 性能监控
|
||||
|
||||
建议集成:
|
||||
- **Prometheus + Grafana**: 指标监控
|
||||
- **Sentry**: 错误追踪
|
||||
- **Datadog / New Relic**: APM
|
||||
|
||||
### 集成 Sentry
|
||||
|
||||
```bash
|
||||
npm install @sentry/nextjs
|
||||
```
|
||||
|
||||
```typescript
|
||||
// sentry.client.config.ts
|
||||
import * as Sentry from '@sentry/nextjs'
|
||||
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
tracesSampleRate: 0.1,
|
||||
})
|
||||
```
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 常见问题
|
||||
|
||||
| 问题 | 可能原因 | 解决方案 |
|
||||
|------|----------|----------|
|
||||
| 页面空白 | 构建失败 | 检查构建日志 |
|
||||
| API 请求失败 | CORS / 网络 | 检查代理配置 |
|
||||
| 静态资源 404 | 路径配置 | 检查 basePath 配置 |
|
||||
| 内存不足 | 并发过高 | 增加实例或内存 |
|
||||
|
||||
### 调试命令
|
||||
|
||||
```bash
|
||||
# 检查进程
|
||||
ps aux | grep node
|
||||
|
||||
# 检查端口
|
||||
netstat -tlnp | grep 3001
|
||||
|
||||
# 检查容器
|
||||
docker ps -a
|
||||
docker inspect mobile-upgrade
|
||||
|
||||
# 检查日志
|
||||
docker logs --tail 100 mobile-upgrade
|
||||
```
|
||||
|
||||
## 回滚策略
|
||||
|
||||
### Docker 回滚
|
||||
|
||||
```bash
|
||||
# 查看历史版本
|
||||
docker images mobile-upgrade
|
||||
|
||||
# 回滚到指定版本
|
||||
docker stop mobile-upgrade
|
||||
docker rm mobile-upgrade
|
||||
docker run -d \
|
||||
--name mobile-upgrade \
|
||||
-p 3001:3000 \
|
||||
mobile-upgrade:previous-tag
|
||||
```
|
||||
|
||||
### PM2 回滚
|
||||
|
||||
```bash
|
||||
# 保存当前配置
|
||||
pm2 save
|
||||
|
||||
# 回滚
|
||||
pm2 reload ecosystem.config.js --update-env
|
||||
```
|
||||
|
||||
## 安全建议
|
||||
|
||||
1. **环境变量**:不要在代码中硬编码敏感信息
|
||||
2. **HTTPS**:生产环境强制使用 HTTPS
|
||||
3. **CSP**:配置内容安全策略
|
||||
4. **更新依赖**:定期更新依赖修复安全漏洞
|
||||
5. **访问控制**:配置合适的访问权限
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [架构文档](./ARCHITECTURE.md)
|
||||
- [API 文档](./API.md)
|
||||
- [开发指南](./DEVELOPMENT.md)
|
||||
- [测试指南](./TESTING.md)
|
||||
|
|
@ -0,0 +1,417 @@
|
|||
# Mobile Upgrade Admin - 开发指南
|
||||
|
||||
## 环境要求
|
||||
|
||||
| 工具 | 版本 | 说明 |
|
||||
|------|------|------|
|
||||
| Node.js | >= 18.x | 运行环境 |
|
||||
| npm | >= 9.x | 包管理器 |
|
||||
| Git | >= 2.x | 版本控制 |
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 克隆项目
|
||||
|
||||
```bash
|
||||
git clone https://git.gdzx.xyz/hailin/rwadurian.git
|
||||
cd rwadurian/frontend/mobile-upgrade
|
||||
```
|
||||
|
||||
### 2. 安装依赖
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 3. 配置环境变量
|
||||
|
||||
```bash
|
||||
# 复制环境变量模板
|
||||
cp .env.local.example .env.local
|
||||
|
||||
# 编辑配置
|
||||
# NEXT_PUBLIC_API_URL=http://localhost:3000
|
||||
```
|
||||
|
||||
### 4. 启动开发服务器
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
访问 http://localhost:3000 查看应用。
|
||||
|
||||
## 项目脚本
|
||||
|
||||
```bash
|
||||
# 开发模式
|
||||
npm run dev
|
||||
|
||||
# 生产构建
|
||||
npm run build
|
||||
|
||||
# 启动生产服务
|
||||
npm run start
|
||||
|
||||
# 代码检查
|
||||
npm run lint
|
||||
|
||||
# 类型检查
|
||||
npm run type-check
|
||||
```
|
||||
|
||||
## 开发规范
|
||||
|
||||
### 目录结构规范
|
||||
|
||||
```
|
||||
src/
|
||||
├── domain/ # 领域层:纯 TypeScript,无框架依赖
|
||||
├── infrastructure/ # 基础设施层:外部服务交互
|
||||
├── application/ # 应用层:业务逻辑和状态管理
|
||||
├── presentation/ # 表示层:React 组件
|
||||
└── app/ # Next.js 页面路由
|
||||
```
|
||||
|
||||
### 命名规范
|
||||
|
||||
| 类型 | 规范 | 示例 |
|
||||
|------|------|------|
|
||||
| 文件名 | kebab-case | `version-card.tsx` |
|
||||
| 组件名 | PascalCase | `VersionCard` |
|
||||
| 函数名 | camelCase | `fetchVersions` |
|
||||
| 常量 | UPPER_SNAKE_CASE | `API_BASE_URL` |
|
||||
| 类型/接口 | PascalCase | `AppVersion` |
|
||||
|
||||
### 代码风格
|
||||
|
||||
项目使用 ESLint + Prettier 进行代码规范检查。
|
||||
|
||||
```json
|
||||
// .eslintrc.json
|
||||
{
|
||||
"extends": ["next/core-web-vitals"]
|
||||
}
|
||||
```
|
||||
|
||||
## 开发流程
|
||||
|
||||
### 1. 新增功能开发流程
|
||||
|
||||
```
|
||||
1. 在 Domain 层定义实体和接口
|
||||
2. 在 Infrastructure 层实现 API 调用
|
||||
3. 在 Application 层添加状态管理逻辑
|
||||
4. 在 Presentation 层创建 UI 组件
|
||||
5. 编写测试用例
|
||||
6. 提交代码
|
||||
```
|
||||
|
||||
### 2. 示例:添加新的 API 功能
|
||||
|
||||
#### Step 1: Domain 层 - 定义类型
|
||||
|
||||
```typescript
|
||||
// src/domain/entities/version.ts
|
||||
export interface BatchDeleteInput {
|
||||
ids: string[]
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 2: Domain 层 - 定义接口
|
||||
|
||||
```typescript
|
||||
// src/domain/repositories/version-repository.ts
|
||||
export interface IVersionRepository {
|
||||
// ... 现有方法
|
||||
batchDelete(input: BatchDeleteInput): Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 3: Infrastructure 层 - 实现接口
|
||||
|
||||
```typescript
|
||||
// src/infrastructure/repositories/version-repository-impl.ts
|
||||
export class VersionRepositoryImpl implements IVersionRepository {
|
||||
// ... 现有方法
|
||||
|
||||
async batchDelete(input: BatchDeleteInput): Promise<void> {
|
||||
await this.client.post('/api/v1/versions/batch-delete', input)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 4: Application 层 - 添加状态管理
|
||||
|
||||
```typescript
|
||||
// src/application/stores/version-store.ts
|
||||
interface VersionState {
|
||||
// ... 现有状态
|
||||
batchDeleteVersions: (ids: string[]) => Promise<void>
|
||||
}
|
||||
|
||||
export const useVersionStore = create<VersionState>((set, get) => ({
|
||||
// ... 现有实现
|
||||
|
||||
batchDeleteVersions: async (ids: string[]) => {
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
await versionRepository.batchDelete({ ids })
|
||||
const { versions } = get()
|
||||
set({
|
||||
versions: versions.filter((v) => !ids.includes(v.id)),
|
||||
isLoading: false,
|
||||
})
|
||||
} catch (err) {
|
||||
set({
|
||||
error: err instanceof Error ? err.message : 'Failed to delete',
|
||||
isLoading: false,
|
||||
})
|
||||
throw err
|
||||
}
|
||||
},
|
||||
}))
|
||||
```
|
||||
|
||||
#### Step 5: Application 层 - 添加 Hook
|
||||
|
||||
```typescript
|
||||
// src/application/hooks/use-versions.ts
|
||||
export function useVersionActions() {
|
||||
const { batchDeleteVersions, ... } = useVersionStore()
|
||||
|
||||
return {
|
||||
batchDelete: batchDeleteVersions,
|
||||
// ... 其他方法
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 6: Presentation 层 - 使用
|
||||
|
||||
```typescript
|
||||
// src/presentation/components/batch-actions.tsx
|
||||
export function BatchActions({ selectedIds }: { selectedIds: string[] }) {
|
||||
const { batchDelete } = useVersionActions()
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
try {
|
||||
await batchDelete(selectedIds)
|
||||
toast.success('批量删除成功')
|
||||
} catch (err) {
|
||||
toast.error('批量删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button onClick={handleBatchDelete}>
|
||||
批量删除 ({selectedIds.length})
|
||||
</button>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## 状态管理
|
||||
|
||||
### Zustand Store 设计
|
||||
|
||||
```typescript
|
||||
// 状态结构
|
||||
interface VersionState {
|
||||
// 数据状态
|
||||
versions: AppVersion[]
|
||||
selectedVersion: AppVersion | null
|
||||
|
||||
// UI 状态
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
|
||||
// 筛选状态
|
||||
filter: {
|
||||
platform?: Platform
|
||||
includeDisabled: boolean
|
||||
}
|
||||
|
||||
// Actions
|
||||
fetchVersions: () => Promise<void>
|
||||
setFilter: (filter: Partial<VersionState['filter']>) => void
|
||||
clearError: () => void
|
||||
}
|
||||
```
|
||||
|
||||
### 使用 Hooks
|
||||
|
||||
```typescript
|
||||
// 在组件中使用
|
||||
function VersionList() {
|
||||
const { versions, isLoading, error, refetch, setFilter } = useVersions()
|
||||
const { deleteVersion, toggleVersion } = useVersionActions()
|
||||
|
||||
// 筛选
|
||||
const handleFilterChange = (platform: Platform | 'all') => {
|
||||
setFilter({ platform: platform === 'all' ? undefined : platform })
|
||||
}
|
||||
|
||||
// 删除
|
||||
const handleDelete = async (id: string) => {
|
||||
await deleteVersion(id)
|
||||
refetch()
|
||||
}
|
||||
|
||||
return (/* JSX */)
|
||||
}
|
||||
```
|
||||
|
||||
## 组件开发
|
||||
|
||||
### 组件结构规范
|
||||
|
||||
```typescript
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useVersions } from '@/application'
|
||||
import { AppVersion } from '@/domain'
|
||||
|
||||
// Props 接口定义
|
||||
interface VersionCardProps {
|
||||
version: AppVersion
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
// 组件实现
|
||||
export function VersionCard({ version, onEdit, onDelete }: VersionCardProps) {
|
||||
// Hooks
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
|
||||
// 事件处理
|
||||
const handleToggle = () => {
|
||||
setIsExpanded(!isExpanded)
|
||||
}
|
||||
|
||||
// 渲染
|
||||
return (
|
||||
<div className="card">
|
||||
{/* 组件内容 */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 样式规范
|
||||
|
||||
使用 Tailwind CSS 原子类:
|
||||
|
||||
```tsx
|
||||
// 推荐
|
||||
<button className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||
提交
|
||||
</button>
|
||||
|
||||
// 复用样式定义在 globals.css
|
||||
<button className="btn btn-primary">提交</button>
|
||||
```
|
||||
|
||||
全局样式定义:
|
||||
|
||||
```css
|
||||
/* src/app/globals.css */
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply px-4 py-2 rounded-lg font-medium transition-colors;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply bg-blue-600 text-white hover:bg-blue-700;
|
||||
}
|
||||
.card {
|
||||
@apply bg-white rounded-lg shadow-sm border p-6;
|
||||
}
|
||||
.input {
|
||||
@apply w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 调试技巧
|
||||
|
||||
### 1. React DevTools
|
||||
|
||||
安装浏览器扩展查看组件树和状态。
|
||||
|
||||
### 2. Zustand DevTools
|
||||
|
||||
```typescript
|
||||
import { devtools } from 'zustand/middleware'
|
||||
|
||||
export const useVersionStore = create<VersionState>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
// store 实现
|
||||
}),
|
||||
{ name: 'version-store' }
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
### 3. API 请求调试
|
||||
|
||||
```typescript
|
||||
// 在 api-client.ts 添加请求日志
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
console.log('API Request:', config.method?.toUpperCase(), config.url)
|
||||
return config
|
||||
})
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
console.log('API Response:', response.status, response.config.url)
|
||||
return response
|
||||
},
|
||||
(error) => {
|
||||
console.error('API Error:', error.response?.status, error.config.url)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 1. 类型错误
|
||||
|
||||
确保导入路径使用别名:
|
||||
|
||||
```typescript
|
||||
// 正确
|
||||
import { AppVersion } from '@/domain'
|
||||
|
||||
// 错误
|
||||
import { AppVersion } from '../../../domain/entities/version'
|
||||
```
|
||||
|
||||
### 2. 环境变量不生效
|
||||
|
||||
Next.js 要求客户端环境变量以 `NEXT_PUBLIC_` 开头:
|
||||
|
||||
```bash
|
||||
# 正确 - 客户端可访问
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3000
|
||||
|
||||
# 错误 - 仅服务端可访问
|
||||
API_URL=http://localhost:3000
|
||||
```
|
||||
|
||||
### 3. 热更新不工作
|
||||
|
||||
```bash
|
||||
# 删除 .next 缓存目录
|
||||
rm -rf .next
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [架构文档](./ARCHITECTURE.md)
|
||||
- [API 文档](./API.md)
|
||||
- [测试指南](./TESTING.md)
|
||||
- [部署指南](./DEPLOYMENT.md)
|
||||
|
|
@ -0,0 +1,706 @@
|
|||
# Mobile Upgrade Admin - 测试指南
|
||||
|
||||
## 测试架构概述
|
||||
|
||||
本项目采用分层测试策略,针对 Clean Architecture 的每一层设计相应的测试方案。
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ E2E Tests │
|
||||
│ (Playwright / Cypress) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Integration Tests │
|
||||
│ (React Testing Library) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Unit Tests │
|
||||
│ (Jest / Vitest) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 测试类型
|
||||
|
||||
| 测试类型 | 目标层 | 工具 | 覆盖率目标 |
|
||||
|----------|--------|------|------------|
|
||||
| 单元测试 | Domain, Application | Jest/Vitest | 80% |
|
||||
| 集成测试 | Infrastructure, Presentation | React Testing Library | 70% |
|
||||
| E2E 测试 | 全链路 | Playwright | 关键路径 |
|
||||
|
||||
## 测试环境配置
|
||||
|
||||
### 安装测试依赖
|
||||
|
||||
```bash
|
||||
npm install -D jest @types/jest ts-jest
|
||||
npm install -D @testing-library/react @testing-library/jest-dom
|
||||
npm install -D @testing-library/user-event
|
||||
npm install -D msw # Mock Service Worker
|
||||
npm install -D playwright @playwright/test # E2E 测试
|
||||
```
|
||||
|
||||
### Jest 配置
|
||||
|
||||
```javascript
|
||||
// jest.config.js
|
||||
const nextJest = require('next/jest')
|
||||
|
||||
const createJestConfig = nextJest({
|
||||
dir: './',
|
||||
})
|
||||
|
||||
const customJestConfig = {
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
||||
testEnvironment: 'jest-environment-jsdom',
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
},
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.{ts,tsx}',
|
||||
'!src/**/*.d.ts',
|
||||
'!src/app/**/*',
|
||||
],
|
||||
coverageThreshold: {
|
||||
global: {
|
||||
branches: 70,
|
||||
functions: 70,
|
||||
lines: 70,
|
||||
statements: 70,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = createJestConfig(customJestConfig)
|
||||
```
|
||||
|
||||
### Jest Setup
|
||||
|
||||
```javascript
|
||||
// jest.setup.js
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
// Mock next/navigation
|
||||
jest.mock('next/navigation', () => ({
|
||||
useRouter() {
|
||||
return {
|
||||
push: jest.fn(),
|
||||
replace: jest.fn(),
|
||||
prefetch: jest.fn(),
|
||||
}
|
||||
},
|
||||
usePathname() {
|
||||
return ''
|
||||
},
|
||||
}))
|
||||
```
|
||||
|
||||
## 单元测试
|
||||
|
||||
### Domain 层测试
|
||||
|
||||
Domain 层是纯 TypeScript,测试最简单。
|
||||
|
||||
```typescript
|
||||
// src/domain/entities/__tests__/version.test.ts
|
||||
import { AppVersion, Platform } from '../version'
|
||||
|
||||
describe('AppVersion Entity', () => {
|
||||
const mockVersion: AppVersion = {
|
||||
id: 'test-id',
|
||||
platform: 'android',
|
||||
versionCode: 100,
|
||||
versionName: '1.0.0',
|
||||
buildNumber: '100',
|
||||
downloadUrl: 'https://example.com/app.apk',
|
||||
fileSize: '52428800',
|
||||
fileSha256: 'abc123',
|
||||
changelog: 'Test changelog',
|
||||
isForceUpdate: false,
|
||||
isEnabled: true,
|
||||
minOsVersion: '8.0',
|
||||
releaseDate: '2024-01-01',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
}
|
||||
|
||||
it('should have correct platform type', () => {
|
||||
expect(['android', 'ios']).toContain(mockVersion.platform)
|
||||
})
|
||||
|
||||
it('should have valid version code', () => {
|
||||
expect(mockVersion.versionCode).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Application 层测试
|
||||
|
||||
测试 Zustand Store 和 Hooks。
|
||||
|
||||
```typescript
|
||||
// src/application/stores/__tests__/version-store.test.ts
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useVersionStore } from '../version-store'
|
||||
|
||||
// Mock repository
|
||||
jest.mock('@/infrastructure', () => ({
|
||||
versionRepository: {
|
||||
list: jest.fn(),
|
||||
getById: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
toggle: jest.fn(),
|
||||
upload: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { versionRepository } from '@/infrastructure'
|
||||
|
||||
const mockVersionRepository = versionRepository as jest.Mocked<typeof versionRepository>
|
||||
|
||||
describe('useVersionStore', () => {
|
||||
beforeEach(() => {
|
||||
// Reset store state
|
||||
useVersionStore.setState({
|
||||
versions: [],
|
||||
selectedVersion: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
filter: { includeDisabled: true },
|
||||
})
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('fetchVersions', () => {
|
||||
it('should fetch versions successfully', async () => {
|
||||
const mockVersions = [
|
||||
{ id: '1', versionName: '1.0.0', platform: 'android' },
|
||||
{ id: '2', versionName: '1.0.1', platform: 'android' },
|
||||
]
|
||||
mockVersionRepository.list.mockResolvedValue(mockVersions as any)
|
||||
|
||||
const { result } = renderHook(() => useVersionStore())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchVersions()
|
||||
})
|
||||
|
||||
expect(result.current.versions).toEqual(mockVersions)
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle fetch error', async () => {
|
||||
mockVersionRepository.list.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const { result } = renderHook(() => useVersionStore())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchVersions()
|
||||
})
|
||||
|
||||
expect(result.current.versions).toEqual([])
|
||||
expect(result.current.error).toBe('Network error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteVersion', () => {
|
||||
it('should delete version and update list', async () => {
|
||||
useVersionStore.setState({
|
||||
versions: [
|
||||
{ id: '1', versionName: '1.0.0' },
|
||||
{ id: '2', versionName: '1.0.1' },
|
||||
] as any,
|
||||
})
|
||||
mockVersionRepository.delete.mockResolvedValue()
|
||||
|
||||
const { result } = renderHook(() => useVersionStore())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteVersion('1')
|
||||
})
|
||||
|
||||
expect(result.current.versions).toHaveLength(1)
|
||||
expect(result.current.versions[0].id).toBe('2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toggleVersion', () => {
|
||||
it('should toggle version enabled status', async () => {
|
||||
useVersionStore.setState({
|
||||
versions: [
|
||||
{ id: '1', versionName: '1.0.0', isEnabled: true },
|
||||
] as any,
|
||||
})
|
||||
mockVersionRepository.toggle.mockResolvedValue()
|
||||
|
||||
const { result } = renderHook(() => useVersionStore())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.toggleVersion('1', false)
|
||||
})
|
||||
|
||||
expect(result.current.versions[0].isEnabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setFilter', () => {
|
||||
it('should update filter', () => {
|
||||
const { result } = renderHook(() => useVersionStore())
|
||||
|
||||
act(() => {
|
||||
result.current.setFilter({ platform: 'ios' })
|
||||
})
|
||||
|
||||
expect(result.current.filter.platform).toBe('ios')
|
||||
})
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Hooks 测试
|
||||
|
||||
```typescript
|
||||
// src/application/hooks/__tests__/use-versions.test.ts
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { useVersions, useVersionActions } from '../use-versions'
|
||||
|
||||
jest.mock('../stores/version-store')
|
||||
|
||||
describe('useVersions', () => {
|
||||
it('should return versions and loading state', async () => {
|
||||
const { result } = renderHook(() => useVersions())
|
||||
|
||||
expect(result.current.versions).toBeDefined()
|
||||
expect(result.current.isLoading).toBeDefined()
|
||||
expect(result.current.error).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useVersionActions', () => {
|
||||
it('should return action functions', () => {
|
||||
const { result } = renderHook(() => useVersionActions())
|
||||
|
||||
expect(result.current.deleteVersion).toBeInstanceOf(Function)
|
||||
expect(result.current.toggleVersion).toBeInstanceOf(Function)
|
||||
expect(result.current.uploadVersion).toBeInstanceOf(Function)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## 集成测试
|
||||
|
||||
### Infrastructure 层测试
|
||||
|
||||
使用 MSW (Mock Service Worker) 模拟 API。
|
||||
|
||||
```typescript
|
||||
// src/infrastructure/repositories/__tests__/version-repository-impl.test.ts
|
||||
import { rest } from 'msw'
|
||||
import { setupServer } from 'msw/node'
|
||||
import { VersionRepositoryImpl } from '../version-repository-impl'
|
||||
|
||||
const server = setupServer(
|
||||
rest.get('*/api/v1/versions', (req, res, ctx) => {
|
||||
return res(
|
||||
ctx.json([
|
||||
{ id: '1', versionName: '1.0.0', platform: 'android' },
|
||||
])
|
||||
)
|
||||
}),
|
||||
rest.post('*/api/v1/versions', (req, res, ctx) => {
|
||||
return res(ctx.json({ id: 'new-id', ...req.body }))
|
||||
}),
|
||||
rest.delete('*/api/v1/versions/:id', (req, res, ctx) => {
|
||||
return res(ctx.status(204))
|
||||
})
|
||||
)
|
||||
|
||||
beforeAll(() => server.listen())
|
||||
afterEach(() => server.resetHandlers())
|
||||
afterAll(() => server.close())
|
||||
|
||||
describe('VersionRepositoryImpl', () => {
|
||||
const repository = new VersionRepositoryImpl()
|
||||
|
||||
describe('list', () => {
|
||||
it('should fetch versions list', async () => {
|
||||
const versions = await repository.list()
|
||||
expect(versions).toHaveLength(1)
|
||||
expect(versions[0].versionName).toBe('1.0.0')
|
||||
})
|
||||
|
||||
it('should apply platform filter', async () => {
|
||||
server.use(
|
||||
rest.get('*/api/v1/versions', (req, res, ctx) => {
|
||||
const platform = req.url.searchParams.get('platform')
|
||||
expect(platform).toBe('ios')
|
||||
return res(ctx.json([]))
|
||||
})
|
||||
)
|
||||
|
||||
await repository.list({ platform: 'ios' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete version by id', async () => {
|
||||
await expect(repository.delete('1')).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Presentation 层测试
|
||||
|
||||
```typescript
|
||||
// src/presentation/components/__tests__/version-card.test.tsx
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { VersionCard } from '../version-card'
|
||||
|
||||
const mockVersion = {
|
||||
id: '1',
|
||||
platform: 'android' as const,
|
||||
versionCode: 100,
|
||||
versionName: '1.0.0',
|
||||
buildNumber: '100',
|
||||
downloadUrl: 'https://example.com/app.apk',
|
||||
fileSize: '52428800',
|
||||
fileSha256: 'abc123',
|
||||
changelog: 'Test changelog',
|
||||
isForceUpdate: false,
|
||||
isEnabled: true,
|
||||
minOsVersion: '8.0',
|
||||
releaseDate: '2024-01-01',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
}
|
||||
|
||||
describe('VersionCard', () => {
|
||||
const mockOnEdit = jest.fn()
|
||||
const mockOnDelete = jest.fn()
|
||||
const mockOnToggle = jest.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render version information', () => {
|
||||
render(
|
||||
<VersionCard
|
||||
version={mockVersion}
|
||||
onEdit={mockOnEdit}
|
||||
onDelete={mockOnDelete}
|
||||
onToggle={mockOnToggle}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('v1.0.0')).toBeInTheDocument()
|
||||
expect(screen.getByText('android')).toBeInTheDocument()
|
||||
expect(screen.getByText('(Build 100)')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show force update badge when isForceUpdate is true', () => {
|
||||
render(
|
||||
<VersionCard
|
||||
version={{ ...mockVersion, isForceUpdate: true }}
|
||||
onEdit={mockOnEdit}
|
||||
onDelete={mockOnDelete}
|
||||
onToggle={mockOnToggle}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('强制更新')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onEdit when edit button clicked', () => {
|
||||
render(
|
||||
<VersionCard
|
||||
version={mockVersion}
|
||||
onEdit={mockOnEdit}
|
||||
onDelete={mockOnDelete}
|
||||
onToggle={mockOnToggle}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('编辑'))
|
||||
expect(mockOnEdit).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onDelete when delete button clicked', () => {
|
||||
render(
|
||||
<VersionCard
|
||||
version={mockVersion}
|
||||
onEdit={mockOnEdit}
|
||||
onDelete={mockOnDelete}
|
||||
onToggle={mockOnToggle}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('删除'))
|
||||
expect(mockOnDelete).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onToggle when toggle button clicked', () => {
|
||||
render(
|
||||
<VersionCard
|
||||
version={mockVersion}
|
||||
onEdit={mockOnEdit}
|
||||
onDelete={mockOnDelete}
|
||||
onToggle={mockOnToggle}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('禁用'))
|
||||
expect(mockOnToggle).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Upload Modal 测试
|
||||
|
||||
```typescript
|
||||
// src/presentation/components/__tests__/upload-modal.test.tsx
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { UploadModal } from '../upload-modal'
|
||||
|
||||
// Mock hooks
|
||||
jest.mock('@/application', () => ({
|
||||
useVersionActions: () => ({
|
||||
uploadVersion: jest.fn().mockResolvedValue({ id: 'new-id' }),
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('UploadModal', () => {
|
||||
const mockOnClose = jest.fn()
|
||||
const mockOnSuccess = jest.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render upload form', () => {
|
||||
render(<UploadModal onClose={mockOnClose} onSuccess={mockOnSuccess} />)
|
||||
|
||||
expect(screen.getByText('上传新版本')).toBeInTheDocument()
|
||||
expect(screen.getByText('点击选择 APK 或 IPA 文件')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onClose when close button clicked', () => {
|
||||
render(<UploadModal onClose={mockOnClose} onSuccess={mockOnSuccess} />)
|
||||
|
||||
fireEvent.click(screen.getByText('取消'))
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should show error when submitting without file', async () => {
|
||||
render(<UploadModal onClose={mockOnClose} onSuccess={mockOnSuccess} />)
|
||||
|
||||
fireEvent.click(screen.getByText('上传'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('请选择安装包文件')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should auto-detect platform from file extension', async () => {
|
||||
render(<UploadModal onClose={mockOnClose} onSuccess={mockOnSuccess} />)
|
||||
|
||||
const file = new File([''], 'app.apk', { type: 'application/vnd.android.package-archive' })
|
||||
const input = screen.getByRole('textbox', { hidden: true }) || document.querySelector('input[type="file"]')
|
||||
|
||||
// Simulate file selection
|
||||
if (input) {
|
||||
await userEvent.upload(input, file)
|
||||
}
|
||||
|
||||
// Android should be selected
|
||||
const androidRadio = screen.getByLabelText('Android') as HTMLInputElement
|
||||
expect(androidRadio.checked).toBe(true)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## E2E 测试
|
||||
|
||||
### Playwright 配置
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
baseURL: 'http://localhost:3000',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### E2E 测试用例
|
||||
|
||||
```typescript
|
||||
// e2e/version-management.spec.ts
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Version Management', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
})
|
||||
|
||||
test('should display version list', async ({ page }) => {
|
||||
await expect(page.getByText('版本管理')).toBeVisible()
|
||||
await expect(page.getByText('管理移动应用的版本更新')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should filter by platform', async ({ page }) => {
|
||||
// Click Android filter
|
||||
await page.click('button:has-text("Android")')
|
||||
|
||||
// Verify filter is applied
|
||||
const androidButton = page.locator('button:has-text("Android")')
|
||||
await expect(androidButton).toHaveClass(/bg-green-600/)
|
||||
})
|
||||
|
||||
test('should open upload modal', async ({ page }) => {
|
||||
await page.click('button:has-text("上传新版本")')
|
||||
|
||||
await expect(page.getByText('上传新版本')).toBeVisible()
|
||||
await expect(page.getByText('点击选择 APK 或 IPA 文件')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should close upload modal', async ({ page }) => {
|
||||
await page.click('button:has-text("上传新版本")')
|
||||
await page.click('button:has-text("取消")')
|
||||
|
||||
await expect(page.locator('.fixed.inset-0')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('should show validation error on empty submit', async ({ page }) => {
|
||||
await page.click('button:has-text("上传新版本")')
|
||||
await page.click('button:has-text("上传"):not(:has-text("新版本"))')
|
||||
|
||||
await expect(page.getByText('请选择安装包文件')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Version Card Actions', () => {
|
||||
test('should show confirmation on delete', async ({ page }) => {
|
||||
// Assuming there's at least one version
|
||||
await page.goto('/')
|
||||
|
||||
// Mock confirm dialog
|
||||
page.on('dialog', async dialog => {
|
||||
expect(dialog.message()).toContain('确定要删除')
|
||||
await dialog.dismiss()
|
||||
})
|
||||
|
||||
// Click first delete button
|
||||
const deleteButton = page.locator('button:has-text("删除")').first()
|
||||
if (await deleteButton.isVisible()) {
|
||||
await deleteButton.click()
|
||||
}
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## 手动测试清单
|
||||
|
||||
### 功能测试
|
||||
|
||||
| 功能 | 测试步骤 | 预期结果 |
|
||||
|------|----------|----------|
|
||||
| 版本列表 | 打开首页 | 显示所有版本列表 |
|
||||
| 平台筛选 | 点击 Android/iOS 按钮 | 只显示对应平台版本 |
|
||||
| 上传版本 | 点击上传 → 选择文件 → 填写信息 → 提交 | 版本创建成功,列表更新 |
|
||||
| 编辑版本 | 点击编辑 → 修改信息 → 保存 | 版本信息更新 |
|
||||
| 删除版本 | 点击删除 → 确认 | 版本从列表移除 |
|
||||
| 启用/禁用 | 点击启用/禁用按钮 | 状态切换 |
|
||||
| 下载 | 点击下载按钮 | 文件开始下载 |
|
||||
|
||||
### 边界测试
|
||||
|
||||
| 场景 | 测试步骤 | 预期结果 |
|
||||
|------|----------|----------|
|
||||
| 空列表 | 无版本数据 | 显示"暂无版本数据"提示 |
|
||||
| 网络错误 | 断开网络后操作 | 显示错误提示 |
|
||||
| 大文件上传 | 上传 100MB+ 文件 | 上传成功或显示超时提示 |
|
||||
| 并发操作 | 快速连续点击 | 不出现重复操作 |
|
||||
|
||||
### UI/UX 测试
|
||||
|
||||
| 检查项 | 预期 |
|
||||
|--------|------|
|
||||
| 响应式布局 | 移动端/桌面端正常显示 |
|
||||
| 加载状态 | 操作时显示 loading |
|
||||
| 错误提示 | Toast 提示位置正确 |
|
||||
| 弹窗交互 | 点击遮罩可关闭 |
|
||||
|
||||
## 运行测试
|
||||
|
||||
```bash
|
||||
# 运行所有单元测试
|
||||
npm test
|
||||
|
||||
# 运行测试并生成覆盖率报告
|
||||
npm test -- --coverage
|
||||
|
||||
# 运行特定测试文件
|
||||
npm test -- version-store.test.ts
|
||||
|
||||
# 运行 E2E 测试
|
||||
npx playwright test
|
||||
|
||||
# 运行 E2E 测试(带 UI)
|
||||
npx playwright test --ui
|
||||
|
||||
# 查看 E2E 测试报告
|
||||
npx playwright show-report
|
||||
```
|
||||
|
||||
## CI/CD 集成
|
||||
|
||||
```yaml
|
||||
# .github/workflows/test.yml
|
||||
name: Test
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
- run: npm test -- --coverage
|
||||
- run: npx playwright install --with-deps
|
||||
- run: npx playwright test
|
||||
```
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [架构文档](./ARCHITECTURE.md)
|
||||
- [API 文档](./API.md)
|
||||
- [开发指南](./DEVELOPMENT.md)
|
||||
- [部署指南](./DEPLOYMENT.md)
|
||||
Loading…
Reference in New Issue