浏览代码

✨ feat(socket): 集成考试系统的Socket.IO实时通信功能

- 添加socket.io服务器端包及相关依赖
- 创建SocketIOServer类集成到现有HTTP服务器
- 实现考试相关的WebSocket事件处理器
- 添加Redis服务用于存储考试数据
- 实现用户认证中间件支持JWT验证
- 添加考试房间管理、题目推送、答案存储等功能
- 支持开发环境的独立Socket服务器(端口8081)
yourname 5 月之前
父节点
当前提交
84867e824b

+ 1 - 0
package.json

@@ -45,6 +45,7 @@
     "react-router-dom": "^7.6.1",
     "react-toastify": "^11.0.5",
     "reflect-metadata": "^0.2.2",
+    "socket.io": "^4.8.1",
     "socket.io-client": "^4.8.1",
     "typeorm": "^0.3.24",
     "vod-js-sdk-v6": "1.7.1-beta.1"

+ 96 - 0
pnpm-lock.yaml

@@ -119,6 +119,9 @@ importers:
       reflect-metadata:
         specifier: ^0.2.2
         version: 0.2.2
+      socket.io:
+        specifier: ^4.8.1
+        version: 4.8.1
       socket.io-client:
         specifier: ^4.8.1
         version: 4.8.1
@@ -1354,6 +1357,9 @@ packages:
   '@types/bcrypt@5.0.2':
     resolution: {integrity: sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==}
 
+  '@types/cors@2.8.19':
+    resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==}
+
   '@types/debug@4.1.12':
     resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
 
@@ -1439,6 +1445,10 @@ packages:
   '@zxing/text-encoding@0.9.0':
     resolution: {integrity: sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==}
 
+  accepts@1.3.8:
+    resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
+    engines: {node: '>= 0.6'}
+
   acorn-walk@8.3.2:
     resolution: {integrity: sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==}
     engines: {node: '>=0.4.0'}
@@ -1532,6 +1542,10 @@ packages:
   base64-js@1.5.1:
     resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
 
+  base64id@2.0.0:
+    resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==}
+    engines: {node: ^4.5.0 || >= 5.9}
+
   bcrypt@6.0.0:
     resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==}
     engines: {node: '>= 18'}
@@ -1657,6 +1671,10 @@ packages:
   core-js@3.29.1:
     resolution: {integrity: sha512-+jwgnhg6cQxKYIIjGtAHq2nwUOolo9eoFZ4sHfUH09BLXBgxnH4gA0zEd+t+BO2cNB8idaBtZFcFTRjQJRJmAw==}
 
+  cors@2.8.5:
+    resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==}
+    engines: {node: '>= 0.10'}
+
   cosmiconfig@7.1.0:
     resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==}
     engines: {node: '>=10'}
@@ -1773,6 +1791,10 @@ packages:
     resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==}
     engines: {node: '>=10.0.0'}
 
+  engine.io@6.6.4:
+    resolution: {integrity: sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==}
+    engines: {node: '>=10.2.0'}
+
   enhanced-resolve@5.18.1:
     resolution: {integrity: sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==}
     engines: {node: '>=10.13.0'}
@@ -2307,6 +2329,10 @@ packages:
     engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
     hasBin: true
 
+  negotiator@0.6.3:
+    resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
+    engines: {node: '>= 0.6'}
+
   node-addon-api@8.3.1:
     resolution: {integrity: sha512-lytcDEdxKjGJPTLEfW4mYMigRezMlyJY8W4wxJK8zE533Jlb8L8dRuObJFWg2P+AuOIxoCgKF+2Oq4d4Zd0OUA==}
     engines: {node: ^18 || ^20 || >= 21}
@@ -2852,6 +2878,9 @@ packages:
   simple-swizzle@0.2.2:
     resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==}
 
+  socket.io-adapter@2.5.5:
+    resolution: {integrity: sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==}
+
   socket.io-client@4.8.1:
     resolution: {integrity: sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==}
     engines: {node: '>=10.0.0'}
@@ -2860,6 +2889,10 @@ packages:
     resolution: {integrity: sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==}
     engines: {node: '>=10.0.0'}
 
+  socket.io@4.8.1:
+    resolution: {integrity: sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==}
+    engines: {node: '>=10.2.0'}
+
   source-map-js@1.2.1:
     resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
     engines: {node: '>=0.10.0'}
