diff --git a/frontend/mobile-upgrade/docs/API.md b/frontend/mobile-upgrade/docs/API.md new file mode 100644 index 00000000..0f948b82 --- /dev/null +++ b/frontend/mobile-upgrade/docs/API.md @@ -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) diff --git a/frontend/mobile-upgrade/docs/ARCHITECTURE.md b/frontend/mobile-upgrade/docs/ARCHITECTURE.md new file mode 100644 index 00000000..7e08b5ee --- /dev/null +++ b/frontend/mobile-upgrade/docs/ARCHITECTURE.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 + getById(id: string): Promise + create(input: CreateVersionInput): Promise + update(id: string, input: UpdateVersionInput): Promise + delete(id: string): Promise + toggle(id: string, isEnabled: boolean): Promise + upload(input: UploadVersionInput): Promise +} +``` + +### 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 { + const response = await this.client.get('/api/v1/versions', { params: filter }) + return response.data + } + + async upload(input: UploadVersionInput): Promise { + const formData = new FormData() + formData.append('file', input.file) + // ... 其他字段 + const response = await this.client.post('/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 + createVersion: (input: CreateVersionInput) => Promise + updateVersion: (id: string, input: UpdateVersionInput) => Promise + deleteVersion: (id: string) => Promise + toggleVersion: (id: string, isEnabled: boolean) => Promise + uploadVersion: (input: UploadVersionInput) => Promise +} + +export const useVersionStore = create((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) diff --git a/frontend/mobile-upgrade/docs/DEPLOYMENT.md b/frontend/mobile-upgrade/docs/DEPLOYMENT.md new file mode 100644 index 00000000..2101902a --- /dev/null +++ b/frontend/mobile-upgrade/docs/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) diff --git a/frontend/mobile-upgrade/docs/DEVELOPMENT.md b/frontend/mobile-upgrade/docs/DEVELOPMENT.md new file mode 100644 index 00000000..b7da8545 --- /dev/null +++ b/frontend/mobile-upgrade/docs/DEVELOPMENT.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 +} +``` + +#### Step 3: Infrastructure 层 - 实现接口 + +```typescript +// src/infrastructure/repositories/version-repository-impl.ts +export class VersionRepositoryImpl implements IVersionRepository { + // ... 现有方法 + + async batchDelete(input: BatchDeleteInput): Promise { + 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 +} + +export const useVersionStore = create((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 ( + + ) +} +``` + +## 状态管理 + +### Zustand Store 设计 + +```typescript +// 状态结构 +interface VersionState { + // 数据状态 + versions: AppVersion[] + selectedVersion: AppVersion | null + + // UI 状态 + isLoading: boolean + error: string | null + + // 筛选状态 + filter: { + platform?: Platform + includeDisabled: boolean + } + + // Actions + fetchVersions: () => Promise + setFilter: (filter: Partial) => 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 ( +
+ {/* 组件内容 */} +
+ ) +} +``` + +### 样式规范 + +使用 Tailwind CSS 原子类: + +```tsx +// 推荐 + + +// 复用样式定义在 globals.css + +``` + +全局样式定义: + +```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()( + 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) diff --git a/frontend/mobile-upgrade/docs/TESTING.md b/frontend/mobile-upgrade/docs/TESTING.md new file mode 100644 index 00000000..c4c31e48 --- /dev/null +++ b/frontend/mobile-upgrade/docs/TESTING.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: ['/jest.setup.js'], + testEnvironment: 'jest-environment-jsdom', + moduleNameMapper: { + '^@/(.*)$': '/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 + +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( + + ) + + 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( + + ) + + expect(screen.getByText('强制更新')).toBeInTheDocument() + }) + + it('should call onEdit when edit button clicked', () => { + render( + + ) + + fireEvent.click(screen.getByText('编辑')) + expect(mockOnEdit).toHaveBeenCalledTimes(1) + }) + + it('should call onDelete when delete button clicked', () => { + render( + + ) + + fireEvent.click(screen.getByText('删除')) + expect(mockOnDelete).toHaveBeenCalledTimes(1) + }) + + it('should call onToggle when toggle button clicked', () => { + render( + + ) + + 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() + + expect(screen.getByText('上传新版本')).toBeInTheDocument() + expect(screen.getByText('点击选择 APK 或 IPA 文件')).toBeInTheDocument() + }) + + it('should call onClose when close button clicked', () => { + render() + + fireEvent.click(screen.getByText('取消')) + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + + it('should show error when submitting without file', async () => { + render() + + fireEvent.click(screen.getByText('上传')) + + await waitFor(() => { + expect(screen.getByText('请选择安装包文件')).toBeInTheDocument() + }) + }) + + it('should auto-detect platform from file extension', async () => { + render() + + 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)