useSocketClient.ts 20 KB

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