@@ -3129,6 +3162,10 @@ packages:
     deprecated: Please upgrade  to version 7 or higher.  Older versions may use Math.random() in certain circumstances, which is known to be problematic.  See https://v8.dev/blog/math-random for details.
     hasBin: true
 
+  vary@1.1.2:
+    resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
+    engines: {node: '>= 0.8'}
+
   vite-plugin-ssr-hot-reload@0.4.2:
     resolution: {integrity: sha512-KhuXZxYXSlzEo6S3leOj806RI6XGdspDYPwqUYFGJajlsB4MffQ5My6rW+YO9uYkc2eM2Q+7OcCUnPrHaa3Upw==}
     engines: {node: '>=18.0.0'}
@@ -4339,6 +4376,10 @@ snapshots:
     dependencies:
       '@types/node': 22.15.31
 
+  '@types/cors@2.8.19':
+    dependencies:
+      '@types/node': 22.15.31
+
   '@types/debug@4.1.12':
     dependencies:
       '@types/ms': 2.1.0
@@ -4434,6 +4475,11 @@ snapshots:
   '@zxing/text-encoding@0.9.0':
     optional: true
 
+  accepts@1.3.8:
+    dependencies:
+      mime-types: 2.1.35
+      negotiator: 0.6.3
+
   acorn-walk@8.3.2: {}
 
   acorn@8.14.0: {}
@@ -4601,6 +4647,8 @@ snapshots:
 
   base64-js@1.5.1: {}
 
+  base64id@2.0.0: {}
+
   bcrypt@6.0.0:
     dependencies:
       node-addon-api: 8.3.1
@@ -4722,6 +4770,11 @@ snapshots:
 
   core-js@3.29.1: {}
 
+  cors@2.8.5:
+    dependencies:
+      object-assign: 4.1.1
+      vary: 1.1.2
+
   cosmiconfig@7.1.0:
     dependencies:
       '@types/parse-json': 4.0.2
@@ -4823,6 +4876,22 @@ snapshots:
 
   engine.io-parser@5.2.3: {}
 
+  engine.io@6.6.4:
+    dependencies:
+      '@types/cors': 2.8.19
+      '@types/node': 22.15.31
+      accepts: 1.3.8
+      base64id: 2.0.0
+      cookie: 0.7.2
+      cors: 2.8.5
+      debug: 4.3.7
+      engine.io-parser: 5.2.3
+      ws: 8.17.1
+    transitivePeerDependencies:
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+
   enhanced-resolve@5.18.1:
     dependencies:
       graceful-fs: 4.2.11
@@ -5413,6 +5482,8 @@ snapshots:
 
   nanoid@3.3.11: {}
 
+  negotiator@0.6.3: {}
+
   node-addon-api@8.3.1: {}
 
   node-cron@4.1.0: {}
@@ -6045,6 +6116,15 @@ snapshots:
     dependencies:
       is-arrayish: 0.3.2
 
+  socket.io-adapter@2.5.5:
+    dependencies:
+      debug: 4.3.7
+      ws: 8.17.1
+    transitivePeerDependencies:
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+
   socket.io-client@4.8.1:
     dependencies:
       '@socket.io/component-emitter': 3.1.2
@@ -6063,6 +6143,20 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
+  socket.io@4.8.1:
+    dependencies:
+      accepts: 1.3.8
+      base64id: 2.0.0
+      cors: 2.8.5
+      debug: 4.3.7
+      engine.io: 6.6.4
+      socket.io-adapter: 2.5.5
+      socket.io-parser: 4.2.4
+    transitivePeerDependencies:
+      - bufferutil
+      - supports-color
+      - utf-8-validate
+
   source-map-js@1.2.1: {}
 
   source-map@0.5.7: {}
@@ -6268,6 +6362,8 @@ snapshots:
 
   uuid@3.4.0: {}
 
+  vary@1.1.2: {}
+
   vite-plugin-ssr-hot-reload@0.4.2:
     dependencies:
       picomatch: 4.0.2

+ 29 - 1
src/server/index.tsx

@@ -6,6 +6,8 @@ import { swaggerUI } from '@hono/swagger-ui'
 import * as fs from 'fs/promises'
 import { renderer } from './renderer'
 import createApi from './api'
+import { SocketIOServer } from './socket/index'
+import { ServerType } from '@hono/node-server'
 
 
 const app = new Hono();
@@ -89,4 +91,30 @@ app.get('/*', (c) => {
   )
 }) 
 
