|
@@ -4,8 +4,10 @@ import cn.com.qmth.examcloud.api.commons.enums.ExamSpecialSettingsType;
|
|
|
import cn.com.qmth.examcloud.api.commons.enums.ExamType;
|
|
|
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.*;
|
|
|
import cn.com.qmth.examcloud.commons.util.UUID;
|
|
|
-import cn.com.qmth.examcloud.commons.util.Util;
|
|
|
+import cn.com.qmth.examcloud.core.basic.api.bean.CourseBean;
|
|
|
+import cn.com.qmth.examcloud.core.oe.student.base.bean.ExamQuestion;
|
|
|
import cn.com.qmth.examcloud.core.oe.student.base.bean.ExamRecordQuestions;
|
|
|
import cn.com.qmth.examcloud.core.oe.student.base.utils.CommonUtil;
|
|
|
import cn.com.qmth.examcloud.core.oe.student.base.utils.QuestionTypeUtil;
|
|
@@ -36,19 +38,30 @@ import cn.com.qmth.examcloud.support.examing.ExamingSession;
|
|
|
import cn.com.qmth.examcloud.support.examing.ExamingStatus;
|
|
|
import cn.com.qmth.examcloud.support.helper.ExamCacheTransferHelper;
|
|
|
import cn.com.qmth.examcloud.support.helper.FaceBiopsyHelper;
|
|
|
+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 com.google.common.base.Splitter;
|
|
|
+import main.java.com.upyun.Base64Coder;
|
|
|
+import main.java.com.upyun.UpException;
|
|
|
+import main.java.com.upyun.UpYunUtils;
|
|
|
import org.apache.commons.collections.CollectionUtils;
|
|
|
+import org.apache.commons.lang.math.RandomUtils;
|
|
|
import org.apache.commons.lang3.StringUtils;
|
|
|
+import org.apache.commons.lang3.time.DateUtils;
|
|
|
import org.slf4j.Logger;
|
|
|
import org.slf4j.LoggerFactory;
|
|
|
import org.springframework.beans.factory.annotation.Autowired;
|
|
|
+import org.springframework.beans.factory.annotation.Value;
|
|
|
import org.springframework.stereotype.Service;
|
|
|
import org.springframework.transaction.annotation.Transactional;
|
|
|
|
|
|
+import java.text.SimpleDateFormat;
|
|
|
import java.util.*;
|
|
|
import java.util.concurrent.TimeUnit;
|
|
|
+import java.util.stream.Collectors;
|
|
|
|
|
|
/**
|
|
|
* @author chenken
|
|
@@ -85,6 +98,19 @@ public class ExamControlServiceImpl implements ExamControlService {
|
|
|
@Autowired
|
|
|
private ExamFaceLivenessVerifyService examFaceLivenessVerifyService;
|
|
|
|
|
|
+ @Autowired
|
|
|
+ private RedisClient redisClient;
|
|
|
+
|
|
|
+ private static final String SEPARATOR = "/";
|
|
|
+
|
|
|
+ private static final String UNDERLINE = "_";
|
|
|
+
|
|
|
+ // 又拍云音频答案上传目录
|
|
|
+ private static final String OE_ANSWER_FILE_PATH = "oe-answer-file";
|
|
|
+ private static final String SESSION_TIMEOUT = "$core.basic.sessionTimeout";
|
|
|
+ // 又拍云签名有效时间(秒)
|
|
|
+ private static final Integer SIGN_TIMEOUT = 60;
|
|
|
+
|
|
|
@Transactional
|
|
|
@Override
|
|
|
public StartExamInfo startExam(Long examStudentId, User user) {
|
|
@@ -286,9 +312,93 @@ public class ExamControlServiceImpl implements ExamControlService {
|
|
|
|
|
|
@Override
|
|
|
public CheckQrCodeInfo checkQrCode(String qrCode) {
|
|
|
- return null;
|
|
|
+ String str;
|
|
|
+ str = UrlUtil.decode(qrCode);
|
|
|
+ Map<String, String> map = Splitter.on("&").withKeyValueSeparator("=").split(str);
|
|
|
+ String examStudentId = map.get("examStudentId");
|
|
|
+ String examRecordDataId = map.get("examRecordDataId");
|
|
|
+ String order = map.get("order");
|
|
|
+ String key = map.get("key");
|
|
|
+ String token = map.get("token");
|
|
|
+ // 需要签名的参数
|
|
|
+ StringBuffer sourStr = new StringBuffer();
|
|
|
+ sourStr.append(order).append(UNDERLINE).append(examRecordDataId).append(UNDERLINE).append(key).append(UNDERLINE)
|
|
|
+ .append(examStudentId);
|
|
|
+ // 签名
|
|
|
+ byte[] bytes = SHA256.encode(sourStr.toString());
|
|
|
+ String hexAscii = ByteUtil.toHexAscii(bytes);
|
|
|
+ if (!hexAscii.equals(token)) {
|
|
|
+ throw new StatusException("100005", "无效的二维码");
|
|
|
+ }
|
|
|
+
|
|
|
+ ExamRecordData examRecordData = examRecordDataService.getExamRecordDataCache(Long.valueOf(examRecordDataId));
|
|
|
+ // 判断考生是否存在
|
|
|
+ if (!examRecordData.getExamStudentId().equals(Long.valueOf(examStudentId))) {
|
|
|
+ throw new StatusException("100012", "考生不存在");
|
|
|
+ }
|
|
|
+
|
|
|
+ ExamingSession examSessionInfo = examingSessionService.getExamingSession(examRecordData.getStudentId());
|
|
|
+ String clientId;
|
|
|
+ // 未开启环境检测,才进行如下校验
|
|
|
+ if (!isTestDev(Long.valueOf(examRecordDataId))) {
|
|
|
+ // 非环境检测,clientId即examRecordDataId
|
|
|
+ clientId = examRecordDataId;
|
|
|
+ // 判断考试是否结束
|
|
|
+ if (examSessionInfo == null) {
|
|
|
+ throw new StatusException("100006", "考试已结束");
|
|
|
+ }
|
|
|
+ if (examSessionInfo.getExamRecordDataId().longValue() != Long.valueOf(examRecordDataId).longValue()
|
|
|
+ || examSessionInfo.getExamStudentId().longValue() != Long.valueOf(examStudentId).longValue()) {
|
|
|
+ throw new StatusException("100008", "无效的二维码");
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // 环境检测时,clientId即用户id
|
|
|
+ clientId = key.substring(key.lastIndexOf("_") + 1);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 校验通过
|
|
|
+ CheckQrCodeInfo res = new CheckQrCodeInfo();
|
|
|
+ res.setExamRecordDataId(Long.valueOf(examRecordDataId));
|
|
|
+ res.setExamStudentId(Long.valueOf(examStudentId));
|
|
|
+ res.setKey(key);
|
|
|
+ int sessionTimeout = PropertyHolder.getInt(SESSION_TIMEOUT, 3600);
|
|
|
+ User user = redisClient.get(key, User.class, sessionTimeout);
|
|
|
+ if (null == user) {
|
|
|
+ throw new StatusException("100007", "登录信息已失效");
|
|
|
+ }
|
|
|
+ res.setToken(user.getToken());
|
|
|
+ CourseCacheBean courseBean = ExamCacheTransferHelper.getCachedCourse(examRecordData.getCourseId());
|
|
|
+
|
|
|
+ res.setCourseId(courseBean.getId());
|
|
|
+ res.setCourseName(courseBean.getName());
|
|
|
+
|
|
|
+ ExamRecordQuestions examRecordQuestions = examRecordQuestionsService.getExamRecordQuestions(
|
|
|
+ Long.valueOf(examRecordDataId), examSessionInfo.getQuestionCount());
|
|
|
+
|
|
|
+ List<ExamQuestion> examQuestionList = examRecordQuestions.getExamQuestions();
|
|
|
+
|
|
|
+ if (examRecordQuestions == null || examQuestionList == null || examQuestionList.isEmpty()) {
|
|
|
+ throw new StatusException("100008", "无效的二维码");
|
|
|
+ }
|
|
|
+ List<ExamQuestion> filterList = examQuestionList.stream()
|
|
|
+ .filter(p -> p.getOrder().equals(Integer.valueOf(order))).collect(Collectors.toList());
|
|
|
+ if (filterList == null || filterList.isEmpty()) {
|
|
|
+ throw new StatusException("100008", "无效的二维码");
|
|
|
+ }
|
|
|
+ ExamQuestion eqe = filterList.get(0);
|
|
|
+
|
|
|
+ res.setQuestionOrder(eqe.getOrder());
|
|
|
+ res.setQuestionMainNumber(eqe.getMainNumber());
|
|
|
+ res.setSubNumber(getSubNumber(examRecordQuestions, Integer.valueOf(order)));
|
|
|
+ try {
|
|
|
+ this.sendScanQrCodeToWebSocket(clientId, Long.valueOf(examRecordDataId), Integer.valueOf(order));
|
|
|
+ } catch (Exception e) {
|
|
|
+ throw new StatusException("100011", "消息通知失败", e);
|
|
|
+ }
|
|
|
+ return res;
|
|
|
}
|
|
|
|
|
|
+
|
|
|
/**
|
|
|
* 发送消息到websocket
|
|
|
*
|
|
@@ -319,6 +429,125 @@ public class ExamControlServiceImpl implements ExamControlService {
|
|
|
|
|
|
@Override
|
|
|
public UpyunSignatureInfo getUpyunSignature(GetUpyunSignatureReq req) {
|
|
|
+ UpyunSignatureInfo u = new UpyunSignatureInfo();
|
|
|
+ try {
|
|
|
+ ExamRecordData examRecordData = examRecordDataService.getExamRecordDataCache(req.getExamRecordDataId());
|
|
|
+ String md5 = req.getFileMd5();
|
|
|
+ Date signDate = null;
|
|
|
+ Date now = new Date();
|
|
|
+ Date expirationDate = DateUtils.addSeconds(now, SIGN_TIMEOUT);
|
|
|
+
|
|
|
+ StringBuffer filePath = new StringBuffer();
|
|
|
+
|
|
|
+ filePath.append(SEPARATOR).append(OE_ANSWER_FILE_PATH).append(SEPARATOR)
|
|
|
+ .append(examRecordData.getExamStudentId()).append(SEPARATOR).append(req.getExamRecordDataId())
|
|
|
+ .append(SEPARATOR).append(req.getOrder()).append(SEPARATOR)
|
|
|
+ .append(examRecordData.getExamStudentId()).append(UNDERLINE).append(req.getExamRecordDataId())
|
|
|
+ .append(UNDERLINE).append(req.getOrder()).append(UNDERLINE).append(System.currentTimeMillis())
|
|
|
+ .append(RandomUtils.nextInt(8999) + 1000);
|
|
|
+
|
|
|
+ if (StringUtils.isNotEmpty(req.getExt())) {
|
|
|
+ filePath.append(UNDERLINE).append(req.getExt());
|
|
|
+ }
|
|
|
+ filePath.append(".").append(req.getFileSuffix());
|
|
|
+
|
|
|
+ long expiration = expirationDate.getTime() / 1000;
|
|
|
+ String bucketName=PropertyHolder.getString("$upyun.site.1.bucketName");
|
|
|
+ String userName=PropertyHolder.getString("$upyun.site.1.userName");
|
|
|
+ String password=PropertyHolder.getString("$upyun.site.1.password");
|
|
|
+
|
|
|
+ String policy = policy(bucketName, expiration, filePath.toString(), signDate, md5);
|
|
|
+ String sign = sign("POST", getGMTDate(signDate), bucketName, policy, userName, UpYunUtils.md5(password),
|
|
|
+ md5);
|
|
|
+ u.setPolicy(policy);
|
|
|
+ u.setSignature(sign);
|
|
|
+ u.setFilePath(filePath.toString());
|
|
|
+
|
|
|
+ String bucketUrl="https://v0.api.upyun.com";
|
|
|
+ String upyunFileUrl=PropertyHolder.getString("$upyun.site.1.domain");
|
|
|
+ u.setUploadUrl(UrlUtil.joinUrl(bucketUrl, bucketName));
|
|
|
+ u.setUpyunFileDomain(upyunFileUrl);
|
|
|
+ } catch (UpException e) {
|
|
|
+ throw new StatusException("100003", "获取又拍云签名失败");
|
|
|
+ }
|
|
|
+ return u;
|
|
|
+ }
|
|
|
+ /**
|
|
|
+ * @param bucketName //不能为空
|
|
|
+ * @param expiration //不能为空
|
|
|
+ * @param filePath //不能为空
|
|
|
+ * @param date 为空时,以又怕云时间和expiration比较,不为空时以此date和expiration比较
|
|
|
+ * @param md5 //可以为空
|
|
|
+ * @return
|
|
|
+ */
|
|
|
+ private String policy(String bucketName, Long expiration, String filePath, Date date, String md5) {
|
|
|
+ Map<String, Object> paramMap = new HashMap<String, Object>();
|
|
|
+ // 不能为空
|
|
|
+ paramMap.put("bucket", bucketName);
|
|
|
+ Date expirationDate = DateUtils.addSeconds(new Date(), SIGN_TIMEOUT);
|
|
|
+ // 不能为空
|
|
|
+ paramMap.put("expiration", expirationDate.getTime() / 1000);
|
|
|
+ paramMap.put("save-key", filePath);
|
|
|
+ // 为空时,以又怕云时间和expiration比较,不为空时以此date和expiration比较
|
|
|
+ paramMap.put("date", date);
|
|
|
+ // 可以为空
|
|
|
+ paramMap.put("content-md5", md5);
|
|
|
+ String policy = UpYunUtils.getPolicy(paramMap);
|
|
|
+ return policy;
|
|
|
+ }
|
|
|
+
|
|
|
+ private String getGMTDate(Date d) {
|
|
|
+ if (d == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ SimpleDateFormat formater = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.US);
|
|
|
+ formater.setTimeZone(TimeZone.getTimeZone("GMT+8"));
|
|
|
+ return formater.format(d);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 必须和policy中date一致,可以都为空.GMTDate
|
|
|
+ *
|
|
|
+ * @param method
|
|
|
+ * @param date
|
|
|
+ * @param bucketName
|
|
|
+ * @param policy
|
|
|
+ * @param userName
|
|
|
+ * @param password
|
|
|
+ * @param md5
|
|
|
+ * @return
|
|
|
+ * @throws UpException
|
|
|
+ */
|
|
|
+ private String sign(String method, String date, String bucketName, String policy, String userName, String password,
|
|
|
+ String md5) throws UpException {
|
|
|
+
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
+ String sp = "&";
|
|
|
+ sb.append(method);
|
|
|
+ sb.append(sp);
|
|
|
+ sb.append(SEPARATOR + bucketName);
|
|
|
+ if (date != null) {
|
|
|
+ sb.append(sp);
|
|
|
+ sb.append(date);
|
|
|
+ }
|
|
|
+ sb.append(sp);
|
|
|
+ sb.append(policy);
|
|
|
+ if (md5 != null && md5.length() > 0) {
|
|
|
+ sb.append(sp);
|
|
|
+ sb.append(md5);
|
|
|
+ }
|
|
|
+ String raw = sb.toString().trim();
|
|
|
+ byte[] hmac = null;
|
|
|
+ try {
|
|
|
+ hmac = UpYunUtils.calculateRFC2104HMACRaw(password, raw);
|
|
|
+ } catch (Exception e) {
|
|
|
+ throw new UpException("calculate SHA1 wrong.");
|
|
|
+ }
|
|
|
+
|
|
|
+ if (hmac != null) {
|
|
|
+ return "UPYUN " + userName + ":" + Base64Coder.encodeLines(hmac).trim();
|
|
|
+ }
|
|
|
+
|
|
|
return null;
|
|
|
}
|
|
|
|
|
@@ -780,4 +1009,67 @@ public class ExamControlServiceImpl implements ExamControlService {
|
|
|
|
|
|
return examUsedMilliSeconds;
|
|
|
}
|
|
|
+
|
|
|
+ private Integer getSubNumber(ExamRecordQuestions examRecordQuestions, Integer order) {
|
|
|
+ List<UploadedFileAnswerInfo> list = getReSortedQuestionList(examRecordQuestions);
|
|
|
+ for (UploadedFileAnswerInfo info : list) {
|
|
|
+ if (order.intValue() == info.getOrder().intValue()) {
|
|
|
+ return info.getSubNumber();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 根据考试记录id获取所有已经重排序号的考试列表
|
|
|
+ *
|
|
|
+ * @param examRecordQuestions
|
|
|
+ * @return
|
|
|
+ */
|
|
|
+ private List<UploadedFileAnswerInfo> getReSortedQuestionList(ExamRecordQuestions examRecordQuestions) {
|
|
|
+ // 所有试题集合,包括非音频题
|
|
|
+ List<UploadedFileAnswerInfo> resultList = new ArrayList<UploadedFileAnswerInfo>();
|
|
|
+
|
|
|
+ if (null == examRecordQuestions || null == examRecordQuestions.getExamQuestions()
|
|
|
+ || examRecordQuestions.getExamQuestions().isEmpty()) {
|
|
|
+ return resultList;
|
|
|
+ }
|
|
|
+
|
|
|
+ List<ExamQuestion> examQuestionAnswerList = examRecordQuestions.getExamQuestions();
|
|
|
+ // 按大题号分组并排序
|
|
|
+ Map<Integer, List<ExamQuestion>> sortedMainNumberList = examQuestionAnswerList.stream()
|
|
|
+ .sorted(Comparator.comparing(ExamQuestion::getMainNumber))
|
|
|
+ .collect(Collectors.groupingBy(ExamQuestion::getMainNumber, Collectors.toList()));
|
|
|
+ for (Integer mainNum : sortedMainNumberList.keySet()) {
|
|
|
+ // 当前大题下的小题集合
|
|
|
+ List<ExamQuestion> subEmptyExamQuestionAnswerList = sortedMainNumberList.get(mainNum);
|
|
|
+ // 按order对小题进行重新排序
|
|
|
+ subEmptyExamQuestionAnswerList.sort(Comparator.comparing(ExamQuestion::getOrder));
|
|
|
+ for (int i = 0; i < subEmptyExamQuestionAnswerList.size(); i++) {
|
|
|
+ ExamQuestion curExamQuestion = subEmptyExamQuestionAnswerList.get(i);
|
|
|
+ UploadedFileAnswerInfo info = new UploadedFileAnswerInfo();
|
|
|
+ info.setExamRecordDataId(curExamQuestion.getExamRecordDataId());
|
|
|
+ info.setMainNumber(curExamQuestion.getMainNumber());
|
|
|
+ info.setOrder(curExamQuestion.getOrder());
|
|
|
+ // info.setQuestionId(curExamQuestion.getQuestionId());
|
|
|
+ info.setSubNumber(i + 1);// 每个大题下,给小题序号重新赋值
|
|
|
+ resultList.add(info);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return resultList;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 根据考试记录id判断是否开启环境检测
|
|
|
+ *
|
|
|
+ * @param examRecordDataId
|
|
|
+ * @return
|
|
|
+ */
|
|
|
+ private boolean isTestDev(Long examRecordDataId) {
|
|
|
+ // 用于环境检测的考试记录id
|
|
|
+ SysPropertyCacheBean examRecordDataIdObject = CacheHelper.getSysProperty("oe.testDev.examRecordDataId");
|
|
|
+ // 是否开启了环境检测(请求的考试记录id等于用于环境检测的考试记录时,则认为开启了环境检测)
|
|
|
+ return examRecordDataIdObject.getHasValue()
|
|
|
+ && examRecordDataId.equals(Long.valueOf(examRecordDataIdObject.getValue().toString()));
|
|
|
+ }
|
|
|
}
|