Files
DFJ/02_详细设计文档/后端技术架构详设.md

27 KiB

后端技术架构详设文档

文档版本: v1.0
撰写人: 后端架构师
创建日期: 2024年9月11日

1. 后端架构总览

1.1 技术栈详细说明

// 后端技术栈配置
{
  "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应用配置

// 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 认证服务设计

// 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 房间管理服务

// 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 游戏核心服务

// 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连接管理

// 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})`)
  }
}