-export default app
+let exportDefault:any
+if(import.meta.env.PROD){
+  exportDefault = {
+    websocket: (httpServer: ServerType) => {
+      // 创建 Socket.IO 服务器并集成到主服务
+      new SocketIOServer({
+        server: httpServer 
+      });
+    },
+    fetch: app.fetch
+  }
+}else{
+  new SocketIOServer({});
+  exportDefault = app;
+}
+export default exportDefault
+
+// export default {
+//   httpServer: (httpServer) => {
+//     const io = new SocketIOServer(httpServer);
+//     console.log(io)
+//     io.on('connection', (socket) => {
+//       console.log(`Socket.IO client connected: ${socket.id}`)
+//     })
+//   },
+//   fetch: app.fetch
+// }

+ 148 - 0
src/server/socket/handlers/exam.handler.ts

@@ -0,0 +1,148 @@
+import { Server } from 'socket.io';
+import { ExamService } from '../services/exam.service';
+import { AuthenticatedSocket } from '../middleware/auth.middleware';
+import debug from 'debug';
+
+const log = debug('socket:handler:exam');
+
+export class ExamHandler {
+  private examService: ExamService;
+
+  constructor(io: Server) {
+    this.examService = new ExamService(io);
+  }
+
+  register(socket: AuthenticatedSocket) {
+    // 加入考试房间
+    socket.on('exam:join', async (data) => {
+      try {
+        await this.examService.joinRoom(socket, data.roomId);
+      } catch (error) {
+        log('处理加入房间事件失败:', error);
+        socket.emit('error', '加入考试房间失败');
+      }
+    });
+
+    // 离开考试房间
+    socket.on('exam:leave', async (data) => {
+      try {
+        await this.examService.leaveRoom(socket, data.roomId);
+      } catch (error) {
+        log('处理离开房间事件失败:', error);
+        socket.emit('error', '离开考试房间失败');
+      }
+    });
+
+    // 推送题目
+    socket.on('exam:question', async (data) => {
+      try {
+        await this.examService.pushQuestion(socket, data.roomId, data.question);
+      } catch (error) {
+        log('处理推送题目事件失败:', error);
+        socket.emit('error', '推送题目失败');
+      }
+    });
+
+    // 存储答案
+    socket.on('exam:storeAnswer', async (data, callback) => {
+      try {
+        const success = await this.examService.storeAnswer(socket, data.roomId, data.questionId, data.answer);
+        if (callback) callback(success);
+      } catch (error) {
+        log('处理存储答案事件失败:', error);
+        socket.emit('error', '存储答案失败');
+        if (callback) callback(false);
+      }
+    });
+
+    // 获取答案
+    socket.on('exam:getAnswers', async (data, callback) => {
+      try {
+        const answers = await this.examService.getAnswers(data.roomId, data.questionId);
+        if (callback) callback(answers);
+      } catch (error) {
+        log('处理获取答案事件失败:', error);
+        socket.emit('error', '获取答案失败');
+        if (callback) callback([]);
+      }
+    });
+
+    // 存储价格
+    socket.on('exam:storePrice', async (data) => {
+      try {
+        await this.examService.storePrice(socket, data.roomId, data.date, data.price);
+      } catch (error) {
+        log('处理存储价格事件失败:', error);
+        socket.emit('error', '存储价格失败');
+      }
+    });
+
+    // 获取价格
+    socket.on('exam:getPrice', async (data, callback) => {
+      try {
+        const price = await this.examService.getPrice(data.roomId, data.date);
+        if (callback) callback(price || '0');
+      } catch (error) {
+        log('处理获取价格事件失败:', error);
+        socket.emit('error', '获取价格失败');
+        if (callback) callback('0');
+      }
+    });
+
+    // 获取所有价格
+    socket.on('exam:getPrices', async (data, callback) => {
+      try {
+        const prices = await this.examService.getAllPrices(data.roomId);
+        if (callback) callback(prices);
+      } catch (error) {
+        log('处理获取所有价格事件失败:', error);
+        socket.emit('error', '获取价格失败');
+        if (callback) callback({});
+      }
+    });
+
+    // 获取用户答案
+    socket.on('exam:getUserAnswers', async (data, callback) => {
+      try {
+        const answers = await this.examService.getUserAnswers(data.roomId, data.userId);
+        if (callback) callback(answers);
+      } catch (error) {
+        log('处理获取用户答案事件失败:', error);
+        socket.emit('error', '获取用户答案失败');
+        if (callback) callback([]);
+      }
+    });
+
+    // 获取当前题目
+    socket.on('exam:currentQuestion', async (data, callback) => {
+      try {
+        const question = await this.examService.getCurrentQuestion(data.roomId);
+        if (callback) callback(question);
+      } catch (error) {
+        log('处理获取当前题目事件失败:', error);
+        socket.emit('error', '获取当前题目失败');
+        if (callback) callback(null);
+      }
+    });
+
+    // 结算
+    socket.on('exam:settle', async (data) => {
+      try {
+        await this.examService.broadcastSettle(socket, data.roomId);
+      } catch (error) {
+        log('处理结算事件失败:', error);
+        socket.emit('error', '结算失败');
+      }
+    });
+
+    // 清理数据
+    socket.on('exam:cleanup', async (data) => {
+      try {
+        await this.examService.cleanupRoomData(socket, data.roomId, data.questionId);
+      } catch (error) {
+        log('处理清理数据事件失败:', error);
+        socket.emit('error', '清理数据失败');
+      }
+    });
+  }
+}

