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:
Developer 2025-12-02 22:46:42 -08:00
parent 6db948099c
commit 08485d361f
5 changed files with 2378 additions and 0 deletions

View File

@ -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&currentVersion=1.0.0&currentBuild=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)

View File

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

View File

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

View File

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

View File

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