StudentKnowledgeMastery.php 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. <?php
  2. namespace App\Models;
  3. use Illuminate\Database\Eloquent\Factories\HasFactory;
  4. use Illuminate\Database\Eloquent\Model;
  5. use Illuminate\Database\Eloquent\Relations\BelongsTo;
  6. class StudentKnowledgeMastery extends Model
  7. {
  8. use HasFactory;
  9. protected $table = 'student_knowledge_mastery';
  10. protected $fillable = [
  11. 'student_id',
  12. 'kp_code',
  13. 'mastery_level',
  14. 'direct_mastery_level', // 直接学习掌握度(答题计算),判断达标时优先使用
  15. 'confidence_level',
  16. 'total_attempts',
  17. 'correct_attempts',
  18. 'incorrect_attempts',
  19. 'partial_attempts',
  20. 'avg_time_seconds',
  21. 'fastest_time',
  22. 'slowest_time',
  23. 'attempts_easy',
  24. 'attempts_medium',
  25. 'attempts_hard',
  26. 'correct_easy',
  27. 'correct_medium',
  28. 'correct_hard',
  29. 'first_attempt_at',
  30. 'last_attempt_at',
  31. 'last_mastery_update',
  32. 'mastery_trend',
  33. 'mastery_change',
  34. 'calculation_version',
  35. 'notes',
  36. ];
  37. protected $casts = [
  38. 'mastery_level' => 'decimal:4',
  39. 'direct_mastery_level' => 'decimal:4',
  40. 'confidence_level' => 'decimal:4',
  41. 'mastery_change' => 'decimal:4',
  42. 'avg_time_seconds' => 'decimal:2',
  43. 'first_attempt_at' => 'datetime',
  44. 'last_attempt_at' => 'datetime',
  45. 'last_mastery_update' => 'datetime',
  46. 'created_at' => 'datetime',
  47. 'updated_at' => 'datetime',
  48. ];
  49. /**
  50. * 关联学生
  51. */
  52. public function student(): BelongsTo
  53. {
  54. return $this->belongsTo(Student::class, 'student_id', 'student_id');
  55. }
  56. /**
  57. * 关联知识点
  58. */
  59. public function knowledgePoint(): BelongsTo
  60. {
  61. return $this->belongsTo(KnowledgePoint::class, 'kp_code', 'kp_code');
  62. }
  63. /**
  64. * 作用域:按学生筛选
  65. */
  66. public function scopeForStudent($query, string $studentId)
  67. {
  68. return $query->where('student_id', $studentId);
  69. }
  70. /**
  71. * 作用域:按知识点筛选
  72. */
  73. public function scopeForKnowledgePoint($query, string $kpCode)
  74. {
  75. return $query->where('kp_code', $kpCode);
  76. }
  77. /**
  78. * 作用域:薄弱点(掌握度低于阈值)
  79. */
  80. public function scopeWeaknesses($query, float $threshold = 0.7)
  81. {
  82. return $query->where('mastery_level', '<', $threshold);
  83. }
  84. /**
  85. * 作用域:按掌握度排序
  86. */
  87. public function scopeOrderByMastery($query, string $direction = 'asc')
  88. {
  89. return $query->orderBy('mastery_level', $direction);
  90. }
  91. /**
  92. * 获取薄弱点列表
  93. */
  94. public static function getWeaknesses(string $studentId, float $threshold = 0.7, int $limit = 20): array
  95. {
  96. return self::forStudent($studentId)
  97. ->weaknesses($threshold)
  98. ->orderByMastery('asc')
  99. ->limit($limit)
  100. ->get()
  101. ->toArray();
  102. }
  103. public static function allAtLeast(int $studentId, array $kpCodes, float $threshold): bool
  104. {
  105. if (empty($kpCodes)) {
  106. return false;
  107. }
  108. // 使用 DB::table 避免 Eloquent accessor 把数字转成文字标签
  109. // 【新增】同时获取 direct_mastery_level,判断时优先使用
  110. $records = \Illuminate\Support\Facades\DB::table('student_knowledge_mastery')
  111. ->where('student_id', $studentId)
  112. ->whereIn('kp_code', $kpCodes)
  113. ->get(['kp_code', 'mastery_level', 'direct_mastery_level'])
  114. ->keyBy('kp_code');
  115. foreach ($kpCodes as $kpCode) {
  116. $record = $records->get($kpCode);
  117. // 取 direct_mastery_level 和 mastery_level 的最大值
  118. // 避免"学了之后反而从达标变成未达标"的问题
  119. if ($record) {
  120. $direct = $record->direct_mastery_level;
  121. $mastery = (float) $record->mastery_level;
  122. $level = $direct !== null ? max((float) $direct, $mastery) : $mastery;
  123. } else {
  124. $level = 0.0;
  125. }
  126. if ($level < $threshold) {
  127. return false;
  128. }
  129. }
  130. return true;
  131. }
  132. /**
  133. * 判断所有知识点是否达标(跳过没有题目的知识点)
  134. * 用于章节摸底后的知识点学习流程
  135. *
  136. * @param int $studentId 学生ID
  137. * @param array $kpCodes 知识点编码列表
  138. * @param float $threshold 达标阈值(默认0.9)
  139. * @return bool 是否全部达标
  140. */
  141. public static function allAtLeastSkipNoQuestions(int $studentId, array $kpCodes, float $threshold = 0.9): bool
  142. {
  143. if (empty($kpCodes)) {
  144. return true; // 没有知识点,视为达标
  145. }
  146. // 获取掌握度(使用 DB::table 避免 Eloquent accessor 把数字转成文字标签)
  147. // 【新增】同时获取 direct_mastery_level,判断时优先使用
  148. $records = \Illuminate\Support\Facades\DB::table('student_knowledge_mastery')
  149. ->where('student_id', $studentId)
  150. ->whereIn('kp_code', $kpCodes)
  151. ->get(['kp_code', 'mastery_level', 'direct_mastery_level'])
  152. ->keyBy('kp_code');
  153. // 获取有题目的知识点
  154. $kpCodesWithQuestions = \App\Models\Question::query()
  155. ->whereIn('kp_code', $kpCodes)
  156. ->distinct()
  157. ->pluck('kp_code')
  158. ->toArray();
  159. $hasAnyKpWithQuestions = false;
  160. foreach ($kpCodes as $kpCode) {
  161. // 跳过没有题目的知识点
  162. if (!in_array($kpCode, $kpCodesWithQuestions)) {
  163. continue;
  164. }
  165. $hasAnyKpWithQuestions = true;
  166. // 取 direct_mastery_level 和 mastery_level 的最大值
  167. // 避免"学了之后反而从达标变成未达标"的问题
  168. $record = $records->get($kpCode);
  169. if ($record) {
  170. $direct = $record->direct_mastery_level;
  171. $mastery = (float) $record->mastery_level;
  172. $level = $direct !== null ? max((float) $direct, $mastery) : $mastery;
  173. } else {
  174. $level = 0.0;
  175. }
  176. if ($level < $threshold) {
  177. return false;
  178. }
  179. }
  180. // 如果没有任何有题的知识点,视为达标
  181. return $hasAnyKpWithQuestions ? true : true;
  182. }
  183. /**
  184. * 获取第一个未达标的知识点(跳过没有题目的知识点)
  185. *
  186. * @param int $studentId 学生ID
  187. * @param array $kpCodes 知识点编码列表(按顺序)
  188. * @param float $threshold 达标阈值(默认0.9)
  189. * @return string|null 第一个未达标的知识点编码,如果全部达标返回null
  190. */
  191. public static function getFirstUnmasteredKpCode(int $studentId, array $kpCodes, float $threshold = 0.9): ?string
  192. {
  193. if (empty($kpCodes)) {
  194. return null;
  195. }
  196. // 获取掌握度(使用 DB::table 避免 Eloquent accessor 把数字转成文字标签)
  197. // 【新增】同时获取 direct_mastery_level,判断时优先使用
  198. $records = \Illuminate\Support\Facades\DB::table('student_knowledge_mastery')
  199. ->where('student_id', $studentId)
  200. ->whereIn('kp_code', $kpCodes)
  201. ->get(['kp_code', 'mastery_level', 'direct_mastery_level'])
  202. ->keyBy('kp_code');
  203. // 获取有题目的知识点
  204. $kpCodesWithQuestions = \App\Models\Question::query()
  205. ->whereIn('kp_code', $kpCodes)
  206. ->distinct()
  207. ->pluck('kp_code')
  208. ->toArray();
  209. foreach ($kpCodes as $kpCode) {
  210. // 跳过没有题目的知识点
  211. if (!in_array($kpCode, $kpCodesWithQuestions)) {
  212. continue;
  213. }
  214. // 取 direct_mastery_level 和 mastery_level 的最大值
  215. // 避免"学了之后反而从达标变成未达标"的问题
  216. $record = $records->get($kpCode);
  217. if ($record) {
  218. $direct = $record->direct_mastery_level;
  219. $mastery = (float) $record->mastery_level;
  220. $level = $direct !== null ? max((float) $direct, $mastery) : $mastery;
  221. } else {
  222. $level = 0.0;
  223. }
  224. if ($level < $threshold) {
  225. return $kpCode;
  226. }
  227. }
  228. return null; // 全部达标
  229. }
  230. /**
  231. * 计算掌握度等级
  232. */
  233. public function getMasteryLevelAttribute($value): string
  234. {
  235. if ($value >= 0.85) {
  236. return '优秀';
  237. } elseif ($value >= 0.70) {
  238. return '良好';
  239. } elseif ($value >= 0.50) {
  240. return '及格';
  241. } else {
  242. return '薄弱';
  243. }
  244. }
  245. /**
  246. * 获取趋势标签
  247. */
  248. public function getTrendLabelAttribute(): string
  249. {
  250. return match ($this->mastery_trend) {
  251. 'improving' => '上升',
  252. 'declining' => '下降',
  253. 'stable' => '稳定',
  254. 'insufficient' => '数据不足',
  255. default => '未知',
  256. };
  257. }
  258. /**
  259. * 计算成功率
  260. */
  261. public function getSuccessRateAttribute(): float
  262. {
  263. if ($this->total_attempts <= 0) {
  264. return 0.0;
  265. }
  266. return round(($this->correct_attempts / $this->total_attempts) * 100, 2);
  267. }
  268. }