TextbookApiController.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555
  1. <?php
  2. namespace App\Http\Controllers\Api;
  3. use App\Http\Controllers\Controller;
  4. use App\Services\TextbookApiService;
  5. use Illuminate\Http\Request;
  6. use Illuminate\Http\JsonResponse;
  7. use Illuminate\Support\Facades\Log;
  8. class TextbookApiController extends Controller
  9. {
  10. protected TextbookApiService $textbookService;
  11. public function __construct(TextbookApiService $textbookService)
  12. {
  13. $this->textbookService = $textbookService;
  14. }
  15. /**
  16. * 获取教材列表(按年级排序)
  17. *
  18. * @param Request $request
  19. * @return JsonResponse
  20. */
  21. public function index(Request $request): JsonResponse
  22. {
  23. try {
  24. $params = [
  25. 'page' => $request->get('page', 1),
  26. 'per_page' => $request->get('per_page', 50),
  27. ];
  28. // 可选过滤参数
  29. if ($request->has('grade')) {
  30. $params['grade'] = $request->get('grade');
  31. }
  32. if ($request->has('stage')) {
  33. $params['stage'] = $this->convertStageToCode($request->get('stage'));
  34. }
  35. if ($request->has('semester')) {
  36. $params['semester'] = $this->convertSemesterToCode($request->get('semester'));
  37. }
  38. if ($request->has('series_id')) {
  39. $params['series_id'] = $request->get('series_id');
  40. }
  41. if ($request->has('status')) {
  42. $params['status'] = $request->get('status');
  43. }
  44. // 支持包含未发布的教材(默认只返回已发布的)
  45. if ($request->has('include_unpublished')) {
  46. $params['include_unpublished'] = $request->get('include_unpublished');
  47. }
  48. // 支持包含未启用系列的教材(默认只返回启用系列的)
  49. if ($request->has('include_inactive_series')) {
  50. $params['include_inactive_series'] = $request->get('include_inactive_series');
  51. }
  52. $result = $this->textbookService->getTextbooks($params);
  53. // 格式化返回数据
  54. $textbooks = $this->formatTextbookList($result['data'] ?? []);
  55. // 按年级排序
  56. usort($textbooks, function ($a, $b) {
  57. // 先按学段排序:小学 < 初中 < 高中
  58. $stageOrder = ['primary' => 1, 'junior' => 2, 'senior' => 3];
  59. $stageA = $stageOrder[$a['stage_code']] ?? 99;
  60. $stageB = $stageOrder[$b['stage_code']] ?? 99;
  61. if ($stageA !== $stageB) {
  62. return $stageA - $stageB;
  63. }
  64. // 再按年级排序
  65. $gradeA = $a['grade'] ?? 0;
  66. $gradeB = $b['grade'] ?? 0;
  67. if ($gradeA !== $gradeB) {
  68. return $gradeA - $gradeB;
  69. }
  70. // 最后按学期排序
  71. $semesterA = $a['semester_code'] ?? 0;
  72. $semesterB = $b['semester_code'] ?? 0;
  73. return $semesterA - $semesterB;
  74. });
  75. return response()->json([
  76. 'success' => true,
  77. 'data' => $textbooks,
  78. 'meta' => array_merge($result['meta'] ?? [], [
  79. 'page' => $params['page'],
  80. 'per_page' => $params['per_page'],
  81. 'total' => count($textbooks),
  82. 'filters' => [
  83. 'grade' => $request->get('grade'),
  84. 'stage' => $request->get('stage'),
  85. 'semester' => $request->get('semester'),
  86. 'series_id' => $request->get('series_id'),
  87. 'status' => $request->get('status'),
  88. 'include_unpublished' => $request->get('include_unpublished') === 'true',
  89. 'include_inactive_series' => $request->get('include_inactive_series') === 'true',
  90. ]
  91. ])
  92. ]);
  93. } catch (\Exception $e) {
  94. Log::error('获取教材列表失败', ['error' => $e->getMessage()]);
  95. return response()->json([
  96. 'success' => false,
  97. 'message' => '获取教材列表失败: ' . $e->getMessage()
  98. ], 500);
  99. }
  100. }
  101. /**
  102. * 根据年级获取教材
  103. *
  104. * @param int $grade 年级(1-12)
  105. * @param Request $request
  106. * @return JsonResponse
  107. */
  108. public function getByGrade(int $grade, Request $request): JsonResponse
  109. {
  110. try {
  111. $schoolingSystem = (string) $request->get('schooling_system', '');
  112. $stageParam = $request->get('stage');
  113. $params = [
  114. 'grade' => $grade,
  115. 'per_page' => 100,
  116. ];
  117. if ($stageParam) {
  118. $params['stage'] = $this->convertStageToCode((string) $stageParam);
  119. } elseif ($schoolingSystem === '5-4') {
  120. $params['stage'] = $this->getStageByGrade($grade, $schoolingSystem);
  121. }
  122. if ($request->has('semester')) {
  123. $params['semester'] = $this->convertSemesterToCode($request->get('semester'));
  124. }
  125. if ($request->has('series_id')) {
  126. $params['series_id'] = $request->get('series_id');
  127. }
  128. if ($request->has('status')) {
  129. $params['status'] = $request->get('status');
  130. }
  131. // 支持包含未发布的教材(默认只返回已发布的)
  132. if ($request->has('include_unpublished')) {
  133. $params['include_unpublished'] = $request->get('include_unpublished');
  134. }
  135. // 支持包含未启用系列的教材(默认只返回启用系列的)
  136. if ($request->has('include_inactive_series')) {
  137. $params['include_inactive_series'] = $request->get('include_inactive_series');
  138. }
  139. $result = $this->textbookService->getTextbooks($params);
  140. $textbooks = $this->formatTextbookList($result['data'] ?? []);
  141. // 按学期排序
  142. usort($textbooks, function ($a, $b) {
  143. return ($a['semester_code'] ?? 0) - ($b['semester_code'] ?? 0);
  144. });
  145. return response()->json([
  146. 'success' => true,
  147. 'data' => $textbooks,
  148. 'meta' => [
  149. 'grade' => $grade,
  150. 'stage' => $params['stage'] ?? null,
  151. 'stage_label' => isset($params['stage']) ? $this->getStageLabel($params['stage']) : null,
  152. 'total' => count($textbooks),
  153. 'filters' => [
  154. 'semester' => $request->get('semester'),
  155. 'series_id' => $request->get('series_id'),
  156. 'status' => $request->get('status'),
  157. 'include_unpublished' => $request->get('include_unpublished') === 'true',
  158. 'include_inactive_series' => $request->get('include_inactive_series') === 'true',
  159. ]
  160. ]
  161. ]);
  162. } catch (\Exception $e) {
  163. Log::error('根据年级获取教材失败', [
  164. 'grade' => $grade,
  165. 'error' => $e->getMessage()
  166. ]);
  167. return response()->json([
  168. 'success' => false,
  169. 'message' => '获取教材失败: ' . $e->getMessage()
  170. ], 500);
  171. }
  172. }
  173. /**
  174. * 获取单个教材详情
  175. *
  176. * @param int $id 教材ID
  177. * @return JsonResponse
  178. */
  179. public function show(int $id): JsonResponse
  180. {
  181. try {
  182. $textbook = $this->textbookService->getTextbook($id);
  183. if (!$textbook) {
  184. return response()->json([
  185. 'success' => false,
  186. 'message' => '教材不存在'
  187. ], 404);
  188. }
  189. return response()->json([
  190. 'success' => true,
  191. 'data' => $this->formatTextbook($textbook)
  192. ]);
  193. } catch (\Exception $e) {
  194. Log::error('获取教材详情失败', [
  195. 'id' => $id,
  196. 'error' => $e->getMessage()
  197. ]);
  198. return response()->json([
  199. 'success' => false,
  200. 'message' => '获取教材详情失败: ' . $e->getMessage()
  201. ], 500);
  202. }
  203. }
  204. /**
  205. * 获取教材系列列表
  206. *
  207. * @param Request $request
  208. * @return JsonResponse
  209. */
  210. public function getSeries(Request $request): JsonResponse
  211. {
  212. try {
  213. $params = [];
  214. // 支持按学段筛选
  215. if ($request->has('stage')) {
  216. $params['stage'] = $this->convertStageToCode($request->get('stage'));
  217. }
  218. // 支持包含未启用的系列(默认只返回启用的)
  219. if ($request->has('include_inactive')) {
  220. $params['include_inactive'] = $request->get('include_inactive');
  221. }
  222. $result = $this->textbookService->getTextbookSeries($params);
  223. $series = array_map(function ($item) {
  224. return [
  225. 'id' => $item['id'],
  226. 'name' => $item['name'],
  227. 'slug' => $item['slug'] ?? '',
  228. 'publisher' => $item['publisher'] ?? '',
  229. 'region' => $item['region'] ?? '全国',
  230. 'stages' => $this->formatStages($item['stages'] ?? '[]'),
  231. 'is_active' => $item['is_active'] ?? true,
  232. 'sort_order' => $item['sort_order'] ?? 0,
  233. ];
  234. }, $result['data'] ?? []);
  235. // 按排序字段排序
  236. usort($series, fn($a, $b) => ($a['sort_order'] ?? 0) - ($b['sort_order'] ?? 0));
  237. return response()->json([
  238. 'success' => true,
  239. 'data' => $series,
  240. 'meta' => [
  241. 'total' => count($series),
  242. 'filters' => [
  243. 'stage' => $request->get('stage'),
  244. 'include_inactive' => $request->get('include_inactive') === 'true',
  245. ]
  246. ]
  247. ]);
  248. } catch (\Exception $e) {
  249. Log::error('获取教材系列失败', ['error' => $e->getMessage()]);
  250. return response()->json([
  251. 'success' => false,
  252. 'message' => '获取教材系列失败: ' . $e->getMessage()
  253. ], 500);
  254. }
  255. }
  256. /**
  257. * 年级枚举
  258. */
  259. public function getGradeEnums(): JsonResponse
  260. {
  261. $schoolingSystem = (string) request()->get('schooling_system', '');
  262. $grades = [];
  263. $primaryMax = $schoolingSystem === '5-4' ? 5 : 6;
  264. foreach (range(1, $primaryMax) as $grade) {
  265. $grades[] = [
  266. 'grade' => $grade,
  267. 'label' => $this->getGradeLabel($grade, 'primary'),
  268. 'stage' => 'primary',
  269. 'stage_label' => $this->getStageLabel('primary'),
  270. ];
  271. }
  272. $juniorStart = $schoolingSystem === '5-4' ? 6 : 7;
  273. foreach (range($juniorStart, 9) as $grade) {
  274. $grades[] = [
  275. 'grade' => $grade,
  276. 'label' => $this->getGradeLabel($grade, 'junior'),
  277. 'stage' => 'junior',
  278. 'stage_label' => $this->getStageLabel('junior'),
  279. ];
  280. }
  281. foreach (range(10, 12) as $grade) {
  282. $grades[] = [
  283. 'grade' => $grade,
  284. 'label' => $this->getGradeLabel($grade, 'senior'),
  285. 'stage' => 'senior',
  286. 'stage_label' => $this->getStageLabel('senior'),
  287. ];
  288. }
  289. return response()->json([
  290. 'success' => true,
  291. 'data' => $grades,
  292. ]);
  293. }
  294. /**
  295. * 获取教材目录
  296. *
  297. * @param int $textbookId 教材ID
  298. * @param Request $request
  299. * @return JsonResponse
  300. */
  301. public function getCatalog(int $textbookId, Request $request): JsonResponse
  302. {
  303. try {
  304. $format = $request->get('format', 'tree'); // tree 或 flat
  305. $catalog = $this->textbookService->getTextbookCatalog($textbookId, $format);
  306. return response()->json([
  307. 'success' => true,
  308. 'data' => $catalog,
  309. 'meta' => [
  310. 'textbook_id' => $textbookId,
  311. 'format' => $format,
  312. ]
  313. ]);
  314. } catch (\Exception $e) {
  315. Log::error('获取教材目录失败', [
  316. 'textbook_id' => $textbookId,
  317. 'error' => $e->getMessage()
  318. ]);
  319. return response()->json([
  320. 'success' => false,
  321. 'message' => '获取教材目录失败: ' . $e->getMessage()
  322. ], 500);
  323. }
  324. }
  325. /**
  326. * 格式化教材列表
  327. */
  328. private function formatTextbookList(array $textbooks): array
  329. {
  330. return array_map(fn($item) => $this->formatTextbook($item), $textbooks);
  331. }
  332. /**
  333. * 格式化单个教材
  334. */
  335. private function formatTextbook(array $textbook): array
  336. {
  337. $stage = $textbook['stage'] ?? '';
  338. $semester = $textbook['semester'] ?? null;
  339. return [
  340. 'id' => $textbook['id'],
  341. 'name' => $textbook['official_title'] ?? '',
  342. 'display_name' => $textbook['official_title'] ?? '',
  343. 'cover' => $this->formatCoverUrl($textbook['cover_path'] ?? ''),
  344. 'series_id' => $textbook['series_id'] ?? null,
  345. 'series_name' => $textbook['series']['name'] ?? '',
  346. 'publisher' => $textbook['series']['publisher'] ?? '',
  347. 'stage' => $this->getStageLabel($stage),
  348. 'stage_code' => $stage,
  349. 'grade' => $textbook['grade'] ?? null,
  350. 'grade_label' => $this->getGradeLabel($textbook['grade'] ?? null, $stage),
  351. 'semester' => $this->getSemesterLabel($semester),
  352. 'semester_code' => $semester,
  353. 'module_type' => $textbook['module_type'] ?? null,
  354. 'volume_no' => $textbook['volume_no'] ?? null,
  355. 'isbn' => $textbook['isbn'] ?? '',
  356. 'approval_year' => $textbook['approval_year'] ?? null,
  357. 'curriculum_standard_year' => $textbook['curriculum_standard_year'] ?? null,
  358. 'status' => $textbook['status'] ?? 'draft',
  359. 'sort_order' => $textbook['sort_order'] ?? 0,
  360. ];
  361. }
  362. /**
  363. * 格式化封面URL
  364. */
  365. private function formatCoverUrl(?string $coverPath): string
  366. {
  367. if (empty($coverPath)) {
  368. return '';
  369. }
  370. // 如果已经是完整URL,直接返回
  371. if (str_starts_with($coverPath, 'http://') || str_starts_with($coverPath, 'https://')) {
  372. return $coverPath;
  373. }
  374. // 本地存储路径,添加域名
  375. return url('/storage/' . ltrim($coverPath, '/'));
  376. }
  377. /**
  378. * 学段代码转中文
  379. */
  380. private function getStageLabel(string $stage): string
  381. {
  382. return match ($stage) {
  383. 'primary' => '小学',
  384. 'junior' => '初中',
  385. 'senior' => '高中',
  386. default => $stage,
  387. };
  388. }
  389. /**
  390. * 中文学段转代码
  391. */
  392. private function convertStageToCode(string $stage): string
  393. {
  394. return match ($stage) {
  395. '小学' => 'primary',
  396. '初中' => 'junior',
  397. '高中' => 'senior',
  398. default => $stage,
  399. };
  400. }
  401. /**
  402. * 学期代码转中文
  403. */
  404. private function getSemesterLabel(?int $semester): string
  405. {
  406. return match ($semester) {
  407. 1 => '上册',
  408. 2 => '下册',
  409. default => '',
  410. };
  411. }
  412. /**
  413. * 中文学期转代码
  414. */
  415. private function convertSemesterToCode(string $semester): ?int
  416. {
  417. return match ($semester) {
  418. '上册', '1' => 1,
  419. '下册', '2' => 2,
  420. default => null,
  421. };
  422. }
  423. /**
  424. * 年级标签
  425. */
  426. private function getGradeLabel(?int $grade, string $stage): string
  427. {
  428. if ($grade === null) {
  429. return '';
  430. }
  431. return match ($stage) {
  432. 'primary' => $grade . '年级',
  433. 'junior' => match ($grade) {
  434. 7 => '七年级',
  435. 8 => '八年级',
  436. 9 => '九年级',
  437. default => $grade . '年级',
  438. },
  439. 'senior' => match ($grade) {
  440. 10 => '高一',
  441. 11 => '高二',
  442. 12 => '高三',
  443. default => '高' . ($grade - 9),
  444. },
  445. default => $grade . '年级',
  446. };
  447. }
  448. /**
  449. * 根据年级判断学段
  450. */
  451. private function getStageByGrade(int $grade, string $schoolingSystem = ''): string
  452. {
  453. if ($schoolingSystem === '5-4') {
  454. if ($grade >= 1 && $grade <= 5) {
  455. return 'primary';
  456. }
  457. if ($grade >= 6 && $grade <= 9) {
  458. return 'junior';
  459. }
  460. }
  461. if ($grade >= 1 && $grade <= 6) {
  462. return 'primary';
  463. } elseif ($grade >= 7 && $grade <= 9) {
  464. return 'junior';
  465. } else {
  466. return 'senior';
  467. }
  468. }
  469. /**
  470. * 格式化学段数组
  471. */
  472. private function formatStages($stages): array
  473. {
  474. if (is_string($stages)) {
  475. $stages = json_decode($stages, true) ?? [];
  476. }
  477. if (!is_array($stages)) {
  478. return [];
  479. }
  480. return array_map(fn($s) => [
  481. 'code' => $s,
  482. 'label' => $this->getStageLabel($s),
  483. ], $stages);
  484. }
  485. }