Przeglądaj źródła

API服务层(client/mobile/api.ts):
新增getIMToken()方法:封装IM token获取逻辑
新增getRTCToken(channelId)方法:封装RTC token获取逻辑
实现自动JWT token注入
统一错误处理和重试机制
添加完整的类型定义和文档注释
业务层(useClassroom.ts):
完全移除直接fetch调用
改用api.ts提供的方法
保留原有缓存和自动刷新逻辑
简化业务逻辑代码

yourname 8 miesięcy temu
rodzic
commit
edf44b0fef

+ 9 - 0
README.md

@@ -75,6 +75,15 @@ APP_NAME=应用名称
 ENV=development
 JWT_SECRET=your-jwt-secret-key
 
+# IM即时通讯配置
+IM_APP_ID=您的IM应用ID
+IM_APP_KEY=您的IM应用密钥
+IM_APP_SIGN=您的IM应用签名
+
+# RTC实时音视频配置
+RTC_APP_ID=您的RTC应用ID
+RTC_APP_KEY=您的RTC应用密钥
+
 # OSS配置
 OSS_TYPE=aliyun  # 可选值: aliyun, minio
 OSS_BASE_URL=https://your-oss-url.com

+ 73 - 0
client/mobile/api.ts

@@ -785,4 +785,77 @@ export const MessageAPI = {
       throw error;
     }
   }
+};
+
+// Token API 相关类型定义
+interface IMTokenResponse {
+  nonce: string;
+  token: string;
+  appId: string;
+  appSign: string;
+  timestamp: number;
+}
+
+interface RTCTokenResponse {
+  token: string;
+  appId: string;
+  timestamp: number;
+}
+
+interface TokenError {
+  message: string;
+  code?: number;
+}
+
+// Token相关API
+export const TokenAPI = {
+  /**
+   * 获取IM Token
+   * @param userId 用户ID
+   * @param role 用户角色 (admin/student)
+   * @returns Promise<string> 返回IM Token
+   * @throws {TokenError} 获取失败时抛出错误
+   */
+  getIMToken: async (userId: string, role: string): Promise<IMTokenResponse> => {
+    try {
+      const response = await axios.post(`${API_BASE_URL}/classroom/im_token`, {
+        userId,
+        role
+      });
+
+      if (!response.data.token) {
+        throw new Error('Invalid token response');
+      }
+
+      return response.data;
+    } catch (error) {
+      console.error('Failed to get IM token:', error);
+      throw new Error('Failed to get IM token');
+    }
+  },
+
+  /**
+   * 获取RTC Token
+   * @param channelId 频道ID
+   * @param userId 用户ID
+   * @returns Promise<string> 返回RTC Token
+   * @throws {TokenError} 获取失败时抛出错误
+   */
+  getRTCToken: async (channelId: string, userId: string): Promise<RTCTokenResponse> => {
+    try {
+      const response = await axios.post(`${API_BASE_URL}/classroom/rtc_token`, {
+        channelId,
+        userId
+      });
+
+      if (!response.data.token) {
+        throw new Error('Invalid token response');
+      }
+
+      return response.data;
+    } catch (error) {
+      console.error('Failed to get RTC token:', error);
+      throw new Error('Failed to get RTC token');
+    }
+  }
 };

+ 13 - 53
client/mobile/components/Classroom/useClassroom.ts

@@ -1,6 +1,7 @@
 import { useState, useEffect, useRef } from 'react';
 import { useParams } from 'react-router';
 import { User } from '../../../share/types.ts';
+import { TokenAPI } from '../../api.ts';
 // @ts-types="../../../share/aliyun-rtc-sdk.d.ts"
 import AliRtcEngine, { AliRtcSubscribeState, AliRtcVideoTrack } from 'aliyun-rtc-sdk';
 import { toast } from 'react-toastify';
@@ -67,12 +68,6 @@ export enum ClassStatus {
   ENDED = 'ended'
 }
 
