StudentProgressService.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409
  1. <?php
  2. namespace App\Services;
  3. use Illuminate\Support\Facades\DB;
  4. use Illuminate\Support\Facades\Log;
  5. use Illuminate\Support\Facades\Cache;
  6. use App\Models\Student;
  7. class StudentProgressService
  8. {
  9. /**
  10. * 获取单个学生的学习进度
  11. */
  12. public function getProgress(string $studentId): array
  13. {
  14. Log::info('获取学生学习进度', ['student_id' => $studentId]);
  15. $studentGrade = Student::query()
  16. ->where('student_id', $studentId)
  17. ->value('grade');
  18. // 获取知识图谱结构
  19. $graphData = $this->getKnowledgeGraphStructure($studentGrade ? (int) $studentGrade : null);
  20. $leafKpCodes = $graphData['leafKpCodes'];
  21. $leafKpCodesSet = $graphData['leafKpCodesSet'];
  22. $kpCodes = $graphData['kpCodes'];
  23. $maxChildScore = count($leafKpCodes) * 1.0;
  24. // 获取学生掌握度数据
  25. $mergedData = $this->getStudentMasteryData($studentId);
  26. $mergedCountBefore = count($mergedData);
  27. // 仅保留叶子节点数据,避免父节点干扰进度
  28. $mergedData = array_filter($mergedData, function ($item) use ($leafKpCodesSet) {
  29. return isset($leafKpCodesSet[$item['kp_code']]);
  30. });
  31. if ($mergedCountBefore > 0 && count($mergedData) !== $mergedCountBefore) {
  32. Log::info('学习进度过滤父节点', [
  33. 'student_id' => $studentId,
  34. 'before' => $mergedCountBefore,
  35. 'after' => count($mergedData),
  36. ]);
  37. }
  38. // 如果没有数据,返回空结构(和批量接口行为一致)
  39. if (empty($mergedData)) {
  40. return [
  41. 'success' => true,
  42. 'data' => [
  43. 'student_id' => $studentId,
  44. 'learning_progress' => 0,
  45. 'learning_progress_percentage' => 0,
  46. 'mastered_child_count' => 0,
  47. 'total_child_count' => count($leafKpCodes),
  48. 'child_mastery_sum' => 0,
  49. 'has_data' => false,
  50. 'calculated_at' => now()->toISOString()
  51. ],
  52. 'message' => '该学生暂无学习数据'
  53. ];
  54. }
  55. // 筛选叶子节点
  56. $childMasteryData = collect($mergedData);
  57. // 如果没有子知识点数据,也返回空结构
  58. if ($childMasteryData->isEmpty()) {
  59. return [
  60. 'success' => true,
  61. 'data' => [
  62. 'student_id' => $studentId,
  63. 'learning_progress' => 0,
  64. 'learning_progress_percentage' => 0,
  65. 'mastered_child_count' => 0,
  66. 'total_child_count' => count($leafKpCodes),
  67. 'child_mastery_sum' => 0,
  68. 'has_data' => false,
  69. 'calculated_at' => now()->toISOString()
  70. ],
  71. 'message' => '该学生暂无子知识点学习数据'
  72. ];
  73. }
  74. // 计算学习进度
  75. $totalChildMasterySum = $childMasteryData->sum('mastery_level');
  76. $learningProgress = $maxChildScore > 0 ? ($totalChildMasterySum / $maxChildScore) : 0.0;
  77. // 统计信息
  78. $statistics = [
  79. 'total_knowledge_points' => count($kpCodes),
  80. 'child_knowledge_points' => count($leafKpCodes),
  81. 'student_mastered_child_count' => $childMasteryData->count(),
  82. 'student_mastered_child_percentage' => round(($childMasteryData->count() / count($leafKpCodes)) * 100, 2),
  83. 'child_mastery_sum' => round($totalChildMasterySum, 4),
  84. 'max_child_score' => round($maxChildScore, 4),
  85. 'learning_progress_percentage' => round($learningProgress * 100, 2),
  86. 'data_source' => 'student_knowledge_mastery',
  87. 'child_mastery_max' => round($childMasteryData->max('mastery_level'), 4),
  88. 'child_mastery_min' => round($childMasteryData->min('mastery_level'), 4),
  89. 'child_mastery_avg' => round($childMasteryData->avg('mastery_level'), 4),
  90. ];
  91. Log::info('学生学习进度计算成功', [
  92. 'student_id' => $studentId,
  93. 'learning_progress' => $learningProgress,
  94. ]);
  95. return [
  96. 'success' => true,
  97. 'data' => [
  98. 'student_id' => $studentId,
  99. 'learning_progress' => round($learningProgress, 6),
  100. 'learning_progress_percentage' => round($learningProgress * 100, 2),
  101. 'mastered_child_count' => $childMasteryData->count(),
  102. 'total_child_count' => count($leafKpCodes),
  103. 'child_mastery_sum' => round($totalChildMasterySum, 4),
  104. 'has_data' => true,
  105. 'child_knowledge_points' => $childMasteryData->values()->toArray(),
  106. 'statistics' => $statistics,
  107. 'calculated_at' => now()->toISOString()
  108. ],
  109. 'message' => '学习进度计算成功'
  110. ];
  111. }
  112. /**
  113. * 批量获取学生学习进度
  114. */
  115. public function getBatchProgress(array $studentIds): array
  116. {
  117. if (empty($studentIds)) {
  118. return [
  119. 'success' => false,
  120. 'error' => 'student_ids 不能为空'
  121. ];
  122. }
  123. if (count($studentIds) > 100) {
  124. return [
  125. 'success' => false,
  126. 'error' => '单次最多查询 100 个学生'
  127. ];
  128. }
  129. Log::info('批量获取学生学习进度', ['count' => count($studentIds)]);
  130. $gradeMap = Student::query()
  131. ->whereIn('student_id', $studentIds)
  132. ->pluck('grade', 'student_id')
  133. ->toArray();
  134. $stageGraph = [];
  135. $stageStats = [];
  136. // 批量获取掌握度数据
  137. $batchData = $this->getBatchStudentMasteryData($studentIds);
  138. $detailedData = $batchData['detailed'];
  139. $simpleData = $batchData['simple'];
  140. // 为每个学生计算学习进度
  141. $results = [];
  142. foreach ($studentIds as $studentId) {
  143. $studentId = (string) $studentId;
  144. $grade = $gradeMap[$studentId] ?? null;
  145. $stageKey = $this->getStageKey($grade ? (int) $grade : null);
  146. if (!isset($stageGraph[$stageKey])) {
  147. $stageGraph[$stageKey] = $this->getKnowledgeGraphStructure($grade ? (int) $grade : null);
  148. $stageStats[$stageKey] = [
  149. 'total_child_knowledge_points' => count($stageGraph[$stageKey]['leafKpCodes']),
  150. 'student_count' => 0,
  151. ];
  152. }
  153. $stageStats[$stageKey]['student_count']++;
  154. $leafKpCodesSet = $stageGraph[$stageKey]['leafKpCodesSet'];
  155. $maxChildScore = count($stageGraph[$stageKey]['leafKpCodes']) * 1.0;
  156. $mergedData = [];
  157. // 从 student_knowledge_mastery 获取
  158. if (isset($detailedData[$studentId])) {
  159. foreach ($detailedData[$studentId] as $item) {
  160. $mergedData[$item->kp_code] = [
  161. 'kp_code' => $item->kp_code,
  162. 'mastery_level' => (float) $item->mastery_level,
  163. ];
  164. }
  165. }
  166. // 从 student_mastery 补充或更新
  167. if (isset($simpleData[$studentId])) {
  168. foreach ($simpleData[$studentId] as $item) {
  169. $kpCode = $item->kp_code;
  170. $masteryLevel = (float) $item->mastery;
  171. if (isset($mergedData[$kpCode])) {
  172. if ($masteryLevel > $mergedData[$kpCode]['mastery_level']) {
  173. $mergedData[$kpCode]['mastery_level'] = $masteryLevel;
  174. }
  175. } else {
  176. $mergedData[$kpCode] = [
  177. 'kp_code' => $kpCode,
  178. 'mastery_level' => $masteryLevel,
  179. ];
  180. }
  181. }
  182. }
  183. // 计算学习进度
  184. if (empty($mergedData)) {
  185. $results[$studentId] = [
  186. 'student_id' => $studentId,
  187. 'learning_progress' => 0,
  188. 'learning_progress_percentage' => 0,
  189. 'mastered_child_count' => 0,
  190. 'total_child_count' => count($stageGraph[$stageKey]['leafKpCodes']),
  191. 'has_data' => false,
  192. ];
  193. continue;
  194. }
  195. $mergedCountBefore = count($mergedData);
  196. // 仅保留叶子节点数据,避免父节点干扰进度
  197. $mergedData = array_filter($mergedData, function ($item) use ($leafKpCodesSet) {
  198. return isset($leafKpCodesSet[$item['kp_code']]);
  199. });
  200. if ($mergedCountBefore > 0 && count($mergedData) !== $mergedCountBefore) {
  201. Log::info('批量学习进度过滤父节点', [
  202. 'student_id' => $studentId,
  203. 'before' => $mergedCountBefore,
  204. 'after' => count($mergedData),
  205. ]);
  206. }
  207. // 筛选叶子节点并计算
  208. $childMasterySum = 0;
  209. $masteredChildCount = 0;
  210. foreach ($mergedData as $item) {
  211. if (isset($leafKpCodesSet[$item['kp_code']])) {
  212. $childMasterySum += $item['mastery_level'];
  213. $masteredChildCount++;
  214. }
  215. }
  216. $learningProgress = $maxChildScore > 0 ? ($childMasterySum / $maxChildScore) : 0.0;
  217. $results[$studentId] = [
  218. 'student_id' => $studentId,
  219. 'learning_progress' => round($learningProgress, 6),
  220. 'learning_progress_percentage' => round($learningProgress * 100, 2),
  221. 'mastered_child_count' => $masteredChildCount,
  222. 'total_child_count' => count($stageGraph[$stageKey]['leafKpCodes']),
  223. 'child_mastery_sum' => round($childMasterySum, 4),
  224. 'has_data' => true,
  225. ];
  226. }
  227. Log::info('批量学习进度计算完成', ['count' => count($results)]);
  228. return [
  229. 'success' => true,
  230. 'data' => $results,
  231. 'meta' => [
  232. 'total_students' => count($studentIds),
  233. 'stage_summary' => $stageStats,
  234. 'calculated_at' => now()->toISOString(),
  235. ]
  236. ];
  237. }
  238. /**
  239. * 获取知识图谱结构(缓存 5 分钟)
  240. */
  241. private function getKnowledgeGraphStructure(?int $grade = null): array
  242. {
  243. $stageKey = $this->getStageKey($grade);
  244. $cacheKey = 'knowledge_graph_structure_' . $stageKey;
  245. return Cache::remember($cacheKey, 300, function () use ($grade) {
  246. $query = DB::connection('remote_mysql')
  247. ->table('knowledge_points')
  248. ->select(['kp_code', 'parent_kp_code', 'grade']);
  249. $stageLabel = $this->getStageLabel($grade);
  250. if ($stageLabel) {
  251. $query->where('grade', $stageLabel);
  252. }
  253. $allKps = $query->get();
  254. $kpCodes = $allKps->pluck('kp_code')->toArray();
  255. $parentCodes = $allKps->whereNotNull('parent_kp_code')->pluck('parent_kp_code')->unique()->toArray();
  256. $leafKpCodes = array_values(array_diff($kpCodes, $parentCodes));
  257. $maxDepth = $this->calculateMaxDepth($allKps);
  258. return [
  259. 'kpCodes' => $kpCodes,
  260. 'leafKpCodes' => $leafKpCodes,
  261. 'leafKpCodesSet' => array_flip($leafKpCodes),
  262. 'maxDepth' => $maxDepth,
  263. ];
  264. });
  265. }
  266. private function getStageLabel(?int $grade): ?string
  267. {
  268. if ($grade === null || $grade <= 0) {
  269. return null;
  270. }
  271. if ($grade <= 6) {
  272. return '小学';
  273. }
  274. if ($grade <= 9) {
  275. return '初中';
  276. }
  277. return '高中';
  278. }
  279. private function getStageKey(?int $grade): string
  280. {
  281. $label = $this->getStageLabel($grade);
  282. return $label ?: 'all';
  283. }
  284. private function calculateMaxDepth($knowledgePoints): int
  285. {
  286. $children = [];
  287. foreach ($knowledgePoints as $kp) {
  288. if (!empty($kp->parent_kp_code)) {
  289. $children[$kp->parent_kp_code][] = $kp->kp_code;
  290. }
  291. }
  292. $depthCache = [];
  293. $maxDepth = 1;
  294. $visit = function ($kpCode) use (&$visit, &$children, &$depthCache, &$maxDepth): int {
  295. if (isset($depthCache[$kpCode])) {
  296. return $depthCache[$kpCode];
  297. }
  298. if (empty($children[$kpCode])) {
  299. $depthCache[$kpCode] = 1;
  300. return 1;
  301. }
  302. $childDepths = array_map(fn ($child) => $visit($child), $children[$kpCode]);
  303. $depthCache[$kpCode] = 1 + max($childDepths);
  304. $maxDepth = max($maxDepth, $depthCache[$kpCode]);
  305. return $depthCache[$kpCode];
  306. };
  307. foreach ($knowledgePoints as $kp) {
  308. $visit($kp->kp_code);
  309. }
  310. return $maxDepth;
  311. }
  312. /**
  313. * 获取单个学生的掌握度数据
  314. */
  315. private function getStudentMasteryData(string $studentId): array
  316. {
  317. $mergedData = [];
  318. try {
  319. $detailedData = DB::connection('remote_mysql')
  320. ->table('student_knowledge_mastery')
  321. ->where('student_id', $studentId)
  322. ->select(['kp_code', 'mastery_level', 'total_attempts', 'correct_attempts', 'updated_at'])
  323. ->get();
  324. foreach ($detailedData as $item) {
  325. $mergedData[$item->kp_code] = [
  326. 'kp_code' => $item->kp_code,
  327. 'mastery_level' => (float) $item->mastery_level,
  328. 'total_attempts' => $item->total_attempts,
  329. 'correct_attempts' => $item->correct_attempts,
  330. 'source_table' => 'student_knowledge_mastery',
  331. 'updated_at' => $item->updated_at
  332. ];
  333. }
  334. } catch (\Exception $e) {
  335. Log::warning('从 student_knowledge_mastery 获取数据失败', ['error' => $e->getMessage()]);
  336. }
  337. return $mergedData;
  338. }
  339. /**
  340. * 批量获取学生掌握度数据
  341. */
  342. private function getBatchStudentMasteryData(array $studentIds): array
  343. {
  344. $detailedData = DB::connection('remote_mysql')
  345. ->table('student_knowledge_mastery')
  346. ->whereIn('student_id', $studentIds)
  347. ->select(['student_id', 'kp_code', 'mastery_level'])
  348. ->get()
  349. ->groupBy('student_id');
  350. return [
  351. 'detailed' => $detailedData->toArray(),
  352. 'simple' => [],
  353. ];
  354. }
  355. }