Browse Source

websocket 公用库

Michael Wang 3 years ago
parent
commit
59e0191596

+ 6 - 0
src/constants/constants.ts

@@ -22,6 +22,12 @@ export const DOMAIN = env.DEV
   ? (env.VITE_DEVELOPMENT_DOMAIN as string)
   : domainCandidate;
 
+export const WEBSOCKET_FOR_FACE_ID =
+  window.location.origin.replace("http", "ws") + "/api/ws/faceBiopsy";
+
+export const WEBSOCKET_FOR_AUDIO =
+  window.location.origin.replace("http", "ws") + "/api/ws/fileAnswer";
+
 export const PRIVACY_READ_VERSION_NUMBER = "1";
 
 /** 限流请求的服务器 */

+ 71 - 10
src/features/OnlineExam/Examing/ExamingHome.vue

@@ -9,9 +9,7 @@ import FaceTracking from "./FaceTracking.vue";
 // import FaceId from "./FaceId.vue";
 // import FaceMotion from "./FaceMotion/FaceMotion";
 import FaceRecognition from "../FaceRecognition.vue";
-// import { openWS, closeWsWithoutReconnect } from "./ws.js";
-
-import { STRICT_CHECK_HOSTS } from "@/constants/constants";
+import { STRICT_CHECK_HOSTS, WEBSOCKET_FOR_AUDIO } from "@/constants/constants";
 import { httpApp } from "@/plugins/axiosApp";
 import { useTimers } from "@/setups/useTimers";
 import { checkMainExe } from "@/utils/nativeMethods";
@@ -22,6 +20,9 @@ import { store } from "@/store/store";
 import { useRemoteAppChecker } from "@/features/UserLogin/useRemoteAppChecker";
 import { ExamQuestion, PaperStruct, Store } from "@/types/student-client";
 import router from "@/router";
+import { useWebSocket } from "@/setups/useWebSocket";
+
+const { startWS } = useWebSocket();
 
 type PRACTICE_TYPE = "IN_PRACTICE" | "NO_ANSWER";
 
@@ -518,13 +519,73 @@ async function initData() {
   // console.log(examQuestionList);
   // console.log(examQuestionList.find(v => v.answerType === "SINGLE_AUDIO"));
 
-  // const shouldOpenWS = exam.WEIXIN_ANSWER_ENABLED;
-
-  // if (shouldOpenWS) {
-  //   // console.log("have single");
-  //   const examRecordDataId = examRecordDataId;
-  //   openWS({ examRecordDataId });
-  // }
+  function onAudioAnswer(event: MessageEvent<string>) {
+    let res: {
+      eventType: string;
+      isSuccess: boolean;
+      errorMessage: string;
+      data: { order: number; fileUrl: string; transferFileType: string };
+    };
+    try {
+      res = JSON.parse(event.data).content;
+    } catch (error) {
+      logger({ cnl: ["server"], act: "JSON.parse出错", possibleError: error });
+      return;
+    }
+    if (!res) {
+      logger({
+        cnl: ["server"],
+        act: "onAudioAnswer",
+        dtl: "ws message format error",
+        ext: { event: JSON.stringify(event) },
+      });
+      return;
+    }
+    if (res.eventType && res.eventType !== "HEARTBEAT" && !res.isSuccess) {
+      $message.error(res.errorMessage, { duration: 10, closable: true });
+      logger({
+        cnl: ["server"],
+        act: "onAudioAnswer",
+        dtl: "error from server",
+        stk: res.errorMessage,
+      });
+      return;
+    }
+    switch (res.eventType) {
+      case "HEARTBEAT":
+        logger({
+          cnl: ["server"],
+          lvl: "debug",
+          act: "ws heartbeat response from server",
+        });
+        break;
+      case "SCAN_QR_CODE":
+        logger({ cnl: ["server"], act: "二维码被扫描" });
+        store.setQuestionQrCodeScanned({ order: res.data.order });
+        break;
+      case "GET_FILE_ANSWER":
+        console.log("get file url", res);
+        logger({ cnl: ["server"], act: "获得音频地址" });
+        store.setQuestionFileAnswerUrl(res.data);
+        break;
+      case "SYSTEM_ERROR":
+        console.log("ws get error", res);
+        logger({
+          cnl: ["server"],
+          act: "ws get error",
+          ejn: JSON.stringify(res),
+        });
+        break;
+    }
+  }
+  if (exam.WEIXIN_ANSWER_ENABLED) {
+    // init data
+    store.exam.questionAnswerFileUrl = [];
+    startWS(
+      WEBSOCKET_FOR_AUDIO + `?key=${store.user.key}&token=${store.user.token}`,
+      onAudioAnswer
+    );
+  }
 }
 
 // async function updateQuestion(next) {

