feat: 添加完整的项目设计文档,替换旧的原型需求文档
This commit is contained in:
268
02_详细设计文档/API接口设计详设.md
Normal file
268
02_详细设计文档/API接口设计详设.md
Normal file
@@ -0,0 +1,268 @@
|
||||
# API接口设计详设文档
|
||||
|
||||
> **文档版本**: v1.0
|
||||
> **撰写人**: 后端架构师
|
||||
> **创建日期**: 2024年9月11日
|
||||
|
||||
## 1. 设计原则与规范
|
||||
|
||||
- **RESTful风格**: API遵循RESTful设计原则,使用标准的HTTP方法 (`GET`, `POST`, `PUT`, `DELETE`)。
|
||||
- **JSON格式**: 所有请求体和响应体均使用`application/json`格式。
|
||||
- **URL版本控制**: API版本通过URL前缀进行管理,例如 `/api/v1/...`。
|
||||
- **身份认证**: 所有需要认证的接口都通过`Authorization: Bearer <JWT>`头进行身份验证。
|
||||
- **统一响应格式**: 所有API响应都遵循统一的数据结构,便于客户端处理。
|
||||
- **错误处理**: 使用标准的HTTP状态码表示请求结果,并在响应体中提供详细的错误信息。
|
||||
|
||||
## 2. 统一响应格式
|
||||
|
||||
### 2.1 成功响应 (`2xx`)
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"code": 200,
|
||||
"message": "操作成功",
|
||||
"data": {
|
||||
"key1": "value1",
|
||||
"key2": "value2"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 失败响应 (`4xx`, `5xx`)
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"code": 401,
|
||||
"message": "身份认证失败",
|
||||
"error": {
|
||||
"type": "AuthenticationError",
|
||||
"details": "JWT token is invalid or expired."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 3. API接口详解
|
||||
|
||||
### 3.1 用户认证模块 (`/api/v1/auth`)
|
||||
|
||||
#### 3.1.1 `POST /auth/login`
|
||||
- **功能**: 微信小程序登录。客户端使用`wx.login()`获取`code`后调用此接口。
|
||||
- **请求体**:
|
||||
```json
|
||||
{
|
||||
"code": "wx-login-code-from-miniprogram"
|
||||
}
|
||||
```
|
||||
- **成功响应 (`200 OK`)**:
|
||||
- **描述**: 返回JWT和新/老用户信息。
|
||||
- **响应体**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"code": 200,
|
||||
"message": "登录成功",
|
||||
"data": {
|
||||
"token": "jwt.string.here",
|
||||
"isNewUser": false,
|
||||
"user": {
|
||||
"id": "user-openid",
|
||||
"nickname": "张三",
|
||||
"avatarUrl": "url-to-avatar"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- **失败响应**:
|
||||
- `400 Bad Request`: `code`无效或缺失。
|
||||
- `500 Internal Server Error`: 微信服务器接口调用失败。
|
||||
|
||||
#### 3.1.2 `PUT /auth/profile`
|
||||
- **功能**: 更新用户信息(昵称、头像)。
|
||||
- **认证**: 需要JWT。
|
||||
- **请求体**:
|
||||
```json
|
||||
{
|
||||
"nickname": "新的昵称",
|
||||
"avatarUrl": "新的头像URL"
|
||||
}
|
||||
```
|
||||
- **成功响应 (`200 OK`)**:
|
||||
- **描述**: 返回更新后的用户信息。
|
||||
- **响应体**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"code": 200,
|
||||
"message": "用户信息更新成功",
|
||||
"data": {
|
||||
"id": "user-openid",
|
||||
"nickname": "新的昵称",
|
||||
"avatarUrl": "新的头像URL"
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 用户信息模块 (`/api/v1/users`)
|
||||
|
||||
#### 3.2.1 `GET /users/me`
|
||||
- **功能**: 获取当前登录用户的详细信息。
|
||||
- **认证**: 需要JWT。
|
||||
- **成功响应 (`200 OK`)**:
|
||||
- **描述**: 返回包含统计数据的完整用户信息。
|
||||
- **响应体**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"code": 200,
|
||||
"message": "获取成功",
|
||||
"data": {
|
||||
"id": "user-openid",
|
||||
"nickname": "张三",
|
||||
"avatarUrl": "url-to-avatar",
|
||||
"stats": {
|
||||
"gamesPlayed": 100,
|
||||
"gamesWon": 60,
|
||||
"winRate": 60.0,
|
||||
"eloRating": 1350
|
||||
},
|
||||
"createdAt": "2024-09-10T..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2.2 `GET /users/:id`
|
||||
- **功能**: 获取指定ID用户的公开信息。
|
||||
- **认证**: 可选。
|
||||
- **成功响应 (`200 OK`)**:
|
||||
- **描述**: 返回用户的公开信息(不含敏感数据)。
|
||||
- **响应体**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"code": 200,
|
||||
"message": "获取成功",
|
||||
"data": {
|
||||
"id": "other-user-id",
|
||||
"nickname": "李四",
|
||||
"avatarUrl": "url-to-avatar",
|
||||
"stats": {
|
||||
"gamesPlayed": 120,
|
||||
"winRate": 55.0,
|
||||
"eloRating": 1300
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- **失败响应**:
|
||||
- `404 Not Found`: 用户不存在。
|
||||
|
||||
### 3.3 游戏模块 (`/api/v1/games`)
|
||||
|
||||
#### 3.3.1 `GET /games/history`
|
||||
- **功能**: 获取当前用户的历史对局列表(分页)。
|
||||
- **认证**: 需要JWT。
|
||||
- **查询参数**:
|
||||
- `page` (number, default: 1): 页码。
|
||||
- `limit` (number, default: 10): 每页数量。
|
||||
- **成功响应 (`200 OK`)**:
|
||||
- **描述**: 返回分页的游戏历史记录。
|
||||
- **响应体**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"code": 200,
|
||||
"message": "获取成功",
|
||||
"data": {
|
||||
"games": [
|
||||
{
|
||||
"gameId": "game-id-1",
|
||||
"opponent": { "id": "user-id-2", "nickname": "李四" },
|
||||
"result": "win", // "win" | "loss"
|
||||
"finishedAt": "2024-09-11T...",
|
||||
"duration": 600 // 秒
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"currentPage": 1,
|
||||
"totalPages": 10,
|
||||
"totalGames": 100
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.3.2 `GET /games/:id`
|
||||
- **功能**: 获取指定游戏对局的详细信息(用于复盘)。
|
||||
- **认证**: 需要JWT,且用户必须是该对局的参与者。
|
||||
- **成功响应 (`200 OK`)**:
|
||||
- **描述**: 返回完整的游戏会话数据。
|
||||
- **响应体**: (完整的 `GameSession` 对象)
|
||||
- **失败响应**:
|
||||
- `403 Forbidden`: 无权访问该对局。
|
||||
- `404 Not Found`: 游戏不存在。
|
||||
|
||||
### 3.4 排行榜模块 (`/api/v1/leaderboard`)
|
||||
|
||||
#### 3.4.1 `GET /leaderboard`
|
||||
- **功能**: 获取Elo积分排行榜。
|
||||
- **认证**: 可选。
|
||||
- **查询参数**:
|
||||
- `limit` (number, default: 100): 返回的条目数。
|
||||
- **成功响应 (`200 OK`)**:
|
||||
- **描述**: 返回排名列表。
|
||||
- **响应体**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"code": 200,
|
||||
"message": "获取成功",
|
||||
"data": {
|
||||
"ranking": [
|
||||
{
|
||||
"rank": 1,
|
||||
"user": { "id": "user-id-1", "nickname": "高手" },
|
||||
"eloRating": 2000
|
||||
},
|
||||
{
|
||||
"rank": 2,
|
||||
"user": { "id": "user-id-2", "nickname": "大师" },
|
||||
"eloRating": 1950
|
||||
}
|
||||
],
|
||||
"lastUpdatedAt": "2024-09-11T..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 4. WebSocket API
|
||||
|
||||
WebSocket通信不属于RESTful API,但其认证和初始状态获取与HTTP API紧密相关。
|
||||
|
||||
- **连接地址**: `wss://your-domain.com/ws`
|
||||
- **认证流程**:
|
||||
1. 客户端通过`POST /api/v1/auth/login`获取JWT。
|
||||
2. 建立WebSocket连接。
|
||||
3. 连接成功后,发送第一条消息进行认证。
|
||||
- **认证消息**:
|
||||
```json
|
||||
{
|
||||
"type": "AUTHENTICATE",
|
||||
"payload": {
|
||||
"token": "jwt.string.here"
|
||||
}
|
||||
}
|
||||
```
|
||||
- **认证成功响应**:
|
||||
```json
|
||||
{
|
||||
"type": "AUTHENTICATED",
|
||||
"payload": {
|
||||
"userId": "user-id",
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
- **后续通信**: 详见《实时通信模块详设文档》。
|
||||
251
02_详细设计文档/UI组件设计详设.md
Normal file
251
02_详细设计文档/UI组件设计详设.md
Normal file
@@ -0,0 +1,251 @@
|
||||
# UI组件设计详设文档
|
||||
|
||||
> **文档版本**: v1.0
|
||||
> **撰写人**: UI/UX设计师 & 前端架构师
|
||||
> **创建日期**: 2024年9月11日
|
||||
|
||||
## 1. 设计系统与主题
|
||||
|
||||
### 1.1 设计语言
|
||||
|
||||
- **主题**: 暗黑科技风 (Dark/Tech Theme),与原型设计稿保持一致。
|
||||
- **主色调**:
|
||||
- 背景: `#1A202C` (深灰蓝)
|
||||
- 主色: `#00BFFF` (深天蓝,用于按钮、高亮、链接)
|
||||
- 辅助色: `#4A5568` (中灰,用于边框、分割线)
|
||||
- 强调色: `#FF4500` (橙红,用于警告、错误提示、攻击命中)
|
||||
- **字体**:
|
||||
- `sans-serif` 系统默认无衬线字体。
|
||||
- 使用 `rem` 和 `em` 作为字体单位,以支持可访问性。
|
||||
|
||||
### 1.2 Design Token
|
||||
|
||||
所有颜色、字体大小、间距、圆角等样式参数都将通过Design Token进行管理,以`Sass`变量形式实现。
|
||||
|
||||
```scss
|
||||
// src/styles/tokens.scss
|
||||
|
||||
// Colors
|
||||
$color-background: #1A202C;
|
||||
$color-primary: #00BFFF;
|
||||
$color-secondary: #4A5568;
|
||||
$color-accent: #FF4500;
|
||||
$color-text-primary: #FFFFFF;
|
||||
$color-text-secondary: #A0AEC0;
|
||||
|
||||
// Spacing
|
||||
$spacing-xs: 0.25rem; // 4px
|
||||
$spacing-sm: 0.5rem; // 8px
|
||||
$spacing-md: 1rem; // 16px
|
||||
$spacing-lg: 1.5rem; // 24px
|
||||
$spacing-xl: 2rem; // 32px
|
||||
|
||||
// Border Radius
|
||||
$border-radius-sm: 4px;
|
||||
$border-radius-md: 8px;
|
||||
$border-radius-lg: 16px;
|
||||
```
|
||||
|
||||
## 2. 原子组件 (Atoms)
|
||||
|
||||
原子组件是UI构成的最小单元,不可再分。
|
||||
|
||||
### 2.1 `Button` 组件
|
||||
|
||||
- **功能**: 标准按钮,支持不同变体和状态。
|
||||
- **Props**:
|
||||
```typescript
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'danger'; // 样式变体
|
||||
size?: 'sm' | 'md' | 'lg'; // 尺寸
|
||||
isLoading?: boolean; // 加载状态
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
}
|
||||
```
|
||||
- **样式**:
|
||||
- `primary`: 主色背景,白色文字。
|
||||
- `secondary`: 辅助色边框,主色文字。
|
||||
- `danger`: 强调色背景,白色文字。
|
||||
- `disabled`: 降低透明度,禁用鼠标事件。
|
||||
- `isLoading`: 显示加载动画,禁用按钮。
|
||||
|
||||
### 2.2 `Input` 组件
|
||||
|
||||
- **功能**: 文本输入框。
|
||||
- **Props**:
|
||||
```typescript
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string; // 标签
|
||||
errorMessage?: string; // 错误信息
|
||||
leftIcon?: React.ReactNode;
|
||||
}
|
||||
```
|
||||
- **样式**:
|
||||
- `focus`: 主色边框高亮。
|
||||
- `error`: 强调色边框和错误信息。
|
||||
|
||||
### 2.3 `Spinner` 组件
|
||||
|
||||
- **功能**: 加载指示器。
|
||||
- **Props**:
|
||||
```typescript
|
||||
interface SpinnerProps {
|
||||
size?: 'sm' | 'md' | 'lg'; // 尺寸
|
||||
color?: string; // 颜色
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 `Icon` 组件
|
||||
|
||||
- **功能**: 图标库封装,使用`react-icons`。
|
||||
- **Props**:
|
||||
```typescript
|
||||
import { IconType } from 'react-icons';
|
||||
|
||||
interface IconProps {
|
||||
as: IconType; // 图标组件
|
||||
size?: string | number;
|
||||
color?: string;
|
||||
}
|
||||
```
|
||||
|
||||
## 3. 分子组件 (Molecules)
|
||||
|
||||
由原子组件组合而成,完成特定功能。
|
||||
|
||||
### 3.1 `FormField` 组件
|
||||
|
||||
- **构成**: `Input` + `Label` + `ErrorMessage`
|
||||
- **功能**: 完整的表单字段,包含标签和验证逻辑。
|
||||
|
||||
### 3.2 `PlayerAvatar` 组件
|
||||
|
||||
- **构成**: `Avatar` + `Text`
|
||||
- **功能**: 显示玩家头像和昵称。
|
||||
- **Props**:
|
||||
```typescript
|
||||
interface PlayerAvatarProps {
|
||||
player: {
|
||||
avatarUrl: string;
|
||||
nickname: string;
|
||||
isOwner?: boolean; // 是否是房主
|
||||
isReady?: boolean; // 是否已准备
|
||||
};
|
||||
size?: 'md' | 'lg';
|
||||
}
|
||||
```
|
||||
- **样式**:
|
||||
- `isReady`: 头像外圈显示主色高亮。
|
||||
- `isOwner`: 昵称旁显示皇冠图标。
|
||||
|
||||
### 3.3 `RoomListItem` 组件
|
||||
|
||||
- **构成**: `Text` + `Icon` + `Button`
|
||||
- **功能**: 在房间列表中显示单个房间的信息。
|
||||
- **Props**:
|
||||
```typescript
|
||||
interface RoomListItemProps {
|
||||
room: {
|
||||
roomCode: string;
|
||||
name: string;
|
||||
playerCount: number;
|
||||
isLocked: boolean;
|
||||
};
|
||||
onJoin: (roomCode: string) => void;
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 生物组件 (Organisms)
|
||||
|
||||
由分子和原子组件构成的更复杂的UI部分。
|
||||
|
||||
### 4.1 `GameBoard` 组件
|
||||
|
||||
- **功能**: 核心游戏棋盘UI,负责渲染10x10的网格和飞机。
|
||||
- **构成**: 多个 `GridCell` 组件。
|
||||
- **Props**:
|
||||
```typescript
|
||||
interface GameBoardProps {
|
||||
boardState: BoardState; // 棋盘状态数据
|
||||
isMyBoard: boolean; // 是否是自己的棋盘
|
||||
onCellClick: (position: { x: number, y: number }) => void; // 单元格点击事件
|
||||
placedPlanes?: Plane[]; // 预放置的飞机
|
||||
}
|
||||
```
|
||||
- **`GridCell` 子组件**:
|
||||
- 根据单元格状态(`empty`, `plane_part`, `miss`, `hit`, `destroy`)显示不同样式。
|
||||
- 在对手棋盘上,`plane_part`状态默认不显示,除非被攻击过。
|
||||
- 鼠标悬停在可攻击的单元格上时显示准星光标。
|
||||
|
||||
### 4.2 `PlanePlacementPanel` 组件
|
||||
|
||||
- **功能**: 飞机布置阶段的操作面板,允许玩家拖拽或旋转飞机。
|
||||
- **构成**: `PlanePreview` 组件 + `Button` (旋转/确认)
|
||||
- **Props**:
|
||||
```typescript
|
||||
interface PlanePlacementPanelProps {
|
||||
onPlanesConfirm: (planes: Plane[]) => void;
|
||||
}
|
||||
```
|
||||
- **交互**:
|
||||
- 提供3个默认形状的飞机供拖拽到棋盘。
|
||||
- 支持点击飞机进行90度旋转。
|
||||
- 棋盘上会实时预览飞机位置,并对非法位置(越界、重叠)进行红色高亮提示。
|
||||
|
||||
### 4.3 `Header` 组件
|
||||
|
||||
- **功能**: 应用的全局顶部导航栏。
|
||||
- **构成**: `Logo` + `UserInfo` + `SettingsButton`
|
||||
|
||||
## 5. 模板组件 (Templates)
|
||||
|
||||
定义页面的整体布局结构。
|
||||
|
||||
### 5.1 `MainLayout` 组件
|
||||
|
||||
- **功能**: 应用的主布局,包含页头、内容区、页脚。
|
||||
- **构成**: `Header` + `children` + `Footer`
|
||||
- **插槽(Slots)**: 通过`children` prop 插入页面具体内容。
|
||||
|
||||
## 6. 页面组件 (Pages)
|
||||
|
||||
完整的页面,由模板和多个生物/分子组件构成。
|
||||
|
||||
### 6.1 `HomePage`
|
||||
|
||||
- **路径**: `/`
|
||||
- **功能**: 游戏入口页面,包含"创建房间"和"加入房间"按钮。
|
||||
- **组件构成**: `MainLayout`, `Logo`, `Button`。
|
||||
|
||||
### 6.2 `RoomListPage`
|
||||
|
||||
- **路径**: `/rooms`
|
||||
- **功能**: 显示公开的房间列表,支持刷新和搜索。
|
||||
- **组件构成**: `MainLayout`, `RoomListItem`, `Spinner`, `Button`。
|
||||
|
||||
### 6.3 `RoomWaitingPage`
|
||||
|
||||
- **路径**: `/room/:roomCode`
|
||||
- **功能**: 玩家等待页面,显示双方玩家信息和准备状态。
|
||||
- **组件构成**: `MainLayout`, `PlayerAvatar` (x2), `Button` (准备/开始游戏), `ChatBox` (可选)。
|
||||
- **逻辑**:
|
||||
- 房主显示"开始游戏"按钮,当所有玩家准备好后激活。
|
||||
- 其他玩家显示"准备"按钮。
|
||||
- 实时监听WebSocket的`ROOM_STATE_UPDATE`事件更新UI。
|
||||
|
||||
### 6.4 `GamePage`
|
||||
|
||||
- **路径**: `/game/:gameId`
|
||||
- **功能**: 核心游戏对战页面。
|
||||
- **组件构成**:
|
||||
- `MainLayout`
|
||||
- `GameBoard` (我方棋盘)
|
||||
- `GameBoard` (敌方棋盘)
|
||||
- `GameStatusPanel`: 显示当前回合、倒计时、游戏日志。
|
||||
- `PlanePlacementPanel`: 仅在飞机布置阶段显示。
|
||||
- **逻辑**:
|
||||
- 页面加载时从Zustand Store获取游戏状态。
|
||||
- 监听WebSocket的`GAME_STATE_UPDATE`事件更新整个页面。
|
||||
- 根据游戏阶段(`placing`, `battling`, `finished`)渲染不同UI。
|
||||
- 在`finished`阶段,显示游戏结果弹窗(`GameResultModal`)。
|
||||
1027
02_详细设计文档/前端技术架构详设.md
Normal file
1027
02_详细设计文档/前端技术架构详设.md
Normal file
File diff suppressed because it is too large
Load Diff
887
02_详细设计文档/后端技术架构详设.md
Normal file
887
02_详细设计文档/后端技术架构详设.md
Normal file
@@ -0,0 +1,887 @@
|
||||
# 后端技术架构详设文档
|
||||
|
||||
> **文档版本**: v1.0
|
||||
> **撰写人**: 后端架构师
|
||||
> **创建日期**: 2024年9月11日
|
||||
|
||||
## 1. 后端架构总览
|
||||
|
||||
### 1.1 技术栈详细说明
|
||||
|
||||
```typescript
|
||||
// 后端技术栈配置
|
||||
{
|
||||
"runtime": "Node.js 18+", // 运行环境
|
||||
"framework": "Fastify 4.x", // Web框架
|
||||
"language": "TypeScript 5.0+", // 开发语言
|
||||
"database": "MongoDB 6.0", // 主数据库
|
||||
"cache": "Redis 7.0", // 缓存数据库
|
||||
"websocket": "ws + socket.io", // WebSocket库
|
||||
"orm": "Mongoose 7.x", // ODM工具
|
||||
"validation": "Joi", // 数据验证
|
||||
"auth": "JWT + 微信登录", // 认证方案
|
||||
"logging": "Winston + Morgan", // 日志系统
|
||||
"testing": "Jest + Supertest", // 测试框架
|
||||
"process": "PM2", // 进程管理
|
||||
"containerization": "Docker" // 容器化
|
||||
}
|
||||
```
|
||||
|
||||
### 1.2 服务架构设计
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Load Balancer │ │ Gateway │ │ Static CDN │
|
||||
│ (Nginx) │ │ (API Route) │ │ (Assets) │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│ │ │
|
||||
└───────────────────────┼───────────────────────┘
|
||||
│
|
||||
┌───────────────────────┴───────────────────────┐
|
||||
│ Application │
|
||||
│ Layer (Fastify) │
|
||||
└───────────────────────┬───────────────────────┘
|
||||
│
|
||||
┌────────────────────────────┼────────────────────────────┐
|
||||
│ │ │
|
||||
┌───▼────┐ ┌─────────▼─────────┐ ┌─────────▼─────────┐
|
||||
│ Auth │ │ Game Service │ │ WebSocket │
|
||||
│Service │ │ (Core Logic) │ │ Service │
|
||||
└────────┘ └───────────────────┘ └───────────────────┘
|
||||
│ │ │
|
||||
└───────────────────┼────────────────────────┘
|
||||
│
|
||||
┌───────────────────┼───────────────────┐
|
||||
│ │ │
|
||||
┌───▼─────┐ ┌───────▼──────┐ ┌──────▼─────┐
|
||||
│MongoDB │ │ Redis │ │ External │
|
||||
│(主数据) │ │ (缓存) │ │ APIs │
|
||||
└─────────┘ └──────────────┘ └────────────┘
|
||||
```
|
||||
|
||||
### 1.3 项目目录结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── app.ts # 应用入口
|
||||
├── server.ts # 服务器启动
|
||||
├── config/ # 配置文件
|
||||
│ ├── database.ts # 数据库配置
|
||||
│ ├── redis.ts # Redis配置
|
||||
│ ├── jwt.ts # JWT配置
|
||||
│ └── environment.ts # 环境变量
|
||||
├── controllers/ # 控制器层
|
||||
│ ├── auth.controller.ts # 认证控制器
|
||||
│ ├── room.controller.ts # 房间控制器
|
||||
│ ├── game.controller.ts # 游戏控制器
|
||||
│ └── user.controller.ts # 用户控制器
|
||||
├── services/ # 业务逻辑层
|
||||
│ ├── auth.service.ts # 认证服务
|
||||
│ ├── room.service.ts # 房间服务
|
||||
│ ├── game.service.ts # 游戏服务
|
||||
│ ├── user.service.ts # 用户服务
|
||||
│ └── websocket.service.ts # WebSocket服务
|
||||
├── models/ # 数据模型
|
||||
│ ├── User.ts # 用户模型
|
||||
│ ├── Room.ts # 房间模型
|
||||
│ ├── Game.ts # 游戏模型
|
||||
│ └── GameSession.ts # 游戏会话模型
|
||||
├── middleware/ # 中间件
|
||||
│ ├── auth.middleware.ts # 认证中间件
|
||||
│ ├── validation.middleware.ts # 验证中间件
|
||||
│ ├── error.middleware.ts # 错误处理中间件
|
||||
│ └── logging.middleware.ts # 日志中间件
|
||||
├── routes/ # 路由定义
|
||||
│ ├── auth.routes.ts # 认证路由
|
||||
│ ├── room.routes.ts # 房间路由
|
||||
│ ├── game.routes.ts # 游戏路由
|
||||
│ └── user.routes.ts # 用户路由
|
||||
├── utils/ # 工具函数
|
||||
│ ├── gameLogic.ts # 游戏逻辑工具
|
||||
│ ├── encryption.ts # 加密工具
|
||||
│ ├── validators.ts # 验证工具
|
||||
│ └── helpers.ts # 通用工具
|
||||
├── websocket/ # WebSocket处理
|
||||
│ ├── handlers/ # 消息处理器
|
||||
│ ├── events.ts # 事件定义
|
||||
│ └── connection.ts # 连接管理
|
||||
└── tests/ # 测试文件
|
||||
├── unit/ # 单元测试
|
||||
├── integration/ # 集成测试
|
||||
└── e2e/ # 端到端测试
|
||||
```
|
||||
|
||||
## 2. 核心服务设计
|
||||
|
||||
### 2.1 Fastify应用配置
|
||||
|
||||
```typescript
|
||||
// app.ts - 应用主配置
|
||||
import Fastify, { FastifyInstance } from 'fastify'
|
||||
import fastifyJwt from '@fastify/jwt'
|
||||
import fastifyWebsocket from '@fastify/websocket'
|
||||
import fastifyCors from '@fastify/cors'
|
||||
import fastifyRateLimit from '@fastify/rate-limit'
|
||||
|
||||
export const createApp = async (): Promise<FastifyInstance> => {
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
transport: process.env.NODE_ENV === 'development' ? {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
colorize: true,
|
||||
translateTime: 'HH:MM:ss',
|
||||
ignore: 'pid,hostname'
|
||||
}
|
||||
} : undefined
|
||||
}
|
||||
})
|
||||
|
||||
// 注册插件
|
||||
await app.register(fastifyJwt, {
|
||||
secret: process.env.JWT_SECRET!,
|
||||
sign: { expiresIn: '7d' }
|
||||
})
|
||||
|
||||
await app.register(fastifyWebsocket, {
|
||||
options: {
|
||||
maxPayload: 1048576, // 1MB
|
||||
verifyClient: (info) => {
|
||||
// WebSocket连接验证
|
||||
return verifyWebSocketClient(info)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await app.register(fastifyCors, {
|
||||
origin: process.env.CORS_ORIGINS?.split(',') || ['http://localhost:3000'],
|
||||
credentials: true
|
||||
})
|
||||
|
||||
await app.register(fastifyRateLimit, {
|
||||
max: 100,
|
||||
timeWindow: '1 minute',
|
||||
errorResponseBuilder: (request, context) => {
|
||||
return {
|
||||
code: 429,
|
||||
error: 'Too Many Requests',
|
||||
message: `请求过于频繁,请${Math.round(context.ttl / 1000)}秒后重试`
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 注册路由
|
||||
await app.register(authRoutes, { prefix: '/api/auth' })
|
||||
await app.register(userRoutes, { prefix: '/api/users' })
|
||||
await app.register(roomRoutes, { prefix: '/api/rooms' })
|
||||
await app.register(gameRoutes, { prefix: '/api/games' })
|
||||
await app.register(websocketRoutes, { prefix: '/ws' })
|
||||
|
||||
// 错误处理
|
||||
app.setErrorHandler(errorHandler)
|
||||
app.setNotFoundHandler(notFoundHandler)
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
// 健康检查端点
|
||||
app.get('/health', async (request, reply) => {
|
||||
const health = {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
services: {
|
||||
database: await checkDatabaseHealth(),
|
||||
redis: await checkRedisHealth(),
|
||||
websocket: checkWebSocketHealth()
|
||||
}
|
||||
}
|
||||
|
||||
reply.send(health)
|
||||
})
|
||||
```
|
||||
|
||||
### 2.2 认证服务设计
|
||||
|
||||
```typescript
|
||||
// services/auth.service.ts
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { User } from '../models/User'
|
||||
import { redisClient } from '../config/redis'
|
||||
|
||||
export class AuthService {
|
||||
// 微信小程序登录
|
||||
async wxLogin(code: string): Promise<{ user: User; token: string }> {
|
||||
try {
|
||||
// 1. 通过code获取openid
|
||||
const wxSession = await this.getWxSession(code)
|
||||
|
||||
// 2. 查找或创建用户
|
||||
let user = await User.findOne({ openid: wxSession.openid })
|
||||
if (!user) {
|
||||
user = await User.create({
|
||||
openid: wxSession.openid,
|
||||
sessionKey: wxSession.session_key,
|
||||
unionid: wxSession.unionid,
|
||||
createdAt: new Date()
|
||||
})
|
||||
} else {
|
||||
// 更新session_key
|
||||
user.sessionKey = wxSession.session_key
|
||||
await user.save()
|
||||
}
|
||||
|
||||
// 3. 生成JWT token
|
||||
const token = this.generateToken(user._id.toString())
|
||||
|
||||
// 4. 缓存用户会话
|
||||
await this.cacheUserSession(user._id.toString(), token)
|
||||
|
||||
return { user, token }
|
||||
} catch (error) {
|
||||
throw new Error('微信登录失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 获取微信session
|
||||
private async getWxSession(code: string): Promise<WxSessionResponse> {
|
||||
const response = await fetch(`https://api.weixin.qq.com/sns/jscode2session`, {
|
||||
method: 'GET',
|
||||
params: {
|
||||
appid: process.env.WX_APPID!,
|
||||
secret: process.env.WX_APP_SECRET!,
|
||||
js_code: code,
|
||||
grant_type: 'authorization_code'
|
||||
}
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.errcode) {
|
||||
throw new Error(`微信API错误: ${data.errmsg}`)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// 生成JWT token
|
||||
private generateToken(userId: string): string {
|
||||
return jwt.sign(
|
||||
{ userId, type: 'access' },
|
||||
process.env.JWT_SECRET!,
|
||||
{ expiresIn: '7d' }
|
||||
)
|
||||
}
|
||||
|
||||
// 验证token
|
||||
async verifyToken(token: string): Promise<{ userId: string; valid: boolean }> {
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as any
|
||||
|
||||
// 检查会话是否仍然有效
|
||||
const sessionExists = await redisClient.exists(`session:${decoded.userId}`)
|
||||
|
||||
return {
|
||||
userId: decoded.userId,
|
||||
valid: sessionExists === 1
|
||||
}
|
||||
} catch (error) {
|
||||
return { userId: '', valid: false }
|
||||
}
|
||||
}
|
||||
|
||||
// 缓存用户会话
|
||||
private async cacheUserSession(userId: string, token: string): Promise<void> {
|
||||
const sessionData = {
|
||||
token,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastActive: new Date().toISOString()
|
||||
}
|
||||
|
||||
await redisClient.setex(
|
||||
`session:${userId}`,
|
||||
7 * 24 * 60 * 60, // 7天过期
|
||||
JSON.stringify(sessionData)
|
||||
)
|
||||
}
|
||||
|
||||
// 刷新用户活跃时间
|
||||
async refreshUserActivity(userId: string): Promise<void> {
|
||||
const sessionKey = `session:${userId}`
|
||||
const sessionData = await redisClient.get(sessionKey)
|
||||
|
||||
if (sessionData) {
|
||||
const session = JSON.parse(sessionData)
|
||||
session.lastActive = new Date().toISOString()
|
||||
|
||||
await redisClient.setex(
|
||||
sessionKey,
|
||||
7 * 24 * 60 * 60,
|
||||
JSON.stringify(session)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 用户登出
|
||||
async logout(userId: string): Promise<void> {
|
||||
await redisClient.del(`session:${userId}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 认证中间件
|
||||
export const authMiddleware = async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
try {
|
||||
const authHeader = request.headers.authorization
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return reply.code(401).send({ error: '缺少认证令牌' })
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7)
|
||||
const authService = new AuthService()
|
||||
const { userId, valid } = await authService.verifyToken(token)
|
||||
|
||||
if (!valid) {
|
||||
return reply.code(401).send({ error: '无效的认证令牌' })
|
||||
}
|
||||
|
||||
// 刷新用户活跃时间
|
||||
await authService.refreshUserActivity(userId)
|
||||
|
||||
// 将用户ID添加到请求上下文
|
||||
request.user = { id: userId }
|
||||
} catch (error) {
|
||||
return reply.code(401).send({ error: '认证失败' })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 房间管理服务
|
||||
|
||||
```typescript
|
||||
// services/room.service.ts
|
||||
import { Room } from '../models/Room'
|
||||
import { User } from '../models/User'
|
||||
import { redisClient } from '../config/redis'
|
||||
import { websocketService } from './websocket.service'
|
||||
|
||||
export class RoomService {
|
||||
// 创建房间
|
||||
async createRoom(hostId: string): Promise<Room> {
|
||||
const roomCode = this.generateRoomCode()
|
||||
|
||||
const room = await Room.create({
|
||||
code: roomCode,
|
||||
hostId,
|
||||
status: 'waiting',
|
||||
maxPlayers: 2,
|
||||
currentPlayers: 1,
|
||||
createdAt: new Date()
|
||||
})
|
||||
|
||||
// 缓存房间信息
|
||||
await this.cacheRoomData(room)
|
||||
|
||||
// 通知房间列表更新
|
||||
await websocketService.broadcastRoomListUpdate()
|
||||
|
||||
return room
|
||||
}
|
||||
|
||||
// 加入房间
|
||||
async joinRoom(roomCode: string, playerId: string): Promise<Room> {
|
||||
const room = await Room.findOne({ code: roomCode })
|
||||
|
||||
if (!room) {
|
||||
throw new Error('房间不存在')
|
||||
}
|
||||
|
||||
if (room.status !== 'waiting') {
|
||||
throw new Error('房间已开始游戏或已结束')
|
||||
}
|
||||
|
||||
if (room.currentPlayers >= room.maxPlayers) {
|
||||
throw new Error('房间已满')
|
||||
}
|
||||
|
||||
if (room.hostId === playerId) {
|
||||
throw new Error('不能加入自己创建的房间')
|
||||
}
|
||||
|
||||
// 更新房间信息
|
||||
room.guestId = playerId
|
||||
room.currentPlayers = 2
|
||||
room.status = 'ready'
|
||||
await room.save()
|
||||
|
||||
// 更新缓存
|
||||
await this.cacheRoomData(room)
|
||||
|
||||
// 通知房间内玩家
|
||||
await websocketService.notifyRoomUpdate(roomCode, {
|
||||
type: 'PLAYER_JOINED',
|
||||
room: room.toObject(),
|
||||
playerId
|
||||
})
|
||||
|
||||
return room
|
||||
}
|
||||
|
||||
// 离开房间
|
||||
async leaveRoom(roomCode: string, playerId: string): Promise<void> {
|
||||
const room = await Room.findOne({ code: roomCode })
|
||||
|
||||
if (!room) {
|
||||
throw new Error('房间不存在')
|
||||
}
|
||||
|
||||
if (room.hostId === playerId) {
|
||||
// 房主离开,删除房间
|
||||
await Room.deleteOne({ code: roomCode })
|
||||
await redisClient.del(`room:${roomCode}`)
|
||||
|
||||
// 通知客人
|
||||
if (room.guestId) {
|
||||
await websocketService.notifyPlayer(room.guestId, {
|
||||
type: 'ROOM_CLOSED',
|
||||
message: '房主已离开,房间关闭'
|
||||
})
|
||||
}
|
||||
} else if (room.guestId === playerId) {
|
||||
// 客人离开
|
||||
room.guestId = undefined
|
||||
room.currentPlayers = 1
|
||||
room.status = 'waiting'
|
||||
await room.save()
|
||||
|
||||
await this.cacheRoomData(room)
|
||||
|
||||
// 通知房主
|
||||
await websocketService.notifyPlayer(room.hostId, {
|
||||
type: 'PLAYER_LEFT',
|
||||
room: room.toObject()
|
||||
})
|
||||
}
|
||||
|
||||
// 更新房间列表
|
||||
await websocketService.broadcastRoomListUpdate()
|
||||
}
|
||||
|
||||
// 获取房间列表
|
||||
async getRoomList(): Promise<Room[]> {
|
||||
const rooms = await Room.find({
|
||||
status: 'waiting',
|
||||
currentPlayers: { $lt: 2 }
|
||||
}).populate('host', 'nickname avatar').lean()
|
||||
|
||||
return rooms
|
||||
}
|
||||
|
||||
// 获取房间详情
|
||||
async getRoomDetails(roomCode: string): Promise<Room | null> {
|
||||
// 先从缓存获取
|
||||
const cachedRoom = await redisClient.get(`room:${roomCode}`)
|
||||
if (cachedRoom) {
|
||||
return JSON.parse(cachedRoom)
|
||||
}
|
||||
|
||||
// 从数据库获取
|
||||
const room = await Room.findOne({ code: roomCode })
|
||||
.populate('host', 'nickname avatar')
|
||||
.populate('guest', 'nickname avatar')
|
||||
.lean()
|
||||
|
||||
if (room) {
|
||||
await this.cacheRoomData(room)
|
||||
}
|
||||
|
||||
return room
|
||||
}
|
||||
|
||||
// 开始游戏
|
||||
async startGame(roomCode: string, hostId: string): Promise<void> {
|
||||
const room = await Room.findOne({ code: roomCode })
|
||||
|
||||
if (!room) {
|
||||
throw new Error('房间不存在')
|
||||
}
|
||||
|
||||
if (room.hostId !== hostId) {
|
||||
throw new Error('只有房主可以开始游戏')
|
||||
}
|
||||
|
||||
if (room.currentPlayers < 2) {
|
||||
throw new Error('等待其他玩家加入')
|
||||
}
|
||||
|
||||
// 更新房间状态
|
||||
room.status = 'playing'
|
||||
await room.save()
|
||||
|
||||
// 创建游戏会话
|
||||
const gameService = new GameService()
|
||||
const gameSession = await gameService.createGameSession(room)
|
||||
|
||||
// 通知玩家游戏开始
|
||||
await websocketService.notifyRoomUpdate(roomCode, {
|
||||
type: 'GAME_STARTED',
|
||||
gameSession: gameSession.toObject()
|
||||
})
|
||||
}
|
||||
|
||||
// 生成房间码
|
||||
private generateRoomCode(): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||
let result = ''
|
||||
for (let i = 0; i < 6; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// 缓存房间数据
|
||||
private async cacheRoomData(room: any): Promise<void> {
|
||||
await redisClient.setex(
|
||||
`room:${room.code}`,
|
||||
30 * 60, // 30分钟过期
|
||||
JSON.stringify(room)
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 游戏核心服务
|
||||
|
||||
```typescript
|
||||
// services/game.service.ts
|
||||
import { GameSession } from '../models/GameSession'
|
||||
import { Room } from '../models/Room'
|
||||
import { GameLogic } from '../utils/gameLogic'
|
||||
import { websocketService } from './websocket.service'
|
||||
|
||||
export class GameService {
|
||||
// 创建游戏会话
|
||||
async createGameSession(room: Room): Promise<GameSession> {
|
||||
const gameSession = await GameSession.create({
|
||||
roomId: room._id,
|
||||
roomCode: room.code,
|
||||
players: [room.hostId, room.guestId],
|
||||
gameState: {
|
||||
phase: 'PLACING',
|
||||
currentPlayer: room.hostId, // 房主先开始
|
||||
boards: {
|
||||
[room.hostId]: GameLogic.createEmptyBoard(),
|
||||
[room.guestId!]: GameLogic.createEmptyBoard()
|
||||
},
|
||||
moves: [],
|
||||
startedAt: new Date()
|
||||
},
|
||||
createdAt: new Date()
|
||||
})
|
||||
|
||||
return gameSession
|
||||
}
|
||||
|
||||
// 放置飞机
|
||||
async placePlanes(
|
||||
gameSessionId: string,
|
||||
playerId: string,
|
||||
planes: PlaneData[]
|
||||
): Promise<void> {
|
||||
const session = await GameSession.findById(gameSessionId)
|
||||
|
||||
if (!session) {
|
||||
throw new Error('游戏会话不存在')
|
||||
}
|
||||
|
||||
if (session.gameState.phase !== 'PLACING') {
|
||||
throw new Error('当前不是飞机放置阶段')
|
||||
}
|
||||
|
||||
// 验证飞机放置是否合法
|
||||
const isValid = GameLogic.validatePlanesPlacement(planes)
|
||||
if (!isValid) {
|
||||
throw new Error('飞机放置不合法')
|
||||
}
|
||||
|
||||
// 更新游戏状态
|
||||
session.gameState.boards[playerId] = GameLogic.placePlanesOnBoard(
|
||||
session.gameState.boards[playerId],
|
||||
planes
|
||||
)
|
||||
|
||||
// 标记玩家已准备
|
||||
session.gameState.playersReady = session.gameState.playersReady || {}
|
||||
session.gameState.playersReady[playerId] = true
|
||||
|
||||
// 检查是否所有玩家都已准备
|
||||
const allReady = session.players.every(p =>
|
||||
session.gameState.playersReady[p]
|
||||
)
|
||||
|
||||
if (allReady) {
|
||||
// 开始对战阶段
|
||||
session.gameState.phase = 'BATTLING'
|
||||
session.gameState.battleStartedAt = new Date()
|
||||
}
|
||||
|
||||
await session.save()
|
||||
|
||||
// 通知所有玩家状态更新
|
||||
await websocketService.notifyGameUpdate(session.roomCode, {
|
||||
type: 'PLACEMENT_UPDATE',
|
||||
playerId,
|
||||
ready: true,
|
||||
allReady,
|
||||
gameState: session.gameState
|
||||
})
|
||||
}
|
||||
|
||||
// 执行攻击
|
||||
async attack(
|
||||
gameSessionId: string,
|
||||
attackerId: string,
|
||||
position: Position
|
||||
): Promise<AttackResult> {
|
||||
const session = await GameSession.findById(gameSessionId)
|
||||
|
||||
if (!session) {
|
||||
throw new Error('游戏会话不存在')
|
||||
}
|
||||
|
||||
if (session.gameState.phase !== 'BATTLING') {
|
||||
throw new Error('当前不是对战阶段')
|
||||
}
|
||||
|
||||
if (session.gameState.currentPlayer !== attackerId) {
|
||||
throw new Error('不是你的回合')
|
||||
}
|
||||
|
||||
// 确定被攻击的玩家
|
||||
const defenderId = session.players.find(p => p !== attackerId)!
|
||||
const defenderBoard = session.gameState.boards[defenderId]
|
||||
|
||||
// 执行攻击逻辑
|
||||
const attackResult = GameLogic.processAttack(defenderBoard, position)
|
||||
|
||||
// 更新游戏状态
|
||||
session.gameState.boards[defenderId] = attackResult.updatedBoard
|
||||
session.gameState.moves.push({
|
||||
playerId: attackerId,
|
||||
type: 'ATTACK',
|
||||
position,
|
||||
result: attackResult.type,
|
||||
timestamp: new Date()
|
||||
})
|
||||
|
||||
// 检查游戏是否结束
|
||||
if (attackResult.gameEnded) {
|
||||
session.gameState.phase = 'FINISHED'
|
||||
session.gameState.winner = attackerId
|
||||
session.gameState.endedAt = new Date()
|
||||
|
||||
// 更新玩家统计
|
||||
await this.updatePlayerStats(attackerId, defenderId)
|
||||
} else {
|
||||
// 切换当前玩家
|
||||
session.gameState.currentPlayer = defenderId
|
||||
}
|
||||
|
||||
await session.save()
|
||||
|
||||
// 通知所有玩家攻击结果
|
||||
await websocketService.notifyGameUpdate(session.roomCode, {
|
||||
type: 'ATTACK_RESULT',
|
||||
attackerId,
|
||||
defenderId,
|
||||
position,
|
||||
result: attackResult,
|
||||
gameState: session.gameState
|
||||
})
|
||||
|
||||
return attackResult
|
||||
}
|
||||
|
||||
// 获取游戏状态
|
||||
async getGameState(gameSessionId: string, playerId: string): Promise<any> {
|
||||
const session = await GameSession.findById(gameSessionId)
|
||||
|
||||
if (!session) {
|
||||
throw new Error('游戏会话不存在')
|
||||
}
|
||||
|
||||
if (!session.players.includes(playerId)) {
|
||||
throw new Error('你不在这个游戏中')
|
||||
}
|
||||
|
||||
// 返回过滤后的游戏状态(隐藏对手飞机位置)
|
||||
const filteredState = {
|
||||
...session.gameState,
|
||||
boards: {
|
||||
[playerId]: session.gameState.boards[playerId],
|
||||
// 对手棋盘只显示攻击结果,不显示飞机位置
|
||||
opponent: GameLogic.filterOpponentBoard(
|
||||
session.gameState.boards[session.players.find(p => p !== playerId)!]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return filteredState
|
||||
}
|
||||
|
||||
// 玩家投降
|
||||
async surrender(gameSessionId: string, playerId: string): Promise<void> {
|
||||
const session = await GameSession.findById(gameSessionId)
|
||||
|
||||
if (!session) {
|
||||
throw new Error('游戏会话不存在')
|
||||
}
|
||||
|
||||
if (session.gameState.phase === 'FINISHED') {
|
||||
throw new Error('游戏已结束')
|
||||
}
|
||||
|
||||
const winnerId = session.players.find(p => p !== playerId)!
|
||||
|
||||
// 更新游戏状态
|
||||
session.gameState.phase = 'FINISHED'
|
||||
session.gameState.winner = winnerId
|
||||
session.gameState.endedAt = new Date()
|
||||
session.gameState.surrendered = true
|
||||
|
||||
await session.save()
|
||||
|
||||
// 更新玩家统计
|
||||
await this.updatePlayerStats(winnerId, playerId)
|
||||
|
||||
// 通知游戏结束
|
||||
await websocketService.notifyGameUpdate(session.roomCode, {
|
||||
type: 'GAME_ENDED',
|
||||
winner: winnerId,
|
||||
reason: 'SURRENDER',
|
||||
gameState: session.gameState
|
||||
})
|
||||
}
|
||||
|
||||
// 更新玩家统计
|
||||
private async updatePlayerStats(winnerId: string, loserId: string): Promise<void> {
|
||||
const User = require('../models/User').User
|
||||
|
||||
// 更新获胜者统计
|
||||
await User.updateOne(
|
||||
{ _id: winnerId },
|
||||
{
|
||||
$inc: {
|
||||
'stats.totalGames': 1,
|
||||
'stats.wins': 1
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 更新失败者统计
|
||||
await User.updateOne(
|
||||
{ _id: loserId },
|
||||
{
|
||||
$inc: {
|
||||
'stats.totalGames': 1
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 重新计算胜率
|
||||
const users = await User.find({ _id: { $in: [winnerId, loserId] } })
|
||||
for (const user of users) {
|
||||
user.stats.winRate = user.stats.totalGames > 0
|
||||
? (user.stats.wins / user.stats.totalGames * 100).toFixed(2)
|
||||
: 0
|
||||
await user.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 3. WebSocket服务设计
|
||||
|
||||
### 3.1 WebSocket连接管理
|
||||
|
||||
```typescript
|
||||
// websocket/connection.ts
|
||||
import { WebSocket } from 'ws'
|
||||
import { EventEmitter } from 'events'
|
||||
import { redisClient } from '../config/redis'
|
||||
|
||||
export class WebSocketManager extends EventEmitter {
|
||||
private connections = new Map<string, WebSocketConnection>()
|
||||
private userConnections = new Map<string, string[]>() // userId -> connectionIds[]
|
||||
private roomConnections = new Map<string, string[]>() // roomCode -> connectionIds[]
|
||||
|
||||
// 添加连接
|
||||
addConnection(connectionId: string, ws: WebSocket, userId: string): void {
|
||||
const connection = new WebSocketConnection(connectionId, ws, userId)
|
||||
|
||||
this.connections.set(connectionId, connection)
|
||||
|
||||
// 建立用户映射
|
||||
if (!this.userConnections.has(userId)) {
|
||||
this.userConnections.set(userId, [])
|
||||
}
|
||||
this.userConnections.get(userId)!.push(connectionId)
|
||||
|
||||
// 设置连接事件处理
|
||||
connection.on('message', (message) => {
|
||||
this.handleMessage(connectionId, message)
|
||||
})
|
||||
|
||||
connection.on('close', () => {
|
||||
this.removeConnection(connectionId)
|
||||
})
|
||||
|
||||
connection.on('error', (error) => {
|
||||
console.error(`WebSocket连接错误 ${connectionId}:`, error)
|
||||
this.removeConnection(connectionId)
|
||||
})
|
||||
|
||||
console.log(`WebSocket连接已建立: ${connectionId} (用户: ${userId})`)
|
||||
}
|
||||
|
||||
// 移除连接
|
||||
removeConnection(connectionId: string): void {
|
||||
const connection = this.connections.get(connectionId)
|
||||
if (!connection) return
|
||||
|
||||
const userId = connection.userId
|
||||
|
||||
// 移除连接映射
|
||||
this.connections.delete(connectionId)
|
||||
|
||||
// 移除用户映射
|
||||
const userConns = this.userConnections.get(userId)
|
||||
if (userConns) {
|
||||
const index = userConns.indexOf(connectionId)
|
||||
if (index > -1) {
|
||||
userConns.splice(index, 1)
|
||||
}
|
||||
if (userConns.length === 0) {
|
||||
this.userConnections.delete(userId)
|
||||
}
|
||||
}
|
||||
|
||||
// 移除房间映射
|
||||
for (const [roomCode, connections] of this.roomConnections.entries()) {
|
||||
const index = connections.indexOf(connectionId)
|
||||
if (index > -1) {
|
||||
connections.splice(index, 1)
|
||||
if (connections.length === 0) {
|
||||
this.roomConnections.delete(roomCode)
|
||||
}
|
||||
|
||||
// 通知房间内其他用户
|
||||
this.notifyRoomUpdate(roomCode, {
|
||||
type: 'PLAYER_DISCONNECTED',
|
||||
playerId: userId
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`WebSocket连接已关闭: ${connectionId} (用户: ${userId})`)
|
||||
}
|
||||
}
|
||||
280
02_详细设计文档/实时通信模块详设.md
Normal file
280
02_详细设计文档/实时通信模块详设.md
Normal file
@@ -0,0 +1,280 @@
|
||||
# 实时通信模块详设文档
|
||||
|
||||
> **文档版本**: v1.0
|
||||
> **撰写人**: 通信架构师
|
||||
> **创建日期**: 2024年9月11日
|
||||
|
||||
## 1. WebSocket通信协议
|
||||
|
||||
### 1.1 协议选型
|
||||
|
||||
- **WebSocket**: 基于TCP的全双工通信协议,提供持久化连接,是实现游戏实时对战、状态同步的最佳选择。
|
||||
- **WSS (WebSocket Secure)**: 在生产环境强制使用WSS协议,确保所有通信数据都经过TLS加密,保障数据安全。
|
||||
|
||||
### 1.2 消息格式
|
||||
|
||||
所有客户端与服务端之间的WebSocket消息都采用统一的JSON格式进行封装,便于解析和扩展。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "MESSAGE_TYPE_ENUM",
|
||||
"payload": {
|
||||
"key1": "value1",
|
||||
"key2": "value2"
|
||||
},
|
||||
"timestamp": "2024-09-11T10:00:00.000Z",
|
||||
"client_message_id": "optional-client-uuid-for-ack"
|
||||
}
|
||||
```
|
||||
|
||||
- `type`: 消息类型,用于路由到不同的处理逻辑。
|
||||
- `payload`: 消息体,包含具体业务数据。
|
||||
- `timestamp`: 消息发送的UTC时间戳。
|
||||
- `client_message_id`: (可选) 客户端生成的消息ID,用于实现消息确认(ACK)机制。
|
||||
|
||||
### 1.3 心跳机制
|
||||
|
||||
为维持连接活性并检测死链,采用双向心跳机制:
|
||||
|
||||
- **客户端**: 每隔25秒向服务端发送一个`PING`消息。
|
||||
- **服务端**:
|
||||
- 收到`PING`消息后,立即回复一个`PONG`消息。
|
||||
- 如果在60秒内未收到任何客户端消息(包括`PING`),则认为连接已断开,主动关闭该WebSocket连接。
|
||||
|
||||
```typescript
|
||||
// 客户端PING消息
|
||||
{
|
||||
"type": "PING",
|
||||
"payload": {},
|
||||
"timestamp": "..."
|
||||
}
|
||||
|
||||
// 服务端PONG消息
|
||||
{
|
||||
"type": "PONG",
|
||||
"payload": {},
|
||||
"timestamp": "..."
|
||||
}
|
||||
```
|
||||
|
||||
## 2. 消息类型与数据结构
|
||||
|
||||
### 2.1 消息类型枚举 (`GameMessageType`)
|
||||
|
||||
```typescript
|
||||
// 客户端 -> 服务端 (C2S)
|
||||
export enum ClientToServerMessageType {
|
||||
// --- 系统级 ---
|
||||
PING = 'PING', // 心跳检测
|
||||
AUTHENTICATE = 'AUTHENTICATE', // 身份认证
|
||||
|
||||
// --- 房间管理 ---
|
||||
CREATE_ROOM = 'CREATE_ROOM', // 创建房间
|
||||
JOIN_ROOM = 'JOIN_ROOM', // 加入房间
|
||||
LEAVE_ROOM = 'LEAVE_ROOM', // 离开房间
|
||||
GET_ROOM_LIST = 'GET_ROOM_LIST', // 获取房间列表
|
||||
PLAYER_READY = 'PLAYER_READY', // 玩家准备
|
||||
|
||||
// --- 游戏逻辑 ---
|
||||
PLACE_PLANES = 'PLACE_PLANES', // 放置飞机
|
||||
EXECUTE_ATTACK = 'EXECUTE_ATTACK', // 执行攻击
|
||||
SURRENDER = 'SURRENDER' // 投降
|
||||
}
|
||||
|
||||
// 服务端 -> 客户端 (S2C)
|
||||
export enum ServerToClientMessageType {
|
||||
// --- 系统级 ---
|
||||
PONG = 'PONG', // 心跳响应
|
||||
AUTHENTICATED = 'AUTHENTICATED', // 认证成功
|
||||
ERROR = 'ERROR', // 错误通知
|
||||
|
||||
// --- 房间与游戏状态同步 ---
|
||||
ROOM_LIST_UPDATE = 'ROOM_LIST_UPDATE', // 房间列表更新
|
||||
ROOM_STATE_UPDATE = 'ROOM_STATE_UPDATE',// 房间状态更新
|
||||
GAME_STATE_UPDATE = 'GAME_STATE_UPDATE',// 游戏状态更新
|
||||
|
||||
// --- 游戏事件通知 ---
|
||||
GAME_STARTED = 'GAME_STARTED', // 游戏开始
|
||||
PLACEMENT_PHASE_START = 'PLACEMENT_PHASE_START', // 放置阶段开始
|
||||
BATTLE_PHASE_START = 'BATTLE_PHASE_START', // 对战阶段开始
|
||||
TURN_CHANGE = 'TURN_CHANGE', // 回合变更
|
||||
ATTACK_RESULT = 'ATTACK_RESULT', // 攻击结果
|
||||
GAME_OVER = 'GAME_OVER', // 游戏结束
|
||||
PLAYER_RECONNECTED = 'PLAYER_RECONNECTED',// 玩家重连
|
||||
PLAYER_DISCONNECTED = 'PLAYER_DISCONNECTED'// 玩家断线
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 核心消息体 (`Payload`) 详解
|
||||
|
||||
#### `AUTHENTICATE` (C2S)
|
||||
- **描述**: 客户端连接后发送的第一条消息,用于身份认证。
|
||||
- **Payload**:
|
||||
```typescript
|
||||
interface AuthenticatePayload {
|
||||
token: string; // 从HTTP登录接口获取的JWT
|
||||
}
|
||||
```
|
||||
|
||||
#### `AUTHENTICATED` (S2C)
|
||||
- **描述**: 服务端认证成功后返回的消息。
|
||||
- **Payload**:
|
||||
```typescript
|
||||
interface AuthenticatedPayload {
|
||||
userId: string;
|
||||
nickname: string;
|
||||
// ... 其他用户信息
|
||||
}
|
||||
```
|
||||
|
||||
#### `CREATE_ROOM` (C2S)
|
||||
- **描述**: 客户端请求创建新房间。
|
||||
- **Payload**:
|
||||
```typescript
|
||||
interface CreateRoomPayload {
|
||||
roomName: string;
|
||||
isPublic: boolean;
|
||||
password?: string; // 如果是私密房间
|
||||
}
|
||||
```
|
||||
|
||||
#### `ROOM_STATE_UPDATE` (S2C)
|
||||
- **描述**: 当房间状态(如玩家加入/退出/准备)发生变化时,服务端向房间内所有客户端广播。
|
||||
- **Payload**: `RoomState` 对象 (详见后端设计文档)
|
||||
|
||||
#### `PLACE_PLANES` (C2S)
|
||||
- **描述**: 玩家在布置阶段提交飞机布局。
|
||||
- **Payload**:
|
||||
```typescript
|
||||
interface PlacePlanesPayload {
|
||||
planes: {
|
||||
center: { x: number, y: number };
|
||||
direction: 'up' | 'down' | 'left' | 'right';
|
||||
}[];
|
||||
}
|
||||
```
|
||||
|
||||
#### `EXECUTE_ATTACK` (C2S)
|
||||
- **描述**: 玩家在对战阶段执行攻击。
|
||||
- **Payload**:
|
||||
```typescript
|
||||
interface ExecuteAttackPayload {
|
||||
position: { x: number, y: number };
|
||||
}
|
||||
```
|
||||
|
||||
#### `GAME_STATE_UPDATE` (S2C)
|
||||
- **描述**: 游戏核心状态发生变化时,服务端向游戏内玩家广播。
|
||||
- **Payload**: `GameState` 对象 (详见游戏核心逻辑设计文档),但会根据接收玩家进行数据裁剪(如隐藏对手未被攻击的飞机位置)。
|
||||
|
||||
#### `ATTACK_RESULT` (S2C)
|
||||
- **描述**: 服务端通知攻击结果。
|
||||
- **Payload**:
|
||||
```typescript
|
||||
interface AttackResultPayload {
|
||||
attackerId: string;
|
||||
position: { x: number, y: number };
|
||||
result: 'miss' | 'hit' | 'destroy';
|
||||
targetPlaneId?: string; // 如果击中
|
||||
isGameOver: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
#### `ERROR` (S2C)
|
||||
- **描述**: 服务端向客户端发送错误信息。
|
||||
- **Payload**:
|
||||
```typescript
|
||||
interface ErrorPayload {
|
||||
code: number; // 错误码
|
||||
message: string; // 错误信息
|
||||
requestType?: string; // 导致错误的请求类型
|
||||
}
|
||||
```
|
||||
|
||||
## 3. 断线重连机制
|
||||
|
||||
### 3.1 核心流程
|
||||
|
||||
1. **客户端检测断线**:
|
||||
- WebSocket `onclose` 事件被触发。
|
||||
- 或,发送`PING`后超过10秒未收到`PONG`。
|
||||
|
||||
2. **自动重连**:
|
||||
- 客户端立即尝试重新建立WebSocket连接。
|
||||
- 采用**指数退避算法**进行重连尝试,例如:首次延迟1秒,然后2秒, 4秒, 8秒... 直到成功或达到最大重连次数(5次)。
|
||||
|
||||
3. **重连后认证**:
|
||||
- 新连接建立后,客户端必须立即发送`AUTHENTICATE`消息,并携带之前的JWT。
|
||||
|
||||
4. **服务端处理重连**:
|
||||
- 服务端通过JWT识别出这是同个玩家的重连请求。
|
||||
- 服务端查找该玩家当前是否处于某个游戏会话中。
|
||||
- 如果在游戏中,服务端将最新的`GameState`发送给该玩家,并向房间内所有玩家广播`PLAYER_RECONNECTED`事件。
|
||||
|
||||
5. **状态同步**:
|
||||
- 客户端收到完整的`GameState`后,恢复游戏界面,确保与服务器状态一致。
|
||||
|
||||
### 3.2 服务端实现要点
|
||||
|
||||
- **会话持久化**: 玩家的`userId`和其`connectionId`的映射关系需要存储在Redis中,并设置合理的过期时间(如5分钟),以便在断线期间保留会话信息。
|
||||
- **游戏状态恢复**: `GameStateMachine`实例必须在玩家断线时保留在内存中,直到游戏结束或超时。当玩家重连时,可以从该实例获取最新状态。
|
||||
|
||||
```typescript
|
||||
// Redis中存储的重连会话信息
|
||||
// Key: "reconnect:session:{userId}"
|
||||
// Value (HASH):
|
||||
// connectionId: "previous-connection-id"
|
||||
// gameId: "current-game-id"
|
||||
// roomCode: "current-room-code"
|
||||
// expireAt: "timestamp"
|
||||
```
|
||||
|
||||
## 4. 多节点部署与消息同步
|
||||
|
||||
### 4.1 挑战
|
||||
|
||||
当后端WebSocket服务部署在多个节点上时,同一房间的两个玩家可能连接到不同的服务器实例。一个玩家发送的消息需要被广播给连接在另一台服务器上的对手。
|
||||
|
||||
### 4.2 解决方案:Redis Pub/Sub
|
||||
|
||||
使用Redis的发布/订阅(Pub/Sub)机制作为消息总线,实现跨节点通信。
|
||||
|
||||
1. **消息流**:
|
||||
- 客户端A向服务器S1发送消息(如`EXECUTE_ATTACK`)。
|
||||
- S1处理消息,更新游戏状态。
|
||||
- S1将需要广播的消息(如`ATTACK_RESULT`, `GAME_STATE_UPDATE`)发布到一个特定的Redis频道,例如`game-room:{roomCode}`。
|
||||
- 所有后端服务器实例(S1, S2, ...)都订阅了相关的频道。
|
||||
- S2接收到`game-room:{roomCode}`频道的消息。
|
||||
|
||||
2. **消息投递**:
|
||||
- S2查找连接在本机且属于`roomCode`房间的客户端(即客户端B)。
|
||||
- S2将消息通过WebSocket连接发送给客户端B。
|
||||
|
||||
### 4.3 `socket.io` 与 `socket.io-redis-adapter`
|
||||
|
||||
为简化实现,推荐使用`socket.io`库及其官方Redis适配器`socket.io-redis-adapter`。
|
||||
|
||||
- **`socket.io`**: 提供了房间(room)、广播(broadcast)、命名空间(namespace)等高级抽象,并内置了心跳和自动重连机制。
|
||||
- **`socket.io-redis-adapter`**: 自动处理了上述的Redis Pub/Sub逻辑。只需简单配置,即可实现多节点间的无缝消息广播。
|
||||
|
||||
#### 示例配置
|
||||
```typescript
|
||||
import { Server } from 'socket.io';
|
||||
import { createAdapter } from '@socket.io/redis-adapter';
|
||||
import { createClient } from 'redis';
|
||||
|
||||
const io = new Server();
|
||||
|
||||
const pubClient = createClient({ url: 'redis://localhost:6379' });
|
||||
const subClient = pubClient.duplicate();
|
||||
|
||||
Promise.all([pubClient.connect(), subClient.connect()]).then(() => {
|
||||
io.adapter(createAdapter(pubClient, subClient));
|
||||
io.listen(3000);
|
||||
});
|
||||
|
||||
// 使用方法
|
||||
io.to('some-room-code').emit('event_name', { data: '...' });
|
||||
```
|
||||
|
||||
此方案将所有跨节点通信的复杂性都交由`socket.io-redis-adapter`处理,使业务代码可以专注于游戏逻辑,无需关心底层的消息路由。
|
||||
210
02_详细设计文档/数据库设计详设.md
Normal file
210
02_详细设计文档/数据库设计详设.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# 数据库设计详设文档
|
||||
|
||||
> **文档版本**: v1.0
|
||||
> **撰写人**: 数据库架构师
|
||||
> **创建日期**: 2024年9月11日
|
||||
|
||||
## 1. 技术选型
|
||||
|
||||
- **主数据库**: **MongoDB 6.0+**
|
||||
- **原因**: 采用面向文档的存储模型,非常适合存储游戏会话、用户配置等半结构化数据。其灵活的Schema设计能快速迭代,内建的复制和分片功能为高可用和高扩展性提供了有力支持。
|
||||
- **缓存/消息队列**: **Redis 7.0+**
|
||||
- **原因**: 基于内存的高性能键值存储,用于缓存热点数据(如用户信息、排行榜)、管理WebSocket会话、实现分布式锁及作为多节点部署时的消息总线(Pub/Sub)。
|
||||
- **ORM/ODM**: **Mongoose 7.x**
|
||||
- **原因**: 为MongoDB提供强大的对象数据建模(ODM)能力,支持Schema定义、数据校验、中间件、查询构建等功能,能显著提升开发效率和代码健壮性。
|
||||
|
||||
## 2. MongoDB 数据模型设计
|
||||
|
||||
### 2.1 `users` 集合
|
||||
|
||||
存储用户信息。
|
||||
|
||||
- **Schema 定义**:
|
||||
```typescript
|
||||
import { Schema, model } from 'mongoose';
|
||||
|
||||
const userSchema = new Schema({
|
||||
_id: { type: String, required: true }, // 使用微信的 openid 作为主键
|
||||
nickname: { type: String, required: true },
|
||||
avatarUrl: { type: String, required: true },
|
||||
|
||||
stats: {
|
||||
gamesPlayed: { type: Number, default: 0 },
|
||||
gamesWon: { type: Number, default: 0 },
|
||||
winRate: { type: Number, default: 0.0 },
|
||||
totalShots: { type: Number, default: 0 },
|
||||
totalHits: { type: Number, default: 0 },
|
||||
accuracy: { type: Number, default: 0.0 },
|
||||
eloRating: { type: Number, default: 1200 } // Elo积分系统
|
||||
},
|
||||
|
||||
lastLoginAt: { type: Date, default: Date.now },
|
||||
createdAt: { type: Date, default: Date.now, immutable: true },
|
||||
updatedAt: { type: Date, default: Date.now }
|
||||
}, {
|
||||
timestamps: { createdAt: 'createdAt', updatedAt: 'updatedAt' }
|
||||
});
|
||||
|
||||
// Mongoose 中间件,用于在胜率和准确率变化时自动更新
|
||||
userSchema.pre('save', function(next) {
|
||||
if (this.isModified('stats.gamesPlayed') || this.isModified('stats.gamesWon')) {
|
||||
this.stats.winRate = this.stats.gamesPlayed > 0 ? (this.stats.gamesWon / this.stats.gamesPlayed) * 100 : 0;
|
||||
}
|
||||
if (this.isModified('stats.totalShots') || this.isModified('stats.totalHits')) {
|
||||
this.stats.accuracy = this.stats.totalShots > 0 ? (this.stats.totalHits / this.stats.totalShots) * 100 : 0;
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
export const UserModel = model('User', userSchema);
|
||||
```
|
||||
|
||||
- **索引 (Indexes)**:
|
||||
- `_id`: (主键) 唯一索引,用于快速查找用户。
|
||||
- `{ "stats.eloRating": -1 }`: 降序索引,用于实现排行榜。
|
||||
|
||||
### 2.2 `game_sessions` 集合
|
||||
|
||||
存储完整的游戏对局信息,用于复盘、数据分析和问题排查。
|
||||
|
||||
- **Schema 定义**:
|
||||
```typescript
|
||||
import { Schema, model } from 'mongoose';
|
||||
|
||||
const positionSchema = new Schema({ x: Number, y: Number }, { _id: false });
|
||||
const planeSchema = new Schema({
|
||||
center: positionSchema,
|
||||
direction: String,
|
||||
positions: [positionSchema]
|
||||
}, { _id: false });
|
||||
|
||||
const playerStateSchema = new Schema({
|
||||
userId: { type: String, ref: 'User', required: true },
|
||||
board: {
|
||||
planes: [planeSchema],
|
||||
attackHistory: [{
|
||||
position: positionSchema,
|
||||
result: String, // 'miss', 'hit', 'destroy'
|
||||
timestamp: Date
|
||||
}]
|
||||
},
|
||||
stats: {
|
||||
shots: Number,
|
||||
hits: Number,
|
||||
planesDestroyed: Number
|
||||
}
|
||||
}, { _id: false });
|
||||
|
||||
const gameSessionSchema = new Schema({
|
||||
_id: { type: String, required: true }, // 游戏会话ID
|
||||
roomCode: { type: String, required: true, index: true },
|
||||
status: { type: String, required: true, enum: ['placing', 'battling', 'finished'], default: 'placing' },
|
||||
|
||||
players: [{ type: String, ref: 'User' }],
|
||||
playerStates: [playerStateSchema],
|
||||
|
||||
winnerId: { type: String, ref: 'User' },
|
||||
winReason: { type: String, enum: ['ALL_PLANES_DESTROYED', 'SURRENDER', 'TIMEOUT'] },
|
||||
|
||||
gameEvents: [{
|
||||
type: String,
|
||||
playerId: String,
|
||||
data: Schema.Types.Mixed,
|
||||
timestamp: Date
|
||||
}],
|
||||
|
||||
startedAt: { type: Date, default: Date.now },
|
||||
finishedAt: { type: Date }
|
||||
});
|
||||
|
||||
export const GameSessionModel = model('GameSession', gameSessionSchema);
|
||||
```
|
||||
|
||||
- **索引 (Indexes)**:
|
||||
- `_id`: (主键) 唯一索引。
|
||||
- `{ roomCode: 1 }`: 用于通过房间码快速查找游戏。
|
||||
- `{ "players": 1 }`: 多键索引,用于查询某玩家参与的所有对局。
|
||||
- `{ status: 1, startedAt: -1 }`: 复合索引,用于查找特定状态的游戏并按时间排序。
|
||||
|
||||
## 3. Redis 数据结构设计
|
||||
|
||||
Redis 用于存储高频访问、易失性或需要原子操作的数据。
|
||||
|
||||
### 3.1 用户会话 (User Session)
|
||||
|
||||
- **用途**: 存储用户登录状态和WebSocket连接信息。
|
||||
- **数据结构**: `HASH`
|
||||
- **Key**: `session:{userId}`
|
||||
- **Value**:
|
||||
- `token`: `string` (JWT)
|
||||
- `connectionId`: `string` (当前WebSocket连接ID)
|
||||
- `status`: `'online' | 'offline' | 'in-game'`
|
||||
- `gameId`: `string` (如果`status`为`in-game`)
|
||||
- **TTL**: 24小时 (每次访问刷新)
|
||||
|
||||
### 3.2 游戏房间 (Game Rooms)
|
||||
|
||||
- **用途**: 管理游戏房间列表和房间内玩家状态。
|
||||
- **数据结构**: `HASH`
|
||||
- **Key**: `room:{roomCode}`
|
||||
- **Value**:
|
||||
- `name`: `string` (房间名)
|
||||
- `ownerId`: `string` (房主用户ID)
|
||||
- `status`: `'waiting' | 'full' | 'in-game'`
|
||||
- `player1Id`: `string`
|
||||
- `player2Id`: `string`
|
||||
- `player1Ready`: `'0' | '1'`
|
||||
- `player2Ready`: `'0' | '1'`
|
||||
- **TTL**: 2小时 (从最后一次活动开始计算)
|
||||
|
||||
### 3.3 游戏状态 (Live Game State)
|
||||
|
||||
- **用途**: 缓存进行中游戏的核心状态,减少对MongoDB的读写压力。
|
||||
- **数据结构**: `STRING` (存储序列化后的`GameState`对象)
|
||||
- **Key**: `game:state:{gameId}`
|
||||
- **Value**: `JSON.stringify(GameState)`
|
||||
- **TTL**: 1小时 (游戏结束后删除)
|
||||
|
||||
### 3.4 排行榜 (Leaderboard)
|
||||
|
||||
- **用途**: 实时更新和查询玩家排名。
|
||||
- **数据结构**: `SORTED SET` (ZSET)
|
||||
- **Key**: `leaderboard:elo`
|
||||
- **Value**:
|
||||
- `member`: `userId`
|
||||
- `score`: `eloRating` (整数)
|
||||
|
||||
- **操作**:
|
||||
- **更新排名**: `ZADD leaderboard:elo <eloRating> <userId>`
|
||||
- **查询Top 100**: `ZREVRANGE leaderboard:elo 0 99 WITHSCORES`
|
||||
- **查询玩家排名**: `ZREVRANK leaderboard:elo <userId>`
|
||||
|
||||
### 3.5 分布式锁 (Distributed Lock)
|
||||
|
||||
- **用途**: 在关键操作(如匹配玩家、创建游戏)中防止并发冲突。
|
||||
- **数据结构**: `STRING`
|
||||
- **Key**: `lock:{resource_name}:{resource_id}` (e.g., `lock:room:join:{roomCode}`)
|
||||
- **Value**: `unique_lock_id` (e.g., a UUID)
|
||||
- **操作**: 使用`SET key value NX PX milliseconds`命令实现原子性的加锁操作。
|
||||
- `NX`: 只在键不存在时设置。
|
||||
- `PX`: 设置过期时间(毫秒),防止死锁。
|
||||
|
||||
## 4. 数据一致性策略
|
||||
|
||||
- **写操作**:
|
||||
1. **关键操作** (如`EXECUTE_ATTACK`):
|
||||
- 开启**分布式锁**。
|
||||
- 更新Redis中的**实时游戏状态** (`game:state:{gameId}`)。
|
||||
- 将操作事件**异步写入**一个队列(如Redis Stream或RabbitMQ)。
|
||||
- **释放锁**。
|
||||
- 立即向客户端返回成功响应。
|
||||
2. 一个独立的**后台Worker**消费队列中的事件,批量将游戏会话数据持久化到MongoDB (`game_sessions` 集合)。
|
||||
|
||||
- **读操作**:
|
||||
- **进行中的游戏**: 优先从**Redis**读取实时状态。
|
||||
- **历史游戏/玩家统计**: 从**MongoDB**读取。
|
||||
|
||||
- **优势**:
|
||||
- **低延迟**: 游戏核心逻辑的读写都在内存中完成,响应迅速。
|
||||
- **高吞吐**: 将对DB的写操作异步化和批量化,减轻数据库压力。
|
||||
- **数据最终一致性**: 即使后台Worker暂时失败,数据也保留在队列中,保证最终会持久化到MongoDB。
|
||||
908
02_详细设计文档/游戏核心逻辑详设.md
Normal file
908
02_详细设计文档/游戏核心逻辑详设.md
Normal file
@@ -0,0 +1,908 @@
|
||||
# 游戏核心逻辑详设文档
|
||||
|
||||
> **文档版本**: v1.0
|
||||
> **撰写人**: 游戏逻辑架构师
|
||||
> **创建日期**: 2024年9月11日
|
||||
|
||||
## 1. 游戏逻辑总览
|
||||
|
||||
### 1.1 核心游戏机制
|
||||
|
||||
基于经典"打飞机"游戏规则,实现回合制战略对战:
|
||||
|
||||
```typescript
|
||||
// 游戏核心参数
|
||||
const GAME_CONFIG = {
|
||||
BOARD_SIZE: 10, // 10x10棋盘
|
||||
PLANE_COUNT: 3, // 每位玩家3架飞机
|
||||
PLANE_SIZE: 11, // 每架飞机占11个格子
|
||||
TURN_TIME_LIMIT: 30, // 每回合30秒限时
|
||||
GAME_TIME_LIMIT: 1800 // 游戏总时长30分钟
|
||||
}
|
||||
|
||||
// 游戏阶段枚举
|
||||
enum GamePhase {
|
||||
WAITING = 'waiting', // 等待玩家
|
||||
PLACING = 'placing', // 飞机布置阶段
|
||||
BATTLING = 'battling', // 对战阶段
|
||||
FINISHED = 'finished' // 游戏结束
|
||||
}
|
||||
|
||||
// 攻击结果类型
|
||||
enum AttackResult {
|
||||
MISS = 'miss', // 未命中
|
||||
HIT = 'hit', // 命中
|
||||
DESTROY = 'destroy' // 击毁飞机
|
||||
}
|
||||
```
|
||||
|
||||
### 1.2 飞机几何模型
|
||||
|
||||
```typescript
|
||||
// 飞机形状定义
|
||||
interface PlaneShape {
|
||||
id: string
|
||||
center: Position // 飞机头部位置(中心点)
|
||||
direction: Direction // 飞机朝向
|
||||
positions: Position[] // 飞机占据的所有位置
|
||||
parts: {
|
||||
head: Position // 头部(1个格子)
|
||||
wings: Position[] // 翅膀(5个格子)
|
||||
body: Position[] // 机身(2个格子)
|
||||
tail: Position[] // 尾翼(3个格子)
|
||||
}
|
||||
}
|
||||
|
||||
// 方向枚举
|
||||
enum Direction {
|
||||
UP = 'up',
|
||||
DOWN = 'down',
|
||||
LEFT = 'left',
|
||||
RIGHT = 'right'
|
||||
}
|
||||
|
||||
// 位置坐标
|
||||
interface Position {
|
||||
x: number // 行坐标 (0-9)
|
||||
y: number // 列坐标 (0-9)
|
||||
}
|
||||
|
||||
// 飞机几何生成器
|
||||
export class PlaneGeometry {
|
||||
static generatePlane(center: Position, direction: Direction): PlaneShape {
|
||||
const plane: PlaneShape = {
|
||||
id: generateId(),
|
||||
center,
|
||||
direction,
|
||||
positions: [],
|
||||
parts: {
|
||||
head: center,
|
||||
wings: [],
|
||||
body: [],
|
||||
tail: []
|
||||
}
|
||||
}
|
||||
|
||||
switch (direction) {
|
||||
case Direction.UP:
|
||||
plane.parts = {
|
||||
head: center,
|
||||
wings: [
|
||||
{ x: center.x + 1, y: center.y - 2 },
|
||||
{ x: center.x + 1, y: center.y - 1 },
|
||||
{ x: center.x + 1, y: center.y },
|
||||
{ x: center.x + 1, y: center.y + 1 },
|
||||
{ x: center.x + 1, y: center.y + 2 }
|
||||
],
|
||||
body: [
|
||||
{ x: center.x + 2, y: center.y },
|
||||
{ x: center.x + 3, y: center.y }
|
||||
],
|
||||
tail: [
|
||||
{ x: center.x + 4, y: center.y - 1 },
|
||||
{ x: center.x + 4, y: center.y },
|
||||
{ x: center.x + 4, y: center.y + 1 }
|
||||
]
|
||||
}
|
||||
break
|
||||
|
||||
case Direction.DOWN:
|
||||
plane.parts = {
|
||||
head: center,
|
||||
wings: [
|
||||
{ x: center.x - 1, y: center.y - 2 },
|
||||
{ x: center.x - 1, y: center.y - 1 },
|
||||
{ x: center.x - 1, y: center.y },
|
||||
{ x: center.x - 1, y: center.y + 1 },
|
||||
{ x: center.x - 1, y: center.y + 2 }
|
||||
],
|
||||
body: [
|
||||
{ x: center.x - 2, y: center.y },
|
||||
{ x: center.x - 3, y: center.y }
|
||||
],
|
||||
tail: [
|
||||
{ x: center.x - 4, y: center.y - 1 },
|
||||
{ x: center.x - 4, y: center.y },
|
||||
{ x: center.x - 4, y: center.y + 1 }
|
||||
]
|
||||
}
|
||||
break
|
||||
|
||||
case Direction.LEFT:
|
||||
plane.parts = {
|
||||
head: center,
|
||||
wings: [
|
||||
{ x: center.x - 2, y: center.y + 1 },
|
||||
{ x: center.x - 1, y: center.y + 1 },
|
||||
{ x: center.x, y: center.y + 1 },
|
||||
{ x: center.x + 1, y: center.y + 1 },
|
||||
{ x: center.x + 2, y: center.y + 1 }
|
||||
],
|
||||
body: [
|
||||
{ x: center.x, y: center.y + 2 },
|
||||
{ x: center.x, y: center.y + 3 }
|
||||
],
|
||||
tail: [
|
||||
{ x: center.x - 1, y: center.y + 4 },
|
||||
{ x: center.x, y: center.y + 4 },
|
||||
{ x: center.x + 1, y: center.y + 4 }
|
||||
]
|
||||
}
|
||||
break
|
||||
|
||||
case Direction.RIGHT:
|
||||
plane.parts = {
|
||||
head: center,
|
||||
wings: [
|
||||
{ x: center.x - 2, y: center.y - 1 },
|
||||
{ x: center.x - 1, y: center.y - 1 },
|
||||
{ x: center.x, y: center.y - 1 },
|
||||
{ x: center.x + 1, y: center.y - 1 },
|
||||
{ x: center.x + 2, y: center.y - 1 }
|
||||
],
|
||||
body: [
|
||||
{ x: center.x, y: center.y - 2 },
|
||||
{ x: center.x, y: center.y - 3 }
|
||||
],
|
||||
tail: [
|
||||
{ x: center.x - 1, y: center.y - 4 },
|
||||
{ x: center.x, y: center.y - 4 },
|
||||
{ x: center.x + 1, y: center.y - 4 }
|
||||
]
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// 合并所有位置
|
||||
plane.positions = [
|
||||
plane.parts.head,
|
||||
...plane.parts.wings,
|
||||
...plane.parts.body,
|
||||
...plane.parts.tail
|
||||
]
|
||||
|
||||
return plane
|
||||
}
|
||||
|
||||
// 验证飞机位置是否合法
|
||||
static validatePlanePosition(plane: PlaneShape, boardSize: number = 10): boolean {
|
||||
return plane.positions.every(pos =>
|
||||
pos.x >= 0 && pos.x < boardSize &&
|
||||
pos.y >= 0 && pos.y < boardSize
|
||||
)
|
||||
}
|
||||
|
||||
// 检查两架飞机是否重叠
|
||||
static checkPlanesOverlap(plane1: PlaneShape, plane2: PlaneShape): boolean {
|
||||
return plane1.positions.some(pos1 =>
|
||||
plane2.positions.some(pos2 =>
|
||||
pos1.x === pos2.x && pos1.y === pos2.y
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 2. 棋盘状态管理
|
||||
|
||||
### 2.1 棋盘数据结构
|
||||
|
||||
```typescript
|
||||
// 单元格状态
|
||||
enum CellState {
|
||||
EMPTY = 'empty', // 空格
|
||||
PLANE_PART = 'plane_part', // 飞机部件
|
||||
ATTACKED_MISS = 'attacked_miss', // 攻击未命中
|
||||
ATTACKED_HIT = 'attacked_hit' // 攻击命中
|
||||
}
|
||||
|
||||
// 棋盘单元格
|
||||
interface BoardCell {
|
||||
position: Position
|
||||
state: CellState
|
||||
planeId?: string // 所属飞机ID
|
||||
partType?: 'head' | 'wing' | 'body' | 'tail' // 部件类型
|
||||
isDestroyed?: boolean // 是否已被击毁
|
||||
attackedAt?: Date // 攻击时间
|
||||
}
|
||||
|
||||
// 游戏棋盘
|
||||
interface GameBoard {
|
||||
size: number // 棋盘大小 (10x10)
|
||||
cells: BoardCell[][] // 二维单元格数组
|
||||
planes: PlaneShape[] // 放置的飞机
|
||||
attackHistory: AttackRecord[] // 攻击历史
|
||||
remainingPlanes: number // 剩余飞机数量
|
||||
}
|
||||
|
||||
// 攻击记录
|
||||
interface AttackRecord {
|
||||
position: Position
|
||||
result: AttackResult
|
||||
timestamp: Date
|
||||
targetPlaneId?: string
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 棋盘操作类
|
||||
|
||||
```typescript
|
||||
export class BoardManager {
|
||||
// 创建空棋盘
|
||||
static createEmptyBoard(size: number = 10): GameBoard {
|
||||
const cells: BoardCell[][] = []
|
||||
|
||||
for (let x = 0; x < size; x++) {
|
||||
cells[x] = []
|
||||
for (let y = 0; y < size; y++) {
|
||||
cells[x][y] = {
|
||||
position: { x, y },
|
||||
state: CellState.EMPTY
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
size,
|
||||
cells,
|
||||
planes: [],
|
||||
attackHistory: [],
|
||||
remainingPlanes: 0
|
||||
}
|
||||
}
|
||||
|
||||
// 在棋盘上放置飞机
|
||||
static placePlane(board: GameBoard, plane: PlaneShape): boolean {
|
||||
// 验证飞机位置合法性
|
||||
if (!PlaneGeometry.validatePlanePosition(plane, board.size)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查是否与现有飞机重叠
|
||||
for (const existingPlane of board.planes) {
|
||||
if (PlaneGeometry.checkPlanesOverlap(plane, existingPlane)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 在棋盘上标记飞机位置
|
||||
plane.positions.forEach(pos => {
|
||||
const cell = board.cells[pos.x][pos.y]
|
||||
cell.state = CellState.PLANE_PART
|
||||
cell.planeId = plane.id
|
||||
|
||||
// 标记部件类型
|
||||
if (pos.x === plane.parts.head.x && pos.y === plane.parts.head.y) {
|
||||
cell.partType = 'head'
|
||||
} else if (plane.parts.wings.some(w => w.x === pos.x && w.y === pos.y)) {
|
||||
cell.partType = 'wing'
|
||||
} else if (plane.parts.body.some(b => b.x === pos.x && b.y === pos.y)) {
|
||||
cell.partType = 'body'
|
||||
} else {
|
||||
cell.partType = 'tail'
|
||||
}
|
||||
})
|
||||
|
||||
// 添加飞机到棋盘
|
||||
board.planes.push(plane)
|
||||
board.remainingPlanes++
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// 批量放置飞机
|
||||
static placePlanes(board: GameBoard, planes: PlaneShape[]): boolean {
|
||||
if (planes.length !== 3) {
|
||||
throw new Error('必须放置3架飞机')
|
||||
}
|
||||
|
||||
// 创建临时棋盘进行验证
|
||||
const tempBoard = this.createEmptyBoard(board.size)
|
||||
|
||||
// 逐个放置验证
|
||||
for (const plane of planes) {
|
||||
if (!this.placePlane(tempBoard, plane)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 验证通过,应用到实际棋盘
|
||||
board.cells = tempBoard.cells
|
||||
board.planes = tempBoard.planes
|
||||
board.remainingPlanes = tempBoard.remainingPlanes
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// 执行攻击
|
||||
static executeAttack(board: GameBoard, position: Position): AttackResult {
|
||||
const cell = board.cells[position.x][position.y]
|
||||
|
||||
// 检查是否已经攻击过该位置
|
||||
if (cell.state === CellState.ATTACKED_MISS || cell.state === CellState.ATTACKED_HIT) {
|
||||
throw new Error('该位置已被攻击过')
|
||||
}
|
||||
|
||||
let result: AttackResult
|
||||
let targetPlaneId: string | undefined
|
||||
|
||||
if (cell.state === CellState.PLANE_PART) {
|
||||
// 命中飞机
|
||||
cell.state = CellState.ATTACKED_HIT
|
||||
cell.isDestroyed = true
|
||||
targetPlaneId = cell.planeId
|
||||
|
||||
// 检查飞机是否完全被击毁
|
||||
const plane = board.planes.find(p => p.id === targetPlaneId)!
|
||||
const allPartsDestroyed = plane.positions.every(pos => {
|
||||
const targetCell = board.cells[pos.x][pos.y]
|
||||
return targetCell.isDestroyed
|
||||
})
|
||||
|
||||
if (allPartsDestroyed) {
|
||||
result = AttackResult.DESTROY
|
||||
board.remainingPlanes--
|
||||
|
||||
// 标记整架飞机为已击毁
|
||||
plane.positions.forEach(pos => {
|
||||
board.cells[pos.x][pos.y].isDestroyed = true
|
||||
})
|
||||
} else {
|
||||
result = AttackResult.HIT
|
||||
}
|
||||
} else {
|
||||
// 未命中
|
||||
cell.state = CellState.ATTACKED_MISS
|
||||
result = AttackResult.MISS
|
||||
}
|
||||
|
||||
// 记录攻击历史
|
||||
const attackRecord: AttackRecord = {
|
||||
position,
|
||||
result,
|
||||
timestamp: new Date(),
|
||||
targetPlaneId
|
||||
}
|
||||
board.attackHistory.push(attackRecord)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// 检查游戏是否结束
|
||||
static isGameOver(board: GameBoard): boolean {
|
||||
return board.remainingPlanes === 0
|
||||
}
|
||||
|
||||
// 获取对手视图的棋盘(隐藏未被攻击的飞机位置)
|
||||
static getOpponentView(board: GameBoard): GameBoard {
|
||||
const opponentBoard = JSON.parse(JSON.stringify(board)) as GameBoard
|
||||
|
||||
// 隐藏未被攻击的飞机位置
|
||||
for (let x = 0; x < board.size; x++) {
|
||||
for (let y = 0; y < board.size; y++) {
|
||||
const cell = opponentBoard.cells[x][y]
|
||||
if (cell.state === CellState.PLANE_PART && !cell.isDestroyed) {
|
||||
cell.state = CellState.EMPTY
|
||||
delete cell.planeId
|
||||
delete cell.partType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return opponentBoard
|
||||
}
|
||||
|
||||
// 获取棋盘统计信息
|
||||
static getBoardStats(board: GameBoard): BoardStats {
|
||||
const totalCells = board.size * board.size
|
||||
const attackedCells = board.attackHistory.length
|
||||
const hitCells = board.attackHistory.filter(a => a.result !== AttackResult.MISS).length
|
||||
const accuracy = attackedCells > 0 ? (hitCells / attackedCells * 100) : 0
|
||||
|
||||
return {
|
||||
totalCells,
|
||||
attackedCells,
|
||||
hitCells,
|
||||
accuracy: Math.round(accuracy * 100) / 100,
|
||||
remainingPlanes: board.remainingPlanes,
|
||||
destroyedPlanes: 3 - board.remainingPlanes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface BoardStats {
|
||||
totalCells: number
|
||||
attackedCells: number
|
||||
hitCells: number
|
||||
accuracy: number
|
||||
remainingPlanes: number
|
||||
destroyedPlanes: number
|
||||
}
|
||||
```
|
||||
|
||||
## 3. 游戏状态机
|
||||
|
||||
### 3.1 游戏状态管理
|
||||
|
||||
```typescript
|
||||
// 游戏状态
|
||||
interface GameState {
|
||||
gameId: string
|
||||
roomCode: string
|
||||
phase: GamePhase
|
||||
players: GamePlayer[]
|
||||
currentPlayer: string
|
||||
boards: { [playerId: string]: GameBoard }
|
||||
gameConfig: GameConfig
|
||||
timeState: TimeState
|
||||
events: GameEvent[]
|
||||
result?: GameResult
|
||||
}
|
||||
|
||||
// 游戏玩家
|
||||
interface GamePlayer {
|
||||
id: string
|
||||
nickname: string
|
||||
avatar?: string
|
||||
isReady: boolean
|
||||
isOnline: boolean
|
||||
stats: PlayerGameStats
|
||||
}
|
||||
|
||||
// 玩家游戏内统计
|
||||
interface PlayerGameStats {
|
||||
attacksCount: number
|
||||
hitsCount: number
|
||||
planesDestroyed: number
|
||||
accuracy: number
|
||||
timeUsed: number
|
||||
}
|
||||
|
||||
// 时间状态
|
||||
interface TimeState {
|
||||
gameStartTime?: Date
|
||||
gameEndTime?: Date
|
||||
currentTurnStartTime?: Date
|
||||
turnTimeLimit: number
|
||||
totalTimeLimit: number
|
||||
turnTimeRemaining: number
|
||||
gameTimeRemaining: number
|
||||
}
|
||||
|
||||
// 游戏事件
|
||||
interface GameEvent {
|
||||
id: string
|
||||
type: GameEventType
|
||||
playerId: string
|
||||
timestamp: Date
|
||||
data: any
|
||||
}
|
||||
|
||||
enum GameEventType {
|
||||
GAME_STARTED = 'game_started',
|
||||
PLANE_PLACED = 'plane_placed',
|
||||
PLACEMENT_COMPLETED = 'placement_completed',
|
||||
TURN_STARTED = 'turn_started',
|
||||
ATTACK_EXECUTED = 'attack_executed',
|
||||
PLANE_DESTROYED = 'plane_destroyed',
|
||||
TURN_TIMEOUT = 'turn_timeout',
|
||||
PLAYER_DISCONNECTED = 'player_disconnected',
|
||||
PLAYER_RECONNECTED = 'player_reconnected',
|
||||
GAME_ENDED = 'game_ended'
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 游戏状态机实现
|
||||
|
||||
```typescript
|
||||
export class GameStateMachine {
|
||||
private state: GameState
|
||||
private timers: Map<string, NodeJS.Timeout> = new Map()
|
||||
|
||||
constructor(gameState: GameState) {
|
||||
this.state = gameState
|
||||
}
|
||||
|
||||
// 开始游戏
|
||||
startGame(): void {
|
||||
if (this.state.phase !== GamePhase.WAITING) {
|
||||
throw new Error('游戏状态错误,无法开始游戏')
|
||||
}
|
||||
|
||||
this.state.phase = GamePhase.PLACING
|
||||
this.state.timeState.gameStartTime = new Date()
|
||||
|
||||
// 设置游戏总时长定时器
|
||||
this.setGameTimeLimit()
|
||||
|
||||
this.addEvent({
|
||||
type: GameEventType.GAME_STARTED,
|
||||
playerId: '',
|
||||
data: { startTime: this.state.timeState.gameStartTime }
|
||||
})
|
||||
}
|
||||
|
||||
// 玩家放置飞机
|
||||
placePlanes(playerId: string, planes: PlaneShape[]): void {
|
||||
if (this.state.phase !== GamePhase.PLACING) {
|
||||
throw new Error('当前不是飞机放置阶段')
|
||||
}
|
||||
|
||||
const player = this.getPlayer(playerId)
|
||||
if (player.isReady) {
|
||||
throw new Error('玩家已经完成飞机放置')
|
||||
}
|
||||
|
||||
// 放置飞机到棋盘
|
||||
const board = this.state.boards[playerId]
|
||||
const success = BoardManager.placePlanes(board, planes)
|
||||
|
||||
if (!success) {
|
||||
throw new Error('飞机放置失败')
|
||||
}
|
||||
|
||||
// 标记玩家已准备
|
||||
player.isReady = true
|
||||
|
||||
this.addEvent({
|
||||
type: GameEventType.PLACEMENT_COMPLETED,
|
||||
playerId,
|
||||
data: { planes: planes.length }
|
||||
})
|
||||
|
||||
// 检查是否所有玩家都已准备
|
||||
if (this.allPlayersReady()) {
|
||||
this.startBattle()
|
||||
}
|
||||
}
|
||||
|
||||
// 开始对战阶段
|
||||
private startBattle(): void {
|
||||
this.state.phase = GamePhase.BATTLING
|
||||
|
||||
// 随机选择先手玩家
|
||||
const firstPlayer = this.state.players[Math.floor(Math.random() * this.state.players.length)]
|
||||
this.state.currentPlayer = firstPlayer.id
|
||||
|
||||
this.startTurn()
|
||||
}
|
||||
|
||||
// 开始新回合
|
||||
private startTurn(): void {
|
||||
this.state.timeState.currentTurnStartTime = new Date()
|
||||
this.state.timeState.turnTimeRemaining = this.state.timeState.turnTimeLimit
|
||||
|
||||
// 设置回合时间限制
|
||||
this.setTurnTimeLimit()
|
||||
|
||||
this.addEvent({
|
||||
type: GameEventType.TURN_STARTED,
|
||||
playerId: this.state.currentPlayer,
|
||||
data: { timeLimit: this.state.timeState.turnTimeLimit }
|
||||
})
|
||||
}
|
||||
|
||||
// 执行攻击
|
||||
executeAttack(playerId: string, position: Position): AttackResult {
|
||||
if (this.state.phase !== GamePhase.BATTLING) {
|
||||
throw new Error('当前不是对战阶段')
|
||||
}
|
||||
|
||||
if (this.state.currentPlayer !== playerId) {
|
||||
throw new Error('不是你的回合')
|
||||
}
|
||||
|
||||
// 获取对手棋盘
|
||||
const opponentId = this.getOpponent(playerId).id
|
||||
const opponentBoard = this.state.boards[opponentId]
|
||||
|
||||
// 执行攻击
|
||||
const result = BoardManager.executeAttack(opponentBoard, position)
|
||||
|
||||
// 更新玩家统计
|
||||
const player = this.getPlayer(playerId)
|
||||
player.stats.attacksCount++
|
||||
if (result !== AttackResult.MISS) {
|
||||
player.stats.hitsCount++
|
||||
player.stats.accuracy = (player.stats.hitsCount / player.stats.attacksCount) * 100
|
||||
}
|
||||
if (result === AttackResult.DESTROY) {
|
||||
player.stats.planesDestroyed++
|
||||
}
|
||||
|
||||
this.addEvent({
|
||||
type: GameEventType.ATTACK_EXECUTED,
|
||||
playerId,
|
||||
data: { position, result, opponentId }
|
||||
})
|
||||
|
||||
if (result === AttackResult.DESTROY) {
|
||||
this.addEvent({
|
||||
type: GameEventType.PLANE_DESTROYED,
|
||||
playerId: opponentId,
|
||||
data: { attackerId: playerId, position }
|
||||
})
|
||||
}
|
||||
|
||||
// 检查游戏是否结束
|
||||
if (BoardManager.isGameOver(opponentBoard)) {
|
||||
this.endGame(playerId)
|
||||
} else {
|
||||
// 切换回合
|
||||
this.switchTurn()
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// 切换回合
|
||||
private switchTurn(): void {
|
||||
this.clearTurnTimer()
|
||||
|
||||
const currentPlayerIndex = this.state.players.findIndex(p => p.id === this.state.currentPlayer)
|
||||
const nextPlayerIndex = (currentPlayerIndex + 1) % this.state.players.length
|
||||
this.state.currentPlayer = this.state.players[nextPlayerIndex].id
|
||||
|
||||
this.startTurn()
|
||||
}
|
||||
|
||||
// 回合超时处理
|
||||
private handleTurnTimeout(): void {
|
||||
this.addEvent({
|
||||
type: GameEventType.TURN_TIMEOUT,
|
||||
playerId: this.state.currentPlayer,
|
||||
data: { timeUsed: this.state.timeState.turnTimeLimit }
|
||||
})
|
||||
|
||||
// 自动跳过回合
|
||||
this.switchTurn()
|
||||
}
|
||||
|
||||
// 结束游戏
|
||||
private endGame(winnerId: string): void {
|
||||
this.state.phase = GamePhase.FINISHED
|
||||
this.state.timeState.gameEndTime = new Date()
|
||||
|
||||
const winner = this.getPlayer(winnerId)
|
||||
const loser = this.getOpponent(winnerId)
|
||||
|
||||
this.state.result = {
|
||||
winnerId,
|
||||
loserId: loser.id,
|
||||
winReason: 'ALL_PLANES_DESTROYED',
|
||||
gameStats: {
|
||||
duration: this.getGameDuration(),
|
||||
totalMoves: this.state.events.filter(e => e.type === GameEventType.ATTACK_EXECUTED).length,
|
||||
winnerStats: winner.stats,
|
||||
loserStats: loser.stats
|
||||
}
|
||||
}
|
||||
|
||||
// 清除所有定时器
|
||||
this.clearAllTimers()
|
||||
|
||||
this.addEvent({
|
||||
type: GameEventType.GAME_ENDED,
|
||||
playerId: winnerId,
|
||||
data: this.state.result
|
||||
})
|
||||
}
|
||||
|
||||
// 玩家断线处理
|
||||
handlePlayerDisconnection(playerId: string): void {
|
||||
const player = this.getPlayer(playerId)
|
||||
player.isOnline = false
|
||||
|
||||
this.addEvent({
|
||||
type: GameEventType.PLAYER_DISCONNECTED,
|
||||
playerId,
|
||||
data: { timestamp: new Date() }
|
||||
})
|
||||
|
||||
// 如果是对战阶段且是当前玩家断线,暂停计时
|
||||
if (this.state.phase === GamePhase.BATTLING && this.state.currentPlayer === playerId) {
|
||||
this.pauseTurnTimer()
|
||||
}
|
||||
}
|
||||
|
||||
// 玩家重连处理
|
||||
handlePlayerReconnection(playerId: string): void {
|
||||
const player = this.getPlayer(playerId)
|
||||
player.isOnline = true
|
||||
|
||||
this.addEvent({
|
||||
type: GameEventType.PLAYER_RECONNECTED,
|
||||
playerId,
|
||||
data: { timestamp: new Date() }
|
||||
})
|
||||
|
||||
// 如果是对战阶段且是当前玩家重连,恢复计时
|
||||
if (this.state.phase === GamePhase.BATTLING && this.state.currentPlayer === playerId) {
|
||||
this.resumeTurnTimer()
|
||||
}
|
||||
}
|
||||
|
||||
// 定时器管理方法
|
||||
private setGameTimeLimit(): void {
|
||||
const timer = setTimeout(() => {
|
||||
this.endGameByTimeout()
|
||||
}, this.state.timeState.totalTimeLimit * 1000)
|
||||
|
||||
this.timers.set('gameTime', timer)
|
||||
}
|
||||
|
||||
private setTurnTimeLimit(): void {
|
||||
const timer = setTimeout(() => {
|
||||
this.handleTurnTimeout()
|
||||
}, this.state.timeState.turnTimeLimit * 1000)
|
||||
|
||||
this.timers.set('turnTime', timer)
|
||||
}
|
||||
|
||||
private clearTurnTimer(): void {
|
||||
const timer = this.timers.get('turnTime')
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
this.timers.delete('turnTime')
|
||||
}
|
||||
}
|
||||
|
||||
private clearAllTimers(): void {
|
||||
this.timers.forEach(timer => clearTimeout(timer))
|
||||
this.timers.clear()
|
||||
}
|
||||
|
||||
private pauseTurnTimer(): void {
|
||||
// 实现回合计时器暂停逻辑
|
||||
this.clearTurnTimer()
|
||||
}
|
||||
|
||||
private resumeTurnTimer(): void {
|
||||
// 实现回合计时器恢复逻辑
|
||||
this.setTurnTimeLimit()
|
||||
}
|
||||
|
||||
// 辅助方法
|
||||
private getPlayer(playerId: string): GamePlayer {
|
||||
const player = this.state.players.find(p => p.id === playerId)
|
||||
if (!player) {
|
||||
throw new Error('玩家不存在')
|
||||
}
|
||||
return player
|
||||
}
|
||||
|
||||
private getOpponent(playerId: string): GamePlayer {
|
||||
const opponent = this.state.players.find(p => p.id !== playerId)
|
||||
if (!opponent) {
|
||||
throw new Error('对手不存在')
|
||||
}
|
||||
return opponent
|
||||
}
|
||||
|
||||
private allPlayersReady(): boolean {
|
||||
return this.state.players.every(p => p.isReady)
|
||||
}
|
||||
|
||||
private addEvent(event: Omit<GameEvent, 'id' | 'timestamp'>): void {
|
||||
const gameEvent: GameEvent = {
|
||||
id: generateId(),
|
||||
timestamp: new Date(),
|
||||
...event
|
||||
}
|
||||
this.state.events.push(gameEvent)
|
||||
}
|
||||
|
||||
private getGameDuration(): number {
|
||||
if (!this.state.timeState.gameStartTime || !this.state.timeState.gameEndTime) {
|
||||
return 0
|
||||
}
|
||||
return this.state.timeState.gameEndTime.getTime() - this.state.timeState.gameStartTime.getTime()
|
||||
}
|
||||
|
||||
private endGameByTimeout(): void {
|
||||
// 根据当前分数决定胜负
|
||||
const player1 = this.state.players[0]
|
||||
const player2 = this.state.players[1]
|
||||
|
||||
const player1Score = player1.stats.planesDestroyed
|
||||
const player2Score = player2.stats.planesDestroyed
|
||||
|
||||
let winnerId: string
|
||||
if (player1Score > player2Score) {
|
||||
winnerId = player1.id
|
||||
} else if (player2Score > player1Score) {
|
||||
winnerId = player2.id
|
||||
} else {
|
||||
// 平局,根据命中率决定
|
||||
winnerId = player1.stats.accuracy >= player2.stats.accuracy ? player1.id : player2.id
|
||||
}
|
||||
|
||||
this.endGame(winnerId)
|
||||
}
|
||||
|
||||
// 获取当前游戏状态
|
||||
getState(): GameState {
|
||||
return { ...this.state }
|
||||
}
|
||||
|
||||
// 获取玩家视图的游戏状态
|
||||
getPlayerView(playerId: string): any {
|
||||
const state = this.getState()
|
||||
|
||||
// 隐藏对手棋盘上未被攻击的飞机
|
||||
const opponentId = this.getOpponent(playerId).id
|
||||
state.boards[opponentId] = BoardManager.getOpponentView(state.boards[opponentId])
|
||||
|
||||
return state
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 游戏规则验证
|
||||
|
||||
### 4.1 输入验证器
|
||||
|
||||
```typescript
|
||||
export class GameValidator {
|
||||
// 验证飞机放置是否合法
|
||||
static validatePlanesPlacement(planes: PlaneShape[]): ValidationResult {
|
||||
const errors: string[] = []
|
||||
|
||||
// 检查飞机数量
|
||||
if (planes.length !== 3) {
|
||||
errors.push('必须放置3架飞机')
|
||||
}
|
||||
|
||||
// 检查每架飞机的合法性
|
||||
planes.forEach((plane, index) => {
|
||||
// 检查飞机形状是否正确
|
||||
if (plane.positions.length !== 11) {
|
||||
errors.push(`第${index + 1}架飞机形状不正确`)
|
||||
}
|
||||
|
||||
// 检查飞机是否在棋盘范围内
|
||||
if (!PlaneGeometry.validatePlanePosition(plane)) {
|
||||
errors.push(`第${index + 1}架飞机位置超出棋盘范围`)
|
||||
}
|
||||
})
|
||||
|
||||
// 检查飞机之间是否重叠
|
||||
for (let i = 0; i < planes.length; i++) {
|
||||
for (let j = i + 1; j < planes.length; j++) {
|
||||
if (PlaneGeometry.checkPlanesOverlap(planes[i], planes[j])) {
|
||||
errors.push(`第${i + 1}架和第${j + 1}架飞机位置重叠`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface ValidationResult {
|
||||
isValid: boolean
|
||||
errors: string[]
|
||||
}
|
||||
256
02_详细设计文档/部署运维详设.md
Normal file
256
02_详细设计文档/部署运维详设.md
Normal file
@@ -0,0 +1,256 @@
|
||||
# 部署运维详设文档
|
||||
|
||||
> **文档版本**: v1.0
|
||||
> **撰写人**: DevOps工程师
|
||||
> **创建日期**: 2024年9月11日
|
||||
|
||||
## 1. 架构总览
|
||||
|
||||
我们将采用云原生技术栈,以容器化为核心,利用Kubernetes进行服务编排,实现高可用、可扩展、易于维护的部署架构。
|
||||
|
||||
- **云服务提供商**: 推荐使用腾讯云、阿里云等主流云厂商,以获得稳定的基础设施和丰富的云产品支持。
|
||||
- **容器化**: Docker
|
||||
- **容器编排**: Kubernetes (K8s)
|
||||
- **CI/CD**: GitHub Actions
|
||||
- **监控与告警**: Prometheus + Grafana + Alertmanager
|
||||
- **日志管理**: ELK Stack (Elasticsearch, Logstash, Kibana) 或 Loki
|
||||
|
||||
## 2. 容器化 (Docker)
|
||||
|
||||
### 2.1 后端服务 Dockerfile
|
||||
|
||||
一个优化的、多阶段构建的`Dockerfile`示例:
|
||||
|
||||
```dockerfile
|
||||
# ---- Base Stage ----
|
||||
FROM node:18-alpine AS base
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
|
||||
# ---- Dependencies Stage ----
|
||||
FROM base AS dependencies
|
||||
RUN npm install --frozen-lockfile
|
||||
|
||||
# ---- Build Stage ----
|
||||
FROM dependencies AS build
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ---- Production Stage ----
|
||||
FROM node:18-alpine AS production
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY --from=dependencies /app/node_modules ./node_modules
|
||||
COPY package*.json ./
|
||||
|
||||
EXPOSE 8080
|
||||
CMD ["node", "dist/main.js"]
|
||||
```
|
||||
|
||||
- **多阶段构建**: 减小最终镜像体积,只包含生产运行所需的依赖。
|
||||
- **使用`alpine`镜像**: 基于轻量级的Alpine Linux,进一步减小镜像大小。
|
||||
- **缓存优化**: 将`package.json`的复制和`npm install`分层,利用Docker的层缓存机制,只有在依赖变更时才重新安装。
|
||||
|
||||
### 2.2 镜像仓库
|
||||
|
||||
- **推荐**: 使用云厂商提供的容器镜像服务(如腾讯云TCR、阿里云ACR)。
|
||||
- **CI/CD集成**: GitHub Actions将在构建成功后,自动将Docker镜像推送到指定的镜像仓库,并打上版本标签(如`git commit hash`)。
|
||||
|
||||
## 3. Kubernetes (K8s) 部署
|
||||
|
||||
### 3.1 部署物清单 (Manifests)
|
||||
|
||||
我们将使用YAML文件来定义所有K8s资源。
|
||||
|
||||
#### a) `deployment.yaml` (后端服务)
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: game-server-deployment
|
||||
spec:
|
||||
replicas: 3 # 初始副本数为3,可根据负载自动伸缩
|
||||
selector:
|
||||
matchLabels:
|
||||
app: game-server
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: game-server
|
||||
spec:
|
||||
containers:
|
||||
- name: game-server
|
||||
image: your-registry/game-server:latest # 镜像地址
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
resources:
|
||||
requests:
|
||||
cpu: "250m"
|
||||
memory: "512Mi"
|
||||
limits:
|
||||
cpu: "500m"
|
||||
memory: "1Gi"
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: game-server-config
|
||||
- secretRef:
|
||||
name: game-server-secrets
|
||||
livenessProbe: # 存活探针
|
||||
httpGet:
|
||||
path: /api/v1/healthz
|
||||
port: 8080
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 20
|
||||
readinessProbe: # 就绪探针
|
||||
httpGet:
|
||||
path: /api/v1/healthz
|
||||
port: 8080
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
```
|
||||
|
||||
#### b) `service.yaml` (服务暴露)
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: game-server-service
|
||||
spec:
|
||||
type: LoadBalancer # 使用云厂商的LB暴露服务
|
||||
selector:
|
||||
app: game-server
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 80 # LB监听80端口
|
||||
targetPort: 8080
|
||||
```
|
||||
|
||||
#### c) `hpa.yaml` (水平Pod自动伸缩)
|
||||
```yaml
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: game-server-hpa
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: game-server-deployment
|
||||
minReplicas: 3
|
||||
maxReplicas: 10
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: 75 # CPU使用率超过75%时扩容
|
||||
```
|
||||
|
||||
### 3.2 配置与密钥管理
|
||||
|
||||
- **ConfigMap**: 用于存储非敏感配置,如数据库地址、Redis地址、日志级别等。
|
||||
- **Secret**: 用于存储敏感信息,如数据库密码、JWT密钥等。必须进行Base64编码。
|
||||
|
||||
## 4. CI/CD (GitHub Actions)
|
||||
|
||||
### 4.1 工作流 (`.github/workflows/deploy.yml`)
|
||||
|
||||
```yaml
|
||||
name: Deploy to Production
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install --frozen-lockfile
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
|
||||
- name: Log in to Docker Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: your-registry.com
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: your-registry.com/game-server:${{ github.sha }}
|
||||
|
||||
- name: Set up Kubeconfig
|
||||
uses: azure/k8s-set-context@v3
|
||||
with:
|
||||
method: kubeconfig
|
||||
kubeconfig: ${{ secrets.KUBECONFIG }}
|
||||
|
||||
- name: Deploy to Kubernetes
|
||||
uses: azure/k8s-deploy@v4
|
||||
with:
|
||||
action: 'deploy'
|
||||
manifests: |
|
||||
k8s/deployment.yaml
|
||||
k8s/service.yaml
|
||||
images: |
|
||||
your-registry.com/game-server:${{ github.sha }}
|
||||
```
|
||||
|
||||
- **触发条件**: 当代码推送到`main`分支时自动触发。
|
||||
- **流程**: 拉取代码 -> 安装依赖 -> 运行测试 -> 登录镜像仓库 -> 构建并推送镜像 -> 部署到K8s。
|
||||
- **密钥管理**: 所有敏感信息(如密码、Kubeconfig)都存储在GitHub Secrets中。
|
||||
|
||||
## 5. 监控与告警
|
||||
|
||||
- **Prometheus**:
|
||||
- 通过`kube-prometheus-stack`部署在K8s集群中。
|
||||
- 自动发现并抓取K8s Pod和Node的指标。
|
||||
- 后端服务需要暴露一个`/metrics`端点,提供自定义业务指标(如在线玩家数、活跃游戏数、API响应延迟等)。
|
||||
- **Grafana**:
|
||||
- 提供可视化的监控仪表盘(Dashboard)。
|
||||
- 预置Dashboard用于监控集群资源、Node.js应用性能等。
|
||||
- 自定义Dashboard展示核心业务指标。
|
||||
- **Alertmanager**:
|
||||
- 根据Prometheus中预设的告警规则(如CPU使用率过高、服务Pod重启频繁、API错误率上升),通过邮件、钉钉、企业微信等方式发送告警通知。
|
||||
|
||||
## 6. 日志管理
|
||||
|
||||
- **方案**: **Loki + Promtail**
|
||||
- **Promtail**: 作为日志代理,部署在每个K8s节点上,负责收集容器日志并发送给Loki。
|
||||
- **Loki**: 轻量级的日志聚合系统,对日志进行索引和存储。
|
||||
- **集成**: 在Grafana中配置Loki作为数据源,可以直接在Grafana中查询和分析日志,与监控指标在同一平台展示,方便问题排查。
|
||||
|
||||
## 7. 部署策略 (蓝绿部署)
|
||||
|
||||
为实现零停机更新,采用蓝绿部署策略。
|
||||
|
||||
1. **当前版本 (Blue)**: `game-server-deployment-blue` 正在运行,并通过`game-server-service`对外提供服务。
|
||||
2. **部署新版本 (Green)**:
|
||||
- 创建一个新的Deployment `game-server-deployment-green`,包含新版本的代码。
|
||||
- 等待`green`环境的所有Pod都进入`Ready`状态。
|
||||
3. **流量切换**:
|
||||
- 修改`game-server-service`的`selector`,将其指向`green`环境的Pod (`app: game-server-green`)。
|
||||
- K8s会自动将流量无缝切换到新版本。
|
||||
4. **观察期**:
|
||||
- 观察新版本运行是否稳定,监控核心指标是否正常。
|
||||
5. **下线旧版本**:
|
||||
- 如果一切正常,删除`game-server-deployment-blue`。
|
||||
- 如果出现问题,可以快速将Service的`selector`切回`blue`环境,实现秒级回滚。
|
||||
|
||||
此流程可通过CI/CD脚本自动化。
|
||||
Reference in New Issue
Block a user