+ 102 - 0
src/server/socket/index.ts

@@ -0,0 +1,102 @@
+import { Server } from 'socket.io';
+import { Hono } from 'hono';
+import { createSocketAuthMiddleware } from './middleware/auth.middleware';
+import { ExamHandler } from './handlers/exam.handler';
+import { redisService } from './services/redis.service';
+import debug from 'debug';
+import { AuthenticatedSocket } from './middleware/auth.middleware';
+import type { ServerType } from '@hono/node-server'
+
+const log = debug('socket:server');
+
+export interface SocketIOServerOptions {
+  server?: ServerType; // Node.js HTTP server
+}
+
+export class SocketIOServer {
+  private io!: Server;
+  private examHandler!: ExamHandler;
+
+  constructor(options: SocketIOServerOptions) {
+    this.setupSocketIOServer(options.server);
+  }
+
+  private setupSocketIOServer(httpServer: any) {
+    // 创建 Socket.IO 服务器并直接附加到现有的HTTP服务器
+    if(import.meta.env.PROD){
+      this.io = new Server(httpServer, {
+        // cors: {
+        //   origin: process.env.CORS_ORIGIN?.split(',') || ['http://localhost:3000', 'http://localhost:5173'],
+        //   methods: ['GET', 'POST'],
+        //   credentials: true,
+        // },
+        // transports: ['websocket', 'polling'],
+      });
+    }else{
+      this.io = new Server();
+      this.io.listen(8081);
+    }
+
+    // 设置认证中间件
+    const authMiddleware = createSocketAuthMiddleware();
+    this.io.use(authMiddleware);
+
+    // 初始化事件处理器
+    this.examHandler = new ExamHandler(this.io);
+
+    // 设置连接事件
+    this.io.on('connection', (socket: AuthenticatedSocket) => {
+      if (!socket.user) {
+        log('未认证用户尝试连接,断开连接');
+        socket.disconnect();
+        return;
+      }
+
+      log(`用户连接成功: ${socket.user.username} (Socket ID: ${socket.id})`);
+
+      // 注册事件处理器
+      this.examHandler.register(socket);
+
+      // 监听断开连接
+      socket.on('disconnect', (reason) => {
+        log(`用户断开连接: ${socket.user?.username} (原因: ${reason})`);
+      });
+
+      // 错误处理
+      socket.on('error', (error) => {
+        log(`Socket 错误: ${socket.user?.username}`, error);
+      });
+    });
+
+    log('Socket.IO 服务器初始化完成');
+  }
+
+  public getIOServer() {
+    return this.io;
+  }
+
+  public async start() {
+    try {
+      // 确保Redis已连接
+    //   await redisService.connect();
+      log('Redis 连接成功');
+      
+      const port = parseInt(process.env.SOCKET_PORT || '3001', 10);
+      log(`Socket.IO 服务器已附加到现有HTTP服务器,端口: ${port}`);
+      
+    } catch (error) {
+      log('服务器启动失败:', error);
+      throw error;
+    }
+  }
+
+  public async stop() {
+    try {
+      await redisService.disconnect();
+      this.io.close();
+      log('Socket.IO 服务器已停止');
+    } catch (error) {
+      log('停止服务器失败:', error);
+    }
+  }
+}

+ 64 - 0
src/server/socket/middleware/auth.middleware.ts

