实现一个安全的在线考试平台与RTC ENGINE:开发者指南

10 分钟阅读
Feb 18, 2025

场景概述

在线考试是指通过计算机在网络上进行的一种考试形式,脱离纸质媒体,也可以描述为通过在线媒体进行的考试。

  • 在线考试涵盖各种无纸化考试系统,如全国计算机等级考试、大学英语考试、研究生入学考试、职业资格考试、金融考试、工程考试、外贸考试、大专升本科考试、公务员考试和企业笔试。考试完成后,可以进行自动评分等操作,以获取最终成绩报告,可发送到电子邮件以供保存或在线打印。
  • 传统考试涉及从出题、编写和打印试卷,到分发试卷、提交答案、收集和批改试卷、公布结果及统计分析考试结果等整个过程的手动参与。这个过程漫长、劳动密集,容易出错,并且需要足够的保密工作,使整体学习和考试成本较高。在线考试学习系统可以完全实现无纸化、网络化和自动化的计算机在线学习与考试,这对单位的信息化建设具有深远的现实意义和实践价值。

需要注意的是,“在线考试”和“离线计算机考试”并不是同一种商业模式。离线计算机考试是指在离线IDC中通过在线系统进行考试,与传统考试的区别仅在于试题是否在纸质上完成。

实施方案

在线考试场景的整体参考业务流程如图所示。在线考试系统的用户通常包括考生、监考人员、考试管理员和出题人(阅卷人)。

  • 考生:考生需要登录考生账户进入考场,激活摄像头和屏幕共享,并在监控条件下完成并提交试卷。
  • 监考人员:监考人员需要登录监考账户,能够查看所有参加考试的考生的摄像头画面,以判断是否有作弊行为。系统本身也具备一定的自动监控能力,例如监控考试应用切换的次数和持续时间。在某些场景中,他们还需要回答考生可能提出的问题,在允许的范围内。
  • 考试管理员:他们需要创建考场,将试题上传到相应的考场,并控制考场的访问权限。通常,一名监考人员也可以担任此角色。
  • 教师:他们的主要任务是在考试前出题,并在考试后手动批改试卷(如果必要的话)。

在线考试业务流程图:

Online Examination Process Flowchart: This flowchart outlines the steps involved in an online examination process, including actions for students

业务流程

本文档总结了一些常见的业务流程,以帮助您更好地理解整个场景的实施流程。

  • 登录和登出
Flowchart detailing the process of initializing and managing a chat instance, including user authentication, event listening
  • 创建/销毁房间
  • 音视频流传输
  • 音视频拉流

应用产品

RTC引擎 & 聊天

基本集成指南

为了确保在线考试的顺利进行,建议您提前通过控制台提交工单,注册考试人数、人员地区分布、分辨率、比特率等信息。

激活服务

语音聊天室场景通常需要依赖云平台的两个付费PaaS服务,聊天实时通信(TRTC) 进行构建。互动白板是可选的,可以选择或自托管。

1. 您需要登录控制台 创建应用程序。选择RTC引擎和聊天服务。这时,SDKAppID会自动在控制台中创建。两者的账户和认证系统可以重用。随后,您可以根据需要选择升级RTC或聊天应用版本。例如,高级版本可以解锁更多增值功能和服务。

注意:

  • 建议为测试和生产环境分别创建两个独立的应用程序。每个账户(UIN)在一年内提供10,000分钟的免费使用时间。
  • TRTC月度套餐分为试用版、基础版和专业版,可以解锁不同的增值功能和服务。有关详细信息,请参见版本功能和月度套餐说明

2. 一旦应用程序创建成功,您可以在应用管理 - 应用概览部分找到其基本信息。务必安全存储SDKAppIDSDKSecretKey以备后用,并避免泄露,以防止未授权的流量使用。

导入SDK

TRTC SDK聊天SDK的zip压缩包下载到本地机器并解压。您可以在项目的根目录下创建一个名为thirdparty的目录来存储所有SDK。将TRTC SDK和聊天SDK移动到thirdparty目录中以备后用。

1. 与QTCreator集成

