DiagnosticChapterService.php 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558
  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. 'chapter_id' => $chapter->id,
  340. 'has_diagnostic' => $hasDiagnostic,
  341. 'kp_count' => count($kpCodes),
  342. ]);
  343. return [
  344. 'chapter_id' => $chapter->id,
  345. 'chapter_name' => $chapter->name ?? '',
  346. 'section_ids' => $chapterData['section_ids'],
  347. 'kp_codes' => $kpCodes,
  348. 'has_diagnostic' => $hasDiagnostic,
  349. ];
  350. }
  351. }
  352. // 所有章节都达标
  353. Log::info('DiagnosticChapterService: 所有章节都达标', [
  354. 'textbook_id' => $textbookId,
  355. 'student_id' => $studentId,
  356. ]);
  357. return null;
  358. }
  359. /**
  360. * 获取下一个章节
  361. */
  362. public function getNextChapter(int $textbookId, int $currentChapterId): ?array
  363. {
  364. $chapters = TextbookCatalog::query()
  365. ->where('textbook_id', $textbookId)
  366. ->where('node_type', 'chapter')
  367. ->orderBy('display_no')
  368. ->orderBy('sort_order')
  369. ->orderBy('id')
  370. ->get();
  371. $foundCurrent = false;
  372. foreach ($chapters as $chapter) {
  373. if ($foundCurrent) {
  374. $chapterData = $this->getChapterKnowledgePointsSimple($chapter->id);
  375. $kpCodesWithQuestions = $this->filterKpCodesWithQuestions($chapterData['kp_codes']);
  376. if (!empty($kpCodesWithQuestions)) {
  377. return [
  378. 'chapter_id' => $chapter->id,
  379. 'chapter_name' => $chapter->name ?? '',
  380. 'section_ids' => $chapterData['section_ids'],
  381. 'kp_codes' => $kpCodesWithQuestions,
  382. ];
  383. }
  384. }
  385. if ($chapter->id === $currentChapterId) {
  386. $foundCurrent = true;
  387. }
  388. }
  389. return null; // 没有下一章
  390. }
  391. /**
  392. * 过滤出有题目的知识点
  393. */
  394. public function filterKpCodesWithQuestions(array $kpCodes): array
  395. {
  396. if (empty($kpCodes)) {
  397. return [];
  398. }
  399. $kpCodesWithQuestions = \App\Models\Question::query()
  400. ->whereIn('kp_code', $kpCodes)
  401. ->distinct()
  402. ->pluck('kp_code')
  403. ->toArray();
  404. // 保持原有顺序
  405. return array_values(array_intersect($kpCodes, $kpCodesWithQuestions));
  406. }
  407. /**
  408. * 按顺序获取未达标的知识点(用于智能组卷)
  409. *
  410. * @param int $studentId 学生ID
  411. * @param array $kpCodes 知识点列表(按顺序)
  412. * @param float $threshold 达标阈值
  413. * @param int $maxCount 最多返回几个知识点
  414. * @param int $minQuestions 每个知识点最少需要的题目数
  415. * @return array 未达标的知识点列表
  416. */
  417. public function getUnmasteredKpCodesInOrder(
  418. int $studentId,
  419. array $kpCodes,
  420. float $threshold = 0.9,
  421. int $maxCount = 2,
  422. int $minQuestions = 20
  423. ): array {
  424. if (empty($kpCodes)) {
  425. return [];
  426. }
  427. // 获取掌握度
  428. $levels = StudentKnowledgeMastery::query()
  429. ->where('student_id', $studentId)
  430. ->whereIn('kp_code', $kpCodes)
  431. ->pluck('mastery_level', 'kp_code')
  432. ->toArray();
  433. // 获取每个知识点的题目数量
  434. $questionCounts = \App\Models\Question::query()
  435. ->whereIn('kp_code', $kpCodes)
  436. ->selectRaw('kp_code, COUNT(*) as count')
  437. ->groupBy('kp_code')
  438. ->pluck('count', 'kp_code')
  439. ->toArray();
  440. $result = [];
  441. $totalQuestions = 0;
  442. foreach ($kpCodes as $kpCode) {
  443. // 跳过没有题目的知识点
  444. $count = $questionCounts[$kpCode] ?? 0;
  445. if ($count === 0) {
  446. continue;
  447. }
  448. // 检查是否达标
  449. $level = isset($levels[$kpCode]) ? (float) $levels[$kpCode] : 0.0;
  450. if ($level >= $threshold) {
  451. continue;
  452. }
  453. // 添加到结果
  454. $result[] = $kpCode;
  455. $totalQuestions += $count;
  456. // 检查是否达到最大数量
  457. if (count($result) >= $maxCount) {
  458. break;
  459. }
  460. // 检查题目数量是否足够
  461. if ($totalQuestions >= $minQuestions) {
  462. break;
  463. }
  464. }
  465. Log::info('DiagnosticChapterService: 获取未达标知识点', [
  466. 'student_id' => $studentId,
  467. 'input_kp_count' => count($kpCodes),
  468. 'result_kp_count' => count($result),
  469. 'total_questions' => $totalQuestions,
  470. 'threshold' => $threshold,
  471. ]);
  472. return $result;
  473. }
  474. }