@@ -0,0 +1,64 @@
+import { Socket } from 'socket.io';
+import jwt from 'jsonwebtoken';
+import { UserService } from '@/server/modules/users/user.service';
+import { AppDataSource } from '@/server/data-source';
+import debug from 'debug';
+import { UserEntity } from '@/server/modules/users/user.entity';
+
+const log = debug('socket:auth');
+
+export interface AuthenticatedSocket extends Socket {
+  user?: UserEntity;
+}
+
+export const createSocketAuthMiddleware = () => {
+  return async (socket: AuthenticatedSocket, next: (err?: Error) => void) => {
+    try {
+      // 获取 token
+      const token = socket.handshake.auth?.token || socket.handshake.query?.token;
+      
+      if (!token) {
+        log('未提供token,拒绝连接');
+        return next(new Error('未授权'));
+      }
+
+      // 验证 token
+      const jwtSecret = process.env.JWT_SECRET || 'your-jwt-secret-key';
+      const decoded = jwt.verify(token as string, jwtSecret) as { userId: number };
+
+      // 获取用户服务
+      const userService = new UserService(AppDataSource);
+      const user = await userService.getUserById(decoded.userId);
+
+      if (!user) {
+        log('无效用户,拒绝连接');
+        return next(new Error('无效凭证'));
+      }
+
+      // 检查用户状态
+      if (user.isDisabled === 1) {
+        log('用户被禁用,拒绝连接');
+        return next(new Error('用户被禁用'));
+      }
+
+      if (user.isDeleted === 1) {
+        log('用户已删除,拒绝连接');
+        return next(new Error('用户不存在'));
+      }
+
+      // 设置用户信息到 socket
+      socket.user = user;
+      
+      log(`用户认证成功: ${user.username} (ID: ${user.id})`);
+      next();
+    } catch (error) {
+      log('认证错误:', error);
+      
+      if (error instanceof jwt.JsonWebTokenError) {
+        return next(new Error('无效的token'));
+      }
+      
+      next(new Error('认证失败'));
+    }
+  };
+};

+ 179 - 0
src/server/socket/services/exam.service.ts