+ 1 - 1
src/features/OnlineExam/StartExamModal.vue

@@ -223,7 +223,7 @@ async function enterExam() {
 }
 
 onUnmounted(() => {
-  if (!passedAllChecks) {
+  if (!passedAllChecks && course.faceEnable) {
     // debug级别备选。因为初期上线,摄像头比较容易出错,所以保留此日志
     logger({
       cnl: ["server", "console"],

+ 166 - 0
src/setups/useWebSocket.ts

@@ -0,0 +1,166 @@
+import { onUnmounted } from "vue";
+import { useTimers } from "./useTimers";
+
+/** 调用就连接,被断开就连接,onUnmouted就断开连接,同时清除自动连接的机制
+ * onMessage 接收websocket的消息
+ * 本应用没有发消息的场景
+ */
+export function useWebSocket() {
+  let ws: WebSocket;
+  let heartbeatIds: number[] = [];
+  const RECONNECT_INTERVAL = 6 * 1000;
+  const HEARTBEAT_INTERVAL = 50 * 1000;
+  let reconnectNumber = 0;
+
+  let closeExplicitly = false;
+
+  let url: string;
+  let onMessage: (e: MessageEvent) => void;
+
+  const { addTimeout, addInterval } = useTimers();
+
+  function startWS(_url: string, _onMessage: (e: MessageEvent) => void) {
+    url = _url;
+    onMessage = _onMessage;
+    openWS();
+  }
+
+  // new WebSocket -> open -> heartbeat -> onmessage    happy path
+  // new WebSocket -> onerror -> reconnect
+  // new WebSocket -> onclose(by server) -> reconnect
+  function openWS() {
+    logger({
+      cnl: ["server", "console"],
+      key: "微信小程序websocket",
+      act: "准备连接",
+      // 连接websocket时,ws要么还没初始化,要么是closed,否则均不正常
+      dtl: [undefined, 3].includes(ws?.readyState)
+        ? "websocket未初始化或已关闭"
+        : "不可能的事情发生了",
+      ext: { url, websocketState: ws?.readyState },
+    });
+    try {
+      ws = new WebSocket(url);
+    } catch (error) {
+      // 理论上不该出现:SECURITY_ERR SyntaxError
+      $message.error("Websocket初始化失败", { duration: 5, closable: true });
+      logger({
+        cnl: ["server", "console"],
+        key: "微信小程序websocket",
+        act: "Websocket初始化失败",
+        possibleError: error,
+        ext: { url },
+      });
+    }
+
+    ws.onopen = () => {
+      logger({
+        cnl: ["server", "console"],
+        key: "微信小程序websocket",
+        act: "连接成功",
+        ext: { url },
+      });
+
+      reconnectNumber = 0;
+      heartbeat();
+    };
+
+    ws.onmessage = onMessage;
+
+    ws.onclose = (event) => {
+      logger({
+        cnl: ["server", "console"],
+        key: "微信小程序websocket",
+        act: "ws closed by server",
+        dtl: JSON.stringify(event),
+        ext: { url },
+      });
+      for (const heartbeatId of heartbeatIds) {
+        clearInterval(heartbeatId);
+      }
+      heartbeatIds = [];
+
+      if (!closeExplicitly) reconnect("onclose-by-server");
+    };
+
+    function reconnect(cause: string) {
+      addTimeout(() => {
+        reconnectNumber++;
+        if (reconnectNumber >= 5) {
+          reconnectNumber = 0;
+          $message.error("Websocket重连失败", {
+            duration: 5,
+            closable: true,
+          });
+        }
+        logger({
+          cnl: ["server", "console"],
+          key: "微信小程序websocket",
+          act: "连接被关闭后-准备连接-" + cause,
+          ext: { url },
+        });
+        // 会让断开就重连
+        openWS();
+      }, RECONNECT_INTERVAL);
+    }
+
+    ws.onerror = (event) => {
+      logger({
+        cnl: ["server", "console"],
+        key: "微信小程序websocket",
+        act: "onerror",
+        dtl: JSON.stringify(event),
+        ext: { url },
+      });
+      if (!closeExplicitly) reconnect("onerror");
+    };
+  }
+
+  function heartbeat() {
+    for (const heartbeatId of heartbeatIds) {
+      clearInterval(heartbeatId);
+    }
+    heartbeatIds = [];
+
+    const heartbeatId = addInterval(() => {
+      logger({
+        lvl: "debug",
+        cnl: ["server", "console"],
+        key: "微信小程序websocket",
+        act: "websocket heartbeat",
+        ext: { url },
+      });
+      ws.send(JSON.stringify({ eventType: "HEARTBEAT" }));
+    }, HEARTBEAT_INTERVAL);
+
+    heartbeatIds.push(heartbeatId);
+  }
+
+  function closeWsWithoutReconnect() {
+    closeExplicitly = true;
+    logger({
+      cnl: ["server", "console"],
+      key: "微信小程序websocket",
+      act: "客户端准备关闭ws。",
+      ext: { url },
+    });
+
+    try {
+      // The WebSocket.close() method closes the WebSocket connection or connection attempt, if any.
+      // If the connection is already CLOSED, this method does nothing.
+      if (ws) ws.close();
+    } catch (e) {
+      logger({
+        cnl: ["server", "console"],
+        key: "微信小程序websocket",
+        act: "关闭ws异常。",
+        possibleError: e,
+        ext: { url },
+      });
+    }
+  }
+
+  onUnmounted(closeWsWithoutReconnect);
+
+  return { startWS };
+}

+ 17 - 0
src/store/store.ts

@@ -73,6 +73,23 @@ export const useStore = defineStore("ecs", {
     updateSiteMessagesTimeStamp() {
       store.siteMessagesTimeStamp = Date.now();
     },
+    setQuestionQrCodeScanned(payload: { order: number }) {
+      store.exam.questionQrCodeScanned = payload;
+    },
+    setQuestionFileAnswerUrl(payload: {
+      order: number;
+      fileUrl: string;
+      transferFileType: string;
+    }) {
+      const oldIndex = store.exam.questionAnswerFileUrl.findIndex(
+        (v) => v.order === payload.order
+      );
+      if (oldIndex === -1) {
+        store.exam.questionAnswerFileUrl.push(payload);
+      } else {
+        store.exam.questionAnswerFileUrl[oldIndex] = payload;
+      }
+    },
   },
 });
 

+ 6 - 0
src/types/student-client.d.ts

@@ -166,6 +166,12 @@ export type Store = {
     /** 试题过滤类型 */
     questionFilterType: "ALL" | "ANSWERED" | "SIGNED" | "UNANSWERED";
     allAudioPlayTimes: { audioName: string; times: number }[];
+    questionQrCodeScanned: { order: number };
+    questionAnswerFileUrl: {
+      order: number;
+      fileUrl: string;
+      transferFileType: string;
+    }[];
   };
   // /** 考试中的状态 */
   // examing: {};

+ 1 - 0
vite.config.ts

@@ -53,6 +53,7 @@ export default defineConfig({
         target: SERVER_URL,
         changeOrigin: true,
         secure: false,
+        ws: true,
       },
       "/admin": {
         target: SERVER_URL,