ExamService.php 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500
  1. <?php
  2. /**
  3. * Created by PhpStorm.
  4. * User : zgcLives
  5. * CreateTime : 2022/4/16 16:21
  6. */
  7. namespace addons\exam\library;
  8. use addons\exam\enum\CommonStatus;
  9. use addons\exam\enum\PaperMode;
  10. use addons\exam\enum\RoomSignupStatus;
  11. use addons\exam\model\GradeModel;
  12. use addons\exam\model\PaperModel;
  13. use addons\exam\model\QuestionModel;
  14. use addons\exam\model\RoomGradeModel;
  15. use addons\exam\model\RoomModel;
  16. use addons\exam\model\RoomSignupModel;
  17. use app\admin\model\exam\MaterialQuestionModel;
  18. /**
  19. * 考试相关服务
  20. */
  21. class ExamService
  22. {
  23. /**
  24. * 获取试卷题目
  25. *
  26. * @param $paper_id
  27. * @param int $room_id
  28. * @return array`
  29. */
  30. public static function getExamQuestion($paper_id, $room_id = 0)
  31. {
  32. if (!$paper_id) {
  33. fail('缺少试卷ID');
  34. }
  35. $paper = self::validPaper($paper_id, $room_id);
  36. switch ($paper['mode']) {
  37. case PaperMode::RANDOM:
  38. $questions = self::getRandomQuestions($paper);
  39. break;
  40. case PaperMode::FIX:
  41. $questions = self::getFixQuestions($paper);
  42. break;
  43. default:
  44. fail('试卷取题模式有误');
  45. }
  46. return [
  47. 'paper' => $paper,
  48. 'questions' => $questions,
  49. 'start_time' => time(),
  50. ];
  51. }
  52. /**
  53. * 获取试卷随机题
  54. *
  55. * @param $paper
  56. * @return array
  57. */
  58. public static function getRandomQuestions($paper)
  59. {
  60. $configs = $paper->configs;
  61. $questions = [];
  62. if (!isset($configs['cate_ids'])) {
  63. fail('试卷随机取题配置有误');
  64. }
  65. foreach (QuestionModel::kindList as $kind) {
  66. if (!isset($configs[strtolower($kind)])) {
  67. continue;
  68. }
  69. $kind_config = $configs[strtolower($kind)];
  70. // 使用难度选题
  71. if ($kind_config['use_difficulty']) {
  72. foreach ($kind_config['difficulty'] as $difficulty => $value) {
  73. if ($value['count']) {
  74. $question = QuestionModel::getListByCateAndKind($configs['cate_ids'], $kind, ['materialQuestions.question']);
  75. $questions = array_merge(
  76. $questions,
  77. $question->where('difficulty', $difficulty)->limit($value['count'])->select()
  78. // hidden_list_keys($question->where('difficulty', $difficulty)->limit($value['count'])->select(), ['answer', 'explain'])
  79. );
  80. }
  81. }
  82. } else {
  83. if ($kind_config['count']) {
  84. $question = QuestionModel::getListByCateAndKind($configs['cate_ids'], $kind, ['materialQuestions.question']);
  85. // dd(collection($question->limit($kind_config['count'])->select())->toArray());
  86. $questions = array_merge(
  87. $questions,
  88. $question->limit($kind_config['count'])->select()
  89. // hidden_list_keys($question->limit($kind_config['count'])->select(), ['answer', 'explain'])
  90. );
  91. }
  92. }
  93. }
  94. if (count($questions) < intval($paper['quantity'])) {
  95. fail('试卷题目数量不足,请联系管理员检查试卷配置');
  96. }
  97. // 合并材料题子题目
  98. $questions = QuestionModel::mergeMaterialQuestions($questions);
  99. return hidden_list_keys($questions, ['answer', 'explain', 'origin_answer']);
  100. }
  101. /**
  102. * 获取试卷固定题
  103. *
  104. * @param $paper
  105. * @return array
  106. */
  107. public static function getFixQuestions($paper, $hidden = true)
  108. {
  109. $questions = QuestionModel::getFixListByPaper($paper['id'], ['materialQuestions.question']);
  110. if (count($questions) < intval($paper['quantity'])) {
  111. fail('试卷题目数量不足,请联系管理员检查试卷配置');
  112. }
  113. // 合并材料题子题目
  114. $questions = QuestionModel::mergeMaterialQuestions($questions);
  115. if ($hidden) {
  116. return hidden_list_keys($questions, ['answer', 'explain', 'origin_answer']);
  117. }
  118. return $questions;
  119. }
  120. /**
  121. * 试卷考试
  122. *
  123. * @param $user_id
  124. * @param $paper_id
  125. * @param $user_questions
  126. * @param $start_time
  127. * @param $paper
  128. * @return array
  129. */
  130. public static function paperExam($user_id, $paper_id, $user_questions, $start_time, &$paper, $room = null)
  131. {
  132. $from_room = $room ? true : false;
  133. $source = $from_room ? 'ROOM' : 'PAPER';
  134. // 验证试卷
  135. $paper = self::validPaper($paper_id, $from_room ? 1 : 0);
  136. if (!$questions_ids = array_column($user_questions, 'id')) {
  137. fail('提交的题目数据有误');
  138. }
  139. $answers = array_column($user_questions, 'answer'); // 用户答案
  140. $material_ids = array_column($user_questions, 'material_id'); // 材料题id
  141. $total_score = 0; // 试卷总分
  142. $error_count = 0; // 错误题目数量
  143. $error_ids = []; //错误题目id
  144. if ($paper['mode'] == PaperMode::RANDOM) {
  145. $questions = QuestionModel::whereIn('id', $questions_ids)->orderRaw("find_in_set(id, '" . implode(',', $questions_ids) . "')")->select();
  146. } else {
  147. $questions = self::getFixQuestions($paper, false);
  148. }
  149. // 材料题分数
  150. $material_score = [];
  151. foreach ($questions as $key => $question) {
  152. $score = 0;
  153. // 随机取题
  154. if ($paper['mode'] == PaperMode::RANDOM) {
  155. $kind = $question['kind'];
  156. $difficulty = $question['difficulty'];
  157. // 属于材料题子题
  158. if (isset($material_ids[$key]) && $material_ids[$key]) {
  159. if ($material_question = QuestionModel::where('id', $material_ids[$key])->cache(60)->find()) {
  160. $kind = 'MATERIAL';
  161. $difficulty = $material_question['difficulty'];
  162. // $score = PaperModel::getSingleScore($paper['configs'], strtolower($kind), strtolower($difficulty)); // 每题分数
  163. // 材料题子题目设定的分数
  164. $score = MaterialQuestionModel::where('parent_question_id', $material_ids[$key])
  165. ->where('question_id', $question['id'])
  166. ->cache(60)
  167. ->value('score');
  168. }
  169. } else {
  170. $score = PaperModel::getSingleScore($paper['configs'], strtolower($kind), strtolower($difficulty)); // 每题分数
  171. }
  172. } else {
  173. // 固定取题
  174. $score = $question['score'];
  175. if ($question['id'] == 764) {
  176. // dd([$score, $question, isset($material_ids[$key]), $material_ids[$key]]);
  177. }
  178. }
  179. switch ($question['kind']) {
  180. case 'JUDGE': // 判断题
  181. case 'SINGLE': // 单选题
  182. case 'MULTI': // 多选题
  183. // 答题正确
  184. if (strtoupper($answers[$key]) == strtoupper($question['answer'])) {
  185. $total_score += $score;
  186. $user_questions[$key]['is_right'] = true;
  187. } else {
  188. array_push($error_ids, $question['id']);
  189. $error_count++;
  190. $user_questions[$key]['is_right'] = false;
  191. // 记录错题
  192. QuestionModel::recordWrong($question['kind'], $question['id'], $user_id, $answers[$key], $source, [
  193. 'cate_id' => $question['cate_id'],
  194. 'paper_id' => $paper_id,
  195. 'room_id' => $room['id'] ?? 0,
  196. ]);
  197. }
  198. break;
  199. case 'FILL': // 填空题
  200. $user_answers = $answers[$key];
  201. $fill_right_count = 0;
  202. $question['answer'] = is_array($question['answer']) ? $question['answer'] : json_decode($question['answer'], true);
  203. foreach ($question['answer'] as $fill_key => $fill_answer) {
  204. foreach ($fill_answer['answers'] as $answer) {
  205. if (isset($user_answers[$fill_key]) && str_trim($user_answers[$fill_key]) == str_trim($answer)) {
  206. $fill_right_count++;
  207. break;
  208. }
  209. }
  210. }
  211. // 所有填空项全对
  212. if ($fill_right_count == count($question['answer'])) {
  213. $user_questions[$key]['is_right'] = true;
  214. $total_score += $score;
  215. } else {
  216. $user_questions[$key]['is_right'] = false;
  217. array_push($error_ids, $question['id']);
  218. $error_count++;
  219. // 记录错题
  220. QuestionModel::recordWrong($question['kind'], $question['id'], $user_id, $answers[$key], $source, [
  221. 'cate_id' => $question['cate_id'],
  222. 'paper_id' => $paper_id,
  223. 'room_id' => $room['id'] ?? 0,
  224. ]);
  225. }
  226. break;
  227. case 'SHORT': // 简答题
  228. // 答案得分配置
  229. $answer_config = is_string($question['answer']) ? json_decode($question['answer'], true) : $question['answer'];
  230. $user_answers = $answers[$key];
  231. $right_score = 0;
  232. $answer_score = [];
  233. foreach ($answer_config['config'] as $answer_item) {
  234. if ($right_score < $score) {
  235. // 匹配答案关键词
  236. if (strpos($user_answers, $answer_item['answer']) !== false) {
  237. $right_score += $answer_item['score'];
  238. // 得分情况
  239. $answer_score[] = [
  240. 'answer' => $answer_item['answer'],
  241. 'score' => min($score, $answer_item['score']),
  242. 'keyword_score' => $answer_item['score'],
  243. 'max_score' => $score,
  244. ];
  245. }
  246. }
  247. }
  248. // 最高得分不能超过题目分数
  249. $right_score = min($right_score, $score);
  250. // 有得分
  251. if ($right_score > 0) {
  252. $user_questions[$key]['is_right'] = true;
  253. $total_score += $right_score;
  254. } else {
  255. $user_questions[$key]['is_right'] = false;
  256. array_push($error_ids, $question['id']);
  257. $error_count++;
  258. // 记录错题
  259. QuestionModel::recordWrong($question['kind'], $question['id'], $user_id, $answers[$key], $source, [
  260. 'cate_id' => $question['cate_id'],
  261. 'paper_id' => $paper_id,
  262. 'room_id' => $room['id'] ?? 0,
  263. ]);
  264. }
  265. $user_questions[$key]['answer_score'] = $answer_score;
  266. break;
  267. }
  268. }
  269. // 递增参与人次
  270. $paper->setInc('join_count');
  271. return [
  272. 'total_score' => $paper['total_score'], // 试卷总分
  273. 'score' => $total_score, // 考试分数
  274. 'is_pass' => $total_score >= $paper['pass_score'], // 是否及格
  275. 'pass_score' => $paper['pass_score'], // 及格分数
  276. 'total_count' => count($questions), // 题目数量
  277. 'right_count' => count($questions) - $error_count, // 答对数量
  278. 'error_count' => $error_count, // 答错数量
  279. 'start_time' => $start_time, // 开始时间
  280. 'grade_time' => $paper['limit_time'] ? min(time() - $start_time, $paper['limit_time']) : time() - $start_time,// 考试用时
  281. 'error_ids' => implode(',', $error_ids), // 错误题目id
  282. 'question_ids' => implode(',', $questions_ids), // 试题ID集合
  283. 'user_answers' => json_encode($user_questions, JSON_UNESCAPED_UNICODE), // 用户答案集合
  284. 'configs' => json_encode($paper['configs']), // 试卷配置
  285. 'mode' => $paper['mode'], // 试卷选题模式
  286. ];
  287. }
  288. /**
  289. * 考场考试
  290. *
  291. * @param $user_id
  292. * @param $room_id
  293. * @param $room_grade_id
  294. * @param $questions
  295. * @param $start_time
  296. * @param $paper
  297. * @param $room
  298. * @param $is_makeup
  299. * @param RoomGradeModel|null $room_grade_log
  300. * @return array
  301. */
  302. public static function roomExam($user_id, $room_id, $room_grade_id, $questions, $start_time, &$paper, &$room, &$is_makeup, &$room_grade_log)
  303. {
  304. // 验证考场信息
  305. $room = self::validRoom($user_id, $room_id, $room_grade_id, $is_makeup, $room_grade_log);
  306. return self::paperExam($user_id, $room['paper_id'], $questions, $start_time, $paper, $room);
  307. }
  308. /**
  309. * 预创建考场考试记录(消耗一次考试记录,避免重复进入考场看题)
  310. *
  311. * @param $room_id
  312. * @param $user_id
  313. * @return int
  314. */
  315. public static function preRoomGrade($room_id, $user_id)
  316. {
  317. if (!$room_id) {
  318. return 0;
  319. }
  320. // 验证考场信息
  321. $room = self::validRoom($user_id, $room_id, 0, $is_makeup);
  322. // 创建考场考试记录
  323. $grade = RoomGradeModel::create([
  324. 'user_id' => $user_id,
  325. 'room_id' => $room_id,
  326. 'cate_id' => $room['cate_id'],
  327. 'paper_id' => $room['paper_id'],
  328. 'score' => 0,
  329. 'is_pass' => 0,
  330. 'is_makeup' => $is_makeup,
  331. 'total_score' => $room['paper']['total_score'],
  332. 'total_count' => $room['paper']['quantity'],
  333. 'right_count' => 0,
  334. 'error_count' => $room['paper']['quantity'],
  335. 'rank' => 0,
  336. 'is_pre' => 1,// 标记为预载入,提交成绩时须改为0
  337. 'grade_time' => 0,
  338. ]);
  339. return $grade['id'];
  340. }
  341. /**
  342. * 验证试卷
  343. *
  344. * @param int $paper_id 试卷ID
  345. * @param int $room_id 考场ID
  346. * @return PaperModel|null
  347. */
  348. private static function validPaper($paper_id, $room_id = 0)
  349. {
  350. $paper = PaperModel::get($paper_id);
  351. $user_id = getUserId();
  352. switch (true) {
  353. case !$paper:
  354. fail('试卷信息不存在');
  355. case $paper->status != CommonStatus::NORMAL:
  356. fail('试卷未开启');
  357. case $paper->mode == PaperMode::RANDOM && !$paper->configs:
  358. fail('试卷未配置');
  359. }
  360. // 普通考试
  361. if (!$room_id) {
  362. if ($user_id && $paper['day_limit_count'] > 0 && GradeModel::getUserDateGradeCount($paper_id, $user_id) >= $paper['day_limit_count']) {
  363. fail('当前试卷考试次数已达今日上限,明天再来吧~');
  364. }
  365. if ($paper['end_time'] > 0 && $paper['end_time'] < time()) {
  366. fail('该试卷已失效,不能参与考试了');
  367. }
  368. }
  369. return $paper;
  370. }
  371. /**
  372. * 验证考场
  373. *
  374. * @param int $user_id 考试用户
  375. * @param int $room_id 试卷ID
  376. * @param int $room_grade_id 考场预创建成绩ID
  377. * @param int $is_makeup 返回是否是补考
  378. * @param RoomGradeModel|null $room_grade_log 预创建的成绩记录
  379. * @return RoomModel|null
  380. */
  381. private static function validRoom($user_id, $room_id, $room_grade_id, &$is_makeup, &$room_grade_log = null)
  382. {
  383. $room = RoomModel::get($room_id);
  384. switch (true) {
  385. case !$room:
  386. fail('考场信息不存在');
  387. case $room['status'] != CommonStatus::NORMAL:
  388. fail('考场未开启');
  389. case time() < $room['start_time'] || time() > $room['end_time']:
  390. fail('考场时间未开始或已结束');
  391. case !$roomSignup = RoomSignupModel::where('room_id', $room_id)->where('user_id', $user_id)->find():
  392. fail('您尚未报名此考场');
  393. case $roomSignup['status'] != RoomSignupStatus::ACCEPT:
  394. fail('您的考场报名信息状态有误');
  395. }
  396. // 考场允许补考
  397. if ($room['is_makeup'] == 1 && $room['makeup_count'] > 0) {
  398. // $query = RoomGradeModel::where('room_id', $room_id)->where('paper_id', $room['paper_id'])->where('user_id', $user_id);
  399. // 考试次数
  400. $room_exam_count = RoomGradeModel::where('room_id', $room_id)
  401. ->where('paper_id', $room['paper_id'])
  402. ->where('user_id', $user_id)
  403. ->where('is_pre', 0)
  404. ->count();
  405. // 补考次数
  406. $makeup_count = RoomGradeModel::where('room_id', $room_id)
  407. ->where('paper_id', $room['paper_id'])
  408. ->where('user_id', $user_id)
  409. ->where('is_makeup', 1)
  410. ->count();
  411. $min_makeup_count = $makeup_count - ($room_grade_id ? 1 : 0);
  412. if ($min_makeup_count > $room['makeup_count']) {
  413. fail("您已超过本考场的补考次数,无法继续考试");
  414. }
  415. $last_exam_log = RoomGradeModel::where('room_id', $room_id)
  416. ->where('paper_id', $room['paper_id'])
  417. ->where('user_id', $user_id)
  418. ->order('id desc')
  419. ->find();
  420. if ($last_exam_log && $last_exam_log['is_pass'] != 0) {
  421. fail('最后一次考试已及格,不需要补考了');
  422. }
  423. // 考试次数大于0视为补考
  424. $is_makeup = $room_exam_count >= 1 ? 1 : 0;
  425. if ($user_id == 5134) {
  426. // ddd($room_exam_count, $makeup_count, $min_makeup_count, $is_makeup);
  427. }
  428. } else {
  429. if (RoomGradeModel::where('room_id', $room_id)->where('user_id', $user_id)->where('is_pre', 0)->count() > 0) {
  430. fail('您已参加过该考场考试了');
  431. }
  432. $is_makeup = 0;
  433. }
  434. // 考场预创建记录验证
  435. if ($room_grade_id) {
  436. if (!$room_grade_log = RoomGradeModel::where('id', $room_grade_id)->where('user_id', $user_id)->find()) {
  437. fail('考场成绩错误');
  438. } else if ($room_grade_log['is_pre'] == 0) {
  439. fail('本次考场考试已提交过成绩了,请勿重复提交');
  440. }
  441. }
  442. return $room;
  443. }
  444. }