@@ -0,0 +1,179 @@
+import { Server } from 'socket.io';
+import { AuthenticatedSocket } from '../middleware/auth.middleware';
+import { redisService } from './redis.service';
+import debug from 'debug';
+
+const log = debug('socket:exam');
+
+export class ExamService {
+  private io: Server;
+
+  constructor(io: Server) {
+    this.io = io;
+  }
+
+  async joinRoom(socket: AuthenticatedSocket, roomId: string) {
+    try {
+      if (!socket.user) throw new Error('用户未认证');
+      
+      const user = socket.user;
+      socket.join(roomId);
+      await redisService.addRoomMember(roomId, user.id, user.username);
+      
+      socket.emit('exam:joined', { roomId, message: `成功加入考试房间: ${roomId}` });
+      socket.to(roomId).emit('exam:memberJoined', { roomId, userId: user.id, username: user.username });
+      
+      log(`用户 ${user.username} 加入考试房间 ${roomId}`);
+    } catch (error) {
+      log('加入考试房间失败:', error);
+      socket.emit('error', '加入考试房间失败');
+    }
+  }
+
+  async leaveRoom(socket: AuthenticatedSocket, roomId: string) {
+    try {
+      if (!socket.user) throw new Error('用户未认证');
+      
+      const user = socket.user;
+      socket.leave(roomId);
+      await redisService.removeRoomMember(roomId, user.id);
+      
+      socket.emit('exam:left', { roomId, message: `已离开考试房间: ${roomId}` });
+      socket.to(roomId).emit('exam:memberLeft', { roomId, userId: user.id, username: user.username });
+      
+      log(`用户 ${user.username} 离开考试房间 ${roomId}`);
+    } catch (error) {
+      log('离开考试房间失败:', error);
+      socket.emit('error', '离开考试房间失败');
+    }
+  }
+
+  async pushQuestion(socket: AuthenticatedSocket, roomId: string, question: { date: string; price: string }) {
+    try {
+      if (!socket.user) throw new Error('用户未认证');
+      
+      await redisService.storeCurrentQuestion(roomId, question);
+      await redisService.storePrice(roomId, question.date, question.price);
+      
+      const quizState = { id: question.date, date: question.date, price: question.price };
+      socket.to(roomId).emit('exam:question', quizState);
+      
+      log(`用户 ${socket.user.username} 在房间 ${roomId} 推送题目`);
+    } catch (error) {
+      log('推送题目失败:', error);
+      socket.emit('error', '推送题目失败');
+    }
+  }
+
+  async storeAnswer(socket: AuthenticatedSocket, roomId: string, questionId: string, answer: any): Promise<boolean> {
+    try {
+      if (!socket.user) throw new Error('用户未认证');
+      
+      const user = socket.user;
+      await redisService.storeAnswer(roomId, user.id, questionId, { ...answer, username: user.username });
+      
+      socket.to(roomId).emit('exam:answerUpdated', {
+        roomId, questionId, userId: user.id, username: user.username
+      });
+      
+      log(`用户 ${user.username} 在房间 ${roomId} 存储答案`);
+      return true;
+    } catch (error) {
+      log('存储答案失败:', error);
+      socket.emit('error', '存储答案失败');
+      return false;
+    }
+  }
+
+  async getAnswers(roomId: string, questionId?: string) {
+    try {
+      return await redisService.getAnswers(roomId, questionId);
+    } catch (error) {
+      log('获取答案失败:', error);
+      return [];
+    }
+  }
+
+  async getUserAnswers(roomId: string, userId: number) {
+    try {
+      return await redisService.getUserAnswers(roomId, userId);
+    } catch (error) {
+      log('获取用户答案失败:', error);
+      return [];
+    }
+  }
+
+  async storePrice(socket: AuthenticatedSocket, roomId: string, date: string, price: string) {
+    try {
+      if (!socket.user) throw new Error('用户未认证');
+      
+      await redisService.storePrice(roomId, date, price);
+      log(`用户 ${socket.user.username} 存储房间 ${roomId} 的价格历史: ${date} - ${price}`);
+    } catch (error) {
+      log('存储价格历史失败:', error);
+      socket.emit('error', '存储价格历史失败');
+    }
+  }
+
+  async getPrice(roomId: string, date: string): Promise<string | null> {
+    try {
+      return await redisService.getPrice(roomId, date);
+    } catch (error) {
+      log('获取历史价格失败:', error);
+      return null;
+    }
+  }
+
+  async getAllPrices(roomId: string): Promise<Record<string, number>> {
+    try {
+      return await redisService.getAllPrices(roomId);
+    } catch (error) {
+      log('获取所有价格历史失败:', error);
+      return {};
+    }
+  }
+
+  async getCurrentQuestion(roomId: string) {
+    try {
+      return await redisService.getCurrentQuestion(roomId);
+    } catch (error) {
+      log('获取当前题目失败:', error);
+      return null;
+    }
+  }
+
+  async cleanupRoomData(socket: AuthenticatedSocket, roomId: string, questionId?: string) {
+    try {
+      if (!socket.user) throw new Error('用户未认证');
+      
+      const user = socket.user;
+      await redisService.cleanupRoomData(roomId, questionId);
+      
+      socket.to(roomId).emit('exam:cleaned', {
+        roomId,
+        message: questionId 
+          ? `已清理房间 ${roomId} 的问题 ${questionId} 数据`
+          : `已清理房间 ${roomId} 的所有数据`
+      });
+
+      log(`用户 ${user.username} 清理房间 ${roomId} 数据`);
+    } catch (error) {
+      log('清理房间数据失败:', error);
+      socket.emit('error', '清理房间数据失败');
+    }
+  }
+
+  async broadcastSettle(socket: AuthenticatedSocket, roomId: string) {
+    try {
+      if (!socket.user) throw new Error('用户未认证');
+      
+      const user = socket.user;
+      socket.to(roomId).emit('exam:settle');
+      
+      log(`用户 ${user.username} 在房间 ${roomId} 广播结算消息`);
+    } catch (error) {
+      log('广播结算消息失败:', error);
+      socket.emit('error', '广播结算消息失败');
+    }
+  }
+}

+ 196 - 0
src/server/socket/services/redis.service.ts

