MarkdownImport.php 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  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\HasMany;
  6. use App\Models\PreQuestionCandidate;
  7. class MarkdownImport extends Model
  8. {
  9. use HasFactory;
  10. protected $fillable = [
  11. 'file_name',
  12. 'remote_url',
  13. 'original_markdown',
  14. 'parsed_json',
  15. 'source_type',
  16. 'source_name',
  17. 'status',
  18. 'error_message',
  19. 'progress_stage',
  20. 'progress_message',
  21. 'progress_current',
  22. 'progress_total',
  23. 'progress_updated_at',
  24. 'processing_started_at',
  25. 'processing_finished_at',
  26. ];
  27. protected $casts = [
  28. 'created_at' => 'datetime',
  29. 'updated_at' => 'datetime',
  30. 'progress_updated_at' => 'datetime',
  31. 'processing_started_at' => 'datetime',
  32. 'processing_finished_at' => 'datetime',
  33. ];
  34. public const STATUS_PENDING = 'pending';
  35. public const STATUS_PARSED = 'parsed';
  36. public const STATUS_REVIEWED = 'reviewed';
  37. public const STATUS_COMPLETED = 'completed';
  38. public const STATUS_PROCESSING = 'processing';
  39. public const STATUS_FAILED = 'failed';
  40. public const STAGE_QUEUED = 'queued';
  41. public const STAGE_SPLITTING = 'splitting';
  42. public const STAGE_AI_PARSING = 'ai_parsing';
  43. public const STAGE_WRITING = 'writing';
  44. public const STAGE_PARSED = 'parsed';
  45. public const STAGE_COMPLETED = 'completed';
  46. public const STAGE_FAILED = 'failed';
  47. public function candidates(): HasMany
  48. {
  49. return $this->hasMany(PreQuestionCandidate::class, 'import_id');
  50. }
  51. public function preQuestions(): HasMany
  52. {
  53. return $this->hasMany(PreQuestion::class, 'import_id');
  54. }
  55. public function getStatusBadgeAttribute(): string
  56. {
  57. $badges = [
  58. self::STATUS_PENDING => 'gray',
  59. self::STATUS_PARSED => 'info',
  60. self::STATUS_REVIEWED => 'warning',
  61. self::STATUS_COMPLETED => 'success',
  62. ];
  63. return $badges[$this->status] ?? 'gray';
  64. }
  65. public function getProgressLabelAttribute(): string
  66. {
  67. $stageLabel = match ($this->progress_stage) {
  68. self::STAGE_QUEUED => '已排队',
  69. self::STAGE_SPLITTING => '拆题中',
  70. self::STAGE_AI_PARSING => 'AI 解析中',
  71. self::STAGE_WRITING => '写入候选库',
  72. self::STAGE_PARSED => '已解析',
  73. self::STAGE_COMPLETED => '已完成',
  74. self::STAGE_FAILED => '失败',
  75. default => $this->progress_stage ?: '—',
  76. };
  77. if (($this->progress_total ?? 0) > 0) {
  78. $total = (int) $this->progress_total;
  79. $current = min((int) ($this->progress_current ?? 0), $total);
  80. return sprintf(
  81. '%s %d/%d',
  82. $stageLabel,
  83. $current,
  84. $total
  85. );
  86. }
  87. return $stageLabel;
  88. }
  89. public function getProgressPercentAttribute(): ?int
  90. {
  91. $total = (int) ($this->progress_total ?? 0);
  92. if ($total <= 0) {
  93. return null;
  94. }
  95. $current = (int) ($this->progress_current ?? 0);
  96. return (int) max(0, min(100, round(($current / $total) * 100)));
  97. }
  98. public function getParsedCountAttribute(): int
  99. {
  100. if (array_key_exists('parsed_count', $this->attributes)) {
  101. return (int) $this->attributes['parsed_count'];
  102. }
  103. return $this->candidates()
  104. ->where('status', '!=', PreQuestionCandidate::STATUS_SUPERSEDED)
  105. ->count();
  106. }
  107. public function getAcceptedCountAttribute(): int
  108. {
  109. if (array_key_exists('accepted_count', $this->attributes)) {
  110. return (int) $this->attributes['accepted_count'];
  111. }
  112. return $this->candidates()
  113. ->where('status', '!=', PreQuestionCandidate::STATUS_SUPERSEDED)
  114. ->where('is_question_candidate', true)
  115. ->count();
  116. }
  117. /**
  118. * 获取切分后的候选题目(新的切分格式)
  119. */
  120. public function getSplitCandidatesAttribute(): array
  121. {
  122. if (!$this->parsed_json) {
  123. return [];
  124. }
  125. $data = json_decode($this->parsed_json, true);
  126. return $data['candidates'] ?? [];
  127. }
  128. /**
  129. * 获取统计信息
  130. */
  131. public function getSplitStatisticsAttribute(): array
  132. {
  133. if (!$this->parsed_json) {
  134. return [];
  135. }
  136. $data = json_decode($this->parsed_json, true);
  137. return $data['statistics'] ?? [];
  138. }
  139. /**
  140. * 检查是否已完成
  141. */
  142. public function isCompleted(): bool
  143. {
  144. return $this->status === self::STATUS_COMPLETED;
  145. }
  146. /**
  147. * 检查是否正在处理
  148. */
  149. public function isProcessing(): bool
  150. {
  151. return $this->status === self::STATUS_PROCESSING;
  152. }
  153. /**
  154. * 检查是否失败
  155. */
  156. public function isFailed(): bool
  157. {
  158. return $this->status === self::STATUS_FAILED;
  159. }
  160. /**
  161. * 获取状态标签
  162. */
  163. public function getStatusLabelAttribute(): string
  164. {
  165. return match ($this->status) {
  166. self::STATUS_PENDING => '等待处理',
  167. self::STATUS_PROCESSING => '处理中',
  168. self::STATUS_COMPLETED => '已完成',
  169. self::STATUS_FAILED => '处理失败',
  170. self::STATUS_PARSED => '已解析',
  171. self::STATUS_REVIEWED => '已审核',
  172. default => '未知',
  173. };
  174. }
  175. /**
  176. * 获取状态颜色
  177. */
  178. public function getSplitStatusColorAttribute(): string
  179. {
  180. return match ($this->status) {
  181. self::STATUS_PENDING => 'gray',
  182. self::STATUS_PROCESSING => 'warning',
  183. self::STATUS_COMPLETED => 'success',
  184. self::STATUS_FAILED => 'danger',
  185. self::STATUS_PARSED => 'info',
  186. self::STATUS_REVIEWED => 'primary',
  187. default => 'gray',
  188. };
  189. }
  190. /**
  191. * 检查并修复卡住的状态(超过30分钟无进度则重置)
  192. */
  193. public function checkAndFixStuckStatus(): void
  194. {
  195. if ($this->status !== self::STATUS_PROCESSING) {
  196. return;
  197. }
  198. // 超过30分钟无进度则重置
  199. if ($this->progress_updated_at?->lt(now()->subMinutes(30))) {
  200. \Log::warning('Auto-fixing stuck markdown import', [
  201. 'import_id' => $this->id,
  202. 'last_update' => $this->progress_updated_at,
  203. ]);
  204. $this->update([
  205. 'status' => self::STATUS_PENDING,
  206. 'progress_stage' => self::STAGE_QUEUED,
  207. 'progress_message' => '自动重置为待处理(超时)',
  208. 'error_message' => '队列任务可能已丢失,请重新提交',
  209. 'processing_finished_at' => now(),
  210. ]);
  211. }
  212. }
  213. /**
  214. * 静态方法:批量检查并修复卡住的记录
  215. */
  216. public static function checkAndFixAllStuckRecords(): int
  217. {
  218. $stuckRecords = self::where('status', self::STATUS_PROCESSING)
  219. ->where('progress_updated_at', '<', now()->subMinutes(30))
  220. ->get();
  221. $fixed = 0;
  222. foreach ($stuckRecords as $record) {
  223. $record->checkAndFixStuckStatus();
  224. $fixed++;
  225. }
  226. if ($fixed > 0) {
  227. \Log::info('Auto-fixed stuck markdown imports', [
  228. 'count' => $fixed,
  229. ]);
  230. }
  231. return $fixed;
  232. }
  233. public function parseFilename(): array
  234. {
  235. if (empty($this->file_name)) {
  236. return [];
  237. }
  238. $base = pathinfo((string) $this->file_name, PATHINFO_FILENAME);
  239. $parts = array_map('trim', explode('_', $base));
  240. if (count($parts) < 4) {
  241. return [];
  242. }
  243. $series = $parts[0] ?? null;
  244. $grade = isset($parts[1]) && is_numeric($parts[1]) ? (int) $parts[1] : null;
  245. $termFlag = isset($parts[2]) && is_numeric($parts[2]) ? (int) $parts[2] : null;
  246. $subject = $parts[3] ?? null;
  247. $name = trim(implode('_', array_slice($parts, 4)));
  248. $term = match ($termFlag) {
  249. 1 => '上册',
  250. 2 => '下册',
  251. 0 => '上下册',
  252. default => null,
  253. };
  254. return [
  255. 'series' => $series,
  256. 'grade' => $grade,
  257. 'term' => $term,
  258. 'subject' => $subject,
  259. 'name' => $name !== '' ? $name : $base,
  260. ];
  261. }
  262. }