useSocketClient.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675
  1. import { useEffect, useState, useCallback } from 'react';
  2. import { APIClient } from '@d8d-appcontainer/api';
  3. import { useQuery, useQueryClient } from '@tanstack/react-query';
  4. import type {
  5. QuizContent,
  6. QuizState,
  7. ExamSocketMessage,
  8. ExamSocketRoomMessage,
  9. Answer
  10. } from '../types/exam.ts';
  11. interface LoaderData {
  12. token: string;
  13. serverUrl: string;
  14. }
  15. // 工具函数:统一错误处理
  16. const handleAsyncOperation = async <T>(
  17. operation: () => Promise<T>,
  18. errorMessage: string
  19. ): Promise<T> => {
  20. try {
  21. return await operation();
  22. } catch (error) {
  23. console.error(`${errorMessage}:`, error);
  24. throw error;
  25. }
  26. };
  27. // 计算收益的辅助函数
  28. interface ProfitResult {
  29. profitAmount: number; // 金额收益
  30. profitPercent: number; // 百分比收益
  31. }
  32. function calculateProfit(currentPrice: number, previousPrice: number, holdingStock: string): ProfitResult {
  33. if (holdingStock === '1') {
  34. const profitAmount = currentPrice - previousPrice; // 金额收益
  35. const profitPercent = ((currentPrice - previousPrice) / previousPrice) * 100; // 百分比收益
  36. return {
  37. profitAmount,
  38. profitPercent
  39. };
  40. }
  41. return {
  42. profitAmount: 0,
  43. profitPercent: 0
  44. };
  45. }
  46. // 使用react-query管理socket客户端
  47. export function useSocketClient(roomId: string | null) {
  48. const { token, serverUrl } = {
  49. token: '',
  50. serverUrl: '/wss'
  51. };
  52. const { data: client } = useQuery({
  53. queryKey: ['socket-client'],
  54. queryFn: async () => {
  55. if (!token || !serverUrl) return null;
  56. const apiClient = new APIClient({
  57. scope: 'user',
  58. config: {
  59. serverUrl,
  60. type: 'socket',
  61. token,
  62. }
  63. });
  64. await apiClient.connect();
  65. return apiClient;
  66. },
  67. enabled: !!token && !!serverUrl && !!roomId,
  68. staleTime: Infinity,
  69. retry: 3,
  70. gcTime: 0
  71. });
  72. const joinRoom = useCallback(async (roomId: string) => {
  73. if (client) {
  74. await client.socket.joinRoom(roomId);
  75. }
  76. }, [client]);
  77. const leaveRoom = useCallback(async (roomId: string) => {
  78. if (client) {
  79. await client.socket.leaveRoom(roomId);
  80. }
  81. }, [client]);
  82. const sendRoomMessage = useCallback(async (roomId: string, message: ExamSocketMessage) => {
  83. if (client) {
  84. await client.socket.sendRoomMessage(roomId, message as any);
  85. }
  86. }, [client]);
  87. const onRoomMessage = useCallback((callback: (data: ExamSocketRoomMessage) => void) => {
  88. if (client) {
  89. client.socket.onRoomMessage(callback);
  90. }
  91. }, [client]);
  92. const getAnswers = useCallback(async (roomId: string, questionId: string): Promise<Answer[]> => {
  93. if (!client) return [];
  94. return handleAsyncOperation(async () => {
  95. const answersData = await client.redis.hgetall(`quiz:${roomId}:answers:${questionId}`);
  96. if (!answersData) return [];
  97. return Object.entries(answersData).map(([userId, data]) => ({
  98. ...(JSON.parse(data) as QuizContent),
  99. userId,
  100. })) as Answer[];
  101. }, '获取答案失败');
  102. }, [client]);
  103. const storeAnswer = useCallback(async (roomId: string, questionId: string, userId: string, answer: QuizContent) => {
  104. if (!client) return;
  105. // 获取历史价格数据
  106. const pricesData = await client.redis.hgetall(`quiz:${roomId}:prices`);
  107. if (!pricesData) {
  108. // 如果没有历史数据,存储初始答案
  109. const initialAnswer: Answer = {
  110. ...answer,
  111. userId,
  112. profitAmount: 0,
  113. profitPercent: 0,
  114. totalProfitAmount: 0,
  115. totalProfitPercent: 0
  116. };
  117. await client.redis.hset(
  118. `quiz:${roomId}:answers:${questionId}`,
  119. userId,
  120. JSON.stringify(initialAnswer)
  121. );
  122. return;
  123. }
  124. // 获取该用户的所有历史答案
  125. const dates = Object.keys(pricesData).sort();
  126. const allAnswers = await Promise.all(
  127. dates.map(date => getAnswers(roomId, date))
  128. );
  129. // 过滤出当前用户的答案并添加价格信息
  130. const userAnswers = allAnswers
  131. .flat()
  132. .filter((a: Answer) => a.userId === userId)
  133. .map((a: Answer) => {
  134. if (!a.date) return a;
  135. const priceData = JSON.parse(pricesData[a.date] || '{"price":"0"}');
  136. return {
  137. ...a,
  138. price: priceData.price
  139. };
  140. })
  141. .sort((a: Answer, b: Answer) => new Date(a.date || '').getTime() - new Date(b.date || '').getTime());
  142. // 计算收益
  143. let totalProfitAmount = 0;
  144. let totalProfitPercent = 0;
  145. if (userAnswers.length > 0) {
  146. const prevAnswer = userAnswers[userAnswers.length - 1];
  147. const { profitAmount, profitPercent } = calculateProfit(
  148. parseFloat(String(answer.price)),
  149. parseFloat(String(prevAnswer.price)),
  150. prevAnswer.holdingStock as string
  151. );
  152. totalProfitAmount = (prevAnswer.totalProfitAmount || 0) + profitAmount;
  153. totalProfitPercent = (prevAnswer.totalProfitPercent || 0) + profitPercent;
  154. }
  155. // 存储带有收益信息的答案
  156. const answerWithProfit: Answer = {
  157. ...answer,
  158. userId,
  159. profitAmount: userAnswers.length > 0 ? totalProfitAmount - (userAnswers[userAnswers.length - 1].totalProfitAmount || 0) : 0,
  160. profitPercent: userAnswers.length > 0 ? totalProfitPercent - (userAnswers[userAnswers.length - 1].totalProfitPercent || 0) : 0,
  161. totalProfitAmount,
  162. totalProfitPercent
  163. };
  164. if (client?.redis) {
  165. await client.redis.hset(
  166. `quiz:${roomId}:answers:${questionId}`,
  167. userId,
  168. JSON.stringify(answerWithProfit)
  169. );
  170. }
  171. }, [client, getAnswers]);
  172. const cleanupRoom = useCallback(async (roomId: string, questionId?: string) => {
  173. if (!client) return;
  174. await handleAsyncOperation(async () => {
  175. if (questionId) {
  176. await client.redis.del(`quiz:${roomId}:answers:${questionId}`);
  177. } else {
  178. await Promise.all([
  179. client.redis.delByPattern(`quiz:${roomId}:answers:*`),
  180. client.redis.del(`quiz:${roomId}:prices`)
  181. ]);
  182. }
  183. }, '清理房间数据失败');
  184. }, [client]);
  185. const sendNextQuestion = useCallback(async (roomId: string, state: QuizState) => {
  186. if (!client) return;
  187. return handleAsyncOperation(async () => {
  188. const message = {
  189. type: 'question',
  190. content: {
  191. date: state.date,
  192. price: state.price
  193. }
  194. };
  195. // 存储当前问题状态
  196. await storeAnswer(roomId, 'current_state', 'system', {
  197. date: state.date,
  198. price: state.price
  199. });
  200. // 存储价格历史记录
  201. await client.redis.hset(
  202. `quiz:${roomId}:prices`,
  203. state.date,
  204. JSON.stringify({ price: state.price })
  205. );
  206. await sendRoomMessage(roomId, message);
  207. }, '发送题目失败');
  208. }, [client, sendRoomMessage, storeAnswer]);
  209. const getCurrentQuestion = useCallback(async (roomId: string): Promise<QuizState | null> => {
  210. if (!client) return null;
  211. return handleAsyncOperation(async () => {
  212. const answers = await getAnswers(roomId, 'current_state');
  213. const currentState = answers[0];
  214. if (currentState) {
  215. return {
  216. date: currentState.date || '',
  217. price: currentState.price || '0'
  218. };
  219. }
  220. return null;
  221. }, '获取当前题目状态失败');
  222. }, [client, getAnswers]);
  223. // 添加获取历史价格的函数
  224. const getPriceHistory = useCallback(async (roomId: string, date: string): Promise<string> => {
  225. if (!client) return '0';
  226. return handleAsyncOperation(async () => {
  227. const priceData = await client.redis.hget(`quiz:${roomId}:prices`, date);
  228. if (!priceData) return '0';
  229. const { price } = JSON.parse(priceData);
  230. return String(price);
  231. }, '获取历史价格失败');
  232. }, [client]);
  233. return {
  234. client,
  235. joinRoom,
  236. leaveRoom,
  237. sendRoomMessage,
  238. onRoomMessage,
  239. storeAnswer,
  240. getAnswers,
  241. cleanupRoom,
  242. sendNextQuestion,
  243. getCurrentQuestion,
  244. getPriceHistory
  245. };
  246. }
  247. // Socket Room Hook
  248. export function useSocketRoom(roomId: string | null) {
  249. const socketClient = useSocketClient(roomId);
  250. const { data: roomConnection } = useQuery({
  251. queryKey: ['socket-room', roomId],
  252. queryFn: async () => {
  253. if (!roomId || !socketClient.client) return null;
  254. await socketClient.joinRoom(roomId);
  255. console.log(`Connected to room: ${roomId}`);
  256. return { roomId, connected: true };
  257. },
  258. enabled: !!roomId && !!socketClient.client,
  259. staleTime: Infinity,
  260. gcTime: Infinity,
  261. retry: false
  262. });
  263. return {
  264. connected: roomConnection?.connected || false,
  265. socketClient
  266. };
  267. }
  268. // 使用react-query管理当前题目状态
  269. export function useCurrentQuestion(roomId: string | null) {
  270. const socketClient = useSocketClient(roomId);
  271. const { data: currentQuestion, refetch } = useQuery({
  272. queryKey: ['current-question', roomId],
  273. queryFn: async () => {
  274. if (!roomId || !socketClient) return null;
  275. return socketClient.getCurrentQuestion(roomId);
  276. },
  277. enabled: !!roomId && !!socketClient,
  278. staleTime: 0
  279. });
  280. return {
  281. currentQuestion,
  282. refetchQuestion: refetch
  283. };
  284. }
  285. // 使用react-query管理房间消息
  286. export function useRoomMessages(roomId: string | null) {
  287. const socketClient = useSocketClient(roomId);
  288. const queryClient = useQueryClient();
  289. const [lastMessage, setLastMessage] = useState<ExamSocketRoomMessage | null>(null);
  290. useEffect(() => {
  291. if (!roomId || !socketClient) return;
  292. const handleMessage = (data: ExamSocketRoomMessage) => {
  293. setLastMessage(data);
  294. const { type, content } = data.message;
  295. // 处理不同类型的消息
  296. switch (type) {
  297. case 'question':
  298. queryClient.invalidateQueries({ queryKey: ['current-question', roomId] });
  299. break;
  300. case 'answer':
  301. const { date } = content;
  302. queryClient.invalidateQueries({ queryKey: ['answers', roomId, date] });
  303. break;
  304. case 'settlement':
  305. case 'submit':
  306. // 刷新所有用户的答题历史
  307. queryClient.invalidateQueries({
  308. queryKey: ['user-answers'],
  309. predicate: (query) => query.queryKey[1] === roomId
  310. });
  311. // 刷新当前答案缓存
  312. queryClient.invalidateQueries({
  313. queryKey: ['answers', roomId]
  314. });
  315. break;
  316. case 'restart':
  317. // 重置所有相关查询
  318. queryClient.invalidateQueries({ queryKey: ['current-question', roomId] });
  319. queryClient.invalidateQueries({ queryKey: ['answers', roomId] });
  320. queryClient.invalidateQueries({
  321. queryKey: ['user-answers'],
  322. predicate: (query) => query.queryKey[1] === roomId
  323. });
  324. queryClient.invalidateQueries({ queryKey: ['training-results', roomId] });
  325. break;
  326. }
  327. };
  328. socketClient.onRoomMessage(handleMessage);
  329. }, [roomId, socketClient, queryClient]);
  330. return lastMessage;
  331. }
  332. // 使用react-query管理答案缓存
  333. export function useAnswerCache(roomId: string | null, date: string | null) {
  334. const socketClient = useSocketClient(roomId);
  335. const { data: answers, refetch: refetchAnswers } = useQuery({
  336. queryKey: ['answers', roomId, date],
  337. queryFn: async () => {
  338. if (!roomId || !date || !socketClient) return [];
  339. const answers = await socketClient.getAnswers(roomId, date);
  340. const priceData = await socketClient.client?.redis.hget(`quiz:${roomId}:prices`, date);
  341. if (!priceData) return answers;
  342. const { price } = JSON.parse(priceData);
  343. return answers.map((answer: Answer) => ({
  344. ...answer,
  345. price
  346. }));
  347. },
  348. enabled: !!roomId && !!date && !!socketClient,
  349. staleTime: 0
  350. });
  351. return {
  352. answers: answers || [],
  353. refetchAnswers
  354. };
  355. }
  356. // 使用react-query管理用户答案历史
  357. export function useUserAnswerHistory(roomId: string | null, userId: string | null) {
  358. const socketClient = useSocketClient(roomId);
  359. const { data: userAnswers, refetch: refetchUserAnswers } = useQuery({
  360. queryKey: ['user-answers', roomId, userId],
  361. queryFn: async () => {
  362. if (!roomId || !userId || !socketClient) return [];
  363. // 先获取所有价格记录
  364. const pricesData = await socketClient.client?.redis.hgetall(`quiz:${roomId}:prices`);
  365. if (!pricesData) return [];
  366. // 获取所有日期的答案
  367. const allAnswers = await Promise.all(
  368. Object.keys(pricesData).map(date => socketClient.getAnswers(roomId, date))
  369. );
  370. // 过滤出当前用户的答案并添加价格信息
  371. const userAnswersWithPrice = allAnswers
  372. .flat()
  373. .filter((answer: Answer) => answer.userId === userId)
  374. .map((answer: Answer) => {
  375. if (!answer.date) return answer;
  376. const priceData = JSON.parse(pricesData[answer.date] || '{"price":"0"}');
  377. return {
  378. ...answer,
  379. price: priceData.price
  380. };
  381. })
  382. .sort((a: Answer, b: Answer) => new Date(a.date || '').getTime() - new Date(b.date || '').getTime()); // 按日期排序
  383. // 计算每条记录的收益
  384. let totalProfitAmount = 0;
  385. let totalProfitPercent = 0;
  386. const answersWithProfit = userAnswersWithPrice.map((answer: Answer, index: number) => {
  387. if (index === 0) {
  388. return {
  389. ...answer,
  390. profitAmount: 0,
  391. profitPercent: 0,
  392. totalProfitAmount: 0,
  393. totalProfitPercent: 0
  394. };
  395. }
  396. const prevAnswer = userAnswersWithPrice[index - 1];
  397. const { profitAmount, profitPercent } = calculateProfit(
  398. parseFloat(answer.price as string),
  399. parseFloat(prevAnswer.price as string),
  400. prevAnswer.holdingStock as string
  401. );
  402. totalProfitAmount += profitAmount;
  403. totalProfitPercent += profitPercent;
  404. return {
  405. ...answer,
  406. profitAmount,
  407. profitPercent,
  408. totalProfitAmount,
  409. totalProfitPercent
  410. };
  411. });
  412. return answersWithProfit;
  413. },
  414. enabled: !!roomId && !!userId && !!socketClient,
  415. staleTime: 0
  416. });
  417. return {
  418. userAnswers: userAnswers || [],
  419. refetchUserAnswers
  420. };
  421. }
  422. // 使用react-query管理答案提交
  423. export function useAnswerSubmission(roomId: string | null) {
  424. const { client, sendRoomMessage, storeAnswer } = useSocketClient(roomId);
  425. const queryClient = useQueryClient();
  426. const submitAnswer = useCallback(async (date: string, nickname: string, answer: any) => {
  427. if (!client) return;
  428. return handleAsyncOperation(async () => {
  429. await storeAnswer(roomId, date, nickname, answer);
  430. await sendRoomMessage(roomId, {
  431. type: 'answer',
  432. content: answer
  433. });
  434. queryClient.invalidateQueries({ queryKey: ['answers', roomId, date] });
  435. queryClient.invalidateQueries({ queryKey: ['user-answers', roomId, nickname] });
  436. }, '提交答案失败');
  437. }, [roomId, client, storeAnswer, sendRoomMessage, queryClient]);
  438. return { submitAnswer };
  439. }
  440. // 使用react-query管理答案提交到后端
  441. export function useAnswerManagement(roomId: string | null) {
  442. const socketClient = useSocketClient(roomId);
  443. const queryClient = useQueryClient();
  444. // 添加自动结算函数
  445. const autoSettlement = useCallback(async (date: string) => {
  446. if (!socketClient?.client) return;
  447. return handleAsyncOperation(async () => {
  448. // 获取当前所有答案
  449. const answers = await socketClient.getAnswers(roomId, date);
  450. const currentPrice = answers[0]?.price; // 使用当前价格作为结算价格
  451. if (!currentPrice) return;
  452. // 找出所有持股的用户
  453. const holdingStockUsers = answers.filter((answer: Answer) => answer.holdingStock === '1');
  454. // 为每个持股用户创建一个结算记录
  455. await Promise.all(holdingStockUsers.map(async (answer: Answer) => {
  456. const settlementAnswer = {
  457. ...answer,
  458. date,
  459. holdingStock: '0', // 清仓
  460. holdingCash: '1', // 全部持币
  461. price: currentPrice,
  462. userId: answer.userId,
  463. };
  464. // 存储结算记录
  465. await socketClient.storeAnswer(roomId, date, answer.userId, settlementAnswer);
  466. }));
  467. // 发送结算消息通知客户端刷新
  468. await socketClient.sendRoomMessage(roomId, {
  469. type: 'settlement',
  470. content: {
  471. date,
  472. price: currentPrice
  473. }
  474. });
  475. // 刷新当前页面的数据
  476. queryClient.invalidateQueries({ queryKey: ['answers', roomId, date] });
  477. }, '自动结算失败');
  478. }, [roomId, socketClient, queryClient]);
  479. const submitAnswersToBackend = useCallback(async (date: string) => {
  480. if (!socketClient) return;
  481. return handleAsyncOperation(async () => {
  482. const allAnswers = await socketClient.getAnswers(roomId, date);
  483. // 检查是否还有持股的用户
  484. const hasHoldingStock = allAnswers.some((answer: Answer) => answer.holdingStock === '1');
  485. if (hasHoldingStock) {
  486. throw new Error('还有用户持股中,请先进行结算');
  487. }
  488. const priceData = await socketClient.client?.redis.hget(`quiz:${roomId}:prices`, date);
  489. const { price } = priceData ? JSON.parse(priceData) : { price: '0' };
  490. // 获取前一天的价格
  491. const allPrices = await socketClient.client?.redis.hgetall(`quiz:${roomId}:prices`);
  492. const dates = Object.keys(allPrices || {}).sort();
  493. const currentDateIndex = dates.indexOf(date);
  494. const prevPrice = currentDateIndex > 0
  495. ? JSON.parse(allPrices![dates[currentDateIndex - 1]]).price
  496. : price;
  497. // 计算每个用户的收益
  498. const answersWithProfit = allAnswers.map((answer: Answer) => ({
  499. ...answer,
  500. price,
  501. profit: calculateProfit(parseFloat(price), parseFloat(prevPrice), answer.holdingStock || '0')
  502. }));
  503. const response = await fetch('/api/v1/classroom-answers', {
  504. method: 'POST',
  505. headers: {
  506. 'Content-Type': 'application/json'
  507. },
  508. body: JSON.stringify({
  509. classroom_no: roomId,
  510. date,
  511. answers: answersWithProfit
  512. })
  513. });
  514. const data = await response.json();
  515. if (!data.success) {
  516. throw new Error(data.message || '提交失败');
  517. }
  518. // 发送收卷消息通知客户端
  519. await socketClient.sendRoomMessage(roomId, {
  520. type: 'submit',
  521. content: {
  522. date,
  523. price
  524. }
  525. });
  526. await socketClient.cleanupRoom(roomId, date);
  527. return data;
  528. }, '提交答案到后端失败');
  529. }, [roomId, socketClient]);
  530. const { data: results, refetch: refetchResults } = useQuery({
  531. queryKey: ['training-results', roomId],
  532. queryFn: async () => {
  533. if (!roomId) return null;
  534. const response = await fetch(`/api/v1/classroom-results?classroom_no=${roomId}`);
  535. const data = await response.json();
  536. if (!data.success) {
  537. throw new Error(data.message || '获取结果失败');
  538. }
  539. return data.data;
  540. },
  541. enabled: false
  542. });
  543. const restartTraining = useCallback(async () => {
  544. if (!socketClient) return;
  545. return handleAsyncOperation(async () => {
  546. await socketClient.cleanupRoom(roomId);
  547. // 发送重开消息
  548. await socketClient.sendRoomMessage(roomId, {
  549. type: 'restart',
  550. content: {}
  551. });
  552. queryClient.invalidateQueries({ queryKey: ['current-question', roomId] });
  553. queryClient.invalidateQueries({ queryKey: ['answers', roomId] });
  554. queryClient.invalidateQueries({ queryKey: ['training-results', roomId] });
  555. }, '重启训练失败');
  556. }, [roomId, socketClient, queryClient]);
  557. return {
  558. autoSettlement, // 暴露结算函数
  559. submitAnswersToBackend,
  560. results,
  561. refetchResults,
  562. restartTraining
  563. };
  564. }
  565. // 使用react-query管理题目发送 - 直接使用 useSocketClient 中的 sendNextQuestion
  566. export function useQuestionManagement(roomId: string | null) {
  567. const socketClient = useSocketClient(roomId);
  568. const queryClient = useQueryClient();
  569. const sendNextQuestion = useCallback(async (state: QuizState) => {
  570. if (!socketClient) return;
  571. return handleAsyncOperation(async () => {
  572. await socketClient.sendNextQuestion(roomId, state);
  573. queryClient.invalidateQueries({ queryKey: ['current-question', roomId] });
  574. }, '发送题目失败');
  575. }, [roomId, socketClient, queryClient]);
  576. return {
  577. sendNextQuestion
  578. };
  579. }