@@ -0,0 +1,196 @@
+import Redis from 'ioredis';
+import debug from 'debug';
+
+const log = debug('socket:redis');
+
+export class RedisService {
+  private client: Redis;
+  private isConnected = false;
+
+  constructor() {
+    this.client = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');
+
+    this.client.on('error', (err: Error) => {
+      log('Redis Client Error:', err);
+    });
+
+    this.client.on('connect', () => {
+      log('Redis Client Connected');
+      this.isConnected = true;
+    });
+
+    this.client.on('disconnect', () => {
+      log('Redis Client Disconnected');
+      this.isConnected = false;
+    });
+  }
+
+  // 考试相关的方法
+  async storeCurrentQuestion(roomId: string, question: { date: string; price: string }) {
+    const key = `exam:${roomId}:current_question`;
+    await this.client.hset(key, {
+      date: question.date,
+      price: question.price,
+      updated_at: new Date().toISOString(),
+    });
+    // 设置过期时间,24小时
+    await this.client.expire(key, 24 * 60 * 60);
+  }
+
+  async getCurrentQuestion(roomId: string): Promise<{ date: string; price: string } | null> {
+    const key = `exam:${roomId}:current_question`;
+    const data = await this.client.hgetall(key);
+    
+    if (!data.date || !data.price) {
+      return null;
+    }
+
+    return {
+      date: data.date,
+      price: data.price,
+    };
+  }
+
+  async storePrice(roomId: string, date: string, price: string) {
+    const key = `exam:${roomId}:prices`;
+    await this.client.hset(key, date, price);
+    // 设置过期时间,7天
+    await this.client.expire(key, 7 * 24 * 60 * 60);
+  }
+
+  async getPrice(roomId: string, date: string): Promise<string | null> {
+    const key = `exam:${roomId}:prices`;
+    return await this.client.hget(key, date);
+  }
+
+  async getAllPrices(roomId: string): Promise<Record<string, number>> {
+    const key = `exam:${roomId}:prices`;
+    const prices = await this.client.hgetall(key);
+    
+    const result: Record<string, number> = {};
+    for (const [date, price] of Object.entries(prices)) {
+      const numPrice = parseFloat(price);
+      if (!isNaN(numPrice)) {
+        result[date] = numPrice;
+      }
+    }
+    
+    return result;
+  }
+
+  async storeAnswer(
+    roomId: string,
+    userId: number,
+    questionId: string,
+    answer: any
+  ) {
+    const key = `exam:${roomId}:answers:${userId}:${questionId}`;
+    await this.client.hset(key, 'data', JSON.stringify(answer));
+    // 设置过期时间,30天
+    await this.client.expire(key, 30 * 24 * 60 * 60);
+  }
+
+  async getAnswers(roomId: string, questionId?: string): Promise<any[]> {
+    const pattern = questionId
+      ? `exam:${roomId}:answers:*:${questionId}`
+      : `exam:${roomId}:answers:*`;
+    
+    const keys = await this.client.keys(pattern);
+    const answers: any[] = [];
+
+    for (const key of keys) {
+      const data = await this.client.hget(key, 'data');
+      if (data) {
+        try {
+          const answer = JSON.parse(data);
+          // 从 key 中提取 userId
+          const parts = key.split(':');
+          const userId = parts[3];
+          answers.push({
+            ...answer,
+            userId,
+          });
+        } catch (error) {
+          log('解析答案数据失败:', error);
+        }
+      }
+    }
+
+    return answers;
+  }
+
+  async getUserAnswers(roomId: string, userId: number): Promise<any[]> {
+    const pattern = `exam:${roomId}:answers:${userId}:*`;
+    const keys = await this.client.keys(pattern);
+    const answers: any[] = [];
+
+    for (const key of keys) {
+      const data = await this.client.hget(key, 'data');
+      if (data) {
+        try {
+          const answer = JSON.parse(data);
+          answers.push(answer);
+        } catch (error) {
+          log('解析用户答案数据失败:', error);
+        }
+      }
+    }
+
+    return answers;
+  }
+
+  async cleanupRoomData(roomId: string, questionId?: string) {
+    if (questionId) {
+      // 清理特定问题的数据
+      const pattern = `exam:${roomId}:${questionId}:*`;
+      const keys = await this.client.keys(pattern);
+      
+      if (keys.length > 0) {
+        await this.client.del(keys);
+      }
+    } else {
+      // 清理整个房间的数据
+      const pattern = `exam:${roomId}:*`;
+      const keys = await this.client.keys(pattern);
+      
+      if (keys.length > 0) {
+        await this.client.del(keys);
+      }
+    }
+  }
+
+  // 房间成员管理
+  async addRoomMember(roomId: string, userId: number, username: string) {
+    const key = `exam:${roomId}:members`;
+    await this.client.hset(key, userId.toString(), username);
+    await this.client.expire(key, 24 * 60 * 60);
+  }
+
+  async removeRoomMember(roomId: string, userId: number) {
+    const key = `exam:${roomId}:members`;
+    await this.client.hdel(key, userId.toString());
+  }
+
+  async getRoomMembers(roomId: string): Promise<Record<string, string>> {
+    const key = `exam:${roomId}:members`;
+    return await this.client.hgetall(key);
+  }
+
+  // 通用方法
+  async ping(): Promise<boolean> {
+    try {
+      const result = await this.client.ping();
+      return result === 'PONG';
+    } catch (error) {
+      log('Redis ping failed:', error);
+      return false;
+    }
+  }
+
+  async disconnect() {
+    await this.client.disconnect();
+  }
+}
+
+// 单例实例
+export const redisService = new RedisService();