// *.pro
INCLUDEPATH +=  $$PWD/thirdparty/TRTC_SDK/CPlusPlus/Win64/include \
              $$PWD/thirdparty/TRTC_SDK/CPlusPlus/Win64/include/TRTC \
              $$PWD/thirdparty/IM_SDK/include

LIBS += -L'$$PWD/thirdparty/TRTC_SDK/CPlusPlus/Win64/lib' -lliteav \
        -L'$$PWD/thirdparty/IM_SDK/lib/Win64' -lImSDK

2. 与Visual Studio集成

  • 要在Visual Studio中添加头文件目录,请导航到配置属性> C/C++> 常规> 附加包含目录。然后,添加您的头文件路径。
$(SolutionDir)thirdparty/TRTC_SDK/CPlusPlus/Win64/include
$(SolutionDir)thirdparty/TRTC_SDK/CPlusPlus/Win64/include/TRTC
$(SolutionDir)thirdparty/IM_SDK/include
  • 要添加库文件目录,请导航到配置属性> 链接> 常规> 附加库目录。
$(SolutionDir)thirdparty/TRTC_SDK/CPlusPlus/Win64/lib$(SolutionDir)thirdparty/IM_SDK/lib/Win64
  • 引用库文件。
#pragma comment(lib,"liteav.lib")
#pragma comment(lib,"ImSDK.lib")

注意:

  • 根据您的具体业务需求,如果需要集成x86,请使用Win32目录下的头文件和库文件。
  • DLL动态库需要复制到可执行文件(exe)所在的目录。
  • x86和x64的LIB文件和DLL文件不能混合,需要保持一致。

场景特定实现

身份验证凭据生成

UserSig是腾讯云设计的一种安全签名,用于防止攻击者访问您的云账户。实时通信(TRTC)和聊天等云服务都采用这种安全保护机制。TRTC在进入房间时进行身份验证,而聊天在登录时进行身份验证。

  • 调试和测试阶段:UserSig可以通过客户端UserSig生成TRTC控制台UserSig生成生成,仅用于调试和测试。
  • 生产阶段:建议使用服务器端UserSig生成,安全级别更高,有助于防止客户端被反编译和逆向,从而避免密钥泄露的风险。

具体的实现过程如下:

1. 在您的应用程序调用SDK的初始化API之前,请向您的服务器请求UserSig。

2. 您的服务器将根据SDKAppID和UserID生成UserSig。

3. 服务器将UserSig返回给您的应用程序。

4. 您的应用程序通过特定API将UserSig发送到SDK。

5. SDK将SDKAppID、UserID和UserSig提交给云服务器进行验证。

6. 云平台验证UserSig的有效性。

7. 如果UserSig有效,将为Chat SDK和TRTC SDK提供服务。

