routes_io_exam.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479
  1. import { Variables } from './router_io.ts';
  2. import type { QuizContent, QuizState, Answer } from '../client/mobile/components/Exam/types.ts';
  3. interface ExamRoomData {
  4. roomId: string;
  5. userId?: string;
  6. }
  7. interface ExamQuestionData extends ExamRoomData {
  8. question: QuizContent;
  9. }
  10. interface ExamAnswerData extends ExamRoomData {
  11. questionId: string;
  12. answer: Answer;
  13. }
  14. interface ExamPriceData extends ExamRoomData {
  15. date: string;
  16. price: string;
  17. }
  18. export function setupExamEvents({ socket, apiClient }: Variables) {
  19. // 加入考试房间
  20. socket.on('exam:join', async (data: ExamRoomData) => {
  21. try {
  22. const { roomId } = data;
  23. const user = socket.user;
  24. if (!user) {
  25. socket.emit('error', '未授权访问');
  26. return;
  27. }
  28. // 加入房间
  29. socket.join(roomId);
  30. // 通知用户加入成功
  31. socket.emit('exam:joined', {
  32. roomId,
  33. message: `成功加入考试房间: ${roomId}`
  34. });
  35. // 通知房间其他用户有新成员加入
  36. socket.to(roomId).emit('exam:memberJoined', {
  37. roomId,
  38. userId: user.id,
  39. username: user.username
  40. });
  41. console.log(`用户 ${user.username} 加入考试房间 ${roomId}`);
  42. } catch (error) {
  43. console.error('加入考试房间失败:', error);
  44. socket.emit('error', '加入考试房间失败');
  45. }
  46. });
  47. // 离开考试房间
  48. socket.on('exam:leave', async (data: ExamRoomData) => {
  49. try {
  50. const { roomId } = data;
  51. const user = socket.user;
  52. if (!user) {
  53. socket.emit('error', '未授权访问');
  54. return;
  55. }
  56. // 离开房间
  57. socket.leave(roomId);
  58. // 通知用户离开成功
  59. socket.emit('exam:left', {
  60. roomId,
  61. message: `已离开考试房间: ${roomId}`
  62. });
  63. // 通知房间其他用户有成员离开
  64. socket.to(roomId).emit('exam:memberLeft', {
  65. roomId,
  66. userId: user.id,
  67. username: user.username
  68. });
  69. console.log(`用户 ${user.username} 离开考试房间 ${roomId}`);
  70. } catch (error) {
  71. console.error('离开考试房间失败:', error);
  72. socket.emit('error', '离开考试房间失败');
  73. }
  74. });
  75. // 发送考试房间消息
  76. socket.on('exam:message', async (data: {
  77. roomId: string;
  78. message: {
  79. type: string;
  80. content: any;
  81. }
  82. }) => {
  83. try {
  84. const { roomId, message } = data;
  85. const user = socket.user;
  86. if (!user) {
  87. socket.emit('error', '未授权访问');
  88. return;
  89. }
  90. // 广播消息到房间
  91. socket.to(roomId).emit('exam:message', {
  92. roomId,
  93. message: {
  94. ...message,
  95. from: user.id,
  96. username: user.username,
  97. timestamp: new Date().toISOString()
  98. }
  99. });
  100. console.log(`用户 ${user.username} 在房间 ${roomId} 发送消息: ${message.type}`);
  101. } catch (error) {
  102. console.error('发送考试消息失败:', error);
  103. socket.emit('error', '发送考试消息失败');
  104. }
  105. });
  106. // 推送题目
  107. socket.on('exam:question', async (data: ExamQuestionData) => {
  108. try {
  109. const { roomId, question } = data;
  110. const user = socket.user;
  111. if (!user) {
  112. socket.emit('error', '未授权访问');
  113. return;
  114. }
  115. // 存储当前问题状态到Redis
  116. const questionKey = `exam:${roomId}:current_question`;
  117. await apiClient.redis.hset(questionKey, 'date', String(question.date));
  118. await apiClient.redis.hset(questionKey, 'price', String(question.price));
  119. await apiClient.redis.hset(questionKey, 'updated_at', new Date().toISOString());
  120. // TODO: 需要Redis服务端配置自动过期或通过其他方式实现TTL
  121. // 广播题目到房间
  122. // socket.to(roomId).emit('exam:question', {
  123. // roomId,
  124. // question: {
  125. // ...question,
  126. // timestamp: new Date().toISOString()
  127. // }
  128. // });
  129. // 存储价格到Redis
  130. const priceKey = `exam:${roomId}:prices`;
  131. await apiClient.redis.hset(priceKey, question.date, String(question.price));
  132. // console.log(`用户 ${user.username} 存储房间 ${roomId} 的价格历史: ${date} - ${price}`);
  133. const quizState:QuizState = {
  134. // id: `question-${Date.now()}`,
  135. id: question.date,
  136. date: question.date,
  137. price: question.price
  138. }
  139. socket.to(roomId).emit('exam:question', quizState);
  140. console.log(`用户 ${user.username} 在房间 ${roomId} 推送题目`);
  141. } catch (error) {
  142. console.error('推送题目失败:', error);
  143. socket.emit('error', '推送题目失败');
  144. }
  145. });
  146. // 存储答案
  147. socket.on('exam:storeAnswer', async (data: ExamAnswerData, callback: (success: boolean) => void) => {
  148. try {
  149. const { roomId, questionId, answer } = data;
  150. const user = socket.user;
  151. if (!user) {
  152. socket.emit('error', '未授权访问');
  153. callback(false);
  154. return;
  155. }
  156. // 存储答案到Redis
  157. // 新key格式: exam:answers:{roomId}:{userId}:{questionId}
  158. // 优点:
  159. // 1. 更清晰的命名空间划分(exam:answers开头)
  160. // 2. 支持两种查询模式:
  161. // - 按用户查询: exam:answers:{roomId}:{userId}:*
  162. // - 按题目查询: exam:answers:{roomId}:*:{questionId}
  163. // 3. 每个用户答案独立存储,避免大hash性能问题
  164. const answerKey = `exam:answers:${roomId}:${user.id}:${questionId}`;
  165. await apiClient.redis.hset(answerKey, 'data', JSON.stringify({
  166. username: user.username,
  167. date: answer.date,
  168. price: answer.price,
  169. holdingStock: answer.holdingStock,
  170. holdingCash: answer.holdingCash,
  171. profitAmount: answer.profitAmount,
  172. profitPercent: answer.profitPercent,
  173. totalProfitAmount: answer.totalProfitAmount,
  174. totalProfitPercent: answer.totalProfitPercent
  175. }));
  176. // 广播答案更新到房间
  177. socket.to(roomId).emit('exam:answerUpdated', {
  178. roomId,
  179. questionId,
  180. userId: user.id,
  181. username: user.username
  182. });
  183. // // 通知管理员有新答案提交
  184. // socket.to(`admin-${roomId}`).emit('exam:newAnswer', {
  185. // roomId,
  186. // questionId,
  187. // userId: user.id,
  188. // username: user.username,
  189. // timestamp: new Date().toISOString()
  190. // });
  191. console.log(`用户 ${user.username} 在房间 ${roomId} 存储答案`);
  192. callback(true);
  193. } catch (error) {
  194. console.error('存储答案失败:', error);
  195. socket.emit('error', '存储答案失败');
  196. callback(false);
  197. }
  198. });
  199. // 获取答案
  200. socket.on('exam:getAnswers', async (data: {
  201. roomId: string;
  202. questionId: string;
  203. }, callback: (answers: Answer[]) => void) => {
  204. try {
  205. const { roomId, questionId } = data;
  206. const user = socket.user;
  207. if (!user) {
  208. socket.emit('error', '未授权访问');
  209. return;
  210. }
  211. // 从Redis获取答案
  212. // 支持两种查询模式:
  213. // 1. 查询特定问题的所有答案: exam:answers:{roomId}:{questionId}:*
  214. // 2. 查询房间的所有答案: exam:answers:{roomId}:*
  215. const pattern = questionId
  216. ? `exam:answers:${roomId}:*:${questionId}`
  217. : `exam:answers:${roomId}:*`;
  218. const keys = await apiClient.redis.keys(pattern);
  219. const formattedAnswers:Answer[] = [];
  220. // 并行获取所有答案
  221. await Promise.all(keys.map(async (key) => {
  222. try {
  223. const answerStr = await apiClient.redis.hget(key, 'data');
  224. if (answerStr) {
  225. const answer = JSON.parse(answerStr);
  226. // 从key中提取userId (第四部分)
  227. const userId = key.split(':')[3];
  228. formattedAnswers.push({
  229. ...answer,
  230. userId
  231. });
  232. }
  233. } catch (error) {
  234. console.log('获取答案失败:', error);
  235. }
  236. }));
  237. callback(formattedAnswers);
  238. } catch (error) {
  239. console.error('获取答案失败:', error);
  240. socket.emit('error', '获取答案失败');
  241. callback([]);
  242. }
  243. });
  244. // 清理房间数据
  245. socket.on('exam:cleanup', async (data: {
  246. roomId: string;
  247. questionId?: string;
  248. }) => {
  249. try {
  250. const { roomId, questionId } = data;
  251. const user = socket.user;
  252. if (!user) {
  253. socket.emit('error', '未授权访问');
  254. return;
  255. }
  256. if (questionId) {
  257. // 清理特定问题的数据
  258. const answerKey = `exam:${roomId}:${questionId}:answers`;
  259. const keys = await apiClient.redis.keys(answerKey);
  260. if (keys.length > 0) {
  261. await Promise.all(keys.map((key) => apiClient.redis.del(key)))
  262. }
  263. } else {
  264. // 清理整个房间的数据
  265. const keys = await apiClient.redis.keys(`exam:${roomId}:*`);
  266. if (keys.length > 0) {
  267. await Promise.all(keys.map((key) => apiClient.redis.del(key)))
  268. }
  269. }
  270. socket.emit('exam:cleaned', {
  271. roomId,
  272. message: questionId
  273. ? `已清理房间 ${roomId} 的问题 ${questionId} 数据`
  274. : `已清理房间 ${roomId} 的所有数据`
  275. });
  276. console.log(`用户 ${user.username} 清理房间 ${roomId} 数据`);
  277. } catch (error) {
  278. console.error('清理房间数据失败:', error);
  279. socket.emit('error', '清理房间数据失败');
  280. }
  281. });
  282. // 存储价格历史
  283. socket.on('exam:storePrice', async (data: ExamPriceData) => {
  284. try {
  285. const { roomId, date, price } = data;
  286. const user = socket.user;
  287. if (!user) {
  288. socket.emit('error', '未授权访问');
  289. return;
  290. }
  291. // 存储价格到Redis
  292. const priceKey = `exam:${roomId}:prices`;
  293. await apiClient.redis.hset(priceKey, date, price);
  294. console.log(`用户 ${user.username} 存储房间 ${roomId} 的价格历史: ${date} - ${price}`);
  295. } catch (error) {
  296. console.error('存储价格历史失败:', error);
  297. socket.emit('error', '存储价格历史失败');
  298. }
  299. });
  300. // 获取历史价格
  301. socket.on('exam:getPrice', async (data: {
  302. roomId: string;
  303. date: string;
  304. }, callback: (price: string) => void) => {
  305. try {
  306. const { roomId, date } = data;
  307. const user = socket.user;
  308. if (!user) {
  309. socket.emit('error', '未授权访问');
  310. return;
  311. }
  312. // 从Redis获取价格
  313. const priceKey = `exam:${roomId}:prices`;
  314. const price = await apiClient.redis.hget(priceKey, date);
  315. callback(price || '0');
  316. } catch (error) {
  317. console.error('获取历史价格失败:', error);
  318. socket.emit('error', '获取历史价格失败');
  319. callback('0');
  320. }
  321. });
  322. // 获取所有价格历史
  323. socket.on('exam:getPrices', async (data: {
  324. roomId: string;
  325. }, callback: (prices: Record<string, number>) => void) => {
  326. try {
  327. const { roomId } = data;
  328. const user = socket.user;
  329. if (!user) {
  330. socket.emit('error', '未授权访问');
  331. return;
  332. }
  333. // 从Redis获取所有价格
  334. const priceKey = `exam:${roomId}:prices`;
  335. const priceMap = await apiClient.redis.hgetall(priceKey);
  336. const entries = Object.entries(priceMap);
  337. const convertedEntries = entries.map(([date, price]) => [date, Number(price)]);
  338. const convertedMap = Object.fromEntries(convertedEntries);
  339. callback(convertedMap);
  340. } catch (error) {
  341. console.error('获取所有价格历史失败:', error);
  342. socket.emit('error', '获取所有价格历史失败');
  343. callback({});
  344. }
  345. });
  346. // 获取用户答案
  347. socket.on('exam:getUserAnswers', async (data: {
  348. roomId: string;
  349. userId: string;
  350. }, callback: (answers: Array<Answer>) => void) => {
  351. try {
  352. const { roomId, userId } = data;
  353. const user = socket.user;
  354. if (!user) {
  355. socket.emit('error', '未授权访问');
  356. callback([]);
  357. return;
  358. }
  359. // 从Redis获取该用户的所有答案
  360. const pattern = `exam:answers:${roomId}:${userId}:*`;
  361. const keys = await apiClient.redis.keys(pattern);
  362. const userAnswers: Array<Answer> = [];
  363. // 并行获取所有答案
  364. await Promise.all(keys.map(async (key: string) => {
  365. try {
  366. const answerStr = await apiClient.redis.hget(key, 'data');
  367. if (answerStr) {
  368. const answer = JSON.parse(answerStr) as Answer;
  369. userAnswers.push(answer)
  370. }
  371. } catch (error) {
  372. console.log('获取用户答案失败:', error);
  373. }
  374. }));
  375. callback(userAnswers);
  376. } catch (error) {
  377. console.error('获取用户答案失败:', error);
  378. socket.emit('error', '获取用户答案失败');
  379. callback([]);
  380. }
  381. });
  382. // 获取当前问题
  383. socket.on('exam:currentQuestion', async (data: ExamRoomData, callback: (question: QuizState | null) => void) => {
  384. try {
  385. const { roomId } = data;
  386. const user = socket.user;
  387. if (!user) {
  388. socket.emit('error', '未授权访问');
  389. callback(null);
  390. return;
  391. }
  392. // 从Redis获取当前问题数据
  393. const questionKey = `exam:${roomId}:current_question`;
  394. const date = await apiClient.redis.hget(questionKey, 'date');
  395. const price = await apiClient.redis.hget(questionKey, 'price');
  396. if (!date || !price) {
  397. callback(null);
  398. return;
  399. }
  400. const quizState: QuizState = {
  401. id: date,
  402. date,
  403. price
  404. };
  405. callback(quizState);
  406. console.log(`用户 ${user.username} 获取房间 ${roomId} 的当前问题`);
  407. } catch (error) {
  408. console.error('获取当前问题失败:', error);
  409. socket.emit('error', '获取当前问题失败');
  410. callback(null);
  411. }
  412. });
  413. }