소스 검색

merge from release_v4.1.1

deason 3 년 전
부모
커밋
23277d88cc
48개의 변경된 파일2869개의 추가작업 그리고 308개의 파일을 삭제
  1. 14 66
      examcloud-core-oe-student-api-provider/src/main/java/cn/com/qmth/examcloud/core/oe/student/api/controller/ExamControlController.java
  2. 2 2
      examcloud-core-oe-student-api-provider/src/main/java/cn/com/qmth/examcloud/core/oe/student/api/controller/ExamDataCleanController.java
  3. 6 51
      examcloud-core-oe-student-api-provider/src/main/java/cn/com/qmth/examcloud/core/oe/student/api/controller/ExamQuestionController.java
  4. 0 2
      examcloud-core-oe-student-api-provider/src/main/java/cn/com/qmth/examcloud/core/oe/student/api/controller/ExamRecordPaperStructController.java
  5. 14 10
      examcloud-core-oe-student-api-provider/src/main/java/cn/com/qmth/examcloud/core/oe/student/api/controller/FaceBiopsyController.java
  6. 191 5
      examcloud-core-oe-student-api-provider/src/main/java/cn/com/qmth/examcloud/core/oe/student/api/controller/client/ExamProcessController.java
  7. 29 4
      examcloud-core-oe-student-api-provider/src/main/java/cn/com/qmth/examcloud/core/oe/student/api/provider/ExamRecordDataCloudServiceProvider.java
  8. 126 0
      examcloud-core-oe-student-base/src/main/java/cn/com/qmth/examcloud/core/oe/student/base/bean/CompareFaceSyncInfo.java
  9. 116 0
      examcloud-core-oe-student-dao/src/main/java/cn/com/qmth/examcloud/core/oe/student/dao/ExamCaptureQueueRepo.java
  10. 33 0
      examcloud-core-oe-student-dao/src/main/java/cn/com/qmth/examcloud/core/oe/student/dao/ExamCaptureRepo.java
  11. 18 0
      examcloud-core-oe-student-dao/src/main/java/cn/com/qmth/examcloud/core/oe/student/dao/ExamFaceLiveVerifyRepo.java
  12. 9 9
      examcloud-core-oe-student-dao/src/main/java/cn/com/qmth/examcloud/core/oe/student/dao/ExamRecordDataRepo.java
  13. 19 0
      examcloud-core-oe-student-dao/src/main/java/cn/com/qmth/examcloud/core/oe/student/dao/ExamSyncCaptureRepo.java
  14. 223 0
      examcloud-core-oe-student-dao/src/main/java/cn/com/qmth/examcloud/core/oe/student/dao/entity/ExamCaptureEntity.java
  15. 271 0
      examcloud-core-oe-student-dao/src/main/java/cn/com/qmth/examcloud/core/oe/student/dao/entity/ExamCaptureQueueEntity.java
  16. 155 0
      examcloud-core-oe-student-dao/src/main/java/cn/com/qmth/examcloud/core/oe/student/dao/entity/ExamFaceLiveVerifyEntity.java
  17. 223 0
      examcloud-core-oe-student-dao/src/main/java/cn/com/qmth/examcloud/core/oe/student/dao/entity/ExamSyncCaptureEntity.java
  18. 45 0
      examcloud-core-oe-student-dao/src/main/java/cn/com/qmth/examcloud/core/oe/student/dao/enums/ExamCaptureQueueStatus.java
  19. 0 41
      examcloud-core-oe-student-dao/src/main/java/cn/com/qmth/examcloud/core/oe/student/dao/enums/FaceBiopsyScheme.java
  20. 6 2
      examcloud-core-oe-student-dao/src/main/java/cn/com/qmth/examcloud/core/oe/student/dao/enums/FaceBiopsyType.java
  21. 28 0
      examcloud-core-oe-student-dao/src/main/java/cn/com/qmth/examcloud/core/oe/student/dao/enums/FaceLiveVerifyStatus.java
  22. 1 8
      examcloud-core-oe-student-dao/src/main/java/cn/com/qmth/examcloud/core/oe/student/dao/enums/FaceVerifyResult.java
  23. 37 0
      examcloud-core-oe-student-service/src/main/java/cn/com/qmth/examcloud/core/oe/student/bean/CheckExamInProgressInfo.java
  24. 47 0
      examcloud-core-oe-student-service/src/main/java/cn/com/qmth/examcloud/core/oe/student/bean/SwitchScreenCountInfo.java
  25. 43 0
      examcloud-core-oe-student-service/src/main/java/cn/com/qmth/examcloud/core/oe/student/bean/client/CourseInfo.java
  26. 134 0
      examcloud-core-oe-student-service/src/main/java/cn/com/qmth/examcloud/core/oe/student/bean/client/FaceCaptureResult.java
  27. 79 0
      examcloud-core-oe-student-service/src/main/java/cn/com/qmth/examcloud/core/oe/student/bean/client/FaceCompareResult.java
  28. 79 0
      examcloud-core-oe-student-service/src/main/java/cn/com/qmth/examcloud/core/oe/student/bean/client/FaceLiveVerifyAction.java
  29. 43 0
      examcloud-core-oe-student-service/src/main/java/cn/com/qmth/examcloud/core/oe/student/bean/client/FaceLiveVerifyInfo.java
  30. 126 0
      examcloud-core-oe-student-service/src/main/java/cn/com/qmth/examcloud/core/oe/student/bean/client/FaceLiveVerifyResult.java
  31. 12 7
      examcloud-core-oe-student-service/src/main/java/cn/com/qmth/examcloud/core/oe/student/service/ExamControlService.java
  32. 14 0
      examcloud-core-oe-student-service/src/main/java/cn/com/qmth/examcloud/core/oe/student/service/ExamFaceLiveVerifyService.java
  33. 2 1
      examcloud-core-oe-student-service/src/main/java/cn/com/qmth/examcloud/core/oe/student/service/ExamRecordDataService.java
  34. 4 0
      examcloud-core-oe-student-service/src/main/java/cn/com/qmth/examcloud/core/oe/student/service/ExamRecordQuestionsService.java
  35. 15 0
      examcloud-core-oe-student-service/src/main/java/cn/com/qmth/examcloud/core/oe/student/service/FaceProcessService.java
  36. 122 33
      examcloud-core-oe-student-service/src/main/java/cn/com/qmth/examcloud/core/oe/student/service/impl/ExamControlServiceImpl.java
  37. 211 0
      examcloud-core-oe-student-service/src/main/java/cn/com/qmth/examcloud/core/oe/student/service/impl/ExamFaceLiveVerifyServiceImpl.java
  38. 10 7
      examcloud-core-oe-student-service/src/main/java/cn/com/qmth/examcloud/core/oe/student/service/impl/ExamFaceLivenessVerifyServiceImpl.java
  39. 64 9
      examcloud-core-oe-student-service/src/main/java/cn/com/qmth/examcloud/core/oe/student/service/impl/ExamRecordDataServiceImpl.java
  40. 2 0
      examcloud-core-oe-student-service/src/main/java/cn/com/qmth/examcloud/core/oe/student/service/impl/ExamRecordPaperStructServiceImpl.java
  41. 91 2
      examcloud-core-oe-student-service/src/main/java/cn/com/qmth/examcloud/core/oe/student/service/impl/ExamRecordQuestionsServiceImpl.java
  42. 119 0
      examcloud-core-oe-student-service/src/main/java/cn/com/qmth/examcloud/core/oe/student/service/impl/FaceProcessServiceImpl.java
  43. 4 4
      examcloud-core-oe-student-starter/shell/start.sh
  44. 2 1
      examcloud-core-oe-student-starter/shell/stop.sh
  45. 17 13
      examcloud-core-oe-student-starter/src/main/java/cn/com/qmth/examcloud/core/oe/student/starter/config/SwaggerConfig.java
  46. 22 15
      examcloud-core-oe-student-starter/src/main/resources/aliyun.xml
  47. 23 16
      examcloud-core-oe-student-starter/src/main/resources/upyun.xml
  48. 18 0
      examcloud-core-oe-student-starter/src/test/java/cn/com/qmth/examcloud/core/oe/student/test/OeStudentTest.java

+ 14 - 66
examcloud-core-oe-student-api-provider/src/main/java/cn/com/qmth/examcloud/core/oe/student/api/controller/ExamControlController.java

@@ -5,16 +5,14 @@ import cn.com.qmth.examcloud.commons.exception.StatusException;
 import cn.com.qmth.examcloud.commons.util.Util;
 import cn.com.qmth.examcloud.core.oe.student.base.utils.Check;
 import cn.com.qmth.examcloud.core.oe.student.bean.*;
+import cn.com.qmth.examcloud.core.oe.student.bean.client.CourseInfo;
 import cn.com.qmth.examcloud.core.oe.student.service.ExamControlService;
 import cn.com.qmth.examcloud.core.oe.student.service.ExamFileAnswerService;
 import cn.com.qmth.examcloud.core.oe.student.service.ExamRecordDataService;
-import cn.com.qmth.examcloud.core.oe.student.service.ExamingSessionService;
 import cn.com.qmth.examcloud.support.Constants;
 import cn.com.qmth.examcloud.support.enums.FileAnswerAcknowledgeStatus;
-import cn.com.qmth.examcloud.support.enums.HandInExamType;
 import cn.com.qmth.examcloud.support.examing.ExamFileAnswer;
 import cn.com.qmth.examcloud.support.examing.ExamRecordData;
-import cn.com.qmth.examcloud.support.examing.ExamingSession;
 import cn.com.qmth.examcloud.support.filestorage.FileStorageUtil;
 import cn.com.qmth.examcloud.support.redis.RedisKeyHelper;
 import cn.com.qmth.examcloud.web.filestorage.FileStorageHelper;