+ 91 - 0
src/server/socket/types/socket.types.ts

@@ -0,0 +1,91 @@
+import { Socket } from 'socket.io';
+import { UserEntity } from '@/server/modules/users/user.entity';
+
+export interface AuthenticatedSocket extends Socket {
+  user?: UserEntity;
+}
+
+export interface SocketContext {
+  socket: AuthenticatedSocket;
+  user: UserEntity;
+}
+
+// 考试相关的事件类型
+export interface ExamRoomData {
+  roomId: string;
+}
+
+export interface ExamQuestionData {
+  roomId: string;
+  question: QuizContent;
+}
+
+export interface ExamAnswerData {
+  roomId: string;
+  questionId: string;
+  answer: Answer;
+}
+
+export interface ExamPriceData {
+  roomId: string;
+  date: string;
+  price: string;
+}
+
+// 与前端共享的类型
+export interface QuizContent {
+  date: string;
+  price: string;
+}
+
+export interface QuizState {
+  id: string;
+  date: string;
+  price: string;
+}
+
+export interface Answer {
+  username: string;
+  date: string;
+  price: string;
+  holdingStock: number;
+  holdingCash: number;
+  profitAmount: number;
+  profitPercent: number;
+  totalProfitAmount: number;
+  totalProfitPercent: number;
+}
+
+// Socket.IO 事件映射
+export interface ClientToServerEvents {
+  'exam:join': (data: ExamRoomData) => void;
+  'exam:leave': (data: ExamRoomData) => void;
+  'exam:question': (data: ExamQuestionData) => void;
+  'exam:storeAnswer': (data: ExamAnswerData, callback: (success: boolean) => void) => void;
+  'exam:getAnswers': (data: { roomId: string; questionId?: string }, callback: (answers: Answer[]) => void) => void;
+  'exam:storePrice': (data: ExamPriceData) => void;
+  'exam:getPrice': (data: { roomId: string; date: string }, callback: (price: string) => void) => void;
+  'exam:getPrices': (data: { roomId: string }, callback: (prices: Record<string, number>) => void) => void;
+  'exam:getUserAnswers': (data: { roomId: string; userId: string }, callback: (answers: Answer[]) => void) => void;
+  'exam:currentQuestion': (data: ExamRoomData, callback: (question: QuizState | null) => void) => void;
+  'exam:settle': (data: ExamRoomData) => void;
+  'exam:cleanup': (data: { roomId: string; questionId?: string }) => void;
+}
+
+export interface ServerToClientEvents {
+  'exam:joined': (data: { roomId: string; message: string }) => void;
+  'exam:left': (data: { roomId: string; message: string }) => void;
+  'exam:memberJoined': (data: { roomId: string; userId: number; username: string }) => void;
+  'exam:memberLeft': (data: { roomId: string; userId: number; username: string }) => void;
+  'exam:question': (quizState: QuizState) => void;
+  'exam:answerUpdated': (data: { roomId: string; questionId: string; userId: number; username: string }) => void;
+  'exam:cleaned': (data: { roomId: string; message: string }) => void;
+  'exam:settle': () => void;
+  'error': (message: string) => void;
+}
+
+export interface InterServerEvents {}
+
+export interface SocketData {
+  user: UserEntity;
+}

+ 7 - 0
vite.config.ts

@@ -41,5 +41,12 @@ export default defineConfig({
     host:'0.0.0.0',
     port: 8080,
     allowedHosts: true,
+    proxy: {
+      '/socket.io': {
+        target: 'ws://localhost:8081', // WebSocket 服务器地址
+        ws: true, // 启用 WebSocket 代理
+        changeOrigin: true, // 改变源地址
+      }
+    }
   },
 })