QuestionLocalService.php 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817
  1. <?php
  2. namespace App\Services;
  3. use App\Models\KnowledgePoint;
  4. use App\Models\Question;
  5. use App\Services\KnowledgeServiceApi;
  6. use Illuminate\Support\Collection;
  7. use Illuminate\Support\Facades\Cache;
  8. use Illuminate\Support\Facades\DB;
  9. use Illuminate\Support\Facades\Log;
  10. use Illuminate\Support\Str;
  11. class QuestionLocalService
  12. {
  13. private DifficultyDistributionService $difficultyDistributionService;
  14. public function __construct(?DifficultyDistributionService $difficultyDistributionService = null)
  15. {
  16. $this->difficultyDistributionService = $difficultyDistributionService
  17. ?? app(DifficultyDistributionService::class);
  18. }
  19. public function listQuestions(int $page = 1, int $perPage = 50, array $filters = []): array
  20. {
  21. $query = $this->applyFilters(Question::query(), $filters);
  22. $paginator = $query->orderByDesc('id')->paginate($perPage, ['*'], 'page', $page);
  23. $data = $this->mapQuestions(collect($paginator->items()));
  24. return [
  25. 'data' => $data,
  26. 'meta' => [
  27. 'page' => $paginator->currentPage(),
  28. 'per_page' => $paginator->perPage(),
  29. 'total' => $paginator->total(),
  30. 'total_pages' => $paginator->lastPage(),
  31. ],
  32. ];
  33. }
  34. public function getQuestionById(int $id): ?array
  35. {
  36. $question = Question::find($id);
  37. if (!$question) {
  38. return null;
  39. }
  40. return $this->mapQuestion($question);
  41. }
  42. public function getQuestionByCode(string $questionCode): ?array
  43. {
  44. $question = Question::where('question_code', $questionCode)->first();
  45. if (!$question) {
  46. return null;
  47. }
  48. return $this->mapQuestion($question);
  49. }
  50. public function updateQuestionByCode(string $questionCode, array $payload): bool
  51. {
  52. $question = Question::where('question_code', $questionCode)->first();
  53. if (!$question) {
  54. return false;
  55. }
  56. $question->fill($this->normalizePayload($payload));
  57. $question->save();
  58. return true;
  59. }
  60. public function deleteQuestionByCode(string $questionCode): bool
  61. {
  62. $question = Question::where('question_code', $questionCode)->first();
  63. if (!$question) {
  64. return false;
  65. }
  66. $question->delete();
  67. return true;
  68. }
  69. public function deleteQuestionById(int $id): bool
  70. {
  71. $question = Question::find($id);
  72. if (!$question) {
  73. return false;
  74. }
  75. $question->delete();
  76. return true;
  77. }
  78. public function searchQuestions(string $query, int $limit = 20): array
  79. {
  80. $questions = Question::query()
  81. ->search($query)
  82. ->orderByDesc('id')
  83. ->limit($limit)
  84. ->get();
  85. return [
  86. 'data' => $this->mapQuestions($questions),
  87. ];
  88. }
  89. public function getQuestionsByIds(array $ids): array
  90. {
  91. if (empty($ids)) {
  92. return ['data' => []];
  93. }
  94. $questions = Question::query()
  95. ->whereIn('id', $ids)
  96. ->orderByDesc('id')
  97. ->get();
  98. return [
  99. 'data' => $this->mapQuestions($questions),
  100. ];
  101. }
  102. public function getQuestionsByKpCode(string $kpCode, int $limit = 100): array
  103. {
  104. $kpCode = trim($kpCode);
  105. $query = Question::query()->where(function ($q) use ($kpCode): void {
  106. $q->where('kp_code', $kpCode);
  107. if ($kpCode !== '' && Schema::hasTable('question_kp_relations')) {
  108. $q->orWhereIn('id', function ($sub) use ($kpCode): void {
  109. $sub->select('question_id')
  110. ->from('question_kp_relations')
  111. ->where('kp_code', $kpCode);
  112. });
  113. }
  114. });
  115. $questions = $query->orderByDesc('id')->limit($limit)->get();
  116. return [
  117. 'data' => $this->mapQuestions($questions),
  118. ];
  119. }
  120. /**
  121. * 按 kp 统计正式库题目数:主表 kp_code 与 question_kp_relations 合并,同一题同一 kp 只计一次。
  122. *
  123. * @param list<string>|null $onlyKpCodes 非空时仅返回这些 kp 的计数
  124. * @return array<string, int>
  125. */
  126. public function questionCountsGroupedByKp(?array $onlyKpCodes = null): array
  127. {
  128. if (! Schema::hasTable('questions')) {
  129. return [];
  130. }
  131. $onlyKpCodes = $onlyKpCodes !== null
  132. ? array_values(array_unique(array_filter(array_map('strval', $onlyKpCodes))))
  133. : null;
  134. if ($onlyKpCodes === []) {
  135. return [];
  136. }
  137. $relationUnion = '';
  138. if (Schema::hasTable('question_kp_relations')) {
  139. $relationUnion = " UNION ALL SELECT question_id AS qid, kp_code FROM question_kp_relations WHERE kp_code IS NOT NULL AND kp_code != ''";
  140. }
  141. $sql = "SELECT kp_code, COUNT(DISTINCT qid) AS c FROM (
  142. SELECT id AS qid, kp_code FROM questions WHERE kp_code IS NOT NULL AND kp_code != ''
  143. {$relationUnion}
  144. ) merged";
  145. $bindings = [];
  146. if ($onlyKpCodes !== null) {
  147. $placeholders = implode(',', array_fill(0, count($onlyKpCodes), '?'));
  148. $sql .= " WHERE kp_code IN ({$placeholders})";
  149. $bindings = $onlyKpCodes;
  150. }
  151. $sql .= ' GROUP BY kp_code';
  152. $out = [];
  153. foreach (DB::select($sql, $bindings) as $row) {
  154. $k = (string) ($row->kp_code ?? '');
  155. if ($k !== '') {
  156. $out[$k] = (int) ($row->c ?? 0);
  157. }
  158. }
  159. return $out;
  160. }
  161. public function getStatistics(array $filters = []): array
  162. {
  163. $baseQuery = $this->applyFilters(Question::query(), $filters);
  164. $total = (clone $baseQuery)->count();
  165. $byDifficulty = (clone $baseQuery)
  166. ->selectRaw('difficulty, COUNT(*) as total')
  167. ->groupBy('difficulty')
  168. ->pluck('total', 'difficulty')
  169. ->toArray();
  170. $byTypeRaw = (clone $baseQuery)
  171. ->selectRaw('question_type, COUNT(*) as total')
  172. ->groupBy('question_type')
  173. ->pluck('total', 'question_type')
  174. ->toArray();
  175. $byType = [];
  176. foreach ($byTypeRaw as $type => $count) {
  177. $label = $this->mapQuestionTypeLabel((string) $type);
  178. $byType[$label] = ($byType[$label] ?? 0) + $count;
  179. }
  180. $byKp = (clone $baseQuery)
  181. ->selectRaw('kp_code, COUNT(*) as total')
  182. ->groupBy('kp_code')
  183. ->pluck('total', 'kp_code')
  184. ->toArray();
  185. $bySource = (clone $baseQuery)
  186. ->selectRaw('source, COUNT(*) as total')
  187. ->groupBy('source')
  188. ->pluck('total', 'source')
  189. ->toArray();
  190. return [
  191. 'total' => $total,
  192. 'by_difficulty' => $byDifficulty,
  193. 'by_type' => $byType,
  194. 'by_kp' => $byKp,
  195. 'by_source' => $bySource,
  196. ];
  197. }
  198. public function generateQuestions(array $params): array
  199. {
  200. $kpCode = $params['kp_code'] ?? null;
  201. // 允许 kp_code 为空,此时从所有可用题目中选择
  202. if (!$kpCode) {
  203. // 从 params 中获取 kp_codes 数组
  204. $kpCodes = $params['kp_codes'] ?? [];
  205. if (is_string($kpCodes)) {
  206. $kpCodes = array_map('trim', explode(',', $kpCodes));
  207. }
  208. if (is_array($kpCodes) && !empty($kpCodes)) {
  209. $kpCode = $kpCodes[0]; // 使用第一个知识点
  210. } else {
  211. // 如果没有指定知识点,从数据库中随机选择一个可用的知识点
  212. $availableKp = Question::query()
  213. ->whereNotNull('kp_code')
  214. ->where('kp_code', '!=', '')
  215. ->distinct()
  216. ->pluck('kp_code')
  217. ->first();
  218. if ($availableKp) {
  219. $kpCode = $availableKp;
  220. } else {
  221. return [
  222. 'success' => false,
  223. 'message' => '系统中没有可用的题目,请先添加题目数据',
  224. ];
  225. }
  226. }
  227. }
  228. $count = max(1, (int) ($params['count'] ?? 1));
  229. $keyword = (string) ($params['keyword'] ?? '');
  230. $type = $params['type'] ?? null;
  231. $difficulty = $params['difficulty'] ?? null;
  232. $skills = $params['skills'] ?? [];
  233. $solutionService = app(AiSolutionService::class);
  234. $created = [];
  235. DB::transaction(function () use (
  236. $count,
  237. $kpCode,
  238. $keyword,
  239. $type,
  240. $difficulty,
  241. $skills,
  242. $solutionService,
  243. &$created,
  244. $params
  245. ) {
  246. for ($i = 1; $i <= $count; $i++) {
  247. $questionCode = $this->generateQuestionCode();
  248. $stemSuffix = $keyword ? "({$keyword})" : '';
  249. $stem = "【AI生成】{$kpCode} 题目 {$i}{$stemSuffix}";
  250. $options = null;
  251. $answer = null;
  252. $questionType = $type ?? 'CALCULATION';
  253. if (in_array($questionType, ['CHOICE', 'MULTIPLE_CHOICE'], true)) {
  254. $options = [
  255. ['label' => 'A', 'text' => '选项 A'],
  256. ['label' => 'B', 'text' => '选项 B'],
  257. ['label' => 'C', 'text' => '选项 C'],
  258. ['label' => 'D', 'text' => '选项 D'],
  259. ];
  260. $answer = 'A';
  261. }
  262. $solution = $solutionService->generateSolution($stem, [
  263. 'kp_code' => $kpCode,
  264. 'difficulty' => $difficulty,
  265. 'question_type' => $questionType,
  266. ]);
  267. $question = Question::create([
  268. 'question_code' => $questionCode,
  269. 'kp_code' => $kpCode,
  270. 'stem' => $stem,
  271. 'options' => $options,
  272. 'answer' => $answer,
  273. 'solution' => $solution['solution'] ?? null,
  274. 'difficulty' => $difficulty,
  275. 'source' => 'ai::local',
  276. 'question_type' => $questionType,
  277. 'meta' => [
  278. 'skills' => $skills,
  279. 'prompt_template' => $params['prompt_template'] ?? null,
  280. 'strategy' => $params['strategy'] ?? null,
  281. 'generated_at' => now()->toDateTimeString(),
  282. 'solution_steps' => $solution['steps'] ?? [],
  283. ],
  284. ]);
  285. $created[] = $this->mapQuestion($question);
  286. }
  287. });
  288. return [
  289. 'success' => true,
  290. 'message' => '生成完成',
  291. 'count' => count($created),
  292. 'data' => $created,
  293. ];
  294. }
  295. public function importQuestions(array $questions): array
  296. {
  297. if (empty($questions)) {
  298. return [
  299. 'success' => false,
  300. 'message' => '题目为空',
  301. 'count' => 0,
  302. ];
  303. }
  304. $created = 0;
  305. DB::transaction(function () use ($questions, &$created) {
  306. foreach ($questions as $payload) {
  307. $questionCode = $payload['question_code'] ?? $this->generateQuestionCode();
  308. $question = Question::firstOrNew(['question_code' => $questionCode]);
  309. $question->fill($this->normalizePayload($payload));
  310. $question->save();
  311. $created++;
  312. }
  313. });
  314. return [
  315. 'success' => true,
  316. 'message' => '导入完成',
  317. 'count' => $created,
  318. ];
  319. }
  320. public function selectQuestionsForExam(int $totalQuestions, array $filters): array
  321. {
  322. $query = Question::query();
  323. // 【新增】只获取审核通过的题目(audit_status = 0 表示合格)
  324. $query->where('audit_status', 0);
  325. if (!empty($filters['kp_codes'])) {
  326. $query->whereIn('kp_code', $filters['kp_codes']);
  327. }
  328. if (!empty($filters['skills'])) {
  329. $skills = array_values(array_filter($filters['skills']));
  330. if (!empty($skills)) {
  331. $query->where(function ($q) use ($skills) {
  332. foreach ($skills as $skill) {
  333. $q->orWhereJsonContains('meta->skills', $skill);
  334. }
  335. });
  336. }
  337. }
  338. if (!empty($filters['grade'])) {
  339. $stageGrade = $this->normalizeStageGrade((int) $filters['grade']);
  340. if ($stageGrade !== null) {
  341. $query->where('grade', $stageGrade);
  342. }
  343. }
  344. $questions = $query->get();
  345. $selected = $this->applyRatioSelection($questions, $totalQuestions, $filters);
  346. return $this->mapQuestions(collect($selected));
  347. }
  348. public function getKnowledgePointOptions(): array
  349. {
  350. return KnowledgePoint::query()
  351. ->orderBy('kp_code')
  352. ->pluck('name', 'kp_code')
  353. ->toArray();
  354. }
  355. public function getSkillNameMapping(?string $kpCode = null): array
  356. {
  357. return [];
  358. }
  359. private function applyFilters($query, array $filters)
  360. {
  361. if (!empty($filters['kp_code'])) {
  362. $query->where('kp_code', $filters['kp_code']);
  363. }
  364. if (!empty($filters['difficulty'])) {
  365. $query->where('difficulty', $filters['difficulty']);
  366. }
  367. if (!empty($filters['type'])) {
  368. $query->where('question_type', $filters['type']);
  369. }
  370. if (!empty($filters['search'])) {
  371. $query->search($filters['search']);
  372. }
  373. if (!empty($filters['grade'])) {
  374. $stageGrade = $this->normalizeStageGrade((int) $filters['grade']);
  375. if ($stageGrade !== null) {
  376. $query->where('grade', $stageGrade);
  377. }
  378. }
  379. return $query;
  380. }
  381. private function normalizeStageGrade(int $grade): ?int
  382. {
  383. if ($grade <= 0) {
  384. return null;
  385. }
  386. return $grade <= 9 ? 2 : 3;
  387. }
  388. private function normalizePayload(array $payload): array
  389. {
  390. $normalized = [
  391. 'question_code' => $payload['question_code'] ?? null,
  392. 'kp_code' => $payload['kp_code'] ?? null,
  393. 'stem' => $payload['stem'] ?? ($payload['content'] ?? ''),
  394. 'options' => $payload['options'] ?? null,
  395. 'answer' => $payload['answer'] ?? null,
  396. 'solution' => $payload['solution'] ?? null,
  397. 'difficulty' => $payload['difficulty'] ?? null,
  398. 'source' => $payload['source'] ?? null,
  399. 'tags' => $payload['tags'] ?? null,
  400. 'question_type' => $payload['question_type'] ?? ($payload['type'] ?? null),
  401. 'meta' => $payload['meta'] ?? null,
  402. ];
  403. if (isset($payload['skills'])) {
  404. $meta = $normalized['meta'] ?? [];
  405. $meta['skills'] = is_array($payload['skills'])
  406. ? $payload['skills']
  407. : array_filter(array_map('trim', explode(',', (string) $payload['skills'])));
  408. $normalized['meta'] = $meta;
  409. }
  410. return array_filter($normalized, static fn ($value) => $value !== null);
  411. }
  412. private function mapQuestions(Collection $questions): array
  413. {
  414. $kpCodes = $questions->pluck('kp_code')->filter()->unique()->values();
  415. $kpMap = $this->resolveKnowledgePointNames($kpCodes->all());
  416. return $questions->map(function (Question $question) use ($kpMap) {
  417. $data = $this->mapQuestion($question);
  418. $data['kp_name'] = $kpMap[$question->kp_code] ?? null;
  419. return $data;
  420. })->values()->all();
  421. }
  422. private function mapQuestion(Question $question): array
  423. {
  424. $meta = $question->meta ?? [];
  425. $data = [
  426. 'id' => $question->id,
  427. 'question_code' => $question->question_code,
  428. 'kp_code' => $question->kp_code,
  429. 'stem' => $question->stem,
  430. 'options' => $question->options,
  431. 'answer' => $question->answer,
  432. 'solution' => $question->solution,
  433. 'difficulty' => $question->difficulty,
  434. 'source' => $question->source,
  435. 'tags' => $question->tags,
  436. 'type' => $question->question_type,
  437. 'question_type' => $question->question_type,
  438. 'skills' => $meta['skills'] ?? [],
  439. 'meta' => $meta,
  440. 'created_at' => $question->created_at?->toDateTimeString(),
  441. 'updated_at' => $question->updated_at?->toDateTimeString(),
  442. ];
  443. return MathFormulaProcessor::processQuestionData($data);
  444. }
  445. private function generateQuestionCode(): string
  446. {
  447. return 'Q' . Str::upper(Str::random(10));
  448. }
  449. private function mapQuestionTypeLabel(string $type): string
  450. {
  451. return match (strtoupper($type)) {
  452. 'CHOICE' => '选择题',
  453. 'MULTIPLE_CHOICE' => '多选题',
  454. 'FILL_IN_THE_BLANK', 'FILL' => '填空题',
  455. 'CALCULATION', 'WORD_PROBLEM', 'ANSWER' => '解答题',
  456. 'PROOF' => '证明题',
  457. default => '其他',
  458. };
  459. }
  460. private function applyRatioSelection(Collection $questions, int $totalQuestions, array $filters): array
  461. {
  462. $questionsByType = $questions->groupBy(fn (Question $q) => $q->question_type ?? 'CALCULATION');
  463. $questionsByDifficulty = $questions->groupBy(function (Question $q) {
  464. $difficulty = (float) ($q->difficulty ?? 0);
  465. if ($difficulty <= 0.4) {
  466. return 'easy';
  467. }
  468. if ($difficulty <= 0.7) {
  469. return 'medium';
  470. }
  471. return 'hard';
  472. });
  473. $typeRatio = $filters['question_type_ratio'] ?? [];
  474. $difficultyRatio = $filters['difficulty_ratio'] ?? [];
  475. $selected = collect();
  476. if (!empty($typeRatio)) {
  477. foreach ($typeRatio as $type => $ratio) {
  478. $bucket = $questionsByType->get($type, collect());
  479. $count = (int) round($totalQuestions * (float) $ratio);
  480. $selected = $selected->merge($bucket->shuffle()->take($count));
  481. }
  482. }
  483. if (!empty($difficultyRatio)) {
  484. foreach ($difficultyRatio as $key => $ratio) {
  485. $bucketKey = $this->normalizeDifficultyKey($key);
  486. $bucket = $questionsByDifficulty->get($bucketKey, collect());
  487. $count = (int) round($totalQuestions * (float) $ratio);
  488. $selected = $selected->merge($bucket->shuffle()->take($count));
  489. }
  490. }
  491. if ($selected->isEmpty()) {
  492. return $questions->shuffle()->take($totalQuestions)->values()->all();
  493. }
  494. if ($selected->count() < $totalQuestions) {
  495. $missing = $totalQuestions - $selected->count();
  496. $fill = $questions->diff($selected)->shuffle()->take($missing);
  497. $selected = $selected->merge($fill);
  498. }
  499. return $selected->values()->all();
  500. }
  501. private function normalizeDifficultyKey(string $key): string
  502. {
  503. if (in_array($key, ['easy', 'medium', 'hard'], true)) {
  504. return $key;
  505. }
  506. $value = (float) $key;
  507. if ($value <= 0.4) {
  508. return 'easy';
  509. }
  510. if ($value <= 0.7) {
  511. return 'medium';
  512. }
  513. return 'hard';
  514. }
  515. private function resolveKnowledgePointNames(array $kpCodes): array
  516. {
  517. $kpCodes = array_values(array_filter(array_unique($kpCodes)));
  518. if (empty($kpCodes)) {
  519. return [];
  520. }
  521. $cacheKey = 'kp-name-map-' . md5(implode('|', $kpCodes));
  522. return Cache::remember($cacheKey, now()->addMinutes(30), function () use ($kpCodes) {
  523. $kpMap = KnowledgePoint::query()
  524. ->whereIn('kp_code', $kpCodes)
  525. ->pluck('name', 'kp_code')
  526. ->toArray();
  527. $missing = array_values(array_filter($kpCodes, fn ($code) => empty($kpMap[$code])));
  528. if (empty($missing)) {
  529. return $kpMap;
  530. }
  531. try {
  532. $api = app(KnowledgeServiceApi::class);
  533. $all = $api->listKnowledgePoints();
  534. foreach ($all as $kp) {
  535. $code = $kp['kp_code'] ?? null;
  536. $name = $kp['cn_name'] ?? $kp['name'] ?? null;
  537. if ($code && $name && in_array($code, $missing, true)) {
  538. $kpMap[$code] = $name;
  539. }
  540. }
  541. } catch (\Throwable $e) {
  542. // Fallback: keep existing mapping
  543. }
  544. return $kpMap;
  545. });
  546. }
  547. /**
  548. * 根据难度系数分布选择题目
  549. *
  550. * @param array $questions 候选题目数组
  551. * @param int $totalQuestions 总题目数
  552. * @param int $difficultyCategory 难度类别 (0-4)
  553. * - 0: 0-0.1占90%,0.1-0.25占10%
  554. * - 1: 0-0.25占90%,0.25-1占10%
  555. * - 2: 0.25-0.5范围占50%,<0.25占25%,>0.5占25%
  556. * - 3: 0.5-0.75范围占50%,<0.5占25%,>0.75占25%
  557. * - 4: 0.75-1范围占50%,其他占50%
  558. * @param array $filters 其他筛选条件
  559. * @return array 分布后的题目
  560. */
  561. public function selectQuestionsByDifficultyDistribution(array $questions, int $totalQuestions, int $difficultyCategory = 1, array $filters = []): array
  562. {
  563. Log::info('QuestionLocalService: 根据难度系数分布选择题目', [
  564. 'total_questions' => $totalQuestions,
  565. 'difficulty_category' => $difficultyCategory,
  566. ]);
  567. if (empty($questions)) {
  568. Log::warning('QuestionLocalService: 输入题目为空');
  569. return [];
  570. }
  571. // 【恢复】简化逻辑,避免复杂处理
  572. $distribution = $this->difficultyDistributionService->calculateDistribution($difficultyCategory, $totalQuestions);
  573. // 按难度范围分桶
  574. $buckets = $this->difficultyDistributionService->groupQuestionsByDifficultyRange($questions, $difficultyCategory);
  575. Log::info('QuestionLocalService: 题目分桶', [
  576. 'buckets' => array_map(fn($bucket) => count($bucket), $buckets),
  577. 'total_input' => count($questions),
  578. 'distribution' => $distribution
  579. ]);
  580. // 根据分布选择题目
  581. $selected = [];
  582. $usedIds = [];
  583. foreach ($distribution as $level => $config) {
  584. $targetCount = $config['count'];
  585. if ($targetCount <= 0) {
  586. Log::debug('QuestionLocalService: 跳过难度层级', [
  587. 'level' => $level,
  588. 'target_count' => $targetCount
  589. ]);
  590. continue;
  591. }
  592. $rangeKey = $this->difficultyDistributionService->mapDifficultyLevelToRangeKey($level, $difficultyCategory);
  593. $bucket = $buckets[$rangeKey] ?? [];
  594. // 随机打乱
  595. shuffle($bucket);
  596. // 选择题目
  597. $taken = 0;
  598. foreach ($bucket as $question) {
  599. if ($taken >= $targetCount) break;
  600. $questionId = $question['id'] ?? null;
  601. if ($questionId && !in_array($questionId, $usedIds)) {
  602. $selected[] = $question;
  603. $usedIds[] = $questionId;
  604. $taken++;
  605. }
  606. }
  607. // 【修复】如果某个难度范围题目不足,记录日志但不截断
  608. if ($taken < $targetCount) {
  609. Log::warning('QuestionLocalService: 难度范围题目不足,允许后续补充', [
  610. 'level' => $level,
  611. 'range_key' => $rangeKey,
  612. 'target' => $targetCount,
  613. 'actual' => $taken,
  614. 'bucket_size' => count($bucket)
  615. ]);
  616. }
  617. }
  618. // 如果数量不足,从剩余题目中补充
  619. if (count($selected) < $totalQuestions) {
  620. Log::warning('QuestionLocalService: 开始补充题目(难度分布无法满足要求)', [
  621. 'need_more' => $totalQuestions - count($selected),
  622. 'selected_count' => count($selected),
  623. 'difficulty_category' => $difficultyCategory,
  624. 'note' => '优先从次级桶补充,不足再放宽'
  625. ]);
  626. $needMore = $totalQuestions - count($selected);
  627. $supplemented = 0;
  628. $supplementOrder = $this->difficultyDistributionService->getSupplementOrder($difficultyCategory);
  629. foreach ($supplementOrder as $bucketKey) {
  630. if ($supplemented >= $needMore) {
  631. break;
  632. }
  633. $bucket = $buckets[$bucketKey] ?? [];
  634. if (empty($bucket)) {
  635. continue;
  636. }
  637. shuffle($bucket);
  638. foreach ($bucket as $q) {
  639. if ($supplemented >= $needMore) {
  640. break;
  641. }
  642. $id = $q['id'] ?? null;
  643. if ($id && !in_array($id, $usedIds)) {
  644. $selected[] = $q;
  645. $usedIds[] = $id;
  646. $supplemented++;
  647. }
  648. }
  649. }
  650. if ($supplemented < $needMore) {
  651. $remaining = [];
  652. foreach ($questions as $q) {
  653. $id = $q['id'] ?? null;
  654. if ($id && !in_array($id, $usedIds)) {
  655. $remaining[] = $q;
  656. }
  657. }
  658. shuffle($remaining);
  659. $supplementCount = min($needMore - $supplemented, count($remaining));
  660. $selected = array_merge($selected, array_slice($remaining, 0, $supplementCount));
  661. $supplemented += $supplementCount;
  662. }
  663. Log::warning('QuestionLocalService: 补充完成', [
  664. 'supplement_added' => $supplemented,
  665. 'final_count_before_truncate' => count($selected),
  666. 'remaining_unused' => max(0, count($questions) - count($selected))
  667. ]);
  668. }
  669. // 截断至目标数量
  670. $selected = array_slice($selected, 0, $totalQuestions);
  671. $finalBuckets = $this->difficultyDistributionService->groupQuestionsByDifficultyRange($selected, $difficultyCategory);
  672. $finalTotal = max(1, count($selected));
  673. $distributionStats = array_map(static function ($bucket) use ($finalTotal) {
  674. $count = count($bucket);
  675. return [
  676. 'count' => $count,
  677. 'ratio' => round(($count / $finalTotal) * 100, 2),
  678. ];
  679. }, $finalBuckets);
  680. Log::info('QuestionLocalService: 难度分布选择完成', [
  681. 'final_count' => count($selected),
  682. 'target_count' => $totalQuestions,
  683. 'success' => count($selected) === $totalQuestions,
  684. 'input_count' => count($questions),
  685. 'distribution_applied' => true,
  686. 'final_distribution' => $distributionStats
  687. ]);
  688. return $selected;
  689. }
  690. }