DiagnosticChapterService.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578
  1. <?php
  2. namespace App\Services;
  3. use Illuminate\Support\Facades\Log;
  4. use App\Models\KnowledgePoint;
  5. use App\Models\StudentKnowledgeMastery;
  6. use App\Models\TextbookCatalog;
  7. use App\Models\TextbookChapterKnowledgeRelation;
  8. class DiagnosticChapterService
  9. {
  10. public function getTextbookKnowledgePointsInOrder(int $textbookId): array
  11. {
  12. $chapters = TextbookCatalog::query()
  13. ->where('textbook_id', $textbookId)
  14. ->where('node_type', 'chapter')
  15. ->orderBy('display_no')
  16. ->orderBy('sort_order')
  17. ->orderBy('id')
  18. ->get();
  19. if ($chapters->isEmpty()) {
  20. Log::warning('DiagnosticChapterService: 未找到章节节点', [
  21. 'textbook_id' => $textbookId,
  22. ]);
  23. return [];
  24. }
  25. $orderedCodes = [];
  26. $seen = [];
  27. foreach ($chapters as $chapter) {
  28. $sectionIds = TextbookCatalog::query()
  29. ->where('parent_id', $chapter->id)
  30. ->where('node_type', 'section')
  31. ->orderBy('display_no')
  32. ->orderBy('sort_order')
  33. ->orderBy('id')
  34. ->pluck('id')
  35. ->toArray();
  36. if (empty($sectionIds)) {
  37. continue;
  38. }
  39. $kpCodes = TextbookChapterKnowledgeRelation::query()
  40. ->whereIn('catalog_chapter_id', $sectionIds)
  41. ->pluck('kp_code')
  42. ->filter()
  43. ->unique()
  44. ->values()
  45. ->toArray();
  46. $kpCodes = $this->expandWithChildKnowledgePoints($kpCodes);
  47. foreach ($kpCodes as $kpCode) {
  48. if (isset($seen[$kpCode])) {
  49. continue;
  50. }
  51. $seen[$kpCode] = true;
  52. $orderedCodes[] = $kpCode;
  53. }
  54. }
  55. Log::info('DiagnosticChapterService: 获取教材知识点顺序列表', [
  56. 'textbook_id' => $textbookId,
  57. 'kp_count' => count($orderedCodes),
  58. ]);
  59. return $orderedCodes;
  60. }
  61. public function getInitialChapterKnowledgePoints(int $textbookId): array
  62. {
  63. $chapter = TextbookCatalog::query()
  64. ->where('textbook_id', $textbookId)
  65. ->where('node_type', 'chapter')
  66. ->orderBy('display_no')
  67. ->orderBy('sort_order')
  68. ->orderBy('id')
  69. ->first();
  70. if (!$chapter) {
  71. Log::warning('DiagnosticChapterService: 未找到章节节点', [
  72. 'textbook_id' => $textbookId,
  73. ]);
  74. return [];
  75. }
  76. $sectionIds = TextbookCatalog::query()
  77. ->where('parent_id', $chapter->id)
  78. ->where('node_type', 'section')
  79. ->orderBy('display_no')
  80. ->orderBy('sort_order')
  81. ->orderBy('id')
  82. ->pluck('id')
  83. ->toArray();
  84. if (empty($sectionIds)) {
  85. Log::warning('DiagnosticChapterService: 章节下未找到section节点', [
  86. 'textbook_id' => $textbookId,
  87. 'chapter_id' => $chapter->id,
  88. ]);
  89. return [];
  90. }
  91. $kpCodes = TextbookChapterKnowledgeRelation::query()
  92. ->whereIn('catalog_chapter_id', $sectionIds)
  93. ->pluck('kp_code')
  94. ->filter()
  95. ->unique()
  96. ->values()
  97. ->toArray();
  98. $kpCodes = $this->expandWithChildKnowledgePoints($kpCodes);
  99. Log::info('DiagnosticChapterService: 获取首章知识点', [
  100. 'textbook_id' => $textbookId,
  101. 'chapter_id' => $chapter->id,
  102. 'section_count' => count($sectionIds),
  103. 'kp_count' => count($kpCodes),
  104. ]);
  105. return [
  106. 'chapter_id' => $chapter->id,
  107. 'section_ids' => $sectionIds,
  108. 'kp_codes' => $kpCodes,
  109. ];
  110. }
  111. public function getFirstUnmasteredChapterKnowledgePoints(int $textbookId, int $studentId, float $threshold = 0.9): array
  112. {
  113. $chapters = TextbookCatalog::query()
  114. ->where('textbook_id', $textbookId)
  115. ->where('node_type', 'chapter')
  116. ->orderBy('display_no')
  117. ->orderBy('sort_order')
  118. ->orderBy('id')
  119. ->get();
  120. if ($chapters->isEmpty()) {
  121. Log::warning('DiagnosticChapterService: 未找到章节节点', [
  122. 'textbook_id' => $textbookId,
  123. ]);
  124. return [];
  125. }
  126. foreach ($chapters as $chapter) {
  127. $sectionIds = TextbookCatalog::query()
  128. ->where('parent_id', $chapter->id)
  129. ->where('node_type', 'section')
  130. ->orderBy('display_no')
  131. ->orderBy('sort_order')
  132. ->orderBy('id')
  133. ->pluck('id')
  134. ->toArray();
  135. if (empty($sectionIds)) {
  136. continue;
  137. }
  138. $kpCodes = TextbookChapterKnowledgeRelation::query()
  139. ->whereIn('catalog_chapter_id', $sectionIds)
  140. ->pluck('kp_code')
  141. ->filter()
  142. ->unique()
  143. ->values()
  144. ->toArray();
  145. $kpCodes = $this->expandWithChildKnowledgePoints($kpCodes);
  146. if (empty($kpCodes)) {
  147. continue;
  148. }
  149. $allMastered = StudentKnowledgeMastery::allAtLeast($studentId, $kpCodes, $threshold);
  150. Log::info('DiagnosticChapterService: 章节掌握度评估', [
  151. 'student_id' => $studentId,
  152. 'textbook_id' => $textbookId,
  153. 'chapter_id' => $chapter->id,
  154. 'section_count' => count($sectionIds),
  155. 'kp_count' => count($kpCodes),
  156. 'all_mastered' => $allMastered,
  157. 'threshold' => $threshold,
  158. ]);
  159. if (!$allMastered) {
  160. return [
  161. 'chapter_id' => $chapter->id,
  162. 'section_ids' => $sectionIds,
  163. 'kp_codes' => $kpCodes,
  164. 'all_mastered' => $allMastered,
  165. ];
  166. }
  167. }
  168. Log::info('DiagnosticChapterService: 所有章节均达到掌握度阈值', [
  169. 'student_id' => $studentId,
  170. 'textbook_id' => $textbookId,
  171. 'threshold' => $threshold,
  172. ]);
  173. return [];
  174. }
  175. private function expandWithChildKnowledgePoints(array $kpCodes): array
  176. {
  177. if (empty($kpCodes)) {
  178. return [];
  179. }
  180. $baseCodes = collect($kpCodes)->filter()->values()->all();
  181. $children = KnowledgePoint::query()
  182. ->whereIn('parent_kp_code', $baseCodes)
  183. ->orderBy('kp_code')
  184. ->get(['parent_kp_code', 'kp_code']);
  185. $childrenMap = [];
  186. foreach ($children as $child) {
  187. $childrenMap[$child->parent_kp_code][] = $child->kp_code;
  188. }
  189. $ordered = [];
  190. $seen = [];
  191. foreach ($baseCodes as $kpCode) {
  192. if (!isset($seen[$kpCode])) {
  193. $seen[$kpCode] = true;
  194. $ordered[] = $kpCode;
  195. }
  196. foreach ($childrenMap[$kpCode] ?? [] as $childCode) {
  197. if (isset($seen[$childCode])) {
  198. continue;
  199. }
  200. $seen[$childCode] = true;
  201. $ordered[] = $childCode;
  202. }
  203. }
  204. return $ordered;
  205. }
  206. // ========== 以下是新增的方法(不扩展子知识点)==========
  207. /**
  208. * 获取章节的知识点(不扩展子知识点)
  209. * 直接返回 section 绑定的知识点
  210. */
  211. public function getChapterKnowledgePointsSimple(int $chapterId): array
  212. {
  213. $sectionIds = TextbookCatalog::query()
  214. ->where('parent_id', $chapterId)
  215. ->where('node_type', 'section')
  216. ->orderBy('display_no')
  217. ->orderBy('sort_order')
  218. ->orderBy('id')
  219. ->pluck('id')
  220. ->toArray();
  221. if (empty($sectionIds)) {
  222. return [
  223. 'section_ids' => [],
  224. 'kp_codes' => [],
  225. ];
  226. }
  227. $kpCodes = TextbookChapterKnowledgeRelation::query()
  228. ->whereIn('catalog_chapter_id', $sectionIds)
  229. ->pluck('kp_code')
  230. ->filter()
  231. ->unique()
  232. ->values()
  233. ->toArray();
  234. // 不扩展子知识点,直接返回
  235. return [
  236. 'section_ids' => $sectionIds,
  237. 'kp_codes' => $kpCodes,
  238. ];
  239. }
  240. /**
  241. * 判断章节是否已经摸底过
  242. */
  243. public function hasChapterDiagnostic(int $studentId, int $chapterId): bool
  244. {
  245. return \App\Models\Paper::query()
  246. ->where('student_id', $studentId)
  247. ->where('paper_type', 0) // 摸底类型
  248. ->where('diagnostic_chapter_id', $chapterId)
  249. ->exists();
  250. }
  251. /**
  252. * 获取第一个未摸底的章节
  253. * 用于章节摸底流程
  254. */
  255. public function getFirstUndiagnosedChapter(int $textbookId, int $studentId): ?array
  256. {
  257. $chapters = TextbookCatalog::query()
  258. ->where('textbook_id', $textbookId)
  259. ->where('node_type', 'chapter')
  260. ->orderBy('display_no')
  261. ->orderBy('sort_order')
  262. ->orderBy('id')
  263. ->get();
  264. if ($chapters->isEmpty()) {
  265. return null;
  266. }
  267. foreach ($chapters as $chapter) {
  268. // 检查是否已摸底
  269. if (!$this->hasChapterDiagnostic($studentId, $chapter->id)) {
  270. $chapterData = $this->getChapterKnowledgePointsSimple($chapter->id);
  271. // 过滤掉没有题目的知识点
  272. $kpCodesWithQuestions = $this->filterKpCodesWithQuestions($chapterData['kp_codes']);
  273. if (empty($kpCodesWithQuestions)) {
  274. // 这个章节没有题目,跳过
  275. continue;
  276. }
  277. Log::info('DiagnosticChapterService: 找到第一个未摸底的章节', [
  278. 'textbook_id' => $textbookId,
  279. 'student_id' => $studentId,
  280. 'chapter_id' => $chapter->id,
  281. 'chapter_name' => $chapter->name ?? '',
  282. 'kp_count' => count($kpCodesWithQuestions),
  283. ]);
  284. return [
  285. 'chapter_id' => $chapter->id,
  286. 'chapter_name' => $chapter->name ?? '',
  287. 'section_ids' => $chapterData['section_ids'],
  288. 'kp_codes' => $kpCodesWithQuestions,
  289. ];
  290. }
  291. }
  292. // 所有章节都已摸底,返回第一章(重新开始)
  293. $firstChapter = $chapters->first();
  294. $chapterData = $this->getChapterKnowledgePointsSimple($firstChapter->id);
  295. $kpCodesWithQuestions = $this->filterKpCodesWithQuestions($chapterData['kp_codes']);
  296. Log::info('DiagnosticChapterService: 所有章节都已摸底,返回第一章', [
  297. 'textbook_id' => $textbookId,
  298. 'student_id' => $studentId,
  299. 'chapter_id' => $firstChapter->id,
  300. ]);
  301. return [
  302. 'chapter_id' => $firstChapter->id,
  303. 'chapter_name' => $firstChapter->name ?? '',
  304. 'section_ids' => $chapterData['section_ids'],
  305. 'kp_codes' => $kpCodesWithQuestions,
  306. 'is_restart' => true, // 标记是重新开始
  307. ];
  308. }
  309. /**
  310. * 获取当前应该学习的章节(第一个有未达标知识点的章节)
  311. * 用于智能组卷流程
  312. */
  313. public function getCurrentLearningChapter(int $textbookId, int $studentId, float $threshold = 0.9): ?array
  314. {
  315. $chapters = TextbookCatalog::query()
  316. ->where('textbook_id', $textbookId)
  317. ->where('node_type', 'chapter')
  318. ->orderBy('display_no')
  319. ->orderBy('sort_order')
  320. ->orderBy('id')
  321. ->get();
  322. if ($chapters->isEmpty()) {
  323. return null;
  324. }
  325. foreach ($chapters as $chapter) {
  326. $chapterData = $this->getChapterKnowledgePointsSimple($chapter->id);
  327. $kpCodes = $chapterData['kp_codes'];
  328. if (empty($kpCodes)) {
  329. continue;
  330. }
  331. // 使用新方法判断是否达标(跳过无题知识点)
  332. $allMastered = StudentKnowledgeMastery::allAtLeastSkipNoQuestions($studentId, $kpCodes, $threshold);
  333. if (!$allMastered) {
  334. // 检查是否已摸底
  335. $hasDiagnostic = $this->hasChapterDiagnostic($studentId, $chapter->id);
  336. Log::info('DiagnosticChapterService: 找到当前学习章节', [
  337. 'textbook_id' => $textbookId,
  338. 'student_id' => $studentId,
  339. 'student_id_type' => gettype($studentId),
  340. 'chapter_id' => $chapter->id,
  341. 'chapter_name' => $chapter->name ?? '',
  342. 'has_diagnostic' => $hasDiagnostic,
  343. 'kp_codes' => $kpCodes, // 显示实际的知识点列表
  344. 'kp_count' => count($kpCodes),
  345. ]);
  346. return [
  347. 'chapter_id' => $chapter->id,
  348. 'chapter_name' => $chapter->name ?? '',
  349. 'section_ids' => $chapterData['section_ids'],
  350. 'kp_codes' => $kpCodes,
  351. 'has_diagnostic' => $hasDiagnostic,
  352. ];
  353. }
  354. }
  355. // 所有章节都达标
  356. Log::info('DiagnosticChapterService: 所有章节都达标', [
  357. 'textbook_id' => $textbookId,
  358. 'student_id' => $studentId,
  359. ]);
  360. return null;
  361. }
  362. /**
  363. * 获取下一个章节
  364. */
  365. public function getNextChapter(int $textbookId, int $currentChapterId): ?array
  366. {
  367. $chapters = TextbookCatalog::query()
  368. ->where('textbook_id', $textbookId)
  369. ->where('node_type', 'chapter')
  370. ->orderBy('display_no')
  371. ->orderBy('sort_order')
  372. ->orderBy('id')
  373. ->get();
  374. $foundCurrent = false;
  375. foreach ($chapters as $chapter) {
  376. if ($foundCurrent) {
  377. $chapterData = $this->getChapterKnowledgePointsSimple($chapter->id);
  378. $kpCodesWithQuestions = $this->filterKpCodesWithQuestions($chapterData['kp_codes']);
  379. if (!empty($kpCodesWithQuestions)) {
  380. return [
  381. 'chapter_id' => $chapter->id,
  382. 'chapter_name' => $chapter->name ?? '',
  383. 'section_ids' => $chapterData['section_ids'],
  384. 'kp_codes' => $kpCodesWithQuestions,
  385. ];
  386. }
  387. }
  388. if ($chapter->id === $currentChapterId) {
  389. $foundCurrent = true;
  390. }
  391. }
  392. return null; // 没有下一章
  393. }
  394. /**
  395. * 过滤出有题目的知识点
  396. */
  397. public function filterKpCodesWithQuestions(array $kpCodes): array
  398. {
  399. if (empty($kpCodes)) {
  400. return [];
  401. }
  402. $kpCodesWithQuestions = \App\Models\Question::query()
  403. ->whereIn('kp_code', $kpCodes)
  404. ->distinct()
  405. ->pluck('kp_code')
  406. ->toArray();
  407. // 保持原有顺序
  408. return array_values(array_intersect($kpCodes, $kpCodesWithQuestions));
  409. }
  410. /**
  411. * 按顺序获取未达标的知识点(用于智能组卷)
  412. *
  413. * @param int $studentId 学生ID
  414. * @param array $kpCodes 知识点列表(按顺序)
  415. * @param float $threshold 达标阈值
  416. * @param int $maxCount 最多返回几个知识点
  417. * @param int $minQuestions 每个知识点最少需要的题目数
  418. * @return array 未达标的知识点列表
  419. */
  420. public function getUnmasteredKpCodesInOrder(
  421. int $studentId,
  422. array $kpCodes,
  423. float $threshold = 0.9,
  424. int $maxCount = 2,
  425. int $minQuestions = 20
  426. ): array {
  427. if (empty($kpCodes)) {
  428. return [];
  429. }
  430. // 获取掌握度(使用 DB::table 避免 Eloquent accessor 把数字转成文字标签)
  431. $levels = \Illuminate\Support\Facades\DB::table('student_knowledge_mastery')
  432. ->where('student_id', $studentId)
  433. ->whereIn('kp_code', $kpCodes)
  434. ->pluck('mastery_level', 'kp_code')
  435. ->toArray();
  436. // 【调试】记录查询到的掌握度
  437. Log::info('DiagnosticChapterService: 查询掌握度结果', [
  438. 'student_id' => $studentId,
  439. 'student_id_type' => gettype($studentId),
  440. 'input_kp_codes' => $kpCodes,
  441. 'found_levels' => $levels,
  442. ]);
  443. // 获取每个知识点的题目数量
  444. $questionCounts = \App\Models\Question::query()
  445. ->whereIn('kp_code', $kpCodes)
  446. ->selectRaw('kp_code, COUNT(*) as count')
  447. ->groupBy('kp_code')
  448. ->pluck('count', 'kp_code')
  449. ->toArray();
  450. $result = [];
  451. $totalQuestions = 0;
  452. foreach ($kpCodes as $kpCode) {
  453. // 跳过没有题目的知识点
  454. $count = $questionCounts[$kpCode] ?? 0;
  455. if ($count === 0) {
  456. continue;
  457. }
  458. // 检查是否达标
  459. $level = isset($levels[$kpCode]) ? (float) $levels[$kpCode] : 0.0;
  460. $isMastered = $level >= $threshold;
  461. Log::info("DiagnosticChapterService: 检查知识点", [
  462. 'kp_code' => $kpCode,
  463. 'level' => $level,
  464. 'threshold' => $threshold,
  465. 'is_mastered' => $isMastered,
  466. 'level_found_in_db' => isset($levels[$kpCode]),
  467. ]);
  468. if ($level >= $threshold) {
  469. continue;
  470. }
  471. // 添加到结果
  472. $result[] = $kpCode;
  473. $totalQuestions += $count;
  474. // 检查是否达到最大数量
  475. if (count($result) >= $maxCount) {
  476. break;
  477. }
  478. // 检查题目数量是否足够
  479. if ($totalQuestions >= $minQuestions) {
  480. break;
  481. }
  482. }
  483. Log::info('DiagnosticChapterService: 获取未达标知识点', [
  484. 'student_id' => $studentId,
  485. 'input_kp_codes' => $kpCodes,
  486. 'result_kp_codes' => $result,
  487. 'levels_found' => $levels,
  488. 'total_questions' => $totalQuestions,
  489. 'threshold' => $threshold,
  490. ]);
  491. return $result;
  492. }
  493. }