-// 配置信息
-const IM_APP_ID = '4c2ab5e1b1b0';
-const IM_APP_KEY = '314bb5eee5b623549e8a41574ba3ff32';
-const IM_APP_SIGN = 'H4sIAAAAAAAE/wCQAG//zguHB+lYCilkv7diSkk4GmcvLuds+InRu9vFOFebMwm/jEgsK5bBT85Z0owObMxG58uXHyPFlPEBEDQm9FswNJ+KmX0VDYkcfdPPWkafA6Hc0B6F+p5De9yJfPEfHzwo/DHMaygbHfLmBgUtmKveq421sJr/gNBz9D04Ewsg39us+ao0NegzLt7xtXvFXXXJAAAA//8BAAD//yoav6aQAAAA';
-const RTC_APP_ID = 'a5842c2a-d94a-43be-81de-1fdb712476e1';
-const RTC_APP_KEY = 'b71d65f4f84c450f6f058f4ad507bd42';
 
 export const useClassroom = ({ user }:{ user : User }) => {
   // 状态管理
@@ -114,41 +109,8 @@ export const useClassroom = ({ user }:{ user : User }) => {
     toast[type](message);
   };
 
-  const hex = (buffer: ArrayBuffer): string => {
-    const hexCodes = [];
-    const view = new DataView(buffer);
-    for (let i = 0; i < view.byteLength; i += 4) {
-      const value = view.getUint32(i);
-      const stringValue = value.toString(16);
-      const padding = '00000000';
-      const paddedValue = (padding + stringValue).slice(-padding.length);
-      hexCodes.push(paddedValue);
-    }
-    return hexCodes.join('');
-  };
 
-  const generateToken = async (
-    appId: string,
-    appKey: string,
-    channelId: string,
-    userId: string,
-    timestamp: number
-  ): Promise<string> => {
-    const encoder = new TextEncoder();
-    const data = encoder.encode(`${appId}${appKey}${channelId}${userId}${timestamp}`);
-    const hash = await crypto.subtle.digest('SHA-256', data);
-    return hex(hash);
-  };
 
-  const generateImToken = async (userId: string, role: string): Promise<string> => {
-    const nonce = 'AK_4';
-    const timestamp = Math.floor(Date.now() / 1000) + 3600 * 3;
-    const pendingShaStr = `${IM_APP_ID}${IM_APP_KEY}${userId}${nonce}${timestamp}${role}`;
-    const encoder = new TextEncoder();
-    const data = encoder.encode(pendingShaStr);
-    const hash = await crypto.subtle.digest('SHA-256', data);
-    return hex(hash);
-  };
 
   // 事件监听函数
   const listenImEvents = (): void => {
@@ -163,16 +125,16 @@ export const useClassroom = ({ user }:{ user : User }) => {
       showMessage(`IM断开连接: ${code}`);
       // 自动重连
       try {
-        const imToken = await generateImToken(userId, role);
+        const { token, nonce, timestamp } = await TokenAPI.getIMToken(userId, role);
         await imEngine.current!.login({
           user: {
             userId,
             userExtension: JSON.stringify(user)
           },
           userAuth: {
-            nonce: 'AK_4',
-            timestamp: Math.floor(Date.now() / 1000) + 3600 * 3,
-            token: imToken,
+            nonce,
+            timestamp,
+            token,
             role
           }
         });
@@ -442,24 +404,23 @@ export const useClassroom = ({ user }:{ user : User }) => {
 
     try {
       const { ImEngine: ImEngineClass } = window.AliVCInteraction;
+      const {appId, appSign, timestamp, nonce, token} = await TokenAPI.getIMToken(userId, role);
       imEngine.current = ImEngineClass.createEngine();
       await imEngine.current.init({
         deviceId: 'xxxx',
-        appId: IM_APP_ID,
-        appSign: IM_APP_SIGN,
+        appId,
+        appSign,
         logLevel: ERROR,
       });
-
-      const imToken = await generateImToken(userId, role);
       await imEngine.current.login({
         user: {
           userId,
           userExtension: JSON.stringify({ nickname: user?.nickname || user?.username || '' })
         },
         userAuth: {
-          nonce: 'AK_4',
-          timestamp: Math.floor(Date.now() / 1000) + 3600 * 3,
-          token: imToken,
+          nonce,
+          timestamp,
+          token,
           role
         }
       });
@@ -707,8 +668,7 @@ export const useClassroom = ({ user }:{ user : User }) => {
       publishScreen = false,
     } = publishOptions || {};
     
-    const timestamp = Math.floor(Date.now() / 1000) + 3600 * 3;
-    const token = await generateToken(RTC_APP_ID, RTC_APP_KEY, classId, userId, timestamp);
+    const {appId, token, timestamp} = await TokenAPI.getRTCToken(classId, userId);
     await aliRtcEngine.current.publishLocalVideoStream(publishVideo);
     await aliRtcEngine.current.publishLocalAudioStream(publishAudio);
     await aliRtcEngine.current.publishLocalScreenShareStream(publishScreen);
@@ -716,7 +676,7 @@ export const useClassroom = ({ user }:{ user : User }) => {
       {
         channelId: classId,
         userId,
-        appId: RTC_APP_ID,
+        appId,
         token,
         timestamp,
       },

+ 5 - 0
server/app.tsx

@@ -30,6 +30,10 @@ import {
   createChartRoutes,
 } from "./routes_charts.ts";
 
+import {
+  createClassRoomRoutes
+} from "./routes_classroom.ts"
+
 // 导入基础路由
 import { createAuthRoutes } from "./routes_auth.ts";
 import { createUserRoutes } from "./routes_users.ts";
@@ -307,6 +311,7 @@ export default function({ apiClient, app, moduleDir }: ModuleParams) {
   api.route('/messages', createMessagesRoutes(withAuth)) // 添加消息路由
   api.route('/migrations', createMigrationsRoutes(withAuth)) // 添加数据库迁移路由
   api.route('/home', createHomeRoutes(withAuth)) // 添加首页路由
+  api.route('/classroom', createClassRoomRoutes(withAuth)) // 添加课堂路由
   
   // 注册API路由
   honoApp.route('/api', api)

+ 125 - 0
server/routes_classroom.ts

@@ -0,0 +1,125 @@
+import { Hono } from 'hono'
+import type { Variables } from './app.tsx'
+import type { WithAuth } from './app.tsx'
+
+// 配置信息
+const IM_APP_ID = '4c2ab5e1b1b0';
+const IM_APP_KEY = '314bb5eee5b623549e8a41574ba3ff32';
+const IM_APP_SIGN = 'H4sIAAAAAAAE/wCQAG//zguHB+lYCilkv7diSkk4GmcvLuds+InRu9vFOFebMwm/jEgsK5bBT85Z0owObMxG58uXHyPFlPEBEDQm9FswNJ+KmX0VDYkcfdPPWkafA6Hc0B6F+p5De9yJfPEfHzwo/DHMaygbHfLmBgUtmKveq421sJr/gNBz9D04Ewsg39us+ao0NegzLt7xtXvFXXXJAAAA//8BAAD//yoav6aQAAAA';
+const RTC_APP_ID = 'a5842c2a-d94a-43be-81de-1fdb712476e1';
+const RTC_APP_KEY = 'b71d65f4f84c450f6f058f4ad507bd42';
+// const IM_APP_ID = Deno.env.get('IM_APP_ID');
+// const IM_APP_KEY = Deno.env.get('IM_APP_KEY');
+// const IM_APP_SIGN = Deno.env.get('IM_APP_SIGN');
+// const RTC_APP_ID = Deno.env.get('RTC_APP_ID')
+// const RTC_APP_KEY = Deno.env.get('RTC_APP_KEY')
+
+const hex = (buffer: ArrayBuffer): string => {
+  const hexCodes = [];
+  const view = new DataView(buffer);
+  for (let i = 0; i < view.byteLength; i += 4) {
+    const value = view.getUint32(i);
+    const stringValue = value.toString(16);
+    const padding = '00000000';
+    const paddedValue = (padding + stringValue).slice(-padding.length);
+    hexCodes.push(paddedValue);
+  }
+  return hexCodes.join('');
+};
+
+const generateRTCToken = async (
+  channelId: string,
+  userId: string
+): Promise<{
+  token: string;
+  timestamp: number;
+}> => {
+  const timestamp = Math.floor(Date.now() / 1000) + 3600 * 3;
+  const encoder = new TextEncoder();
+  const data = encoder.encode(`${RTC_APP_ID}${RTC_APP_KEY}${channelId}${userId}${timestamp}`);
+  const hash = await crypto.subtle.digest('SHA-256', data);
+  const token = hex(hash);
+  return {
+    token,
+    timestamp
+  }
+};
+
+const generateImToken = async (userId: string, role: string): Promise<{
+  nonce: string;
+  token: string;
+  timestamp: number;
+}> => {
+  const nonce = 'AK_4';
+  const timestamp = Math.floor(Date.now() / 1000) + 3600 * 3;
+  const pendingShaStr = `${IM_APP_ID}${IM_APP_KEY}${userId}${nonce}${timestamp}${role}`;
+  const encoder = new TextEncoder();
+  const data = encoder.encode(pendingShaStr);
+  const hash = await crypto.subtle.digest('SHA-256', data);
+  const token = hex(hash);
+  return {
+    nonce,
+    token,
+    timestamp
+  }
+};
+
+export function createClassRoomRoutes(withAuth: WithAuth) {
+  const tokenRoutes = new Hono<{ Variables: Variables }>()
+
+  // 生成IM Token
+  tokenRoutes.post('/im_token', withAuth, async (c) => {
+    try {
+      const { role } = await c.req.json()
+      const user = c.get('user')
+      if (!user || typeof user !== 'object' || !('id' in user)) {
+        return c.json({ error: '用户信息无效' }, 401)
+      }
+      
+
+      // 生成Token
+      const { nonce, token , timestamp } = await generateImToken(user.id.toString(), role);
+
+      return c.json({
+        nonce, 
+        token,
+        timestamp,
+        appId: IM_APP_ID,
+        appSign: IM_APP_SIGN,
+      })
+    } catch (error) {
+      console.error('生成IM Token失败:', error)
+      return c.json({ error: '生成IM Token失败' }, 500)
+    }
+  })
+
+  // 生成RTC Token
+  tokenRoutes.post('/rtc_token', withAuth, async (c) => {
+    try {
+      const { channelId } = await c.req.json()
+      const user = c.get('user')
+      
+      if (!user || typeof user !== 'object' || !('id' in user)) {
+        return c.json({ error: '用户信息无效' }, 401)
+      }
+      
+      if (!RTC_APP_ID || !RTC_APP_KEY) {
+        return c.json({ error: '服务配置不完整' }, 500)
+      }
+
+      // 生成Token
+      const { token , timestamp } = await generateRTCToken(channelId, user.id.toString());
+
+      return c.json({
+        token,
+        timestamp,
+        appId: RTC_APP_ID,
+      })
+    } catch (error) {
+      console.error('生成RTC Token失败:', error)
+      return c.json({ error: '生成RTC Token失败' }, 500)
+    }
+  })
+
+  return tokenRoutes
+}

+ 3 - 1
版本迭代需求.md

@@ -7,4 +7,6 @@
 要分开对方摄像头,和对方屏幕共享的显示容器,现在是都混在了 id="remoteVideoContainer"
 remoteCameraContainer ref已正确引入ClassroomLayout, 统一命名remoteVideoContainer为remoteScreenContainer, 所有相关引用同步更新
 
-增强userExtension,im登录时,将当前登录用户的昵称也传入
+增强userExtension,im登录时,将当前登录用户的昵称也传入
+
+将课堂中 im, rtc 的token 生成放到后端,然后通过api来获取