@@ -58,9 +56,6 @@ public class ExamControlController extends ControllerSupport {
     @Autowired
     private ExamControlService examControlService;
 
-    @Autowired
-    private ExamingSessionService examingSessionService;
-
     @Autowired
     private ExamFileAnswerService examFileAnswerService;
 
@@ -80,14 +75,7 @@ public class ExamControlController extends ControllerSupport {
     @GetMapping("/startExam")
     public StartExamInfo startExam(@RequestParam Long examStudentId, HttpServletRequest request) {
         User user = getAccessUser();
-        String sequenceLockKey = Constants.EXAM_CONTROL_LOCK_PREFIX + user.getUserId();
-        StartExamInfo startExamInfo;
-        // 开始考试上锁,分布式锁,系统在请求结束后会,自动释放锁,无需手动解锁
-        SequenceLockHelper.getLock(sequenceLockKey);
-        Check.isNull(examStudentId, "examStudentId不能为空");
-
-        startExamInfo = examControlService.startExam(examStudentId, user, getIp(request));
-        return startExamInfo;
+        return examControlService.startExam(examStudentId, user.getUserId(), getIp(request));
     }
 
     /**
@@ -97,13 +85,7 @@ public class ExamControlController extends ControllerSupport {
     @PostMapping("/startAnswer")
     public StartAnswerInfo startAnswer(@RequestParam @ApiParam(value = "考试记录id") Long examRecordDataId) {
         User user = getAccessUser();
-        String sequenceLockKey = Constants.EXAM_CONTROL_LOCK_PREFIX + user.getUserId();
-        // 开始考试上锁,分布式锁,系统在请求结束后会,自动释放锁,无需手动解锁
-        SequenceLockHelper.getLock(sequenceLockKey);
-        Check.isNull(examRecordDataId, "examRecordDataId不能为空");
-
-        return examControlService.startAnswer(examRecordDataId);
-
+        return examControlService.startAnswer(examRecordDataId, user.getUserId());
     }
 
     /**
@@ -113,24 +95,7 @@ public class ExamControlController extends ControllerSupport {
     @GetMapping("/checkExamInProgress")
     public ExamProcessResultInfo checkExamInProgress(HttpServletRequest request) {
         User user = getAccessUser();
-        String sequenceLockKey = Constants.EXAM_CONTROL_LOCK_PREFIX + user.getUserId();
-        // 系统在请求结束后会,自动释放锁,无需手动解锁
-        SequenceLockHelper.getLock(sequenceLockKey);
-        ExamProcessResultInfo res = new ExamProcessResultInfo();
-        try {
-            CheckExamInProgressInfo info = examControlService.checkExamInProgress(user.getUserId(), getIp(request));
-            res.setCode(Constants.COMMON_SUCCESS_CODE);
-            res.setData(info);
-            return res;
-        } catch (StatusException e) {
-            if (e.getCode().equals(Constants.EXAM_RECORD_NOT_END_STATUS_CODE)) {
-                res.setCode(Constants.PROCESSING_EXAM_RECORD_CODE);
-                return res;
-            }
-            throw e;
-        } catch (Exception e) {
-            throw e;
-        }
+        return examControlService.checkExamInProgress2(user.getUserId(), getIp(request));
     }
 
     /**
@@ -152,27 +117,7 @@ public class ExamControlController extends ControllerSupport {
     @GetMapping("/endExam")
     public void endExam(HttpServletRequest request) {
         User user = getAccessUser();
-        Long studentId = user.getUserId();
-        String sequenceLockKey = Constants.EXAM_CONTROL_LOCK_PREFIX + user.getUserId();
-        //系统在请求结束后会,自动释放锁,无需手动解锁
-        SequenceLockHelper.getLock(sequenceLockKey);
-
-        long st = System.currentTimeMillis();
-        long startTime = System.currentTimeMillis();
-
-        ExamingSession examingSession = examingSessionService.getExamingSession(studentId);
-
-        if (examingSession == null) {
-            throw new StatusException("8010", "无效的会话,请离开考试");
-        }
-
-        if (LOGGER.isDebugEnabled()) {
-            LOGGER.debug("0 [END_EXAM] 交卷前处理耗时:" + (System.currentTimeMillis() - startTime) + " ms");
-        }
-        examControlService.handInExam(examingSession.getExamRecordDataId(), HandInExamType.MANUAL, getIp(request));
-        if (LOGGER.isDebugEnabled()) {
-            LOGGER.debug("1 [END_EXAM]合计 耗时:" + (System.currentTimeMillis() - st) + " ms");
-        }
+        examControlService.manualEndExam(user.getUserId(), getIp(request));
     }
 
     /**
@@ -299,7 +244,6 @@ public class ExamControlController extends ControllerSupport {
         return fileAnswerId;
     }
 
-    //TODO 此方法有修改,微信需要修改代码
 
     /**
      * 查询客户端对上传的文件的响应状态(微信小程序调用)
@@ -453,19 +397,23 @@ public class ExamControlController extends ControllerSupport {
      */
     @ApiOperation(value = "记录切换屏幕次数")
     @PostMapping("/switchScreen")
-    public void switchScreen(@RequestParam @ApiParam(value = "考试记录id") Long examRecordDataId) {
+    public SwitchScreenCountInfo switchScreen(@RequestParam @ApiParam(value = "考试记录id") Long examRecordDataId) {
         User user = getAccessUser();
         String sequenceLockKey = Constants.EXAM_CONTROL_LOCK_PREFIX + user.getUserId();
         // 开始考试上锁,分布式锁,系统在请求结束后会,自动释放锁,无需手动解锁
         SequenceLockHelper.getLock(sequenceLockKey);
         Check.isNull(examRecordDataId, "examRecordDataId不能为空");
 
-        examControlService.switchScreen(examRecordDataId);
+        return examControlService.switchScreen(examRecordDataId);
     }
 
-    @GetMapping("/courseName/{id}")
-    public String courseName(@PathVariable Long id) {
-        return examRecordDataService.findCourseNameById(id);
+    @GetMapping("/courseName/{examRecordDataId}")
+    public String courseName(@PathVariable Long examRecordDataId) {
+        CourseInfo info = examRecordDataService.getCourseInfo(examRecordDataId);
+        if (info != null) {
+            return info.getCourseName();
+        }
+        return null;
     }
 
 }

+ 2 - 2
examcloud-core-oe-student-api-provider/src/main/java/cn/com/qmth/examcloud/core/oe/student/api/controller/ExamDataCleanController.java

@@ -11,7 +11,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
 
-@Api(tags = "考试数据清理")
+@Api(tags = "考试数据清理", hidden = true)
 @RestController
 @RequestMapping("${app.api.oe.student}/examDataClean")
 public class ExamDataCleanController extends ControllerSupport {
@@ -21,7 +21,7 @@ public class ExamDataCleanController extends ControllerSupport {
 
     @ApiOperation(value = "清理")
     @PutMapping("/clean")
-    public void clean(@RequestParam(required = false) @ApiParam(value = "在此日期之前(yyyy-MM-dd HH:mm:ss)") String dateBefore) {
+    public void clean(@RequestParam @ApiParam(value = "在此日期之前(yyyy-MM-dd HH:mm:ss)") String dateBefore) {
         examDataCleanService.cleanData(dateBefore);
     }
 

+ 6 - 51
examcloud-core-oe-student-api-provider/src/main/java/cn/com/qmth/examcloud/core/oe/student/api/controller/ExamQuestionController.java

@@ -1,15 +1,9 @@
 package cn.com.qmth.examcloud.core.oe.student.api.controller;
 
 import cn.com.qmth.examcloud.api.commons.security.bean.User;
-import cn.com.qmth.examcloud.commons.exception.StatusException;
-import cn.com.qmth.examcloud.commons.util.JsonUtil;
-import cn.com.qmth.examcloud.core.oe.student.base.utils.Check;
 import cn.com.qmth.examcloud.core.oe.student.bean.ExamStudentQuestionInfo;
 import cn.com.qmth.examcloud.core.oe.student.service.ExamRecordQuestionsService;
-import cn.com.qmth.examcloud.core.oe.student.service.ExamingSessionService;
-import cn.com.qmth.examcloud.support.examing.*;
-import cn.com.qmth.examcloud.support.redis.RedisKeyHelper;
-import cn.com.qmth.examcloud.web.redis.RedisClient;
+import cn.com.qmth.examcloud.support.examing.ExamQuestion;
 import cn.com.qmth.examcloud.web.support.ControllerSupport;
 import io.swagger.annotations.Api;
 import io.swagger.annotations.ApiOperation;
@@ -33,12 +27,6 @@ public class ExamQuestionController extends ControllerSupport {
     @Autowired
     private ExamRecordQuestionsService examRecordQuestionsService;
 
-    @Autowired
-    private ExamingSessionService examingSessionService;
-
-    @Autowired
-    private RedisClient redisClient;
-
     /**
      * 将mongodb中的答过的题和redis中的题目列表合并返回给前端
      * 返回给前端时注意将正确答案和得分置成null
@@ -49,27 +37,7 @@ public class ExamQuestionController extends ControllerSupport {
     @GetMapping("/findExamQuestionList")
     public List<ExamQuestion> findExamQuestionList() {
         User user = getAccessUser();
-        ExamingSession examSessionInfo = examingSessionService.getExamingSession(user.getUserId());
-        if (examSessionInfo == null
-                || examSessionInfo.getExamingStatus().equals(ExamingStatus.INFORMAL)) {
-            throw new StatusException("1001", "考试会话已过期,请重新开考");
-        }
-
-        String examingHeartbeatKey = RedisKeyHelper.getBuilder().examingHeartbeatKey(examSessionInfo.getExamRecordDataId());
-        ExamingHeartbeat examingHeartbeat = redisClient.get(examingHeartbeatKey, ExamingHeartbeat.class);
-
-        if (null != examingHeartbeat
-                && examingHeartbeat.getCost() >= examSessionInfo.getExamDuration()) {
-            throw new StatusException("1001", "考试会话已过期,请重新开考");
-        }
-
-        ExamRecordQuestions qers = examRecordQuestionsService.getExamRecordQuestions(examSessionInfo.getExamRecordDataId());
-        List<ExamQuestion> examQuestionList = qers.getExamQuestions();
-        for (ExamQuestion examQuestion : examQuestionList) {
-            examQuestion.setCorrectAnswer(null);
-            examQuestion.setStudentScore(null);
-        }
-        return examQuestionList;
+        return examRecordQuestionsService.findExamQuestionList(user.getUserId());
     }
 
     /**
@@ -82,7 +50,6 @@ public class ExamQuestionController extends ControllerSupport {
     @GetMapping("/getQuestionContent")
     public String getQuestionContent(@RequestParam String questionId) {
         User user = getAccessUser();
-        Check.isBlank(questionId, "questionId不能为空");
         return examRecordQuestionsService.getQuestionContent(user.getUserId(), questionId);
     }
 
@@ -93,23 +60,11 @@ public class ExamQuestionController extends ControllerSupport {
      */
     @ApiOperation(value = "考试过程中-考生作答:更新试题作答信息(包括提交试题答案,更新是否标记)")
     @PostMapping("/submitQuestionAnswer")
-    public void submitQuestionAnswer(@RequestBody List<ExamStudentQuestionInfo> examQuestionInfos,
-                                     HttpServletRequest request) {
-        if (LOGGER.isDebugEnabled()) {
-            String strJosn = JsonUtil.toJson(examQuestionInfos);
-            LOGGER.debug("ExamQuestionController--submitQuestionAnswer参数信息:" + strJosn);
-        }
+    public void submitQuestionAnswer(@RequestBody List<ExamStudentQuestionInfo> examQuestionInfos, HttpServletRequest request) {
         User user = getAccessUser();
-        if (examQuestionInfos != null && examQuestionInfos.size() > 0) {
-            for (ExamStudentQuestionInfo examStudentQuestionInfo : examQuestionInfos) {
-                if (examStudentQuestionInfo.getOrder() == null) {
-                    throw new StatusException("2001", "illegal params");
-                }
-            }
-            String referer = request.getHeader("REFERER");
-            String agent = request.getHeader("USER-AGENT");
-            examRecordQuestionsService.submitQuestionAnswer(user.getUserId(), examQuestionInfos, referer, agent);
-        }
+        String referer = request.getHeader("REFERER");
+        String agent = request.getHeader("USER-AGENT");
+        examRecordQuestionsService.submitQuestionAnswer(user.getUserId(), examQuestionInfos, referer, agent);
     }
 
 }

+ 0 - 2
examcloud-core-oe-student-api-provider/src/main/java/cn/com/qmth/examcloud/core/oe/student/api/controller/ExamRecordPaperStructController.java

@@ -1,6 +1,5 @@
 package cn.com.qmth.examcloud.core.oe.student.api.controller;
 
-import cn.com.qmth.examcloud.core.oe.student.base.utils.Check;
 import cn.com.qmth.examcloud.core.oe.student.service.ExamRecordPaperStructService;
 import cn.com.qmth.examcloud.support.examing.ExamRecordPaperStruct;
 import cn.com.qmth.examcloud.web.support.ControllerSupport;
@@ -29,7 +28,6 @@ public class ExamRecordPaperStructController extends ControllerSupport {
     @ApiOperation(value = "获取考试记录试卷结构")
     @GetMapping("/getExamRecordPaperStruct")
     public ExamRecordPaperStruct getExamRecordPaperStruct(@RequestParam Long examRecordDataId) {
-        Check.isNull(examRecordDataId, "examRecordDataId不能为空");
         return examRecordPaperStructService.getExamRecordPaperStruct(examRecordDataId);
     }
 

+ 14 - 10
examcloud-core-oe-student-api-provider/src/main/java/cn/com/qmth/examcloud/core/oe/student/api/controller/FaceBiopsyController.java

@@ -58,6 +58,9 @@ public class FaceBiopsyController extends ControllerSupport {
     @Autowired
     private ExamFaceLivenessVerifyService examFaceLivenessVerifyService;
 
+    @Autowired
+    private ExamFaceLiveVerifyService examFaceLiveVerifyService;
+
     @Autowired
     private ExamControlService examControlService;
 
@@ -100,20 +103,21 @@ public class FaceBiopsyController extends ControllerSupport {
 
         FaceBiopsyScheme faceBiopsyScheme = FaceBiopsyHelper.getFaceBiopsyScheme(user.getRootOrgId());
 
-        Integer faceVerifyMinute = null;
-        // 如果是新活体检测方案,则使用新的计算方案计算活检开始时间
-        if (faceBiopsyScheme == FaceBiopsyScheme.NEW) {
-            faceVerifyMinute = faceBiopsyService.calculateFaceBiopsyStartMinute(examRecordDataId);
-        }
-        // 非新活检,默认使用旧的活检计算方式
-        else {
+        // 活检开始时间
+        Integer faceVerifyMinute;
 
+        if (FaceBiopsyScheme.FACE_CLIENT == faceBiopsyScheme) {
+            // C端活体检测方案
+            faceVerifyMinute = examFaceLiveVerifyService.calculateStartFaceVerifyMinute(examRecordDataId);
+        } else if (FaceBiopsyScheme.FACE_MOTION == faceBiopsyScheme) {
+            // Electron Client 自研活体检测方案
+            faceVerifyMinute = faceBiopsyService.calculateFaceBiopsyStartMinute(examRecordDataId);
+        } else {
+            // FaceID活体检测方案
             String examingHeartbeatKey = RedisKeyHelper.getBuilder().examingHeartbeatKey(examSessionInfo.getExamRecordDataId());
             ExamingHeartbeat examingHeartbeat = redisClient.get(examingHeartbeatKey, ExamingHeartbeat.class);
 
-            int usedMinute = null == examingHeartbeat
-                    ? 0
-                    : examingHeartbeat.getCost().intValue() / 60;
+            int usedMinute = null == examingHeartbeat ? 0 : examingHeartbeat.getCost().intValue() / 60;
             faceVerifyMinute = examFaceLivenessVerifyService.getFaceLivenessVerifyMinute(user.getRootOrgId(),
                     orgId, examId, studentId, examRecordData.getId(), usedMinute);
         }

+ 191 - 5
examcloud-core-oe-student-api-provider/src/main/java/cn/com/qmth/examcloud/core/oe/student/api/controller/client/ExamProcessController.java

@@ -1,31 +1,158 @@
 package cn.com.qmth.examcloud.core.oe.student.api.controller.client;
 
 import cn.com.qmth.examcloud.api.commons.security.bean.User;
-import cn.com.qmth.examcloud.core.oe.student.service.ExamingSessionService;
+import cn.com.qmth.examcloud.commons.exception.StatusException;
+import cn.com.qmth.examcloud.commons.util.FileUtil;
+import cn.com.qmth.examcloud.commons.util.JsonMapper;
+import cn.com.qmth.examcloud.core.oe.student.base.utils.Check;
+import cn.com.qmth.examcloud.core.oe.student.bean.*;
+import cn.com.qmth.examcloud.core.oe.student.bean.client.*;
+import cn.com.qmth.examcloud.core.oe.student.service.*;
 import cn.com.qmth.examcloud.support.Constants;
+import cn.com.qmth.examcloud.support.examing.ExamQuestion;
+import cn.com.qmth.examcloud.support.examing.ExamRecordPaperStruct;
 import cn.com.qmth.examcloud.support.examing.ExamingSession;
+import cn.com.qmth.examcloud.support.filestorage.FileStorageUtil;
+import cn.com.qmth.examcloud.support.filestorage.UploadResult;
+import cn.com.qmth.examcloud.support.handler.richtext2.RichTextConverter;
+import cn.com.qmth.examcloud.web.filestorage.FileStoragePathEnvInfo;
+import cn.com.qmth.examcloud.web.filestorage.YunPathInfo;
 import cn.com.qmth.examcloud.web.redis.RedisClient;
 import cn.com.qmth.examcloud.web.support.ControllerSupport;
+import cn.com.qmth.examcloud.web.support.Naked;
 import io.swagger.annotations.Api;
 import io.swagger.annotations.ApiOperation;
+import io.swagger.annotations.ApiParam;
+import org.apache.commons.codec.digest.DigestUtils;
 import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.web.bind.annotation.PostMapping;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RequestParam;
-import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+import javax.servlet.http.HttpServletRequest;
+import java.util.List;
 
 @Api(tags = "(客户端)考试过程相关接口")
 @RestController
 @RequestMapping("${app.api.oe.student}/client/exam/process")
 public class ExamProcessController extends ControllerSupport {
 
+    private static final Logger log = LoggerFactory.getLogger(ExamProcessController.class);
+
+    @Autowired
+    private ExamRecordQuestionsService examRecordQuestionsService;
+
+    @Autowired
+    private ExamRecordPaperStructService examRecordPaperStructService;
+
+    @Autowired
+    private ExamControlService examControlService;
+
     @Autowired
     private ExamingSessionService examingSessionService;
 
+    @Autowired
+    private ExamRecordDataService examRecordDataService;
+
+    @Autowired
+    private FaceProcessService faceProcessService;
+
+    @Autowired
+    private ExamFaceLiveVerifyService examFaceLiveVerifyService;
+
     @Autowired
     private RedisClient redisClient;
 
+    @ApiOperation(value = "开始考试")
+    @PostMapping("/startExam")
+    public StartExamInfo startExam(@RequestParam Long examStudentId) {
+        User user = getAccessUser();
+        return examControlService.startExam(examStudentId, user.getUserId(), getIp(getRequest()));
+    }
+
+    @ApiOperation(value = "开始答题")
+    @PostMapping("/startAnswer")
+    public StartAnswerInfo startAnswer(@RequestParam @ApiParam(value = "考试记录ID") Long examRecordDataId) {
+        User user = getAccessUser();
+        return examControlService.startAnswer(examRecordDataId, user.getUserId());
+    }
+
+    @ApiOperation(value = "断点续考:检查正在进行中的考试")
+    @PostMapping("/checkExamInProgress")
+    public ExamProcessResultInfo checkExamInProgress() {
+        User user = getAccessUser();
+        return examControlService.checkExamInProgress2(user.getUserId(), getIp(getRequest()));
+    }
+
+    @ApiOperation(value = "考试心跳")
+    @PostMapping("/examHeartbeat")
+    public Long examHeartbeat(HttpServletRequest request) {
+        User user = getAccessUser();
+        return examControlService.examHeartbeat(user, getIp(request));
+    }
+
+    @ApiOperation(value = "结束考试:交卷")
+    @PostMapping("/endExam")
+    public void endExam(HttpServletRequest request) {
+        User user = getAccessUser();
+        examControlService.manualEndExam(user.getUserId(), getIp(request));
+    }
+
+    @ApiOperation(value = "获取考试记录信息")
+    @PostMapping("/getEndExamInfo")
+    public EndExamInfo getEndExamInfo(@RequestParam Long examRecordDataId) {
+        return examControlService.getEndExamInfo(examRecordDataId);
+    }
+
+    @ApiOperation(value = "获取考试记录试卷结构")
+    @PostMapping("/getExamRecordPaperStruct")
+    public ExamRecordPaperStruct getExamRecordPaperStruct(@RequestParam Long examRecordDataId) {
+        return examRecordPaperStructService.getExamRecordPaperStruct(examRecordDataId);
+    }
+
+    @ApiOperation(value = "考试过程中-获取试题列表")
+    @PostMapping("/findExamQuestionList")
+    public List<ExamQuestion> findExamQuestionList() {
+        User user = getAccessUser();
+        List<ExamQuestion> examQuestions = examRecordQuestionsService.findExamQuestionList(user.getUserId());
+
+        JsonMapper jsonMapper = JsonMapper.nonNullMapper();
+        for (ExamQuestion examQuestion : examQuestions) {
+            if (StringUtils.isBlank(examQuestion.getStudentAnswer())) {
+                continue;
+            }
+
+            // 将考生作答内容的 HTML结构转换为“富文本”JSON结构
+            examQuestion.setStudentAnswer(jsonMapper.toJson(RichTextConverter.parse(examQuestion.getStudentAnswer())));
+        }
+
+        return examQuestions;
+    }
+
+    @ApiOperation(value = "考试过程中-获取试题内容")
+    @PostMapping("/getQuestionContent")
+    public String getQuestionContent(@RequestParam String questionId) {
+        User user = getAccessUser();
+        return examRecordQuestionsService.getQuestionContentForClient(user.getUserId(), questionId);
+    }
+
+    @ApiOperation(value = "考试过程中-考生试题作答")
+    @PostMapping("/submitQuestionAnswer")
+    public void submitQuestionAnswer(@RequestBody List<ExamStudentQuestionInfo> examQuestionInfos, HttpServletRequest request) {
+        User user = getAccessUser();
+        String referer = request.getHeader("REFERER");
+        String agent = request.getHeader("USER-AGENT") + "client-" + Constants.ELECTRON_EXAM_SHELL;
+        examRecordQuestionsService.submitQuestionAnswer(user.getUserId(), examQuestionInfos, referer, agent);
+    }
+
+    @ApiOperation(value = "获取课程信息")
+    @PostMapping("/getCourseInfo/{examRecordDataId}")
+    public CourseInfo courseInfo(@PathVariable Long examRecordDataId) {
+        return examRecordDataService.getCourseInfo(examRecordDataId);
+    }
+
     @ApiOperation(value = "违纪(非法考生端应用)")
     @PostMapping("/discipline")
     public void discipline(@RequestParam(required = false) String reason) {
@@ -44,4 +171,63 @@ public class ExamProcessController extends ControllerSupport {
         redisClient.set(cacheKey, reason, 3 * 60 * 60);
     }
 
+    @ApiOperation(value = "文件上传")
+    @PostMapping("/upload")
+    public UploadResult upload(@RequestParam(required = false) String md5,
+                               @RequestPart(value = "file", required = false) MultipartFile file) throws Exception {
+        Check.isBlank(md5, "文件MD5不能为空");
+        Check.isNull(file, "文件不能为空");
+
+        String realMD5 = DigestUtils.md5Hex(file.getInputStream());
+        if (!realMD5.equals(md5)) {
+            log.warn("realMD5 = {}, md5 = {}", realMD5, md5);
+            throw new StatusException("400403", "文件MD5验证失败");
+        }
+
+        User user = getAccessUser();
+
+        final String fileSuffix = FileUtil.getFileSuffix(file.getOriginalFilename());
+        final String newFileName = FileUtil.generateFileName();
+        //路径规则:oe/rootOrgId/yyyyMMdd/userId_fileName.fileSuffix
+        final String uploadPath = String.format("oe/%s/%s/%s_%s%s", user.getRootOrgId(), FileUtil.dateDir(), user.getUserId(), newFileName, fileSuffix);
+
+        FileStoragePathEnvInfo env = new FileStoragePathEnvInfo();
+        env.setRootOrgId(String.valueOf(user.getRootOrgId()));
+        env.setRelativePath(uploadPath);
+        YunPathInfo result = FileStorageUtil.saveFile(Constants.OE_SITEID, env, file.getBytes(), false);
+
+        return new UploadResult(file.getOriginalFilename(), result.getRelativePath(), result.getUrl());
+    }
+
+    @ApiOperation(value = "保存人脸识别比对验证结果")
+    @PostMapping("/saveFaceCompareResult")
+    public void saveFaceCompareResult(@RequestBody FaceCompareResult req) {
+        User user = getAccessUser();
+        req.setStudentId(user.getUserId());
+        faceProcessService.saveFaceCompareResult(req);
+    }
+
+    @ApiOperation(value = "保存人脸抓拍比对验证结果")
+    @PostMapping("/saveFaceCaptureResult")
+    public void saveFaceCaptureResult(@RequestBody FaceCaptureResult req) {
+        User user = getAccessUser();
+        req.setStudentId(user.getUserId());
+        faceProcessService.saveFaceCaptureResult(req);
+    }
+
+    @ApiOperation(value = "开始人脸活体验证")
+    @PostMapping("/startFaceLiveVerify")
+    public FaceLiveVerifyInfo startFaceLiveVerify(@RequestParam Long examRecordDataId) {
+        User user = getAccessUser();
+        return examFaceLiveVerifyService.startFaceLiveVerify(examRecordDataId, user.getUserId());
+    }
+
+    @ApiOperation(value = "保存人脸活体验证结果")
+    @PostMapping("/saveFaceLiveVerifyResult")
+    public void saveFaceLiveVerifyResult(@RequestBody FaceLiveVerifyResult req) {
+        User user = getAccessUser();
+        req.setStudentId(user.getUserId());
+        examFaceLiveVerifyService.saveFaceLiveVerifyResult(req);
+    }
+
 }

+ 29 - 4
examcloud-core-oe-student-api-provider/src/main/java/cn/com/qmth/examcloud/core/oe/student/api/provider/ExamRecordDataCloudServiceProvider.java

@@ -19,11 +19,13 @@ import io.swagger.annotations.Api;
 import io.swagger.annotations.ApiOperation;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.data.domain.Example;
+import org.springframework.data.jpa.domain.Specification;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
 
+import javax.persistence.criteria.Predicate;
 import java.util.ArrayList;
 import java.util.List;
 
@@ -32,10 +34,6 @@ import java.util.List;
 @RequestMapping("${$rmp.cloud.oe.student}/examRecordData")
 public class ExamRecordDataCloudServiceProvider extends ControllerSupport implements ExamRecordDataCloudService {
 
-
-    /**
-     *
-     */
     private static final long serialVersionUID = 6142107111834463854L;
 
     @Autowired
@@ -65,6 +63,33 @@ public class ExamRecordDataCloudServiceProvider extends ControllerSupport implem
     @Autowired
     private ExamControlService examControlService;
 
+    @Override
+    @ApiOperation(value = "查询是否存在考试记录")
+    @PostMapping("/existExamRecordData")
+    public ExistExamRecordDataResp existExamRecordData(@RequestBody ExistExamRecordDataReq req) {
+        if (req.getExamId() == null) {
+            throw new StatusException("400400", "考试ID不允许为空");
+        }
+
+        Specification<ExamRecordDataEntity> specification = (root, query, cb) -> {
+            List<Predicate> predicates = new ArrayList<>();
+            predicates.add(cb.equal(root.get("examId"), req.getExamId()));
+
+            if (req.getCourseId() != null) {
+                predicates.add(cb.equal(root.get("courseId"), req.getCourseId()));
+            }
+
+            if (req.getStudentId() != null) {
+                predicates.add(cb.equal(root.get("studentId"), req.getStudentId()));
+            }
+
+            return cb.and(predicates.toArray(new Predicate[predicates.size()]));
+        };
+
+        long count = examRecordDataRepo.count(specification);
+        return new ExistExamRecordDataResp(count > 0);
+    }
+
     @Override
     @ApiOperation(value = "批量获取未同步的考试记录ID")
     @PostMapping("/getExamRecordDataIds")

+ 126 - 0
examcloud-core-oe-student-base/src/main/java/cn/com/qmth/examcloud/core/oe/student/base/bean/CompareFaceSyncInfo.java

@@ -0,0 +1,126 @@
+package cn.com.qmth.examcloud.core.oe.student.base.bean;
+
+import cn.com.qmth.examcloud.api.commons.exchange.JsonSerializable;
+
+public class CompareFaceSyncInfo implements JsonSerializable {
+
+    private static final long serialVersionUID = 802462624028077528L;
+
+    /**
+     * 人脸比对是否通过
+     */
+    private Boolean isPass;
+
+    /**
+     * 学生ID
+     */
+    private Long studentId;
+
+    /**
+     * 是否有陌生人
+     */
+    private Boolean isStranger;
+
+    /**
+     * 是否存在系统错误
+     */
+    private boolean existsSystemError;
+
+    /**
+     * 错误信息
+     */
+    private String errorMsg;
+
+    /**
+     * 文件名称(无需返回给前台)
+     */
+    private transient String fileName;
+
+    /**
+     * 文件路径(无需返回给前台)
+     */
+    private transient String fileUrl;
+
+    /**
+     * 人脸比对结果(无需返回给前台)
+     */
+    private transient String faceCompareResult;
+
+    /**
+     * 人脸比对的处理时间(无需返回给前台)
+     */
+    private transient Long processTime;
+
+    public Boolean getIsPass() {
+        return isPass;
+    }
+
+    public void setIsPass(Boolean isPass) {
+        this.isPass = isPass;
+    }
+
+    public Long getStudentId() {
+        return studentId;
+    }
+
+    public void setStudentId(Long studentId) {
+        this.studentId = studentId;
+    }
+
+    public String getErrorMsg() {
+        return errorMsg;
+    }
+
+    public void setErrorMsg(String errorMsg) {
+        this.errorMsg = errorMsg;
+    }
+
+    public Boolean getIsStranger() {
+        return isStranger;
+    }
+
+    public void setIsStranger(Boolean isStranger) {
+        this.isStranger = isStranger;
+    }
+
+    public Boolean getExistsSystemError() {
+        return existsSystemError;
+    }
+
+    public void setExistsSystemError(boolean existsSystemError) {
+        this.existsSystemError = existsSystemError;
+    }
+
+    public String getFileName() {
+        return fileName;
+    }
+
+    public void setFileName(String fileName) {
+        this.fileName = fileName;
+    }
+
+    public String getFileUrl() {
+        return fileUrl;
+    }
+
+    public void setFileUrl(String fileUrl) {
+        this.fileUrl = fileUrl;
+    }
+
+    public String getFaceCompareResult() {
+        return faceCompareResult;
+    }
+
+    public void setFaceCompareResult(String faceCompareResult) {
+        this.faceCompareResult = faceCompareResult;
+    }
+
+    public Long getProcessTime() {
+        return processTime;
+    }
+
+    public void setProcessTime(Long processTime) {
+        this.processTime = processTime;
+    }
+
+}

+ 116 - 0
examcloud-core-oe-student-dao/src/main/java/cn/com/qmth/examcloud/core/oe/student/dao/ExamCaptureQueueRepo.java

@@ -0,0 +1,116 @@
+package cn.com.qmth.examcloud.core.oe.student.dao;
+
+import cn.com.qmth.examcloud.core.oe.student.dao.entity.ExamCaptureQueueEntity;
+import cn.com.qmth.examcloud.core.oe.student.dao.enums.ExamCaptureQueueStatus;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.stereotype.Repository;
+
+import javax.transaction.Transactional;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * @Description 抓拍照片队列
+ * @Author lideyin
+ * @Date 2019/12/10 14:04
+ * @Version 1.0
+ */
+@Repository
+public interface ExamCaptureQueueRepo extends JpaRepository<ExamCaptureQueueEntity, Long>, JpaSpecificationExecutor<ExamCaptureQueueEntity> {
+
+    /**
+     * @param limit           一次取值数量
+     * @param processBatchNum 批次号
+     * @return List<ExamCaptureQueueEntity>
+     * @description 根据处理批次号查询没有处理过的数据或者是处理失败的数据,按优先级倒序,创建时间升序
+     * @author lideyin
+     * @date 2019/7/31 16:40
+     */
+    @Query(value = "select * from ec_oet_exam_capture_queue "
+            + " where status IN ('PENDING','PROCESS_FACE_COMPARE_FAILED')  " +
+            " and (process_batch_num is null or (process_batch_num is not null and process_batch_num!=?2))"
+            + " order by priority desc,id asc limit ?1", nativeQuery = true)
+    List<ExamCaptureQueueEntity> findNeedFaceCompareExamCaptureQueuesLimitByProcessBatchNum(Integer limit, String processBatchNum);
+
+    /**
+     * 查询人脸比对处理完成,需要百度活体检测的数据,按优先级倒序,创建时间升序
+     *
+     * @param limit           数量
+     * @param processBatchNum 批次号
+     * @return List<ExamCaptureQueueEntity>
+     */
+    @Query(value = "select * from ec_oet_exam_capture_queue "
+            + " where status IN ('PROCESS_FACE_COMPARE_COMPLETE','PROCESS_FACELIVENESS_FAILED') and process_batch_num!=?2  "
+            + " order by priority desc,id asc limit ?1", nativeQuery = true)
+    List<ExamCaptureQueueEntity> findNeedFacelivenessDetectExamCaptureQueuesLimit(Integer limit, String processBatchNum);
+
+    List<ExamCaptureQueueEntity> findByStatusOrderByCreationTimeAsc(ExamCaptureQueueStatus status);
+
+    List<ExamCaptureQueueEntity> findByExamRecordDataId(Long examRecordDataId);
+
+    /**
+     * 是否存在未处理的图片抓拍队列(处理完成的队列数据都会清理掉)
+     *
+     * @param examRecordDataId
+     * @return
+     */
+    @Query(value = "select * from ec_oet_exam_capture_queue where exam_record_data_id=?1 limit 1", nativeQuery = true)
+    ExamCaptureQueueEntity existsUnhandledByExamRecordDataId(Long examRecordDataId);
+
+    /**
+     * 修改数据状态为处理中
+     *
+     * @param id 主键ID
+     */
+    @Transactional
+    @Modifying
+    @Query(value = "update ec_oet_exam_capture_queue set status = 'PROCESSING' where id = ?1", nativeQuery = true)
+    void updateExamCaptureQueueStatusWithProcessing(Long id);
+
+
+    /**
+     * @param priority         优先级(值越大,优先级越高)
+     * @param examRecordDataId 考试记录id
+     * @description 更新考试抓拍队列优先级
+     * @date 2019/7/31 16:46
+     * @author lideyin
+     */
+    @Transactional
+    @Modifying
+    @Query(value = "update ec_oet_exam_capture_queue set priority=?1 where exam_record_data_id=?2", nativeQuery = true)
+    void updateExamCaptureQueuePriority(int priority, Long examRecordDataId);
+
+    /**
+     * @param captureQueueId
+     * @param errorMsg
+     * @param status
+     * @param batchNumber
+     * @param updateDate
+     */
+    @Transactional
+    @Modifying
+    @Query(value = "update ec_oet_exam_capture_queue set error_msg=?2,`status`=?3,process_batch_num=?4,error_num=error_num+1,update_time=?5 where id=?1", nativeQuery = true)
+    void saveExamCaptureQueueEntityByFailed(Long captureQueueId, String errorMsg, String status, String batchNumber,
+                                            Date updateDate);
+
+    @Transactional
+    @Modifying
+    @Query(value = "update ec_oet_exam_capture_queue set is_pass=?2,is_stranger=?3,`status`=?4,face_compare_result=?5,face_compare_start_time=?6,update_time=?7 where id=?1", nativeQuery = true)
+    void saveExamCaptureQueueEntityBySuccessful(Long captureQueueId, Boolean isPass, Boolean isStranger, String status,
+                                                String faceCompareResult, Long faceCompareStartTime, Date updateDate);
+
+    /**
+     * 更新批次号
+     *
+     * @param captureQueueId 队列id
+     * @param batchNum       批次号
+     */
+    @Transactional
+    @Modifying
+    @Query(value = "update ec_oet_exam_capture_queue set process_batch_num=?2 where id=?1", nativeQuery = true)
+    void updateProcessBatchNum(Long captureQueueId, String batchNum);
+
+}

+ 33 - 0
examcloud-core-oe-student-dao/src/main/java/cn/com/qmth/examcloud/core/oe/student/dao/ExamCaptureRepo.java

@@ -0,0 +1,33 @@
+package cn.com.qmth.examcloud.core.oe.student.dao;
+
+import cn.com.qmth.examcloud.core.oe.student.dao.entity.ExamCaptureEntity;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
+import org.springframework.stereotype.Repository;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+/**
+ * @Description 照片处理结果
+ * @Author lideyin
+ * @Date 2019/12/10 14:04
+ * @Version 1.0
+ */
+@Repository
+public interface ExamCaptureRepo extends JpaRepository<ExamCaptureEntity, Long>, JpaSpecificationExecutor<ExamCaptureEntity> {
+
+    /**
+     * propagation = Propagation.REQUIRES_NEW
+     * 原因:A事务运行中,B事务插入数据,A事务会出现不能读取最新数据的问题
+     *
+     * @param examRecordDataId
+     * @return
+     */
+    @Transactional(propagation = Propagation.REQUIRES_NEW)
+    List<ExamCaptureEntity> findByExamRecordDataId(Long examRecordDataId);
+
+    ExamCaptureEntity findByExamRecordDataIdAndFileName(Long examRecordDataId, String fileName);
+
+}

+ 18 - 0
examcloud-core-oe-student-dao/src/main/java/cn/com/qmth/examcloud/core/oe/student/dao/ExamFaceLiveVerifyRepo.java

@@ -0,0 +1,18 @@
+package cn.com.qmth.examcloud.core.oe.student.dao;
+
+import cn.com.qmth.examcloud.core.oe.student.dao.entity.ExamFaceLiveVerifyEntity;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+
+@Repository
+public interface ExamFaceLiveVerifyRepo extends JpaRepository<ExamFaceLiveVerifyEntity, Long>,
+        JpaSpecificationExecutor<ExamFaceLiveVerifyEntity> {
+
+    List<ExamFaceLiveVerifyEntity> findByExamRecordDataIdAndFinished(Long examRecordDataId, boolean finished);
+
+    List<ExamFaceLiveVerifyEntity> findByExamRecordDataId(Long examRecordDataId);
+
+}

+ 9 - 9
examcloud-core-oe-student-dao/src/main/java/cn/com/qmth/examcloud/core/oe/student/dao/ExamRecordDataRepo.java

@@ -1,14 +1,13 @@
 package cn.com.qmth.examcloud.core.oe.student.dao;
 
-import java.util.List;
-
+import cn.com.qmth.examcloud.core.oe.student.dao.entity.ExamRecordDataEntity;
 import org.springframework.data.jpa.repository.JpaRepository;
 import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
 import org.springframework.data.jpa.repository.Modifying;
 import org.springframework.data.jpa.repository.Query;
 import org.springframework.stereotype.Repository;
 
-import cn.com.qmth.examcloud.core.oe.student.dao.entity.ExamRecordDataEntity;
+import java.util.List;
 
 /**
  * @Description 考试记录
@@ -18,9 +17,10 @@ import cn.com.qmth.examcloud.core.oe.student.dao.entity.ExamRecordDataEntity;
  */
 @Repository
 public interface ExamRecordDataRepo extends JpaRepository<ExamRecordDataEntity, Long>, JpaSpecificationExecutor<ExamRecordDataEntity> {
+
     @Query(value = "select *  from ec_oes_exam_record_data where (batch_num is null or batch_num!=?1) and id>?2  "
-    		+ " and (sync_status is null or sync_status ='UNSYNC') and exam_record_status!='EXAM_ERROR' "
-    		+ " order by id limit ?3 ",nativeQuery = true)
+            + " and (sync_status is null or sync_status ='UNSYNC') and exam_record_status!='EXAM_ERROR' "
+            + " order by id limit ?3 ", nativeQuery = true)
     List<ExamRecordDataEntity> getLimitExamRecordDataList(Long batchNum, Long startId, Integer size);
 
     @Modifying
@@ -35,10 +35,10 @@ public interface ExamRecordDataRepo extends JpaRepository<ExamRecordDataEntity,
     @Query(value = "update ec_oes_exam_record_data set sync_status=?1 where id=?2", nativeQuery = true)
     int updateExamRecordSyncStatusById(String syncStatus, Long id);
 
-    @Query(value = "SELECT c.name from ec_oes_exam_record_data d LEFT JOIN ec_b_course c ON d.course_id = c.id " +
-            "WHERE d.id = ?1", nativeQuery = true)
-    String findCourseNameById(Long id);
-    
+    @Query(value = "SELECT course_id from ec_oes_exam_record_data WHERE id = ?1", nativeQuery = true)
+    Long findCourseIdByExamRecordDataId(Long examRecordDataId);
+
     @Query(value = "select t.id from ec_oes_exam_record_data t where t.base_paper_id=?1 limit 1", nativeQuery = true)
     Long getRecordIdByPaperId(String basePaperId);
+
 }

+ 19 - 0
examcloud-core-oe-student-dao/src/main/java/cn/com/qmth/examcloud/core/oe/student/dao/ExamSyncCaptureRepo.java

@@ -0,0 +1,19 @@
+package cn.com.qmth.examcloud.core.oe.student.dao;
+
+import cn.com.qmth.examcloud.core.oe.student.dao.entity.ExamSyncCaptureEntity;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
+import org.springframework.stereotype.Repository;
+
+/**
+ * @Description 同步抓拍照片
+ * @Author lideyin
+ * @Date 2019/12/10 14:05
+ * @Version 1.0
+ */
+@Repository
+public interface ExamSyncCaptureRepo extends JpaRepository<ExamSyncCaptureEntity, Long>, JpaSpecificationExecutor<ExamSyncCaptureEntity> {
+
+    ExamSyncCaptureEntity findByExamRecordDataId(Long examRecordId);
+
+}

+ 223 - 0
examcloud-core-oe-student-dao/src/main/java/cn/com/qmth/examcloud/core/oe/student/dao/entity/ExamCaptureEntity.java

@@ -0,0 +1,223 @@
+package cn.com.qmth.examcloud.core.oe.student.dao.entity;
+
+import cn.com.qmth.examcloud.web.jpa.JpaEntity;
+import org.hibernate.annotations.DynamicInsert;
+
+import javax.persistence.*;
+
+/**
+ * @Description 照片处理结果队列
+ * @Author lideyin
+ * @Date 2019/12/10 14:01
+ * @Version 1.0
+ */
+@Entity
+@Table(name = "ec_oet_exam_capture", indexes = {
+        @Index(name = "IDX_E_O_E_C_001", columnList = "examRecordDataId, fileName", unique = true),
+        @Index(name = "IDX_E_O_E_C_002", columnList = "examRecordDataId")
+})
+@DynamicInsert
+public class ExamCaptureEntity extends JpaEntity {
+
+    /**
+     *
+     */
+    private static final long serialVersionUID = -6162329876793785782L;
+
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    private Long id;
+
+    /**
+     * ec_oe_exam_record_data  ID
+     */
+    private Long examRecordDataId;
+
+    /**
+     * 文件URL
+     */
+    private String fileUrl;
+
+    private String fileName;
+
+    /**
+     * 比较是否通过
+     */
+    @Column(name = "is_pass")
+    private Boolean isPass;
+
+    /**
+     * 人脸比较返回信息
+     */
+    @Column(name = "face_compare_result", length = 2000)
+    private String faceCompareResult;
+
+    /**
+     * 是否有陌生人
+     * 就是摄像头拍到不止考生一人
+     */
+    @Column(name = "is_stranger")
+    private Boolean isStranger;
+
+    /**
+     * 百度人脸关键点坐标结果
+     */
+    @Column(name = "landmark", length = 4000)
+    private String landmark;
+
+    /**
+     * 百度在线活体检测结果
+     */
+    @Column(name = "faceliveness_result", length = 2000)
+    private String facelivenessResult;
+
+    /**
+     * 从进入队列到处理完成的时间
+     */
+    private Long usedTime;
+
+    /**
+     * 从开始处理到处理完成的时间
+     */
+    private Long processTime;
+
+
+    /**
+     * 是否存在虚拟摄像头
+     */
+    @Column(name = "has_virtual_camera")
+    private Boolean hasVirtualCamera;
+
+    /**
+     * 摄像头信息
+     */
+    @Column(name = "camera_infos", length = 800)
+    private String cameraInfos;
+
+    /**
+     * 其他信息
+     */
+    @Column(name = "ext_msg", length = 800)
+    private String extMsg;
+
+    public ExamCaptureEntity() {
+    }
+
+    public ExamCaptureEntity(ExamCaptureQueueEntity examCaptureQueueEntity) {
+        this.examRecordDataId = examCaptureQueueEntity.getExamRecordDataId();
+        this.fileUrl = examCaptureQueueEntity.getFileUrl();
+    }
+
+    public Long getId() {
+        return id;
+    }
+
+    public void setId(Long id) {
+        this.id = id;
+    }
+
+    public Long getExamRecordDataId() {
+        return examRecordDataId;
+    }
+
+    public void setExamRecordDataId(Long examRecordDataId) {
+        this.examRecordDataId = examRecordDataId;
+    }
+
+    public String getFileUrl() {
+        return fileUrl;
+    }
+
+    public void setFileUrl(String fileUrl) {
+        this.fileUrl = fileUrl;
+    }
+
+    public String getFileName() {
+        return fileName;
+    }
+
+    public void setFileName(String fileName) {
+        this.fileName = fileName;
+    }
+
+    public String getFaceCompareResult() {
+        return faceCompareResult;
+    }
+
+    public void setFaceCompareResult(String faceCompareResult) {
+        this.faceCompareResult = faceCompareResult;
+    }
+
+    public Boolean getIsPass() {
+        return isPass;
+    }
+
+    public void setIsPass(Boolean isPass) {
+        this.isPass = isPass;
+    }
+
+    public Boolean getIsStranger() {
+        return isStranger;
+    }
+
+    public void setIsStranger(Boolean isStranger) {
+        this.isStranger = isStranger;
+    }
+
+    public String getLandmark() {
+        return landmark;
+    }
+
+    public void setLandmark(String landmark) {
+        this.landmark = landmark;
+    }
+
+    public Long getUsedTime() {
+        return usedTime;
+    }
+
+    public void setUsedTime(Long usedTime) {
+        this.usedTime = usedTime;
+    }
+
+    public String getFacelivenessResult() {
+        return facelivenessResult;
+    }
+
+    public void setFacelivenessResult(String facelivenessResult) {
+        this.facelivenessResult = facelivenessResult;
+    }
+
+    public Long getProcessTime() {
+        return processTime;
+    }
+
+    public void setProcessTime(Long processTime) {
+        this.processTime = processTime;
+    }
+
+    public Boolean getHasVirtualCamera() {
+        return hasVirtualCamera;
+    }
+
+    public void setHasVirtualCamera(Boolean hasVirtualCamera) {
+        this.hasVirtualCamera = hasVirtualCamera;
+    }
+
+    public String getCameraInfos() {
+        return cameraInfos;
+    }
+
+    public void setCameraInfos(String cameraInfos) {
+        this.cameraInfos = cameraInfos;
+    }
+
+    public String getExtMsg() {
+        return extMsg;
+    }
+
+    public void setExtMsg(String extMsg) {
+        this.extMsg = extMsg;
+    }
+
+}

+ 271 - 0
examcloud-core-oe-student-dao/src/main/java/cn/com/qmth/examcloud/core/oe/student/dao/entity/ExamCaptureQueueEntity.java

@@ -0,0 +1,271 @@
+package cn.com.qmth.examcloud.core.oe.student.dao.entity;
+
+import cn.com.qmth.examcloud.core.oe.student.dao.enums.ExamCaptureQueueStatus;
+import cn.com.qmth.examcloud.web.jpa.WithIdJpaEntity;
+
+import javax.persistence.*;
+
+/**
+ * @Description 照片抓拍队列
+ * @Author lideyin
+ * @Date 2019/12/10 14:00
+ * @Version 1.0
+ */
+@Entity
+@Table(name = "ec_oet_exam_capture_queue", indexes = {
+        @Index(name = "IDX_E_O_E_C_Q_001", columnList = "examRecordDataId,fileName", unique = true),
+        @Index(name = "IDX_E_O_E_C_Q_002", columnList = "status,errorNum")
+})
+public class ExamCaptureQueueEntity extends WithIdJpaEntity {
+
+    /**
+     *
+     */
+    private static final long serialVersionUID = 4094671807731989565L;
+
+    private Long studentId;
+
+    /**
+     * ec_oe_exam_record_data  ID
+     */
+    private Long examRecordDataId;
+
+
+    /**
+     * 底照Token
+     */
+    private String baseFaceToken;
+
+    /**
+     * 文件URL
+     */
+    private String fileUrl;
+
+    /**
+     * 文件名称
+     */
+    private String fileName;
+
+    /**
+     * 状态
+     */
+    @Enumerated(EnumType.STRING)
+    private ExamCaptureQueueStatus status;
+
+    /**
+     * 错误信息
+     */
+    private String errorMsg;
+
+    /**
+     * 错误次数
+     */
+    private Integer errorNum;
+
+    /**
+     * 是否存在虚拟摄像头
+     */
+    @Column(name = "has_virtual_camera")
+    private Boolean hasVirtualCamera;
+
+    /**
+     * 摄像头信息  json字符串数组
+     */
+    @Column(name = "camera_infos", length = 800)
+    private String cameraInfos;
+
+    /**
+     * 其他信息
+     * Json格式
+     * {
+     * "":""
+     * }
+     */
+    @Column(name = "ext_msg", length = 800)
+    private String extMsg;
+
+    /**
+     * 队列处理批次号(用户判断某一条数据处理状态)
+     */
+    private String processBatchNum;
+
+    /**
+     * 队列处理的优先级,默认值为0
+     */
+    private int priority = 0;
+
+    /**
+     * 是否有陌生人
+     * 就是摄像头拍到不止考生一人
+     */
+    @Column(name = "is_stranger")
+    private Boolean isStranger;
+
+    /**
+     * 比较是否通过
+     */
+    @Column(name = "is_pass")
+    private Boolean isPass;
+
+    /**
+     * 人脸比较返回信息
+     */
+    @Column(name = "face_compare_result", length = 2000)
+    private String faceCompareResult;
+
+    /**
+     * 人脸比对开始时间
+     */
+    private Long faceCompareStartTime;
+
+    /**
+     * 百度在线活体检测结果
+     */
+    @Column(name = "faceliveness_result", length = 2000)
+    private String facelivenessResult;
+
+    public Long getExamRecordDataId() {
+        return examRecordDataId;
+    }
+
+    public void setExamRecordDataId(Long examRecordDataId) {
+        this.examRecordDataId = examRecordDataId;
+    }
+
+    public String getFileUrl() {
+        return fileUrl;
+    }
+
+    public void setFileUrl(String fileUrl) {
+        this.fileUrl = fileUrl;
+    }
+
+    public ExamCaptureQueueStatus getStatus() {
+        return status;
+    }
+
+    public void setStatus(ExamCaptureQueueStatus status) {
+        this.status = status;
+    }
+
+    public String getErrorMsg() {
+        return errorMsg;
+    }
+
+    public void setErrorMsg(String errorMsg) {
+        this.errorMsg = errorMsg;
+    }
+
+    public String getBaseFaceToken() {
+        return baseFaceToken;
+    }
+
+    public void setBaseFaceToken(String baseFaceToken) {
+        this.baseFaceToken = baseFaceToken;
+    }
+
+    public String getFileName() {
+        return fileName;
+    }
+
+    public void setFileName(String fileName) {
+        this.fileName = fileName;
+    }
+
+    public Integer getErrorNum() {
+        return errorNum;
+    }
+
+    public void setErrorNum(Integer errorNum) {
+        this.errorNum = errorNum;
+    }
+
+    public Long getStudentId() {
+        return studentId;
+    }
+
+    public void setStudentId(Long studentId) {
+        this.studentId = studentId;
+    }
+
+    public Boolean getHasVirtualCamera() {
+        return hasVirtualCamera;
+    }
+
+    public void setHasVirtualCamera(Boolean hasVirtualCamera) {
+        this.hasVirtualCamera = hasVirtualCamera;
+    }
+
+    public String getCameraInfos() {
+        return cameraInfos;
+    }
+
+    public void setCameraInfos(String cameraInfos) {
+        this.cameraInfos = cameraInfos;
+    }
+
+    public String getExtMsg() {
+        return extMsg;
+    }
+
+    public void setExtMsg(String extMsg) {
+        this.extMsg = extMsg;
+    }
+
+    public String getProcessBatchNum() {
+        return processBatchNum;
+    }
+
+    public void setProcessBatchNum(String processBatchNum) {
+        this.processBatchNum = processBatchNum;
+    }
+
+    public int getPriority() {
+        return priority;
+    }
+
+    public void setPriority(int priority) {
+        this.priority = priority;
+    }
+
+    public Boolean getIsStranger() {
+        return isStranger;
+    }
+
+    public void setIsStranger(Boolean stranger) {
+        isStranger = stranger;
+    }
+
+    public Boolean getIsPass() {
+        return isPass;
+    }
+
+    public void setIsPass(Boolean pass) {
+        isPass = pass;
+    }
+
+    public String getFaceCompareResult() {
+        return faceCompareResult;
+    }
+
+    public void setFaceCompareResult(String faceCompareResult) {
+        this.faceCompareResult = faceCompareResult;
+    }
+
+    public Long getFaceCompareStartTime() {
+        return faceCompareStartTime;
+    }
+
+    public void setFaceCompareStartTime(Long faceCompareStartTime) {
+        this.faceCompareStartTime = faceCompareStartTime;
+    }
+
+    public String getFacelivenessResult() {
+        return facelivenessResult;
+    }
+
+    public void setFacelivenessResult(String facelivenessResult) {
+        this.facelivenessResult = facelivenessResult;
+    }
+
+}

+ 155 - 0
examcloud-core-oe-student-dao/src/main/java/cn/com/qmth/examcloud/core/oe/student/dao/entity/ExamFaceLiveVerifyEntity.java

@@ -0,0 +1,155 @@
+package cn.com.qmth.examcloud.core.oe.student.dao.entity;
+
+import cn.com.qmth.examcloud.core.oe.student.dao.enums.FaceLiveVerifyStatus;
+import cn.com.qmth.examcloud.web.jpa.JpaEntity;
+
+import javax.persistence.*;
+
+/**
+ * 人脸活体验证结果表(支持C端客户端活检)
+ */
+@Entity
+@Table(name = "ec_oes_exam_face_live_verify", indexes = {
+        @Index(name = "IDX_OES_FLV_01", columnList = "examRecordDataId"),
+        @Index(name = "IDX_OES_FLV_02", columnList = "status")
+})
+public class ExamFaceLiveVerifyEntity extends JpaEntity {
+
+    private static final long serialVersionUID = -3428631813990503829L;
+
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    private Long id;
+
+    /**
+     * 考试记录ID
+     */
+    @Column(nullable = false)
+    private Long examRecordDataId;
+
+    /**
+     * 是否完成
+     */
+    @Column(nullable = false)
+    private Boolean finished;
+
+    /**
+     * 验证状态
+     */
+    @Enumerated(EnumType.STRING)
+    @Column(length = 50, nullable = false)
+    private FaceLiveVerifyStatus status;
+
+    /**
+     * 动作验证列表,JSON格式
+     */
+    @Column(length = 2000)
+    private String actions;
+
+    /**
+     * 人脸数量
+     */
+    private Integer faceCount;
+
+    /**
+     * 相似度分数
+     */
+    private Double similarity;
+
+    /**
+     * 真实性分数
+     */
+    private Double realness;
+
+    /**
+     * 处理耗时(毫秒)
+     */
+    private Long processTime;
+
+    /**
+     * 错误信息
+     */
+    @Column(length = 500)
+    private String errorMsg;
+
+    public Long getId() {
+        return id;
+    }
+
+    public void setId(Long id) {
+        this.id = id;
+    }
+
+    public Long getExamRecordDataId() {
+        return examRecordDataId;
+    }
+
+    public void setExamRecordDataId(Long examRecordDataId) {
+        this.examRecordDataId = examRecordDataId;
+    }
+
+    public Boolean getFinished() {
+        return finished;
+    }
+
+    public void setFinished(Boolean finished) {
+        this.finished = finished;
+    }
+
+    public FaceLiveVerifyStatus getStatus() {
+        return status;
+    }
+
+    public void setStatus(FaceLiveVerifyStatus status) {
+        this.status = status;
+    }
+
+    public String getActions() {
+        return actions;
+    }
+
+    public void setActions(String actions) {
+        this.actions = actions;
+    }
+
+    public Integer getFaceCount() {
+        return faceCount;
+    }
+
+    public void setFaceCount(Integer faceCount) {
+        this.faceCount = faceCount;
+    }
+
+    public Double getSimilarity() {
+        return similarity;
+    }
+
+    public void setSimilarity(Double similarity) {
+        this.similarity = similarity;
+    }
+
+    public Double getRealness() {
+        return realness;
+    }
+
+    public void setRealness(Double realness) {
+        this.realness = realness;
+    }
+
+    public Long getProcessTime() {
+        return processTime;
+    }
+
+    public void setProcessTime(Long processTime) {
+        this.processTime = processTime;
+    }
+
+    public String getErrorMsg() {
+        return errorMsg;
+    }
+
+    public void setErrorMsg(String errorMsg) {
+        this.errorMsg = errorMsg;
+    }
+
+}

+ 223 - 0
examcloud-core-oe-student-dao/src/main/java/cn/com/qmth/examcloud/core/oe/student/dao/entity/ExamSyncCaptureEntity.java

@@ -0,0 +1,223 @@
+package cn.com.qmth.examcloud.core.oe.student.dao.entity;
+
+import cn.com.qmth.examcloud.web.jpa.JpaEntity;
+import org.hibernate.annotations.DynamicInsert;
+
+import javax.persistence.*;
+
+/**
+ * @Description 网考同步抓拍表
+ * @Author lideyin
+ * @Date 2019/12/6 16:07
+ * @Version 1.0
+ */
+@Entity
+@Table(name = "ec_oet_exam_sync_capture", indexes = {
+        @Index(name = "IDX_E_O_E_C_001", columnList = "examRecordDataId, fileName", unique = true),
+        @Index(name = "IDX_E_O_E_C_002", columnList = "examRecordDataId")
+})
+@DynamicInsert
+public class ExamSyncCaptureEntity extends JpaEntity {
+
+    /**
+     *
+     */
+    private static final long serialVersionUID = -6162329876793785782L;
+
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    private Long id;
+
+    /**
+     * ec_oe_exam_record_data  ID
+     */
+    private Long examRecordDataId;
+
+    /**
+     * 文件URL
+     */
+    private String fileUrl;
+
+    private String fileName;
+
+    /**
+     * 比较是否通过
+     */
+    @Column(name = "is_pass")
+    private Boolean isPass;
+
+    /**
+     * 人脸比较返回信息
+     */
+    @Column(name = "face_compare_result", length = 2000)
+    private String faceCompareResult;
+
+    /**
+     * 是否有陌生人
+     * 就是摄像头拍到不止考生一人
+     */
+    @Column(name = "is_stranger")
+    private Boolean isStranger;
+
+    /**
+     * 百度人脸关键点坐标结果
+     */
+    @Column(name = "landmark", length = 4000)
+    private String landmark;
+
+    /**
+     * 百度在线活体检测结果
+     */
+    @Column(name = "faceliveness_result", length = 2000)
+    private String facelivenessResult;
+
+    /**
+     * 从进入队列到处理完成的时间
+     */
+    private Long usedTime;
+
+    /**
+     * 从开始处理到处理完成的时间
+     */
+    private Long processTime;
+
+
+    /**
+     * 是否存在虚拟摄像头
+     */
+    @Column(name = "has_virtual_camera")
+    private Boolean hasVirtualCamera;
+
+    /**
+     * 摄像头信息
+     */
+    @Column(name = "camera_infos", length = 800)
+    private String cameraInfos;
+
+    /**
+     * 其他信息
+     */
+    @Column(name = "ext_msg", length = 800)
+    private String extMsg;
+
+    public ExamSyncCaptureEntity() {
+    }
+
+    public ExamSyncCaptureEntity(ExamCaptureQueueEntity examCaptureQueueEntity) {
+        this.examRecordDataId = examCaptureQueueEntity.getExamRecordDataId();
+        this.fileUrl = examCaptureQueueEntity.getFileUrl();
+    }
+
+    public Long getId() {
+        return id;
+    }
+
+    public void setId(Long id) {
+        this.id = id;
+    }
+
+    public Long getExamRecordDataId() {
+        return examRecordDataId;
+    }
+
+    public void setExamRecordDataId(Long examRecordDataId) {
+        this.examRecordDataId = examRecordDataId;
+    }
+
+    public String getFileUrl() {
+        return fileUrl;
+    }
+
+    public void setFileUrl(String fileUrl) {
+        this.fileUrl = fileUrl;
+    }
+
+    public String getFileName() {
+        return fileName;
+    }
+
+    public void setFileName(String fileName) {
+        this.fileName = fileName;
+    }
+
+    public String getFaceCompareResult() {
+        return faceCompareResult;
+    }
+
+    public void setFaceCompareResult(String faceCompareResult) {
+        this.faceCompareResult = faceCompareResult;
+    }
+
+    public Boolean getIsPass() {
+        return isPass;
+    }
+
+    public void setIsPass(Boolean isPass) {
+        this.isPass = isPass;
+    }
+
+    public Boolean getIsStranger() {
+        return isStranger;
+    }
+
+    public void setIsStranger(Boolean isStranger) {
+        this.isStranger = isStranger;
+    }
+
+    public String getLandmark() {
+        return landmark;
+    }
+
+    public void setLandmark(String landmark) {
+        this.landmark = landmark;
+    }
+
+    public Long getUsedTime() {
+        return usedTime;
+    }
+
+    public void setUsedTime(Long usedTime) {
+        this.usedTime = usedTime;
+    }
+
+    public String getFacelivenessResult() {
+        return facelivenessResult;
+    }
+
+    public void setFacelivenessResult(String facelivenessResult) {
+        this.facelivenessResult = facelivenessResult;
+    }
+
+    public Long getProcessTime() {
+        return processTime;
+    }
+
+    public void setProcessTime(Long processTime) {
+        this.processTime = processTime;
+    }
+
+    public Boolean getHasVirtualCamera() {
+        return hasVirtualCamera;
+    }
+
+    public void setHasVirtualCamera(Boolean hasVirtualCamera) {
+        this.hasVirtualCamera = hasVirtualCamera;
+    }
+
+    public String getCameraInfos() {
+        return cameraInfos;
+    }
+
+    public void setCameraInfos(String cameraInfos) {
+        this.cameraInfos = cameraInfos;
+    }
+
+    public String getExtMsg() {
+        return extMsg;
+    }
+
+    public void setExtMsg(String extMsg) {
+        this.extMsg = extMsg;
+    }
+
+}

+ 45 - 0
examcloud-core-oe-student-dao/src/main/java/cn/com/qmth/examcloud/core/oe/student/dao/enums/ExamCaptureQueueStatus.java

@@ -0,0 +1,45 @@
+package cn.com.qmth.examcloud.core.oe.student.dao.enums;
+
+/**
+ * @Description 抓拍照片处理状态
+ * @Author lideyin
+ * @Date 2019/12/10 13:59
+ * @Version 1.0
+ */
+public enum ExamCaptureQueueStatus {
+
+    /**
+     * 待处理
+     */
+    PENDING("待处理"),
+    /**
+     * 处理中
+     */
+    PROCESSING("处理中"),
+    /**
+     * 人脸比对处理完成
+     */
+    PROCESS_FACE_COMPARE_COMPLETE("人脸比对处理完成"),
+    /**
+     * 人脸比对处理失败
+     */
+    PROCESS_FACE_COMPARE_FAILED("人脸比对处理失败"),
+    /**
+     * 活体检测处理失败
+     */
+    PROCESS_FACELIVENESS_FAILED("活体检测处理失败");
+
+    private String desc;
+
+    private ExamCaptureQueueStatus(String desc) {
+        this.desc = desc;
+    }
+
+    public String getDesc() {
+        return desc;
+    }
+
+    public void setDesc(String desc) {
+        this.desc = desc;
+    }
+}

+ 0 - 41
examcloud-core-oe-student-dao/src/main/java/cn/com/qmth/examcloud/core/oe/student/dao/enums/FaceBiopsyScheme.java

@@ -1,41 +0,0 @@
-package cn.com.qmth.examcloud.core.oe.student.dao.enums;
-
-/**
- * @Description 活体检测方案枚举
- * @Author lideyin
- * @Date 2019/11/6 15:03
- * @Version 1.0
- */
-public enum FaceBiopsyScheme {
-    /**
-     * FaceID活体检测方案(即旧活体检测方案)
-     */
-    FACE_ID("S1", "FaceID活体检测方案"),
-    /**
-     * 新活体检测方案(暂时无法给出具体命名,以后有需要再改动)
-     */
-    NEW("S2", "新活体检测方案");
-    private String code;
-    private String desc;
-
-    FaceBiopsyScheme(String code, String desc) {
-        this.code = code;
-        this.desc = desc;
-    }
-
-    /**
-     * 获取活检方案代码
-     * @return
-     */
-    public String getCode(){
-        return this.code;
-    }
-
-    /**
-     * 获取活检方案描述
-     * @return
-     */
-    public String getDesc(){
-        return this.desc;
-    }
-}

+ 6 - 2
examcloud-core-oe-student-dao/src/main/java/cn/com/qmth/examcloud/core/oe/student/dao/enums/FaceBiopsyType.java

@@ -1,4 +1,5 @@
 package cn.com.qmth.examcloud.core.oe.student.dao.enums;
+
 /*
  * @Description 人脸活体检测类型
  * @Author lideyin
@@ -6,12 +7,15 @@ package cn.com.qmth.examcloud.core.oe.student.dao.enums;
  * @Version 1.0
  */
 public enum FaceBiopsyType {
+
     /**
-     * faceid人脸活体识别技术
+     * FaceID活体检测方案(即旧活体检测方案)
      */
     FACE_ID,
+
     /**
-     * 人脸移动识别技术
+     * Electron Client 自研活体检测方案
      */
     FACE_MOTION
+
 }

+ 28 - 0
examcloud-core-oe-student-dao/src/main/java/cn/com/qmth/examcloud/core/oe/student/dao/enums/FaceLiveVerifyStatus.java

@@ -0,0 +1,28 @@
+package cn.com.qmth.examcloud.core.oe.student.dao.enums;
+
+/**
+ * 人脸活体验证状态
+ */
+public enum FaceLiveVerifyStatus {
+
+    SUCCESS("验证成功"),
+
+    ACTION_FAILED("动作有误,验证失败"),
+
+    NOT_ONESELF("不是本人"),
+
+    TIME_OUT("超时未完成"),
+
+    ERROR("验证异常");
+
+    private String description;
+
+    FaceLiveVerifyStatus(String description) {
+        this.description = description;
+    }
+
+    public String getDescription() {
+        return description;
+    }
+
+}

+ 1 - 8
examcloud-core-oe-student-dao/src/main/java/cn/com/qmth/examcloud/core/oe/student/dao/enums/FaceVerifyResult.java

@@ -35,7 +35,7 @@ public enum FaceVerifyResult {
 
     private String desc;
 
-    private FaceVerifyResult(String desc) {
+    FaceVerifyResult(String desc) {
         this.desc = desc;
     }
 
@@ -43,12 +43,5 @@ public enum FaceVerifyResult {
         return desc;
     }
 
-    public void setDesc(String desc) {
-        this.desc = desc;
-    }
-
-    public static void main(String[] args) {
-
-    }
 }	
 

+ 37 - 0
examcloud-core-oe-student-service/src/main/java/cn/com/qmth/examcloud/core/oe/student/bean/CheckExamInProgressInfo.java

@@ -9,6 +9,19 @@ public class CheckExamInProgressInfo implements JsonSerializable{
 	 * 
 	 */
 	private static final long serialVersionUID = 5411680698118472710L;
+	
+    /**
+     * 限制切屏次数
+     */
+    private Integer maxSwitchScreenCount;
+    
+    /**
+     * 已切屏次数
+     */
+    private Integer switchScreenCount;
+    
+    //是否达到切屏次数
+  	private Boolean exceedMaxSwitchScreenCount;
 
 	//已断点次数
 	private Integer interruptNum;
@@ -102,4 +115,28 @@ public class CheckExamInProgressInfo implements JsonSerializable{
         this.examType = examType;
     }
 
+	public Integer getMaxSwitchScreenCount() {
+		return maxSwitchScreenCount;
+	}
+
+	public void setMaxSwitchScreenCount(Integer maxSwitchScreenCount) {
+		this.maxSwitchScreenCount = maxSwitchScreenCount;
+	}
+
+	public Integer getSwitchScreenCount() {
+		return switchScreenCount;
+	}
+
+	public void setSwitchScreenCount(Integer switchScreenCount) {
+		this.switchScreenCount = switchScreenCount;
+	}
+
+	public Boolean getExceedMaxSwitchScreenCount() {
+		return exceedMaxSwitchScreenCount;
+	}
+
+	public void setExceedMaxSwitchScreenCount(Boolean exceedMaxSwitchScreenCount) {
+		this.exceedMaxSwitchScreenCount = exceedMaxSwitchScreenCount;
+	}
+
 }

+ 47 - 0
examcloud-core-oe-student-service/src/main/java/cn/com/qmth/examcloud/core/oe/student/bean/SwitchScreenCountInfo.java

@@ -0,0 +1,47 @@
+package cn.com.qmth.examcloud.core.oe.student.bean;
+
+import cn.com.qmth.examcloud.api.commons.exchange.JsonSerializable;
+
+public class SwitchScreenCountInfo implements JsonSerializable{
+
+
+	/**
+	 * 
+	 */
+	private static final long serialVersionUID = -8650554167580096412L;
+
+	//已切屏次数
+	private Integer switchScreenCount;
+	
+	//最大切屏次数限制
+	private Integer maxSwitchScreenCount;
+
+	public Integer getSwitchScreenCount() {
+		return switchScreenCount;
+	}
+
+	public void setSwitchScreenCount(Integer switchScreenCount) {
+		this.switchScreenCount = switchScreenCount;
+	}
+
+	public Integer getMaxSwitchScreenCount() {
+		return maxSwitchScreenCount;
+	}
+
+	public void setMaxSwitchScreenCount(Integer maxSwitchScreenCount) {
+		this.maxSwitchScreenCount = maxSwitchScreenCount;
+	}
+
+	public SwitchScreenCountInfo(Integer switchScreenCount, Integer maxSwitchScreenCount) {
+		super();
+		this.switchScreenCount = switchScreenCount;
+		this.maxSwitchScreenCount = maxSwitchScreenCount;
+	}
+
+	public SwitchScreenCountInfo() {
+		super();
+	}
+
+	
+
+}

+ 43 - 0
examcloud-core-oe-student-service/src/main/java/cn/com/qmth/examcloud/core/oe/student/bean/client/CourseInfo.java

@@ -0,0 +1,43 @@
+package cn.com.qmth.examcloud.core.oe.student.bean.client;
+
+import cn.com.qmth.examcloud.api.commons.exchange.JsonSerializable;
+import io.swagger.annotations.ApiModelProperty;
+
+public class CourseInfo implements JsonSerializable {
+
+    private static final long serialVersionUID = 3567311334163339241L;
+
+    @ApiModelProperty(value = "课程ID")
+    private Long courseId;
+
+    @ApiModelProperty(value = "课程代码")
+    private String courseCode;
+
+    @ApiModelProperty(value = "课程名称")
+    private String courseName;
+
+    public Long getCourseId() {
+        return courseId;
+    }
+
+    public void setCourseId(Long courseId) {
+        this.courseId = courseId;
+    }
+
+    public String getCourseCode() {
+        return courseCode;
+    }
+
+    public void setCourseCode(String courseCode) {
+        this.courseCode = courseCode;
+    }
+
+    public String getCourseName() {
+        return courseName;
+    }
+
+    public void setCourseName(String courseName) {
+        this.courseName = courseName;
+    }
+
+}

+ 134 - 0
examcloud-core-oe-student-service/src/main/java/cn/com/qmth/examcloud/core/oe/student/bean/client/FaceCaptureResult.java

@@ -0,0 +1,134 @@
+package cn.com.qmth.examcloud.core.oe.student.bean.client;
+
+import cn.com.qmth.examcloud.api.commons.exchange.JsonSerializable;
+import io.swagger.annotations.ApiModelProperty;
+
+/**
+ * 人脸抓拍比对验证结果
+ */
+public class FaceCaptureResult implements JsonSerializable {
+
+    private static final long serialVersionUID = 3567311334163339241L;
+
+    @ApiModelProperty(value = "学生ID", hidden = true)
+    private Long studentId;
+
+    @ApiModelProperty(value = "考试记录ID")
+    private Long examRecordDataId;
+
+    @ApiModelProperty(value = "人脸比对是否通过")
+    private Boolean pass;
+
+    @ApiModelProperty(value = "是否有陌生人")
+    private Boolean stranger;
+
+    @ApiModelProperty(value = "是否存在虚拟摄像头")
+    private Boolean hasVirtualCamera;
+
+    @ApiModelProperty(value = "图片地址")
+    private String fileUrl;
+
+    @ApiModelProperty(value = "人脸比对结果")
+    private String faceCompareResult;
+
+    @ApiModelProperty(value = "人脸活体结果")
+    private String facelivenessResult;
+
+    @ApiModelProperty(value = "摄像头信息")
+    private String cameraInfos;
+
+    @ApiModelProperty(value = "人脸比对处理耗时(毫秒)")
+    private Long processTime;
+
+    @ApiModelProperty(value = "附加信息")
+    private String extMsg;
+
+    public Long getStudentId() {
+        return studentId;
+    }
+
+    public void setStudentId(Long studentId) {
+        this.studentId = studentId;
+    }
+
+    public Long getExamRecordDataId() {
+        return examRecordDataId;
+    }
+
+    public void setExamRecordDataId(Long examRecordDataId) {
+        this.examRecordDataId = examRecordDataId;
+    }
+
+    public Boolean getPass() {
+        return pass;
+    }
+
+    public void setPass(Boolean pass) {
+        this.pass = pass;
+    }
+
+    public Boolean getStranger() {
+        return stranger;
+    }
+
+    public void setStranger(Boolean stranger) {
+        this.stranger = stranger;
+    }
+
+    public Boolean getHasVirtualCamera() {
+        return hasVirtualCamera;
+    }
+
+    public void setHasVirtualCamera(Boolean hasVirtualCamera) {
+        this.hasVirtualCamera = hasVirtualCamera;
+    }
+
+    public String getFileUrl() {
+        return fileUrl;
+    }
+
+    public void setFileUrl(String fileUrl) {
+        this.fileUrl = fileUrl;
+    }
+
+    public String getFaceCompareResult() {
+        return faceCompareResult;
+    }
+
+    public void setFaceCompareResult(String faceCompareResult) {
+        this.faceCompareResult = faceCompareResult;
+    }
+
+    public String getFacelivenessResult() {
+        return facelivenessResult;
+    }
+
+    public void setFacelivenessResult(String facelivenessResult) {
+        this.facelivenessResult = facelivenessResult;
+    }
+
+    public String getCameraInfos() {
+        return cameraInfos;
+    }
+
+    public void setCameraInfos(String cameraInfos) {
+        this.cameraInfos = cameraInfos;
+    }
+
+    public Long getProcessTime() {
+        return processTime;
+    }
+
+    public void setProcessTime(Long processTime) {
+        this.processTime = processTime;
+    }
+
+    public String getExtMsg() {
+        return extMsg;
+    }
+
+    public void setExtMsg(String extMsg) {
+        this.extMsg = extMsg;
+    }
+
+}

+ 79 - 0
examcloud-core-oe-student-service/src/main/java/cn/com/qmth/examcloud/core/oe/student/bean/client/FaceCompareResult.java

@@ -0,0 +1,79 @@
+package cn.com.qmth.examcloud.core.oe.student.bean.client;
+
+import cn.com.qmth.examcloud.api.commons.exchange.JsonSerializable;
+import io.swagger.annotations.ApiModelProperty;
+
+/**
+ * 人脸识别比对验证结果
+ */
+public class FaceCompareResult implements JsonSerializable {
+
+    private static final long serialVersionUID = 3567311334163339241L;
+
+    @ApiModelProperty(value = "学生ID", hidden = true)
+    private Long studentId;
+
+    @ApiModelProperty(value = "人脸比对是否通过")
+    private Boolean pass;
+
+    @ApiModelProperty(value = "是否有陌生人")
+    private Boolean stranger;
+
+    @ApiModelProperty(value = "图片地址")
+    private String fileUrl;
+
+    @ApiModelProperty(value = "人脸比对结果")
+    private String faceCompareResult;
+
+    @ApiModelProperty(value = "人脸比对处理耗时(毫秒)")
+    private Long processTime;
+
+    public Long getStudentId() {
+        return studentId;
+    }
+
+    public void setStudentId(Long studentId) {
+        this.studentId = studentId;
+    }
+
+    public Boolean getPass() {
+        return pass;
+    }
+
+    public void setPass(Boolean pass) {
+        this.pass = pass;
+    }
+
+    public Boolean getStranger() {
+        return stranger;
+    }
+
+    public void setStranger(Boolean stranger) {
+        this.stranger = stranger;
+    }
+
+    public String getFileUrl() {
+        return fileUrl;
+    }
+
+    public void setFileUrl(String fileUrl) {
+        this.fileUrl = fileUrl;
+    }
+
+    public String getFaceCompareResult() {
+        return faceCompareResult;
+    }
+
+    public void setFaceCompareResult(String faceCompareResult) {
+        this.faceCompareResult = faceCompareResult;
+    }
+
+    public Long getProcessTime() {
+        return processTime;
+    }
+
+    public void setProcessTime(Long processTime) {
+        this.processTime = processTime;
+    }
+
+}

+ 79 - 0
examcloud-core-oe-student-service/src/main/java/cn/com/qmth/examcloud/core/oe/student/bean/client/FaceLiveVerifyAction.java

@@ -0,0 +1,79 @@
+package cn.com.qmth.examcloud.core.oe.student.bean.client;
+
+import cn.com.qmth.examcloud.api.commons.exchange.JsonSerializable;
+import io.swagger.annotations.ApiModelProperty;
+
+/**
+ * 人脸活体具体动作验证结果
+ */
+public class FaceLiveVerifyAction implements JsonSerializable {
+
+    private static final long serialVersionUID = 3567311334163339241L;
+
+    @ApiModelProperty(value = "动作类型")
+    private String type;
+
+    @ApiModelProperty(value = "是否通过")
+    private Boolean pass;
+
+    @ApiModelProperty(value = "动作文件地址")
+    private String fileUrl;
+
+    @ApiModelProperty(value = "尝试操作次数")
+    private Integer retry;
+
+    @ApiModelProperty(value = "处理耗时(毫秒)")
+    private Long processTime;
+
+    @ApiModelProperty(value = "错误信息")
+    private String errorMsg;
+
+    public String getType() {
+        return type;
+    }
+
+    public void setType(String type) {
+        this.type = type;
+    }
+
+    public Boolean getPass() {
+        return pass;
+    }
+
+    public void setPass(Boolean pass) {
+        this.pass = pass;
+    }
+
+    public String getFileUrl() {
+        return fileUrl;
+    }
+
+    public void setFileUrl(String fileUrl) {
+        this.fileUrl = fileUrl;
+    }
+
+    public Integer getRetry() {
+        return retry;
+    }
+
+    public void setRetry(Integer retry) {
+        this.retry = retry;
+    }
+
+    public Long getProcessTime() {
+        return processTime;
+    }
+
+    public void setProcessTime(Long processTime) {
+        this.processTime = processTime;
+    }
+
+    public String getErrorMsg() {
+        return errorMsg;
+    }
+
+    public void setErrorMsg(String errorMsg) {
+        this.errorMsg = errorMsg;
+    }
+
+}

+ 43 - 0
examcloud-core-oe-student-service/src/main/java/cn/com/qmth/examcloud/core/oe/student/bean/client/FaceLiveVerifyInfo.java

@@ -0,0 +1,43 @@
+package cn.com.qmth.examcloud.core.oe.student.bean.client;
+
+import cn.com.qmth.examcloud.api.commons.exchange.JsonSerializable;
+import io.swagger.annotations.ApiModelProperty;
+
+public class FaceLiveVerifyInfo implements JsonSerializable {
+
+    private static final long serialVersionUID = 3567311334163339241L;
+
+    @ApiModelProperty(value = "当前活检记录ID")
+    private Long faceLiveVerifyId;
+
+    @ApiModelProperty(value = "当前活检开始时间")
+    private Integer startMinute;
+
+    @ApiModelProperty(value = "当前第几次活检")
+    private Integer times;
+
+    public Long getFaceLiveVerifyId() {
+        return faceLiveVerifyId;
+    }
+
+    public void setFaceLiveVerifyId(Long faceLiveVerifyId) {
+        this.faceLiveVerifyId = faceLiveVerifyId;
+    }
+
+    public Integer getStartMinute() {
+        return startMinute;
+    }
+
+    public void setStartMinute(Integer startMinute) {
+        this.startMinute = startMinute;
+    }
+
+    public Integer getTimes() {
+        return times;
+    }
+
+    public void setTimes(Integer times) {
+        this.times = times;
+    }
+
+}

+ 126 - 0
examcloud-core-oe-student-service/src/main/java/cn/com/qmth/examcloud/core/oe/student/bean/client/FaceLiveVerifyResult.java

@@ -0,0 +1,126 @@
+package cn.com.qmth.examcloud.core.oe.student.bean.client;
+
+import cn.com.qmth.examcloud.api.commons.exchange.JsonSerializable;
+import cn.com.qmth.examcloud.core.oe.student.dao.enums.FaceLiveVerifyStatus;
+import io.swagger.annotations.ApiModelProperty;
+
+import java.util.List;
+
+/**
+ * 人脸活体验证结果
+ */
+public class FaceLiveVerifyResult implements JsonSerializable {
+
+    private static final long serialVersionUID = 3567311334163339241L;
+
+    @ApiModelProperty(value = "学生ID", hidden = true)
+    private Long studentId;
+
+    @ApiModelProperty(value = "当前活检记录ID")
+    private Long faceLiveVerifyId;
+
+    @ApiModelProperty(value = "考试记录ID")
+    private Long examRecordDataId;
+
+    @ApiModelProperty(value = "验证状态")
+    private FaceLiveVerifyStatus status;
+
+    @ApiModelProperty(value = "动作验证列表")
+    private List<FaceLiveVerifyAction> actions;
+
+    @ApiModelProperty(value = "人脸数量")
+    private Integer faceCount;
+
+    @ApiModelProperty(value = "相似度分数")
+    private Double similarity;
+
+    @ApiModelProperty(value = "真实性分数")
+    private Double realness;
+
+    @ApiModelProperty(value = "处理耗时(毫秒)")
+    private Long processTime;
+
+    @ApiModelProperty(value = "错误信息")
+    private String errorMsg;
+
+    public Long getStudentId() {
+        return studentId;
+    }
+
+    public void setStudentId(Long studentId) {
+        this.studentId = studentId;
+    }
+
+    public Long getFaceLiveVerifyId() {
+        return faceLiveVerifyId;
+    }
+
+    public void setFaceLiveVerifyId(Long faceLiveVerifyId) {
+        this.faceLiveVerifyId = faceLiveVerifyId;
+    }
+
+    public Long getExamRecordDataId() {
+        return examRecordDataId;
+    }
+
+    public void setExamRecordDataId(Long examRecordDataId) {
+        this.examRecordDataId = examRecordDataId;
+    }
+
+    public FaceLiveVerifyStatus getStatus() {
+        return status;
+    }
+
+    public void setStatus(FaceLiveVerifyStatus status) {
+        this.status = status;
+    }
+
+    public List<FaceLiveVerifyAction> getActions() {
+        return actions;
+    }
+
+    public void setActions(List<FaceLiveVerifyAction> actions) {
+        this.actions = actions;
+    }
+
+    public Integer getFaceCount() {
+        return faceCount;
+    }
+
+    public void setFaceCount(Integer faceCount) {
+        this.faceCount = faceCount;
+    }
+
+    public Double getSimilarity() {
+        return similarity;
+    }
+
+    public void setSimilarity(Double similarity) {
+        this.similarity = similarity;
+    }
+
+    public Double getRealness() {
+        return realness;
+    }
+
+    public void setRealness(Double realness) {
+        this.realness = realness;
+    }
+
+    public Long getProcessTime() {
+        return processTime;
+    }
+
+    public void setProcessTime(Long processTime) {
+        this.processTime = processTime;
+    }
+
+    public String getErrorMsg() {
+        return errorMsg;
+    }
+
+    public void setErrorMsg(String errorMsg) {
+        this.errorMsg = errorMsg;
+    }
+
+}

+ 12 - 7
examcloud-core-oe-student-service/src/main/java/cn/com/qmth/examcloud/core/oe/student/service/ExamControlService.java

@@ -5,8 +5,6 @@ import cn.com.qmth.examcloud.api.commons.security.bean.User;
 import cn.com.qmth.examcloud.core.oe.student.bean.*;
 import cn.com.qmth.examcloud.support.enums.HandInExamType;
 
-import javax.validation.constraints.Null;
-
 /**
  * @author chenken
  * @date 2018年8月13日 下午2:09:38
@@ -19,16 +17,21 @@ public interface ExamControlService {
      * 开始考试
      *
      * @param examStudentId
-     * @param user
+     * @param userId
      */
-    StartExamInfo startExam(Long examStudentId, User user, String ip);
+    StartExamInfo startExam(Long examStudentId, Long userId, String ip);
 
     /**
      * 开始答题
      *
      * @param examRecordDataId
      */
-    StartAnswerInfo startAnswer(Long examRecordDataId);
+    StartAnswerInfo startAnswer(Long examRecordDataId, Long userId);
+
+    /**
+     * 手动交卷
+     */
+    void manualEndExam(Long studentId, String ip);
 
     /**
      * 交卷
@@ -37,7 +40,7 @@ public interface ExamControlService {
      * @param handInExamType   交卷类型
      * @param ip               请求ip,可以为空
      */
-    void handInExam(Long examRecordDataId, HandInExamType handInExamType, @Null String ip);
+    void handInExam(Long examRecordDataId, HandInExamType handInExamType, String ip);
 
     /**
      * 断点续考:检查正在进行中的考试
@@ -46,6 +49,8 @@ public interface ExamControlService {
      */
     CheckExamInProgressInfo checkExamInProgress(Long studentId, String ip);
 
+    ExamProcessResultInfo checkExamInProgress2(Long studentId, String ip);
+
     /**
      * 考试心跳
      *
@@ -101,6 +106,6 @@ public interface ExamControlService {
      *
      * @param examRecordDataId
      */
-    void switchScreen(Long examRecordDataId);
+    SwitchScreenCountInfo switchScreen(Long examRecordDataId);
 
 }

+ 14 - 0
examcloud-core-oe-student-service/src/main/java/cn/com/qmth/examcloud/core/oe/student/service/ExamFaceLiveVerifyService.java

@@ -0,0 +1,14 @@
+package cn.com.qmth.examcloud.core.oe.student.service;
+
+import cn.com.qmth.examcloud.core.oe.student.bean.client.FaceLiveVerifyInfo;
+import cn.com.qmth.examcloud.core.oe.student.bean.client.FaceLiveVerifyResult;
+
+public interface ExamFaceLiveVerifyService {
+
+    FaceLiveVerifyInfo startFaceLiveVerify(Long examRecordDataId, Long studentId);
+
+    void saveFaceLiveVerifyResult(FaceLiveVerifyResult req);
+
+    Integer calculateStartFaceVerifyMinute(Long examRecordDataId);
+
+}

+ 2 - 1
examcloud-core-oe-student-service/src/main/java/cn/com/qmth/examcloud/core/oe/student/service/ExamRecordDataService.java

@@ -4,6 +4,7 @@ import cn.com.qmth.examcloud.core.oe.student.api.request.*;
 import cn.com.qmth.examcloud.core.oe.student.api.response.CalcExamScoreResp;
 import cn.com.qmth.examcloud.core.oe.student.api.response.CalcFaceBiopsyResultResp;
 import cn.com.qmth.examcloud.core.oe.student.api.response.CheckPaperInExamResp;
+import cn.com.qmth.examcloud.core.oe.student.bean.client.CourseInfo;
 import cn.com.qmth.examcloud.support.cache.bean.CourseCacheBean;
 import cn.com.qmth.examcloud.support.cache.bean.ExamSettingsCacheBean;
 import cn.com.qmth.examcloud.support.examing.ExamRecordData;
@@ -75,7 +76,7 @@ public interface ExamRecordDataService {
      */
     void updatePartialExamRecord(UpdatePartialExamRecordReq req);
 
-    String findCourseNameById(Long id);
+    CourseInfo getCourseInfo(Long examRecordDataId);
 
     CheckPaperInExamResp checkPaperInExam(CheckPaperInExamReq req);
 

+ 4 - 0
examcloud-core-oe-student-service/src/main/java/cn/com/qmth/examcloud/core/oe/student/service/ExamRecordQuestionsService.java

@@ -36,10 +36,14 @@ public interface ExamRecordQuestionsService {
 
     ExamRecordQuestions createExamRecordQuestions(Long examRecordDataId, DefaultPaper defaultPaper);
 
+    List<ExamQuestion> findExamQuestionList(Long studentId);
+
     ExamRecordQuestions getExamRecordQuestions(Long examRecordDataId);
 
     String getQuestionContent(Long studentId, String questionId);
 
+    String getQuestionContentForClient(Long studentId, String questionId);
+
     void submitQuestionAnswer(Long studentId, List<ExamStudentQuestionInfo> examQuestionInfos, String referer, String agent);
 
     GetExamRecordQuestionsResp getExamRecordQuestions(GetExamRecordQuestionsReq req);

+ 15 - 0
examcloud-core-oe-student-service/src/main/java/cn/com/qmth/examcloud/core/oe/student/service/FaceProcessService.java

@@ -0,0 +1,15 @@
+package cn.com.qmth.examcloud.core.oe.student.service;
+
+import cn.com.qmth.examcloud.core.oe.student.bean.client.FaceCaptureResult;
+import cn.com.qmth.examcloud.core.oe.student.bean.client.FaceCompareResult;
+
+/**
+ * 人脸照片处理相关接口
+ */
+public interface FaceProcessService {
+
+    void saveFaceCompareResult(FaceCompareResult req);
+
+    void saveFaceCaptureResult(FaceCaptureResult req);
+
+}

+ 122 - 33
examcloud-core-oe-student-service/src/main/java/cn/com/qmth/examcloud/core/oe/student/service/impl/ExamControlServiceImpl.java

@@ -25,13 +25,12 @@ import cn.com.qmth.examcloud.core.oe.student.api.request.GetExamRecordQuestionsR
 import cn.com.qmth.examcloud.core.oe.student.api.response.CalcExamScoreResp;
 import cn.com.qmth.examcloud.core.oe.student.api.response.GetExamRecordPaperStructResp;
 import cn.com.qmth.examcloud.core.oe.student.api.response.GetExamRecordQuestionsResp;
+import cn.com.qmth.examcloud.core.oe.student.base.utils.Check;
 import cn.com.qmth.examcloud.core.oe.student.base.utils.CommonUtil;
 import cn.com.qmth.examcloud.core.oe.student.base.utils.QuestionTypeUtil;
 import cn.com.qmth.examcloud.core.oe.student.bean.*;
 import cn.com.qmth.examcloud.core.oe.student.dao.ExamContinuedRecordRepo;
-import cn.com.qmth.examcloud.core.oe.student.dao.ExamRecordDataRepo;
 import cn.com.qmth.examcloud.core.oe.student.dao.entity.ExamContinuedRecordEntity;
-import cn.com.qmth.examcloud.core.oe.student.dao.entity.ExamRecordDataEntity;
 import cn.com.qmth.examcloud.core.oe.student.report.ExamProcessRecordReport;
 import cn.com.qmth.examcloud.core.oe.student.service.*;
 import cn.com.qmth.examcloud.core.oe.task.api.ExamCaptureCloudService;
@@ -57,7 +56,6 @@ import cn.com.qmth.examcloud.support.helper.FaceBiopsyHelper;
 import cn.com.qmth.examcloud.support.redis.RedisKeyHelper;
 import cn.com.qmth.examcloud.web.bootstrap.PropertyHolder;
 import cn.com.qmth.examcloud.web.exception.SequenceLockException;
-import cn.com.qmth.examcloud.web.helpers.GlobalHelper;
 import cn.com.qmth.examcloud.web.helpers.SequenceLockHelper;
 import cn.com.qmth.examcloud.web.redis.RedisClient;
 import cn.com.qmth.examcloud.ws.api.WsCloudService;
@@ -127,6 +125,9 @@ public class ExamControlServiceImpl implements ExamControlService {
     @Autowired
     private ExamFaceLivenessVerifyService examFaceLivenessVerifyService;
 
+    @Autowired
+    private ExamFaceLiveVerifyService examFaceLiveVerifyService;
+
     @Autowired
     private RedisClient redisClient;
 
@@ -139,9 +140,6 @@ public class ExamControlServiceImpl implements ExamControlService {
     @Autowired
     SyncExamDataCloudService syncExamDataCloudService;
 
-    @Autowired
-    private ExamRecordDataRepo examRecordDataRepo;
-
     @Autowired
     private ExamContinuedRecordRepo examContinuedRecordRepo;
 
@@ -159,11 +157,18 @@ public class ExamControlServiceImpl implements ExamControlService {
 
     @Transactional
     @Override
-    public StartExamInfo startExam(Long examStudentId, User user, String ip) {
+    public StartExamInfo startExam(Long examStudentId, Long userId, String ip) {
+        Check.isNull(examStudentId, "examStudentId不能为空");
+        Check.isNull(userId, "userId不能为空");
+
+        String sequenceLockKey = Constants.EXAM_CONTROL_LOCK_PREFIX + userId;
+        // 开始考试上锁,分布式锁,系统在请求结束后会,自动释放锁,无需手动解锁
+        SequenceLockHelper.getLock(sequenceLockKey);
+
         // 开考预处理
-        prepare4Exam(examStudentId, user);
+        prepare4Exam(examStudentId, userId);
 
-        Long studentId = user.getUserId();
+        Long studentId = userId;
         long st = System.currentTimeMillis();
 
         long startTime = System.currentTimeMillis();
@@ -231,7 +236,7 @@ public class ExamControlServiceImpl implements ExamControlService {
         if (FaceBiopsyHelper.isFaceEnable(rootOrgId, examId, studentId)) {
             SaveExamCaptureSyncCompareResultReq req = new SaveExamCaptureSyncCompareResultReq();
             req.setExamRecordDataId(examRecordData.getId());
-            req.setStudentId(user.getUserId());
+            req.setStudentId(userId);
             examCaptureCloudService.saveExamCaptureSyncCompareResult(req);
         }
 
@@ -294,21 +299,27 @@ public class ExamControlServiceImpl implements ExamControlService {
         if (log.isDebugEnabled()) {
             log.debug("10 合计 耗时:" + (System.currentTimeMillis() - st) + " ms");
         }
+
         // 在线考生开考打点
-        ReportsUtil.report(
-                new OnlineExamStudentReport(user.getRootOrgId(), user.getUserId(), examBean.getId(), examStudentId));
+        ReportsUtil.report(new OnlineExamStudentReport(rootOrgId, userId, examBean.getId(), examStudentId));
+
         //考试过程记录(开考)打点
         ReportsUtil.report(
                 new ExamProcessRecordReport(examRecordData.getId(), ExamProcess.START, examRecordData.getEnterExamTime())
         );
 
-        StartExamInfo startExamInfo = buildStartExamInfo(examRecordData.getId(), examingSession, examBean, courseBean);
-        return startExamInfo;
-
+        return buildStartExamInfo(examRecordData.getId(), examingSession, examBean, courseBean);
     }
 
     @Override
-    public StartAnswerInfo startAnswer(Long examRecordDataId) {
+    public StartAnswerInfo startAnswer(Long examRecordDataId, Long userId) {
+        Check.isNull(examRecordDataId, "examRecordDataId不能为空");
+        Check.isNull(userId, "userId不能为空");
+
+        String sequenceLockKey = Constants.EXAM_CONTROL_LOCK_PREFIX + userId;
+        // 开始考试上锁,分布式锁,系统在请求结束后会,自动释放锁,无需手动解锁
+        SequenceLockHelper.getLock(sequenceLockKey);
+
         Date now = new Date();
 
         ExamRecordData examRecordData = examRecordDataService.getExamRecordDataCache(examRecordDataId);
@@ -506,9 +517,9 @@ public class ExamControlServiceImpl implements ExamControlService {
      * 开考预处理
      *
      * @param examStudentId
-     * @param user
+     * @param userId
      */
-    private void prepare4Exam(Long examStudentId, User user) {
+    private void prepare4Exam(Long examStudentId, Long userId) {
         SysPropertyCacheBean stuClientLoginLimit = CacheHelper.getSysProperty("STU_CLIENT_LOGIN_LIMIT");
         Boolean stuClientLoginLimitBoolean = false;
         if (stuClientLoginLimit.getHasValue()) {
@@ -525,11 +536,11 @@ public class ExamControlServiceImpl implements ExamControlService {
         }
 
         Long studentId = examStudent.getStudentId();
-        if (!studentId.equals(user.getUserId().longValue())) {
+        if (!studentId.equals(userId)) {
             throw new StatusException("008003", "考生与当前用户不吻合");
         }
 
-        String examingSessionKey = RedisKeyHelper.getBuilder().examingSessionKey(user.getUserId());
+        String examingSessionKey = RedisKeyHelper.getBuilder().examingSessionKey(userId);
 
         ExamingSession examingSession = redisClient.get(examingSessionKey, ExamingSession.class);
 
@@ -682,6 +693,24 @@ public class ExamControlServiceImpl implements ExamControlService {
         redisClient.set(examBossKey, examBoss, 60);
     }
 
+    @Override
+    public void manualEndExam(Long studentId, String ip) {
+        String sequenceLockKey = Constants.EXAM_CONTROL_LOCK_PREFIX + studentId;
+        //系统在请求结束后会,自动释放锁,无需手动解锁
+        SequenceLockHelper.getLock(sequenceLockKey);
+
+        long startTime = System.currentTimeMillis();
+        ExamingSession examingSession = examingSessionService.getExamingSession(studentId);
+        if (examingSession == null) {
+            throw new StatusException("8010", "无效的会话,请离开考试");
+        }
+
+        this.handInExam(examingSession.getExamRecordDataId(), HandInExamType.MANUAL, ip);
+        if (log.isDebugEnabled()) {
+            log.debug("[manualEndExam] cost " + (System.currentTimeMillis() - startTime) + " ms");
+        }
+    }
+
     /**
      * 交卷
      *
@@ -1132,18 +1161,34 @@ public class ExamControlServiceImpl implements ExamControlService {
      * @param examRecordDataId
      */
     @Override
-    public void switchScreen(Long examRecordDataId) {
-        ExamRecordDataEntity examRecordDataEntity =
-                GlobalHelper.getEntity(examRecordDataRepo, examRecordDataId, ExamRecordDataEntity.class);
-        if (null == examRecordDataEntity) {
+    public SwitchScreenCountInfo switchScreen(Long examRecordDataId) {
+    	ExamRecordData examRecordData = examRecordDataService.getExamRecordDataCache(examRecordDataId);
+        if (null == examRecordData) {
             throw new StatusException("100001", "找不到相关考试记录");
         }
+        
+        ExamingSession examSessionInfo = examingSessionService.getExamingSession(examRecordData.getStudentId());
+        if (examSessionInfo == null
+                || examSessionInfo.getExamingStatus().equals(ExamingStatus.INFORMAL)) {
+            throw new StatusException("101001", "无效的会话,请离开考试");
+        }
+        
+        SwitchScreenCountInfo ret=new SwitchScreenCountInfo();
+        //为开启计算切屏次数的不y
+        if(!examSessionInfo.getRecordSwitchScreen()) {
+        	return ret;
+        }
+        ret.setMaxSwitchScreenCount(examSessionInfo.getMaxSwitchScreenCount());
 
         //更新考试记录缓存
-        ExamRecordData examRecordData = examRecordDataService.getExamRecordDataCache(examRecordDataId);
         int switchScreenCount = null == examRecordData.getSwitchScreenCount() ? 0 : examRecordData.getSwitchScreenCount();
         examRecordData.setSwitchScreenCount(++switchScreenCount);
+        ret.setSwitchScreenCount(examRecordData.getSwitchScreenCount());
+        if(ret.getMaxSwitchScreenCount()!=null&&ret.getSwitchScreenCount()>ret.getMaxSwitchScreenCount()) {
+        	examRecordData.setExceedMaxSwitchScreenCount(true);
+        }
         examRecordDataService.saveExamRecordDataCache(examRecordDataId, examRecordData);
+        return ret;
     }
 
     /**
@@ -1272,7 +1317,7 @@ public class ExamControlServiceImpl implements ExamControlService {
                 }
             }
 
-            //场次禁用,不允许考试 TODO 20200814 跟张莹确认一下,场次被禁用了是不能考试还是使用考试的设置???
+            //场次禁用,不允许考试
             if (examBean.getSpecialSettingsType() == ExamSpecialSettingsType.STAGE_BASED) {
                 if (null != examStageId) {
                     ExamStageCacheBean examStage = CacheHelper.getExamStage(examId, examStageId);
@@ -1495,6 +1540,20 @@ public class ExamControlServiceImpl implements ExamControlService {
      */
     public void initializeExamRecordSession(ExamingSession examSessionInfo, ExamRecordData examRecordData,
                                             final ExamSettingsCacheBean examBean) {
+    	//切屏设置
+    	Boolean isRecordSwitchScreenCount=false;
+    	Integer maxSwitchScreenCount=null;
+    	OrgPropertyCacheBean ss=CacheHelper.getOrgProperty(examRecordData.getRootOrgId(), "PREVENT_CHEATING_CONFIG");
+    	if(ss!=null&&ss.getHasValue()&&ss.getValue().contains("RECORD_SWITCH_SCREEN")) {
+    		isRecordSwitchScreenCount=true;
+    		ExamPropertyCacheBean sc=CacheHelper.getExamProperty(examBean.getId(), ExamProperties.MAX_SWITCH_SCREEN_COUNT.name());
+    		if(sc!=null&&StringUtils.isNotEmpty(sc.getValue())) {
+    			maxSwitchScreenCount=Integer.valueOf(sc.getValue());
+    		}
+    	}
+    	examSessionInfo.setMaxSwitchScreenCount(maxSwitchScreenCount);
+    	examSessionInfo.setRecordSwitchScreen(isRecordSwitchScreenCount);
+    	
         examSessionInfo.setExamRecordDataId(examRecordData.getId());
         //        examSessionInfo.setStartTime(examRecordData.getStartTime().getTime());//调整为在作答页面时赋值
         examSessionInfo.setExamType(examBean.getExamType());
@@ -1521,6 +1580,29 @@ public class ExamControlServiceImpl implements ExamControlService {
         log.debug("11.5 保存考试会话结束 ");
     }
 
+    @Override
+    public ExamProcessResultInfo checkExamInProgress2(Long studentId, String ip) {
+        String sequenceLockKey = Constants.EXAM_CONTROL_LOCK_PREFIX + studentId;
+        // 系统在请求结束后会,自动释放锁,无需手动解锁
+        SequenceLockHelper.getLock(sequenceLockKey);
+
+        ExamProcessResultInfo res = new ExamProcessResultInfo();
+        try {
+            CheckExamInProgressInfo info = this.checkExamInProgress(studentId, ip);
+            res.setCode(Constants.COMMON_SUCCESS_CODE);
+            res.setData(info);
+            return res;
+        } catch (StatusException e) {
+            if (e.getCode().equals(Constants.EXAM_RECORD_NOT_END_STATUS_CODE)) {
+                res.setCode(Constants.PROCESSING_EXAM_RECORD_CODE);
+                return res;
+            }
+            throw e;
+        } catch (Exception e) {
+            throw e;
+        }
+    }
+
     @Override
     public CheckExamInProgressInfo checkExamInProgress(Long studentId, String ip) {
         ExamingSession examSessionInfo = examingSessionService.getExamingSession(studentId);
@@ -1576,18 +1658,20 @@ public class ExamControlServiceImpl implements ExamControlService {
             checkExamInProgressInfo.setExamType(examingRecord.getExamType());
 
             // 断点续考时重新计算活体检测的分钟数
-            Integer faceVerifyMinute = null;
+            Integer faceVerifyMinute;
             FaceBiopsyScheme faceBiopsyScheme = FaceBiopsyHelper.getFaceBiopsyScheme(examSessionInfo.getRootOrgId());
-
-            // 如果是新活体检测方案,则使用新的计算方案计算活检开始时间
-            if (faceBiopsyScheme == FaceBiopsyScheme.NEW) {
+            if (FaceBiopsyScheme.FACE_CLIENT == faceBiopsyScheme) {
+                // C端活体检测方案
+                faceVerifyMinute = examFaceLiveVerifyService.calculateStartFaceVerifyMinute(examRecordDataId);
+            } else if (faceBiopsyScheme == FaceBiopsyScheme.FACE_MOTION) {
+                // Electron Client 自研活体检测方案
                 faceVerifyMinute = faceBiopsyService.calculateFaceBiopsyStartMinute(examRecordDataId);
-            } else {// 非新活检,默认使用旧的活检计算方式
+            } else {
+                // FaceID活体检测方案
                 faceVerifyMinute = examFaceLivenessVerifyService.getFaceLivenessVerifyMinute(
                         examSessionInfo.getRootOrgId(), examSessionInfo.getOrgId(), examSessionInfo.getExamId(),
                         studentId, examSessionInfo.getExamRecordDataId(), (int) usedTime / 60);
             }
-
             checkExamInProgressInfo.setFaceVerifyMinute(faceVerifyMinute);
 
             //考试过程记录(断点)打点
@@ -1601,6 +1685,10 @@ public class ExamControlServiceImpl implements ExamControlService {
             ReportsUtil.report(new ExamProcessRecordReport(examRecordDataId, ExamProcess.CONTINUE, new Date()));
 
             setAndSaveActiveTime(examRecordDataId, ip);
+            
+            checkExamInProgressInfo.setExceedMaxSwitchScreenCount(examingRecord.getExceedMaxSwitchScreenCount());
+            checkExamInProgressInfo.setSwitchScreenCount(examingRecord.getSwitchScreenCount());
+            checkExamInProgressInfo.setMaxSwitchScreenCount(examSessionInfo.getMaxSwitchScreenCount());
 
             return checkExamInProgressInfo;
         }
@@ -1859,7 +1947,8 @@ public class ExamControlServiceImpl implements ExamControlService {
             ExamRecordData examRecordData = examRecordDataService
                     .getExamRecordDataCache(examingSession.getExamRecordDataId());
 
-            if (examRecordData != null && examRecordData.getIsExceed() != null && examRecordData.getIsExceed()) {// 超过断点最大次数的不校验冻结时间
+            if ((examRecordData != null && examRecordData.getIsExceed() != null && examRecordData.getIsExceed())
+            		||examRecordData.getExceedMaxSwitchScreenCount()) {// 超过断点最大次数或超过切屏限制的不校验冻结时间
                 return examUsedMilliSeconds;
             }
             long freezeTime = examingSession.getFreezeTime() * 60 * 1000;

+ 211 - 0
examcloud-core-oe-student-service/src/main/java/cn/com/qmth/examcloud/core/oe/student/service/impl/ExamFaceLiveVerifyServiceImpl.java

@@ -0,0 +1,211 @@
+package cn.com.qmth.examcloud.core.oe.student.service.impl;
+
+import cn.com.qmth.examcloud.commons.exception.StatusException;
+import cn.com.qmth.examcloud.commons.util.JsonMapper;
+import cn.com.qmth.examcloud.core.oe.student.base.utils.Check;
+import cn.com.qmth.examcloud.core.oe.student.base.utils.CommonUtil;
+import cn.com.qmth.examcloud.core.oe.student.bean.client.FaceLiveVerifyInfo;
+import cn.com.qmth.examcloud.core.oe.student.bean.client.FaceLiveVerifyResult;
+import cn.com.qmth.examcloud.core.oe.student.dao.ExamFaceLiveVerifyRepo;
+import cn.com.qmth.examcloud.core.oe.student.dao.entity.ExamFaceLiveVerifyEntity;
+import cn.com.qmth.examcloud.core.oe.student.dao.enums.FaceLiveVerifyStatus;
+import cn.com.qmth.examcloud.core.oe.student.service.ExamFaceLiveVerifyService;
+import cn.com.qmth.examcloud.core.oe.student.service.ExamRecordDataService;
+import cn.com.qmth.examcloud.core.oe.student.service.ExamingSessionService;
+import cn.com.qmth.examcloud.support.Constants;
+import cn.com.qmth.examcloud.support.cache.bean.ExamPropertyCacheBean;
+import cn.com.qmth.examcloud.support.enums.ExamProperties;
+import cn.com.qmth.examcloud.support.enums.ExamRecordStatus;
+import cn.com.qmth.examcloud.support.enums.IsSuccess;
+import cn.com.qmth.examcloud.support.examing.ExamRecordData;
+import cn.com.qmth.examcloud.support.examing.ExamingHeartbeat;
+import cn.com.qmth.examcloud.support.examing.ExamingSession;
+import cn.com.qmth.examcloud.support.helper.ExamCacheTransferHelper;
+import cn.com.qmth.examcloud.support.redis.RedisKeyHelper;
+import cn.com.qmth.examcloud.web.redis.RedisClient;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
+
+import java.util.Date;
+import java.util.List;
+import java.util.Optional;
+
+@Service
+public class ExamFaceLiveVerifyServiceImpl implements ExamFaceLiveVerifyService {
+
+    private static final Logger log = LoggerFactory.getLogger(ExamFaceLiveVerifyServiceImpl.class);
+
+    @Autowired
+    private ExamRecordDataService examRecordDataService;
+
+    @Autowired
+    private ExamingSessionService examingSessionService;
+
+    @Autowired
+    private ExamFaceLiveVerifyRepo examFaceLiveVerifyRepo;
+
+    @Autowired
+    private RedisClient redisClient;
+
+    @Override
+    public FaceLiveVerifyInfo startFaceLiveVerify(Long examRecordDataId, Long studentId) {
+        Check.isNull(studentId, "学生ID不能为空");
+        Check.isNull(examRecordDataId, "考试记录ID不能为空");
+
+        ExamRecordData examRecordData = examRecordDataService.getExamRecordDataCache(examRecordDataId);
+        if (examRecordData == null || ExamRecordStatus.EXAM_ING != examRecordData.getExamRecordStatus()) {
+            throw new StatusException("考试记录无效");
+        }
+
+        ExamingSession examingSession = examingSessionService.getExamingSession(studentId);
+        if (examingSession == null) {
+            throw new StatusException("考试会话已过期");
+        }
+
+        int times = 1;
+        Long faceLiveVerifyId = null;
+        List<ExamFaceLiveVerifyEntity> entities = examFaceLiveVerifyRepo.findByExamRecordDataId(examRecordData.getId());
+        if (!CollectionUtils.isEmpty(entities)) {
+            for (ExamFaceLiveVerifyEntity entity : entities) {
+                if (entity.getFinished()) {
+                    times++;
+                } else {
+                    faceLiveVerifyId = entity.getId();
+                }
+            }
+        }
+
+        if (times > 2) {
+            // 返回空对象供前端判断
+            log.warn("活检次数已超过2次!examRecordDataId = {}, studentId = {}", examRecordDataId, studentId);
+            FaceLiveVerifyInfo info = new FaceLiveVerifyInfo();
+            info.setTimes(times);
+            return info;
+        }
+
+        if (faceLiveVerifyId == null) {
+            ExamFaceLiveVerifyEntity entity = new ExamFaceLiveVerifyEntity();
+            entity.setExamRecordDataId(examRecordDataId);
+            entity.setFinished(false);
+            entity.setStatus(FaceLiveVerifyStatus.ERROR);
+            examFaceLiveVerifyRepo.save(entity);
+            faceLiveVerifyId = entity.getId();
+        }
+
+        Integer startMinute = this.calculateStartFaceVerifyMinute(examRecordDataId);
+
+        FaceLiveVerifyInfo info = new FaceLiveVerifyInfo();
+        info.setFaceLiveVerifyId(faceLiveVerifyId);
+        info.setStartMinute(startMinute);
+        info.setTimes(times);
+        return info;
+    }
+
+    @Override
+    public void saveFaceLiveVerifyResult(FaceLiveVerifyResult req) {
+        Check.isNull(req.getStudentId(), "学生ID不能为空");
+        Check.isNull(req.getFaceLiveVerifyId(), "当前活检记录ID不能为空");
+        Check.isNull(req.getExamRecordDataId(), "考试记录ID不能为空");
+        Check.isNull(req.getStatus(), "人脸活体验证状态不能为空");
+        Check.isEmpty(req.getActions(), "动作验证列表不能为空");
+
+        ExamRecordData examRecordData = examRecordDataService.getExamRecordDataCache(req.getExamRecordDataId());
+        if (examRecordData == null || ExamRecordStatus.EXAM_ING != examRecordData.getExamRecordStatus()) {
+            log.warn("考试记录无效!examRecordDataId = {}, studentId = {}", req.getExamRecordDataId(), req.getStudentId());
+            return;
+        }
+
+        ExamingSession examingSession = examingSessionService.getExamingSession(req.getStudentId());
+        if (examingSession == null) {
+            log.warn("考试会话已过期!examRecordDataId = {}, studentId = {}", req.getExamRecordDataId(), req.getStudentId());
+            return;
+        }
+
+        Optional<ExamFaceLiveVerifyEntity> optional = examFaceLiveVerifyRepo.findById(req.getFaceLiveVerifyId());
+        if (!optional.isPresent()) {
+            log.warn("当前活检记录不存在!faceLiveVerifyId = {}", req.getFaceLiveVerifyId());
+            return;
+        }
+
+        ExamFaceLiveVerifyEntity entity = optional.get();
+        if (entity.getFinished()) {
+            log.warn("当前活检记录已完成!faceLiveVerifyId = {}", req.getFaceLiveVerifyId());
+            return;
+        }
+
+        entity.setFinished(true);
+        entity.setStatus(req.getStatus());
+        entity.setFaceCount(req.getFaceCount());
+        entity.setSimilarity(req.getSimilarity());
+        entity.setRealness(req.getRealness());
+        entity.setErrorMsg(req.getErrorMsg());
+        entity.setProcessTime(req.getProcessTime() != null ? req.getProcessTime() : 1L);
+        entity.setActions(new JsonMapper().toJson(req.getActions()));
+        entity.setUpdateTime(new Date());
+        examFaceLiveVerifyRepo.save(entity);
+
+        examRecordData.setFaceVerifyResult(FaceLiveVerifyStatus.SUCCESS == req.getStatus() ? IsSuccess.SUCCESS : IsSuccess.FAILED);
+        if (FaceLiveVerifyStatus.SUCCESS != req.getStatus()) {
+            // 活检失败,则认为违纪
+            examRecordData.setIsIllegality(true);
+        }
+        examRecordDataService.saveExamRecordDataCache(req.getExamRecordDataId(), examRecordData);
+    }
+
+    @Override
+    public Integer calculateStartFaceVerifyMinute(Long examRecordDataId) {
+        ExamRecordData examRecordData = examRecordDataService.getExamRecordDataCache(examRecordDataId);
+        if (examRecordData == null || ExamRecordStatus.EXAM_ING != examRecordData.getExamRecordStatus()) {
+            throw new StatusException("考试记录无效");
+        }
+
+        ExamingSession examingSession = examingSessionService.getExamingSession(examRecordData.getStudentId());
+        if (examingSession == null) {
+            throw new StatusException("考试会话已过期");
+        }
+
+        // 活体检测开始分钟数
+        int faceVerifyStartMinute = this.getExamPropertyValue(examRecordData.getExamId(),
+                examRecordData.getStudentId(), ExamProperties.FACE_VERIFY_START_MINUTE);
+
+        // 活体检测结束分钟数(默认 <= 交卷冻结时间)
+        int faceVerifyEndMinute = this.getExamPropertyValue(examRecordData.getExamId(),
+                examRecordData.getStudentId(), ExamProperties.FACE_VERIFY_END_MINUTE);
+
+        String examingHeartbeatKey = RedisKeyHelper.getBuilder().examingHeartbeatKey(examRecordDataId);
+        ExamingHeartbeat examingHeartbeat = redisClient.get(examingHeartbeatKey, ExamingHeartbeat.class);
+
+        // 考试已用时间
+        int usedMinute = 0;
+        if (examingHeartbeat != null) {
+            usedMinute = Math.toIntExact(examingHeartbeat.getCost() / 60L);
+        }
+
+        if (usedMinute < faceVerifyStartMinute) {
+            // 考试已用时间 尚未达到 配置的活体检测开始分钟数时,结果 = Random(开始分钟数-结束分钟数)-考试已用时间
+            return CommonUtil.calculationRandomNumber(faceVerifyStartMinute, faceVerifyEndMinute) - usedMinute;
+        } else if (usedMinute >= faceVerifyStartMinute && usedMinute < faceVerifyEndMinute) {
+            // 考试已用时间 介于 配置的活体检测开始和结束分钟数中间时,结果 = Random(考试已用时间-结束分钟数)-考试已用时间,默认最小1分钟
+            int faceVerifyMinute = CommonUtil.calculationRandomNumber(usedMinute, faceVerifyEndMinute) - usedMinute;
+            return faceVerifyMinute > 1 ? faceVerifyMinute : 1;
+        } else if (usedMinute >= faceVerifyEndMinute) {
+            // 考试已用时间 大于 配置的活体检测结束分钟数时,结果 = Random(1,4)
+            return CommonUtil.calculationRandomNumber(1, Constants.MAX_FACE_LIVE_VERIFY_MINUTE);
+        }
+
+        return null;
+    }
+
+    private int getExamPropertyValue(Long examId, Long studentId, ExamProperties propKey) {
+        ExamPropertyCacheBean property = ExamCacheTransferHelper.getCachedExamProperty(examId, studentId, propKey.name());
+        if (property == null || StringUtils.isEmpty(property.getValue())) {
+            throw new StatusException(propKey.getDesc() + "未设置");
+        }
+        return Integer.parseInt(property.getValue());
+    }
+
+}

+ 10 - 7
examcloud-core-oe-student-service/src/main/java/cn/com/qmth/examcloud/core/oe/student/service/impl/ExamFaceLivenessVerifyServiceImpl.java

@@ -1,6 +1,7 @@
 package cn.com.qmth.examcloud.core.oe.student.service.impl;
 
 import cn.com.qmth.examcloud.commons.exception.StatusException;
+import cn.com.qmth.examcloud.commons.util.FileUtil;
 import cn.com.qmth.examcloud.commons.util.PathUtil;
 import cn.com.qmth.examcloud.core.oe.student.base.utils.CommonUtil;
 import cn.com.qmth.examcloud.core.oe.student.base.utils.FileDisposeUtil;
@@ -13,6 +14,7 @@ import cn.com.qmth.examcloud.core.oe.student.service.ExamControlService;
 import cn.com.qmth.examcloud.core.oe.student.service.ExamFaceLivenessVerifyService;
 import cn.com.qmth.examcloud.core.oe.student.service.ExamRecordDataService;
 import cn.com.qmth.examcloud.core.oe.student.service.ExamingSessionService;
+import cn.com.qmth.examcloud.support.Constants;
 import cn.com.qmth.examcloud.support.cache.CacheHelper;
 import cn.com.qmth.examcloud.support.cache.bean.StudentCacheBean;
 import cn.com.qmth.examcloud.support.cache.bean.SysPropertyCacheBean;
@@ -72,11 +74,6 @@ public class ExamFaceLivenessVerifyServiceImpl implements ExamFaceLivenessVerify
     @Autowired
     SystemProperties systemProperties;
 
-    /**
-     * 第二次人脸检测时间范围
-     */
-    private static final int secondFaceCheckMinute = 4;
-
     @Override
     public ExamFaceLivenessVerifyEntity saveFaceVerify(Long examRecordDataId) {
         ExamFaceLivenessVerifyEntity faceVerify = new ExamFaceLivenessVerifyEntity();
@@ -163,7 +160,9 @@ public class ExamFaceLivenessVerifyServiceImpl implements ExamFaceLivenessVerify
         try {
             httpClient = HttpPoolUtil.getHttpClient();
             HttpPost httpPost = new HttpPost(PropertyHolder.getString("app.faceid.get_token_url"));
+
             File basePhotoFile = getStudentBasePhotoFile(studentId);
+
             MultipartEntityBuilder multipartEntityBuilder = getMultipartEntityBuilder(bizNo, basePhotoFile);
             HttpEntity httpEntity = multipartEntityBuilder.build();
             httpPost.setEntity(httpEntity);
@@ -301,6 +300,10 @@ public class ExamFaceLivenessVerifyServiceImpl implements ExamFaceLivenessVerify
         String photoUrl = studentBean.getPhotoPath();
         String photoName = systemProperties.getDataDir() + "/" + photoUrl.substring(photoUrl.lastIndexOf("/") + 1);
         photoName = PathUtil.getCanonicalPath(photoName);
+
+        // 文件夹不存在时创建
+        FileUtil.makeDirs(systemProperties.getDataDir());
+
         FileDisposeUtil.saveUrlAs(photoUrl, photoName);
         return new File(photoName);
     }
@@ -333,14 +336,14 @@ public class ExamFaceLivenessVerifyServiceImpl implements ExamFaceLivenessVerify
                 }
                 //case3如果考试已用时间>配置结束时间,则默认random(1,4)分钟后开始人脸检测
                 else if (usedMinute >= faceVerifyEndMinute) {
-                    return CommonUtil.calculationRandomNumber(1, secondFaceCheckMinute);
+                    return CommonUtil.calculationRandomNumber(1, Constants.MAX_FACE_LIVE_VERIFY_MINUTE);
                 }
             } else if (faceLivenessVerifys.size() == 1) {
                 //如果已经人脸检测过一次且未成功,再安排一次检测
                 ExamFaceLivenessVerifyEntity faceVerify = faceLivenessVerifys.get(0);
                 if (faceVerify.getVerifyResult() == null
                         || faceVerify.getVerifyResult() != FaceVerifyResult.VERIFY_SUCCESS) {
-                    return CommonUtil.calculationRandomNumber(1, secondFaceCheckMinute);
+                    return CommonUtil.calculationRandomNumber(1, Constants.MAX_FACE_LIVE_VERIFY_MINUTE);
                 }
             }
         }

+ 64 - 9
examcloud-core-oe-student-service/src/main/java/cn/com/qmth/examcloud/core/oe/student/service/impl/ExamRecordDataServiceImpl.java

@@ -4,17 +4,25 @@ import cn.com.qmth.examcloud.api.commons.enums.ExamSpecialSettingsType;
 import cn.com.qmth.examcloud.api.commons.enums.ExamStageStartExamStatus;
 import cn.com.qmth.examcloud.api.commons.enums.ExamType;
 import cn.com.qmth.examcloud.commons.exception.StatusException;
+import cn.com.qmth.examcloud.core.basic.api.CourseCloudService;
+import cn.com.qmth.examcloud.core.basic.api.bean.CourseBean;
+import cn.com.qmth.examcloud.core.basic.api.request.GetCoursesByIdListReq;
+import cn.com.qmth.examcloud.core.basic.api.response.GetCoursesByIdListResp;
 import cn.com.qmth.examcloud.core.oe.student.api.request.*;
 import cn.com.qmth.examcloud.core.oe.student.api.response.CalcExamScoreResp;
 import cn.com.qmth.examcloud.core.oe.student.api.response.CalcFaceBiopsyResultResp;
 import cn.com.qmth.examcloud.core.oe.student.api.response.CheckPaperInExamResp;
 import cn.com.qmth.examcloud.core.oe.student.base.utils.QuestionTypeUtil;
+import cn.com.qmth.examcloud.core.oe.student.bean.client.CourseInfo;
+import cn.com.qmth.examcloud.core.oe.student.dao.ExamFaceLiveVerifyRepo;
 import cn.com.qmth.examcloud.core.oe.student.dao.ExamFaceLivenessVerifyRepo;
 import cn.com.qmth.examcloud.core.oe.student.dao.ExamRecordDataRepo;
 import cn.com.qmth.examcloud.core.oe.student.dao.FaceBiopsyRepo;
+import cn.com.qmth.examcloud.core.oe.student.dao.entity.ExamFaceLiveVerifyEntity;
 import cn.com.qmth.examcloud.core.oe.student.dao.entity.ExamFaceLivenessVerifyEntity;
 import cn.com.qmth.examcloud.core.oe.student.dao.entity.ExamRecordDataEntity;
 import cn.com.qmth.examcloud.core.oe.student.dao.entity.FaceBiopsyEntity;
+import cn.com.qmth.examcloud.core.oe.student.dao.enums.FaceLiveVerifyStatus;
 import cn.com.qmth.examcloud.core.oe.student.dao.enums.FaceVerifyResult;
 import cn.com.qmth.examcloud.core.oe.student.service.ExamRecordDataService;
 import cn.com.qmth.examcloud.core.oe.student.service.ExamRecordQuestionsService;
@@ -35,10 +43,12 @@ import cn.com.qmth.examcloud.support.examing.ExamingSession;
 import cn.com.qmth.examcloud.support.helper.FaceBiopsyHelper;
 import cn.com.qmth.examcloud.support.redis.RedisKeyHelper;
 import cn.com.qmth.examcloud.web.redis.RedisClient;
+import com.google.common.collect.Lists;
 import org.apache.commons.lang3.StringUtils;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.CollectionUtils;
 
 import java.math.BigDecimal;
 import java.text.DecimalFormat;
@@ -68,12 +78,18 @@ public class ExamRecordDataServiceImpl implements ExamRecordDataService {
     @Autowired
     private ExamFaceLivenessVerifyRepo examFaceLivenessVerifyRepo;
 
+    @Autowired
+    private ExamFaceLiveVerifyRepo examFaceLiveVerifyRepo;
+
     @Autowired
     private ExamRecordQuestionsService examRecordQuestionsService;
 
     @Autowired
     private ExamStageCloudService examStageCloudService;
 
+    @Autowired
+    private CourseCloudService courseCloudService;
+
     @Transactional
     @Override
     public ExamRecordData createExamRecordData(ExamingSession examingSession, ExamSettingsCacheBean examBean,
@@ -123,6 +139,7 @@ public class ExamRecordDataServiceImpl implements ExamRecordDataService {
         }
 
         ExamRecordData bean = of(examRecordData);
+        bean.setExceedMaxSwitchScreenCount(false);
         //存入redis
         saveExamRecordDataCache(examRecordData.getId(), bean);
         return bean;
@@ -263,8 +280,29 @@ public class ExamRecordDataServiceImpl implements ExamRecordDataService {
             //是否进行活体检测
             if (FaceBiopsyHelper.isFaceVerify(rootOrgId, examId, studentId)) {
                 FaceBiopsyScheme faceBiopsyScheme = FaceBiopsyHelper.getFaceBiopsyScheme(rootOrgId);
-                //新活体检测方案
-                if (faceBiopsyScheme == FaceBiopsyScheme.NEW) {
+
+                if (FaceBiopsyScheme.FACE_CLIENT == faceBiopsyScheme) {
+                    // C端活体检测方案
+                    List<ExamFaceLiveVerifyEntity> entities = examFaceLiveVerifyRepo.findByExamRecordDataId(examRecordData.getId());
+                    if (CollectionUtils.isEmpty(entities)) {
+                        return new CalcFaceBiopsyResultResp(true, true, IsSuccess.FAILED);
+                    }
+
+                    // 最后一次活检成功,则认为成功
+                    ExamFaceLiveVerifyEntity entity = entities.stream().max((a, b) -> {
+                        if (a.getId() > b.getId()) {
+                            return 1;
+                        }
+                        return -1;
+                    }).get();
+
+                    if (FaceLiveVerifyStatus.SUCCESS == entity.getStatus()) {
+                        return new CalcFaceBiopsyResultResp(IsSuccess.SUCCESS);
+                    }
+
+                    return new CalcFaceBiopsyResultResp(true, true, IsSuccess.FAILED);
+                } else if (faceBiopsyScheme == FaceBiopsyScheme.FACE_MOTION) {
+                    // Electron Client 自研活体检测方案
                     FaceBiopsyEntity faceBiopsy = faceBiopsyRepo.findByExamRecordDataId(examRecordData.getId());
 
                     //如果活检结果最终为true,只需要更新活检状态即可
@@ -273,11 +311,9 @@ public class ExamRecordDataServiceImpl implements ExamRecordDataService {
                     }
 
                     return new CalcFaceBiopsyResultResp(true, true, IsSuccess.FAILED);
-                }
-                //旧活体检测方案
-                else {
-                    List<ExamFaceLivenessVerifyEntity> faceVerifies =
-                            examFaceLivenessVerifyRepo.findByExamRecordDataIdOrderById(examRecordData.getId());
+                } else {
+                    // FaceID活体检测方案
+                    List<ExamFaceLivenessVerifyEntity> faceVerifies = examFaceLivenessVerifyRepo.findByExamRecordDataIdOrderById(examRecordData.getId());
 
                     if (null != faceVerifies && faceVerifies.size() > 0) {
                         ExamFaceLivenessVerifyEntity latestFaceVerify = faceVerifies.get(faceVerifies.size() - 1);
@@ -292,6 +328,7 @@ public class ExamRecordDataServiceImpl implements ExamRecordDataService {
                     return new CalcFaceBiopsyResultResp(true, true, IsSuccess.FAILED);
                 }
             }
+
             return new CalcFaceBiopsyResultResp();
         }
     }
@@ -372,8 +409,26 @@ public class ExamRecordDataServiceImpl implements ExamRecordDataService {
     }
 
     @Override
-    public String findCourseNameById(Long id) {
-        return examRecordDataRepo.findCourseNameById(id);
+    public CourseInfo getCourseInfo(Long examRecordDataId) {
+        Long courseId = examRecordDataRepo.findCourseIdByExamRecordDataId(examRecordDataId);
+        if (courseId == null) {
+            throw new StatusException("考试记录不存在!");
+        }
+
+        GetCoursesByIdListReq req = new GetCoursesByIdListReq();
+        req.setCourseIdList(Lists.newArrayList(courseId));
+        GetCoursesByIdListResp resp = courseCloudService.getCoursesByIdList(req);
+        if (CollectionUtils.isEmpty(resp.getCourseList())) {
+            throw new StatusException("课程不存在!");
+        }
+
+        CourseBean bean = resp.getCourseList().get(0);
+
+        CourseInfo info = new CourseInfo();
+        info.setCourseId(bean.getId());
+        info.setCourseCode(bean.getCode());
+        info.setCourseName(bean.getName());
+        return info;
     }
 
     /**

+ 2 - 0
examcloud-core-oe-student-service/src/main/java/cn/com/qmth/examcloud/core/oe/student/service/impl/ExamRecordPaperStructServiceImpl.java

@@ -3,6 +3,7 @@ package cn.com.qmth.examcloud.core.oe.student.service.impl;
 import cn.com.qmth.examcloud.commons.exception.StatusException;
 import cn.com.qmth.examcloud.core.oe.student.api.request.GetExamRecordPaperStructReq;
 import cn.com.qmth.examcloud.core.oe.student.api.response.GetExamRecordPaperStructResp;
+import cn.com.qmth.examcloud.core.oe.student.base.utils.Check;
 import cn.com.qmth.examcloud.core.oe.student.service.ExamRecordPaperStructService;
 import cn.com.qmth.examcloud.support.examing.ExamRecordPaperStruct;
 import cn.com.qmth.examcloud.support.redis.RedisKeyHelper;
@@ -25,6 +26,7 @@ public class ExamRecordPaperStructServiceImpl implements ExamRecordPaperStructSe
 
     @Override
     public ExamRecordPaperStruct getExamRecordPaperStruct(Long examRecordDataId) {
+        Check.isNull(examRecordDataId, "examRecordDataId不能为空");
         String key = RedisKeyHelper.getBuilder().studentPaperKey(examRecordDataId);
         return redisClient.get(key, ExamRecordPaperStruct.class);
     }

+ 91 - 2
examcloud-core-oe-student-service/src/main/java/cn/com/qmth/examcloud/core/oe/student/service/impl/ExamRecordQuestionsServiceImpl.java

@@ -6,6 +6,7 @@ import cn.com.qmth.examcloud.commons.util.JsonUtil;
 import cn.com.qmth.examcloud.core.oe.student.api.bean.StuExamQuestionBean;
 import cn.com.qmth.examcloud.core.oe.student.api.request.GetExamRecordQuestionsReq;
 import cn.com.qmth.examcloud.core.oe.student.api.response.GetExamRecordQuestionsResp;
+import cn.com.qmth.examcloud.core.oe.student.base.utils.Check;
 import cn.com.qmth.examcloud.core.oe.student.bean.ExamStudentQuestionInfo;
 import cn.com.qmth.examcloud.core.oe.student.dao.ExamRecordQuestionTempRepo;
 import cn.com.qmth.examcloud.core.oe.student.dao.entity.ExamQuestionTempEntity;
@@ -16,18 +17,23 @@ import cn.com.qmth.examcloud.question.commons.core.paper.DefaultPaper;
 import cn.com.qmth.examcloud.question.commons.core.paper.DefaultQuestionGroup;
 import cn.com.qmth.examcloud.question.commons.core.paper.DefaultQuestionStructureWrapper;
 import cn.com.qmth.examcloud.question.commons.core.paper.DefaultQuestionUnitWrapper;
+import cn.com.qmth.examcloud.question.commons.core.question.DefaultQuestion;
 import cn.com.qmth.examcloud.question.commons.core.question.DefaultQuestionStructure;
 import cn.com.qmth.examcloud.question.commons.core.question.DefaultQuestionUnit;
 import cn.com.qmth.examcloud.support.Constants;
 import cn.com.qmth.examcloud.support.cache.CacheHelper;
 import cn.com.qmth.examcloud.support.cache.bean.QuestionCacheBean;
 import cn.com.qmth.examcloud.support.examing.*;
+import cn.com.qmth.examcloud.support.handler.QuestionBodyHandler;
 import cn.com.qmth.examcloud.support.redis.RedisKeyHelper;
 import cn.com.qmth.examcloud.web.helpers.GlobalHelper;
 import cn.com.qmth.examcloud.web.redis.RedisClient;
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheBuilder;
+import org.apache.commons.collections4.CollectionUtils;
 import org.apache.commons.lang.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 
@@ -40,6 +46,8 @@ import java.util.concurrent.TimeUnit;
 @Service("examRecordQuestionsService")
 public class ExamRecordQuestionsServiceImpl implements ExamRecordQuestionsService {
 
+    private static final Logger log = LoggerFactory.getLogger(ExamRecordQuestionsServiceImpl.class);
+
     /**
      * 考生作答内容长度界限,超过此长度的答案内容存入mongo
      */
@@ -120,6 +128,29 @@ public class ExamRecordQuestionsServiceImpl implements ExamRecordQuestionsServic
         return examRecordQuestions;
     }
 
+    @Override
+    public List<ExamQuestion> findExamQuestionList(Long studentId) {
+        ExamingSession examSessionInfo = examingSessionService.getExamingSession(studentId);
+        if (examSessionInfo == null || examSessionInfo.getExamingStatus().equals(ExamingStatus.INFORMAL)) {
+            throw new StatusException("1001", "考试会话已过期,请重新开考");
+        }
+
+        String examingHeartbeatKey = RedisKeyHelper.getBuilder().examingHeartbeatKey(examSessionInfo.getExamRecordDataId());
+        ExamingHeartbeat examingHeartbeat = redisClient.get(examingHeartbeatKey, ExamingHeartbeat.class);
+        if (null != examingHeartbeat
+                && examingHeartbeat.getCost() >= examSessionInfo.getExamDuration()) {
+            throw new StatusException("1001", "考试会话已过期,请重新开考");
+        }
+
+        ExamRecordQuestions recordQuestions = this.getExamRecordQuestions(examSessionInfo.getExamRecordDataId());
+        List<ExamQuestion> examQuestionList = recordQuestions.getExamQuestions();
+        for (ExamQuestion examQuestion : examQuestionList) {
+            examQuestion.setCorrectAnswer(null);
+            examQuestion.setStudentScore(null);
+        }
+        return examQuestionList;
+    }
+
     @Override
     public ExamRecordQuestions getExamRecordQuestions(Long examRecordDataId) {
         ExamRecordData ed = examRecordDataService.getExamRecordDataCache(examRecordDataId);
@@ -147,6 +178,8 @@ public class ExamRecordQuestionsServiceImpl implements ExamRecordQuestionsServic
 
     @Override
     public String getQuestionContent(Long studentId, String questionId) {
+        Check.isBlank(questionId, "questionId不能为空");
+
         ExamingSession examSessionInfo = examingSessionService.getExamingSession(studentId);
         if (examSessionInfo == null
                 || examSessionInfo.getExamingStatus().equals(ExamingStatus.INFORMAL)) {
@@ -164,9 +197,54 @@ public class ExamRecordQuestionsServiceImpl implements ExamRecordQuestionsServic
         } else {// 如果本地缓存不存在,则从redis中获取, 并在本地缓存存在存储一份
             QuestionCacheBean getQuestionResp = CacheHelper.getQuestion(examSessionInfo.getExamId(),
                     examSessionInfo.getCourseCode(), examSessionInfo.getPaperType(), questionId);
-            DefaultQuestionStructure questionStructure = getQuestionResp.getDefaultQuestion().getMasterVersion();
+
+            DefaultQuestion defaultQuestion = getQuestionResp.getDefaultQuestion();
+            DefaultQuestionStructure questionStructure = defaultQuestion.getMasterVersion();
+            List<DefaultQuestionUnit> questionUnits = questionStructure.getQuestionUnitList();
+
+            // 在线考试,清除答案
+            if (ExamType.ONLINE.name().equals(examSessionInfo.getExamType())
+                    || ExamType.ONLINE_HOMEWORK.name().equals(examSessionInfo.getExamType())) {
+                for (DefaultQuestionUnit questionUnit : questionUnits) {
+                    questionUnit.setRightAnswer(null);
+                }
+            }
+            String resultJson = JsonUtil.toJson(questionStructure);
+            questionContentCache.put(cacheKey, resultJson);
+            return resultJson;
+        }
+    }
+
+    @Override
+    public String getQuestionContentForClient(Long studentId, String questionId) {
+        Check.isBlank(questionId, "questionId不能为空");
+        Check.isNull(studentId, "studentId不能为空");
+
+        ExamingSession examSessionInfo = examingSessionService.getExamingSession(studentId);
+        if (examSessionInfo == null
+                || examSessionInfo.getExamingStatus().equals(ExamingStatus.INFORMAL)) {
+            throw new StatusException("2001", "考试已结束,不允许获取试题内容");
+        }
+
+        // 本地缓存key规则:examId_courseCode_paperType_questionId
+        String cacheKey = "CLIENT_" + examSessionInfo.getExamId() + "_" + examSessionInfo.getCourseCode() + "_"
+                + examSessionInfo.getPaperType() + "_" + questionId;
+        String contentJson = questionContentCache.getIfPresent(cacheKey);
+
+        // 如果本地缓存中存在,则从本地缓存中获取
+        if (StringUtils.isNotEmpty(contentJson)) {
+            return contentJson;
+        } else {// 如果本地缓存不存在,则从redis中获取, 并在本地缓存存在存储一份
+            QuestionCacheBean getQuestionResp = CacheHelper.getQuestion(examSessionInfo.getExamId(),
+                    examSessionInfo.getCourseCode(), examSessionInfo.getPaperType(), questionId);
+
+            DefaultQuestion defaultQuestion = getQuestionResp.getDefaultQuestion();
+            DefaultQuestionStructure questionStructure = defaultQuestion.getMasterVersion();
             List<DefaultQuestionUnit> questionUnits = questionStructure.getQuestionUnitList();
 
+            // 将题干、选项等 HTML结构转换为“富文本”JSON结构
+            QuestionBodyHandler.convertRichText(defaultQuestion);
+
             // 在线考试,清除答案
             if (ExamType.ONLINE.name().equals(examSessionInfo.getExamType())
                     || ExamType.ONLINE_HOMEWORK.name().equals(examSessionInfo.getExamType())) {
@@ -182,6 +260,17 @@ public class ExamRecordQuestionsServiceImpl implements ExamRecordQuestionsServic
 
     @Override
     public void submitQuestionAnswer(Long studentId, List<ExamStudentQuestionInfo> examQuestionInfos, String referer, String agent) {
+        if (CollectionUtils.isEmpty(examQuestionInfos)) {
+            log.warn("submitQuestionAnswer is empty. studentId = {} ", studentId);
+            return;
+        }
+
+        for (ExamStudentQuestionInfo examStudentQuestionInfo : examQuestionInfos) {
+            if (examStudentQuestionInfo.getOrder() == null) {
+                throw new StatusException("2001", "题目序号不能为空");
+            }
+        }
+
         ExamingSession examSessionInfo = examingSessionService.getExamingSession(studentId);
         if (examSessionInfo == null
                 || examSessionInfo.getExamingStatus().equals(ExamingStatus.INFORMAL)) {
@@ -189,7 +278,7 @@ public class ExamRecordQuestionsServiceImpl implements ExamRecordQuestionsServic
         }
         long examRecordDataId = examSessionInfo.getExamRecordDataId();
 
-        if (StringUtils.isEmpty(referer) || StringUtils.isEmpty(agent) || !agent.contains("electron-exam-shell")) {
+        if (StringUtils.isEmpty(referer) || StringUtils.isEmpty(agent) || !agent.contains(Constants.ELECTRON_EXAM_SHELL)) {
             String cacheKey = Constants.OE_DISCIPLINE_ILLEGAL_DATA + examSessionInfo.getExamRecordDataId();
             redisClient.set(cacheKey, true, 6 * 60 * 60);
         }

+ 119 - 0
examcloud-core-oe-student-service/src/main/java/cn/com/qmth/examcloud/core/oe/student/service/impl/FaceProcessServiceImpl.java

@@ -0,0 +1,119 @@
+package cn.com.qmth.examcloud.core.oe.student.service.impl;
+
+import cn.com.qmth.examcloud.commons.util.FileUtil;
+import cn.com.qmth.examcloud.core.oe.student.base.bean.CompareFaceSyncInfo;
+import cn.com.qmth.examcloud.core.oe.student.base.utils.Check;
+import cn.com.qmth.examcloud.core.oe.student.bean.client.FaceCaptureResult;
+import cn.com.qmth.examcloud.core.oe.student.bean.client.FaceCompareResult;
+import cn.com.qmth.examcloud.core.oe.student.dao.ExamCaptureRepo;
+import cn.com.qmth.examcloud.core.oe.student.dao.entity.ExamCaptureEntity;
+import cn.com.qmth.examcloud.core.oe.student.service.ExamRecordDataService;
+import cn.com.qmth.examcloud.core.oe.student.service.ExamingSessionService;
+import cn.com.qmth.examcloud.core.oe.student.service.FaceProcessService;
+import cn.com.qmth.examcloud.support.Constants;
+import cn.com.qmth.examcloud.support.enums.ExamRecordStatus;
+import cn.com.qmth.examcloud.support.examing.ExamRecordData;
+import cn.com.qmth.examcloud.support.examing.ExamingSession;
+import cn.com.qmth.examcloud.web.redis.RedisClient;
+import org.apache.commons.lang3.StringUtils;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+/**
+ * 人脸照片处理相关接口
+ */
+@Service
+public class FaceProcessServiceImpl implements FaceProcessService {
+
+    private static final Logger log = LoggerFactory.getLogger(FaceProcessServiceImpl.class);
+
+    @Autowired
+    private ExamRecordDataService examRecordDataService;
+
+    @Autowired
+    private ExamingSessionService examingSessionService;
+
+    @Autowired
+    private ExamCaptureRepo examCaptureRepo;
+
+    @Autowired
+    private RedisClient redisClient;
+
+    @Override
+    public void saveFaceCompareResult(FaceCompareResult req) {
+        Check.isNull(req.getStudentId(), "学生ID不能为空");
+        Check.isNull(req.getPass(), "人脸比对是否通过不能为空");
+        Check.isBlank(req.getFileUrl(), "图片地址不能为空");
+
+        CompareFaceSyncInfo info = new CompareFaceSyncInfo();
+        info.setStudentId(req.getStudentId());
+        info.setIsPass(req.getPass());
+        info.setIsStranger(req.getStranger());
+        info.setFileName(FileUtil.getFileName(req.getFileUrl()));
+        info.setFileUrl(req.getFileUrl());
+        info.setFaceCompareResult(req.getFaceCompareResult());
+        info.setProcessTime(req.getProcessTime() != null ? req.getProcessTime() : 1L);
+
+        redisClient.set(Constants.FACE_SYNC_COMPARE_RESULT_PREFIX + req.getStudentId(), info, 5 * 60);
+    }
+
+    @Override
+    public void saveFaceCaptureResult(FaceCaptureResult req) {
+        Check.isNull(req.getStudentId(), "学生ID不能为空");
+        Check.isNull(req.getExamRecordDataId(), "考试记录ID不能为空");
+        Check.isNull(req.getPass(), "人脸比对是否通过不能为空");
+        Check.isBlank(req.getFileUrl(), "图片地址不能为空");
+
+        ExamRecordData examRecordData = examRecordDataService.getExamRecordDataCache(req.getExamRecordDataId());
+        if (examRecordData == null || ExamRecordStatus.EXAM_ING != examRecordData.getExamRecordStatus()) {
+            log.warn("考试记录无效!examRecordDataId = {}, studentId = {}", req.getExamRecordDataId(), req.getStudentId());
+            return;
+        }
+
+        ExamingSession examingSession = examingSessionService.getExamingSession(req.getStudentId());
+        if (examingSession == null) {
+            log.warn("考试会话已过期!examRecordDataId = {}, studentId = {}", req.getExamRecordDataId(), req.getStudentId());
+            return;
+        }
+
+        ExamCaptureEntity entity = new ExamCaptureEntity();
+        entity.setExamRecordDataId(req.getExamRecordDataId());
+        entity.setIsPass(req.getPass());
+        entity.setIsStranger(req.getStranger());
+        entity.setHasVirtualCamera(req.getHasVirtualCamera());
+        entity.setFileName(FileUtil.getFileName(req.getFileUrl()));
+        entity.setFileUrl(req.getFileUrl());
+        entity.setFaceCompareResult(req.getFaceCompareResult());
+        entity.setFacelivenessResult(req.getFacelivenessResult());
+        entity.setProcessTime(req.getProcessTime() != null ? req.getProcessTime() : 1L);
+        entity.setUsedTime(req.getProcessTime() != null ? req.getProcessTime() : 1L);
+        entity.setExtMsg(req.getExtMsg());
+
+        try {
+            // 校验虚拟摄像头格式,必须是Json数组,如果格式不正确,则置为null
+            if (StringUtils.isNotEmpty(req.getCameraInfos())) {
+                JSONArray jsonArray = new JSONArray(req.getCameraInfos());
+                if (jsonArray.length() == 0) {
+                    req.setCameraInfos(null);
+                }
+            }
+        } catch (JSONException e) {
+            req.setCameraInfos(null);
+            log.error("虚拟摄像头信息格式不正确!" + e.getMessage());
+        }
+
+        if (StringUtils.length(req.getCameraInfos()) >= Constants.VM_CAMERA_SIZE_LIMIT) {
+            entity.setCameraInfos(Constants.VM_CAMERA_WARN);
+            log.warn("虚拟摄像头信息超长! " + req.getExamRecordDataId());
+        } else {
+            entity.setCameraInfos(StringUtils.trimToNull(req.getCameraInfos()));
+        }
+
+        examCaptureRepo.save(entity);
+    }
+
+}

+ 4 - 4
examcloud-core-oe-student-starter/shell/start.sh

@@ -1,13 +1,13 @@
 #!/bin/bash
 
-PROJECT_JAR="examcloud-core-oe-student-starter-v4.1.0-RELEASE.jar"
-
-PROJECT_JVM_ARGS=`cat start.vmoptions`
+PROJECT_JAR=`find . -name "examcloud-core-oe-student-starter*.jar"`
+PROJECT_JAR=${PROJECT_JAR:6}
 
 PROJECT_ARGS=`cat start.args`
-
 PROJECT_ARGS=$PROJECT_ARGS" --sys.config.center.secretKey="$1
 
+PROJECT_JVM_ARGS=`cat start.vmoptions`
+
 PID_LIST=`ps -ef | grep $PROJECT_JAR | grep java | awk '{print $2}'`
 if [ ! -z "$PID_LIST" ]; then
     echo "$PROJECT_JAR is already running..."

+ 2 - 1
examcloud-core-oe-student-starter/shell/stop.sh

@@ -1,6 +1,7 @@
 #!/bin/bash
 
-PROJECT_JAR="examcloud-core-oe-student-starter-v4.1.0-RELEASE.jar"
+PROJECT_JAR=`find . -name "examcloud-core-oe-student-starter*.jar"`
+PROJECT_JAR=${PROJECT_JAR:6}
 
 ps -ef | grep $PROJECT_JAR | grep java | awk '{printf("kill -15 %s\n",$2)}' | sh
 BUILD_ID=DONTKILLME

+ 17 - 13
examcloud-core-oe-student-starter/src/main/java/cn/com/qmth/examcloud/core/oe/student/starter/config/SwaggerConfig.java

@@ -1,44 +1,48 @@
-/*
- * *************************************************
- * Copyright (c) 2018 QMTH. All Rights Reserved.
- * Created by Deason on 2018-08-15 16:17:41.
- * *************************************************
- */
-
 package cn.com.qmth.examcloud.core.oe.student.starter.config;
 
-import io.swagger.annotations.ApiOperation;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import springfox.documentation.builders.ApiInfoBuilder;
+import springfox.documentation.builders.ParameterBuilder;
 import springfox.documentation.builders.PathSelectors;
 import springfox.documentation.builders.RequestHandlerSelectors;
+import springfox.documentation.schema.ModelRef;
 import springfox.documentation.service.ApiInfo;
+import springfox.documentation.service.Parameter;
 import springfox.documentation.spi.DocumentationType;
 import springfox.documentation.spring.web.plugins.Docket;
 import springfox.documentation.swagger2.annotations.EnableSwagger2WebMvc;
 
+import java.util.ArrayList;
+import java.util.List;
+
 @Configuration
 @EnableSwagger2WebMvc
 public class SwaggerConfig {
 
     @Bean
     public Docket buildDocket() {
+        List<Parameter> parameters = new ArrayList<>();
+        parameters.add(new ParameterBuilder().name("key").modelRef(new ModelRef("String")).parameterType("header").required(false).build());
+        parameters.add(new ParameterBuilder().name("token").modelRef(new ModelRef("String")).parameterType("header").required(false).build());
+
         return new Docket(DocumentationType.SWAGGER_2)
-                .groupName("Version 3.0")
+                .groupName("default")
                 .apiInfo(buildApiInfo())
+                .globalOperationParameters(parameters)
                 .useDefaultResponseMessages(false)
                 .select()
-                .apis(RequestHandlerSelectors.withMethodAnnotation(ApiOperation.class))
+                // .apis(RequestHandlerSelectors.withMethodAnnotation(ApiOperation.class))
+                .apis(RequestHandlerSelectors.basePackage("cn.com.qmth.examcloud.core.oe.student.api.controller.client"))
                 .paths(PathSelectors.any())
                 .build();
     }
 
     public ApiInfo buildApiInfo() {
         return new ApiInfoBuilder()
-                .title("网考学生端接口文档")
-                .version("3.0")
+                .title("考试云平台接口文档")
+                .version("v4.x.x")
                 .build();
     }
 
-}
+}

+ 22 - 15
examcloud-core-oe-student-starter/src/main/resources/aliyun.xml

@@ -1,17 +1,24 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <sites>
-	<site>
-		<id>capturePhoto</id>
-		<name>网考学生端抓拍照片</name>
-		<aliyunId>2</aliyunId>
-		<maxSize>1M</maxSize>
-		<path>/capture_photo/${rootOrgId}/${userId}/${timeMillis}${fileSuffix}</path>
-	</site>
-	<site>
-		<id>miniProgramAnwser</id>
-		<name>小程序作答文件</name>
-		<aliyunId>2</aliyunId>
-		<maxSize>100M</maxSize>
-		<path>/${relativePath}</path>
-	</site>
-</sites>
+    <site>
+        <id>capturePhoto</id>
+        <name>网考学生端抓拍照片</name>
+        <aliyunId>1</aliyunId>
+        <maxSize>1M</maxSize>
+        <path>/capture_photo/${rootOrgId}/${userId}/${timeMillis}${fileSuffix}</path>
+    </site>
+    <site>
+        <id>miniProgramAnwser</id>
+        <name>小程序作答文件</name>
+        <aliyunId>1</aliyunId>
+        <maxSize>100M</maxSize>
+        <path>/${relativePath}</path>
+    </site>
+    <site>
+        <id>oe</id>
+        <name>考试资源</name>
+        <aliyunId>1</aliyunId>
+        <maxSize>50M</maxSize>
+        <path>/${relativePath}</path>
+    </site>
+</sites>

+ 23 - 16
examcloud-core-oe-student-starter/src/main/resources/upyun.xml

@@ -1,18 +1,25 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <sites>
-	<site>
-		<id>capturePhoto</id>
-		<name>网考学生端抓拍照片</name>
-		<upyunId>1</upyunId>
-		<maxSize>1M</maxSize>
-		<path>/capture_photo/${rootOrgId}/${userId}/${timeMillis}${fileSuffix}
-		</path>
-	</site>
-	<site>
-		<id>miniProgramAnwser</id>
-		<name>小程序作答文件</name>
-		<upyunId>1</upyunId>
-		<maxSize>100M</maxSize>
-		<path>/${relativePath}</path>
-	</site>
-</sites>
+    <site>
+        <id>capturePhoto</id>
+        <name>网考学生端抓拍照片</name>
+        <upyunId>1</upyunId>
+        <maxSize>1M</maxSize>
+        <path>/capture_photo/${rootOrgId}/${userId}/${timeMillis}${fileSuffix}
+        </path>
+    </site>
+    <site>
+        <id>miniProgramAnwser</id>
+        <name>小程序作答文件</name>
+        <upyunId>1</upyunId>
+        <maxSize>100M</maxSize>
+        <path>/${relativePath}</path>
+    </site>
+    <site>
+        <id>oe</id>
+        <name>考试资源</name>
+        <upyunId>1</upyunId>
+        <maxSize>50M</maxSize>
+        <path>/${relativePath}</path>
+    </site>
+</sites>

+ 18 - 0
examcloud-core-oe-student-starter/src/test/java/cn/com/qmth/examcloud/core/oe/student/test/OeStudentTest.java

@@ -0,0 +1,18 @@
+package cn.com.qmth.examcloud.core.oe.student.test;
+
+import cn.com.qmth.examcloud.core.oe.student.starter.OEStudentApp;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.junit4.SpringRunner;
+
+@RunWith(SpringRunner.class)
+@SpringBootTest(classes = OEStudentApp.class)
+public class OeStudentTest {
+
+    @Test
+    public void demo() throws Exception {
+
+    }
+
+}