注意:

  • 在调试和测试阶段本地生成UserSig的方法不推荐用于在线环境,因为它可能很容易被反编译和逆向,导致密钥泄露。
  • 我们提供多种语言(Java/GO/PHP/Nodejs/Python/C#/C++)的UserSig生成源代码。有关详细信息,请参见UserSig生成源代码

初始化和监听

API序列图

1.聊天SDK初始化和添加事件监听器

聊天SDK使用功能回调方法。可以封装成回调类以方便使用。

// IMWrapperCallback.h
class IMWrapperCallback
{
public:
    virtual void OnLogin(int errCode, const char* errMsg) = 0;
    virtual void OnLogout(int errCode, const char* errMsg) = 0;
    virtual void OnError(int code, const char* errMsg) = 0;
    virtual void OnCreateGroup(int errCode, const char* errMsg) = 0;
    virtual void OnJoinGroup(int errCode, const char* errMsg) = 0;
    virtual void OnRecvNewMsg(const char* msg) = 0;
    //…
};

// IMWrapper.h
class IMWrapper
{
public:
    IMWrapper();
    bool InitIM(const char* path);
    bool UnInitIM();
    void SetCallback(IMWrapperCallback* callback);

    bool LoginIM(const char* id, const char* sig);
    bool LogoutIM();

    bool CreateGroup(const char* group_name, const char* group_type, const char* group_id);
    bool JoinGroup(const char* group_id);

    bool SendGroupTextMsg(const char* group_id, const char* text);

private:
    IMWrapperCallback* m_callback;
    std::string m_userId;
};

// IMWrapper.cpp
bool IMWrapper::InitIM(const char* path)
{
    Json::Value json_value_init;
    json_value_init[kTIMSdkConfigLogFilePath] = path;
    int nRet = TIMInit(SDKAppID_IM, json_value_init.toStyledString().c_str());
    if(nRet != TIM_SUCC){
        return false;
    }

    TIMAddRecvNewMsgCallback([](const char* json_param, const void* user_data) {
        if(user_data != nullptr){
            IMWrapper* wrapper = (IMWrapper*)user_data;
            if(wrapper->m_callback != nullptr){
                wrapper->m_callback->OnRecvNewMsg(json_param);
            }
        }
    }, this);

    return true;
}

注意:如果您的应用程序生命周期与SDK生命周期一致,则在退出应用程序之前无需取消初始化。但是,如果您仅在进入特定接口时初始化SDK,并在退出该接口后不再使用它,则可以取消初始化SDK。

2. TRTC SDK中的实例创建和事件监听设置

//OnlineExam.h
#include "ITRTCCloud.h"
class OnlineExam: public ITRTCCloudCallback
{
public:
    OnlineExam();
    ~OnlineExam();
    virtual void onWarning(TXLiteAVWarning warningCode, const char* warningMsg, void* extraInfo) override;
    virtual void onError(TXLiteAVError errCode, const char *errMsg, void* extraInfo) override;
    virtual void onEnterRoom(int result) override;
    virtual void onExitRoom(int reason) override;
    //…
}

OnlineExam::OnlineExam(){
    getTRTCShareInstance()->addCallback(this);// 创建单例模式,设置事件监听。
}

OnlineExam::~OnlineExam(){
    getTRTCShareInstance()->removeCallback(this);// 取消事件监听。
    destroyTRTCShareInstance();// 销毁实例。
}

注意:建议监听SDK事件通知。对于一些常见错误进行日志打印和处理。有关详细信息,请参见错误代码表

登录和登出

在初始化聊天SDK后,您需要调用SDK登录API来验证您的账户身份并拥有使用功能的权限。在使用任何其他功能之前,请确保您已成功登录,否则可能会遇到功能故障或不可用。如果您只需要使用TRTC的音视频服务,则可以跳过此步骤。

API序列图

// 登录:userID可以自定义,userSig在步骤1中获得。
bool IMWrapper::LoginIM(const char* id, const char* sig){
    m_userId = id;
    int nRet = TIMLogin(id, sig, [](int32_t code, const char* desc, const char* json_param, const void* user_data) {
            if(user_data != nullptr){
                IMWrapper* wrapper = (IMWrapper*)user_data;
                if(wrapper->m_callback != nullptr){
                    wrapper->m_callback->OnLogin(code, desc);
                }
            }
    }, this);

    if(nRet != TIM_SUCC){
        return false;
    }
    return true;
}

// 登出
bool IMWrapper::LogoutIM(){
    int nRet = TIMLogout([](int32_t code, const char* desc, const char* json_param, const void* user_data) {
            if(user_data != nullptr){
                IMWrapper* wrapper = (IMWrapper*)user_data;
                if(wrapper->m_callback != nullptr){
                    wrapper->m_callback->OnLogout(code, desc);
                }
            }
    }, this);

    if(nRet != TIM_SUCC){
        return false;
    }
    return true;
}

注意:如果您的应用程序生命周期与聊天SDK一致,则在退出应用程序之前无需登出。但是,如果您仅在进入特定接口后使用聊天SDK,之后不再使用它,则可以登出并取消初始化聊天SDK。

房间管理

API序列图

  1. 创建房间

当主播(房主)开始直播时,需要创建一个房间。这里的“房间”概念对应于聊天中的“群组”。这个例子展示了如何在客户端创建聊天组,但也可以在服务器上创建组。

bool IMWrapper::CreateGroup(const char* name, const char* type, const char* id)
{
    Json::Value param;
    // 群组ID
    param[kTIMCreateGroupParamGroupId] = id;
    // 群组类型
    if (strcmp(type, "Public") == 0) {
        param[kTIMCreateGroupParamGroupType] = kTIMGroup_Public;
    }
    else if(strcmp(type, "Work") == 0) {
        param[kTIMCreateGroupParamGroupType] = kTIMGroup_Private;
    }
    else if(strcmp(type, "Meeting") == 0) {
        param[kTIMCreateGroupParamGroupType] = kTIMGroup_ChatRoom;
    }
    else if(strcmp(type, "AVChatRoom") == 0) {
        param[kTIMCreateGroupParamGroupType] = kTIMGroup_AVChatRoom;
        // 加入群组的邀请方式
        param[kTIMCreateGroupParamApproveOption] = kTIMGroupAddOpt_Forbid;// 禁止加入
    }
    // 群组名称
    param[kTIMCreateGroupParamGroupName] = name;
    std::string createParams = param.toStyledString();
    int nRet = TIMGroupCreate(createParams.c_str(), [](int32_t code, const char* desc, const char* json_params, const void* user_data)  {
            if(user_data != nullptr){
                IMWrapper* wrapper = (IMWrapper*)user_data;
                if(wrapper->m_callback != nullptr){
                    wrapper->m_callback->OnCreateGroup(code, desc);
                }
            }
    }, this);

    if(nRet != TIM_SUCC){
        return false;
    }
    return true;
}

注意:

  • 在在线考试场景中,建议使用会议组类型:kTIMGroup_ChatRoom。
  • TRTC没有单独的创建房间步骤。当您进入一个不存在的房间时,房间会自动创建。
  1. 进入房间

加入聊天组

bool IMWrapper::JoinGroup(const char* id)
{
    int nRet = TIMGroupJoin(id, "想加入群组", [](int32_t code, const char* desc, const char* json_param, const void* user_data) {
            if(user_data != nullptr){
                IMWrapper* wrapper = (IMWrapper*)user_data;
                if(wrapper->m_callback != nullptr){
                    wrapper->m_callback->OnJoinGroup(code, desc);
                }
            }
    }, this);

    if(nRet != TIM_SUCC){
        return false;
    }
    return true;
}

进入TRTC房间

注意:

  • TRTC房间ID分为整数类型roomId和字符串类型strRoomId。这两种类型的房间是互不连接的,只需选择一种。建议统一房间ID类型。
  • 建议在初始化SDK时从业务后端生成和获取UserSig,UserSig仅在进入房间时进行验证。进入后的过期UserSig不会影响体验。
  • TRTC房间入口场景可分为实时通话(AudioCall、VideoCall)和互动直播(Live、VoiceChatRoom)两大类。在线考试场景选择视频通话模式。
  • TRTC用户角色仅在互动直播模式下区分主播和观众。在此模式下,只有主播有权限进行推流,观众需要切换到主播角色才能进行推流。由于在线考试场景使用视频通话模式,因此无需角色参数。
  • 在进入房间的事件回调中,result > 0表示进入房间所花费的时间(以毫秒为单位);result < 0表示进入房间失败的错误代码,请参见错误代码表
// 进入房间。
void OnlineExam::EnterExamRoom(String roomId, String userId, String userName, int roleType, String userSig)
{
    TRTCParams param;
    param.sdkAppId = SDKAppID_TRTC;
    param.strRoomId = roomID.c_str();
    param.userId = userID.c_str();
    param.userSig = userSig.c_str();
    getTRTCShareInstance()->enterRoom(param, TRTCAppSceneVideoCall);
}

// 进入房间结果的事件回调。
void OnlineExam::onEnterRoom(int result)
{    
    if (result > 0) {
        // 成功进入房间。
    } else {
        // 进入房间失败。
    }
}
  1. 退出房间
// 退出房间。
void OnlineExam::ExitExamRoom()
{
   getTRTCShareInstance()->exitRoom();
}

// 退出房间事件回调。
void OnlineExam::onExitRoom(int reason)
{
    // 0:主动调用exitRoom退出房间;1:被服务器从当前房间移除;2:当前房间被解散。
}

音频流管理

TRTC SDK默认自动订阅音频流,这意味着当用户加入房间时,远程用户的音频会自动播放。如果需要手动订阅音频流,请参见设置订阅模式

  1. 学生端流媒体
// 成功进入房间后,学生开始流媒体音视频。
void OnlineExam::onEnterRoom(int result){
    if (result > 0) {
        // 成功进入房间。
        getTRTCShareInstance()->startLocalAudio(TRTCAudioQualitySpeech);
        getTRTCShareInstance()->startLocalPreview(hwndView);
    } else {
        // 进入房间失败。
    }
}

// 考试结束,停止流媒体。
void OnlineExam::ExitExamRoom(){
    getTRTCShareInstance()->stopLocalAudio();
    getTRTCShareInstance()->stopLocalPreview();
    getTRTCShareInstance()->exitRoom();
}
  1. 监考端流媒体拉取

由于学生已经提前进入房间并开始流媒体,监考人员可以在成功进入房间后立即拉取流媒体。在接收到远程流事件回调后,可以确定当前页面的学生。如果是,则可以重新调用以开始拉流,不会产生影响。

void OnlineExam::onUserVideoAvailable(const char* userId, bool available){
    if(available){
        if(userId == 当前页面的学生){
            getTRTCShareInstance()->startRemoteView(userId, TRTCVideoStreamTypeBig, hwndView);
        }
    }
}

高级功能

考试室分区管理

在在线考试场景中,可能会有数千或甚至数万名学生参加一次考试。然而,TRTC房间对每个房间的同时流媒体限制为50个。因此,需要根据学生数量将学生分成虚拟考试室。如果考试不太严格,学生可以只使用一条视频流,留一条流给监考人员。建议每个虚拟考试室分配40-49名学生到同一个roomId。在进入房间后,学生只推流而不拉流。

对于同时参加同一考试的学生,可以在业务后端将他们划分到每个虚拟考试室,每个房间应映射到一个TRTC房间ID(roomId)。在考试前,应指导学生提前30分钟进入考场并等待。一旦进入房间,应指导学生完成摄像头、麦克风和扬声器的测试。流媒体可以提前5分钟开始,以便尽早发现学生流媒体存在的问题。

// 学生端(以Web为例)。

/// 摄像头测试。
TRTC.startLocalVideo({ view: 'camera-video', publish: false });
/// 麦克风测试。
TRTC.startLocalAudio({ publish: false });
// 扬声器测试,请准备一个可播放的mp3网址,<audio id='audio-player'" src="mp3 url" controls></audio>
const audioPlayer = document.getElementById('audio-player');
if (!audioPlayer.paused) {
    audioPlayer.pause();
}
audioPlayer.currentTime = 0;

/// 仅推流,不拉流。
const localStream = TRTC.createStream({ userId, audio: true, video: true });
try {
  await localStream.initialize();
  console.log("本地流初始化成功");
} catch (error) {
  console.error("本地流初始化失败" + error);
}
try {
  await client.publish(localStream);
  console.log("本地流发布成功");
} catch (error) {
  console.error("发布本地流失败" + error);
}

TRTC提供了一个设备检测 React组件,建议快速集成设备检测功能。

视频分页管理

在监考人员端,不建议一次性拉取所有学生的视频,因为这需要非常高的带宽。例如,一个360P的视频流需要400-600 kbps的带宽,因此,对于40名学生,需要16-24 Mbps的带宽。如果有10名监考人员在同一办公室拉流,则需要160-240 Mbps的带宽。因此,考试监控终端可以实现分页显示。例如,在一页上显示9条流,当点击下一页时,停止拉取当前9条流并拉取下一9条流。

// 监考人员端(以Windows为例)。
void OnlineExam::onClickNext(){
    getTRTCShareInstance()->stopAllRemoteView();
    getTRTCShareInstance()->muteAllRemoteAudio(true);
    
    for(int i = 0; i < m_curUserList.size(); i++){
       getTRTCShareInstance()->startRemoteView(m_curUserList[i].userId, TRTCVideoStreamTypeBig, m_hwndList[i]);
    }
}

注意:由于底层流媒体是异步操作,因此可能会有短暂的延迟,因此需要对分页按钮实施点击频率限制,限制在1-2秒左右。如果点击过于频繁,可能会导致底层系统响应延迟,从而导致意外异常。

分屏显示

在普通在线考试中,摄像头通常使用笔记本电脑的内置摄像头,仅能捕捉学生的正面。对于更严格的考试,这并不能满足需求。为了防止考试作弊,有必要同时捕捉学生的后侧方向。

与单流监控不同,增加了额外的侧背景视频监控,通常通过手机进行,以便于移动和调整位置。重要的是指导学生如何定位他们的设置,例如提供设置的示意图。在定位后,将捕捉到正面和侧面视频的帧图像,并发送到业务后端进行判断,自动确定学生的位置是否合格。

例如,如果用户ID为1234,则侧面视频的userId可以添加自定义后缀为1234_side。

// 学生端 - 正面视频(以Web为例)。
const trtc = TRTC.create();
await trtc.enterRoom({ roomId, sdkAppId, "1234", userSig });

const localStream = TRTC.createStream({ userId, audio: true, video: true });
try {
  await localStream.initialize();
  console.log("本地流初始化成功");
} catch (error) {
  console.error("本地流初始化失败" + error);
}
try {
  await client.publish(localStream);
  console.log("本地流发布成功");
} catch (error) {
  console.error("发布本地流失败" + error);
}
// 学生端 - 侧面视频(以Android为例)。
TRTCCloudDef.TRTCParams params = new TRTCCloudDef.TRTCParams();
params.sdkAppId = SDKAPPID;
params.userId = "1234_side";
params.roomId = roomId;
params.userSig = usersig;

mCloud.enterRoom(params, TRTCCloudDef.TRTC_APP_SCENE_VIDEOCALL);  

mTRTCCloud.startLocalPreview(true, mTXCloudPreviewView);
mTRTCCloud.startLocalAudio(TRTCCloudDef.TRTC_AUDIO_QUALITY_DEFAULT);

音量检测

在更严格的在线考试场景中,不仅需要监控考生的正面和侧面,还必须检测考生周围的环境声音。这是因为考生可能试图通过声音作弊,例如播放录音材料、与他人交谈或打电话。

TRTC SDK提供了音量检测回调功能。该功能允许实时监控每个用户的音量水平,有助于检测可疑的作弊行为。例如,如果考生的声音突然变大,他可能正在播放预先录制的答案或与他人交谈。此时,教师可以通过收听该考生的音频进行干预。

API序列图

// 禁用自动流拉取。
void OnlineExam::enterExamRoom(){
    getTRTCShareInstance()->setDefaultStreamRecvMode(false, false);
    getTRTCShareInstance()->enterRoom(params);    
    getTRTCShareInstance()->enableAudioVolumeEvaluation();
}

// 拉取当前分页用户的流。
void OnlineExam::onEnterRoom(){
    for(int i = 0; i < m_curUserList.size(); i++){
       getTRTCShareInstance()->muteRemoteAudio(m_curUserList[i].userId, false);
       getTRTCShareInstance()->startRemoteView(m_curUserList[i].userId, TRTCVideoStreamTypeBig, m_hwndList[i]);
    }
}

// 检测音量水平。
void OnlineExam::onUserVoiceVolume(TRTCVolumeInfo* userVolumes, uint32_t userVolumesCount, uint32_t totalVolume){
    int count = userVolumesCount;
    TRTCVolumeInfo* remoteUserVolumes = userVolumes;
    std::string userId;
    int userVolume = 0;
    for(int i = 0; i < count; i++){
        userId = remoteUserVolumes->userId;
        if(!userId.empty()){
            userVolume = remoteUserVolumes->volume;
            if(userVolume > m_maxVolume){
                // 在界面上标记该学生。
            }
        }
        remoteUserVolumes++;
    }
}

立即订购

点击这里 快速进入购买页面进行订单。