KnowledgeServiceApi.php 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. <?php
  2. namespace App\Services;
  3. use Illuminate\Http\Client\PendingRequest;
  4. use Illuminate\Http\Client\RequestException;
  5. use Illuminate\Support\Collection;
  6. use Illuminate\Support\Facades\Cache;
  7. use Illuminate\Support\Facades\Http;
  8. class KnowledgeServiceApi
  9. {
  10. public function __construct(
  11. protected string $baseUrl = '',
  12. protected int $timeout = 10,
  13. protected int $cacheTtl = 300,
  14. ) {
  15. $this->baseUrl = rtrim($this->baseUrl ?: config('knowledge.base_url'), '/');
  16. $this->timeout = $this->timeout ?: (int) config('knowledge.timeout', 10);
  17. $this->cacheTtl = $this->cacheTtl ?: (int) config('knowledge.cache_ttl', 300);
  18. }
  19. /**
  20. * Fetch and cache all knowledge points by iterating through paginated API responses.
  21. *
  22. * @return Collection<int, array<string, mixed>>
  23. */
  24. public function listKnowledgePoints(int $perPage = 200, array $filters = []): Collection
  25. {
  26. $cacheKey = sprintf(
  27. 'knowledge-points-all-%d-%s',
  28. $perPage,
  29. md5(json_encode($filters))
  30. );
  31. return Cache::remember(
  32. $cacheKey,
  33. now()->addSeconds($this->cacheTtl),
  34. function () use ($perPage, $filters): Collection {
  35. $page = 1;
  36. $results = collect();
  37. while (true) {
  38. $response = $this->paginateKnowledgePoints($page, $perPage, $filters);
  39. $chunk = collect($response['data'] ?? []);
  40. if ($chunk->isEmpty()) {
  41. break;
  42. }
  43. $results = $results->concat($chunk);
  44. $meta = $response['meta'] ?? [];
  45. $hasNext = $meta['has_next'] ?? ($page < ($meta['total_pages'] ?? $page));
  46. if (! $hasNext) {
  47. break;
  48. }
  49. $page++;
  50. }
  51. return $results->values();
  52. },
  53. );
  54. }
  55. /**
  56. * @return array{data: array<int, array<string, mixed>>, meta: array<string, mixed>}
  57. */
  58. public function paginateKnowledgePoints(int $page = 1, int $perPage = 50, array $filters = []): array
  59. {
  60. $query = array_filter([
  61. 'page' => $page,
  62. 'per_page' => $perPage,
  63. 'phase' => $filters['phase'] ?? null,
  64. 'category' => $filters['category'] ?? null,
  65. 'grade' => $filters['grade'] ?? null,
  66. 'search' => $filters['search'] ?? null,
  67. ], fn ($value) => filled($value));
  68. $response = $this->request('GET', '/knowledge-points', $query);
  69. if (is_array($response) && array_key_exists('data', $response)) {
  70. return [
  71. 'data' => $response['data'] ?? [],
  72. 'meta' => $response['meta'] ?? [],
  73. ];
  74. }
  75. return [
  76. 'data' => is_array($response) ? $response : [],
  77. 'meta' => [
  78. 'page' => $page,
  79. 'per_page' => $perPage,
  80. 'total' => is_array($response) ? count($response) : 0,
  81. 'total_pages' => 1,
  82. 'has_next' => false,
  83. 'has_prev' => $page > 1,
  84. ],
  85. ];
  86. }
  87. /**
  88. * @return array<string, mixed>
  89. */
  90. public function getKnowledgePointDetail(string $kpCode): array
  91. {
  92. return Cache::remember(
  93. key: "knowledge-point-detail-{$kpCode}",
  94. ttl: now()->addSeconds($this->cacheTtl),
  95. callback: fn () => $this->request('GET', "/graph/node/{$kpCode}"),
  96. );
  97. }
  98. /**
  99. * 获取包含上下游节点的完整图谱数据
  100. */
  101. public function getFullGraphData(string $kpCode): array
  102. {
  103. $data = $this->getKnowledgePointDetail($kpCode);
  104. if (empty($data)) {
  105. return $data;
  106. }
  107. // 获取父节点详细信息,并构建可点击的父节点列表
  108. $data['parent_nodes'] = [];
  109. $data['parent_details'] = [];
  110. if (!empty($data['parents']) && is_array($data['parents'])) {
  111. foreach ($data['parents'] as $parentCode) {
  112. $parentDetail = $this->getKnowledgePointDetail($parentCode);
  113. if ($parentDetail) {
  114. $data['parent_nodes'][] = $parentDetail;
  115. $data['parent_details'][] = [
  116. 'kp_code' => $parentDetail['kp_code'] ?? $parentCode,
  117. 'cn_name' => $parentDetail['cn_name'] ?? $parentCode,
  118. ];
  119. }
  120. }
  121. }
  122. // 获取子节点(通过反向查询)
  123. $data['child_nodes'] = $this->findChildNodes($kpCode);
  124. return $data;
  125. }
  126. /**
  127. * 反向查找子节点(以当前节点为父节点的节点)
  128. */
  129. private function findChildNodes(string $kpCode): array
  130. {
  131. // 缓存键
  132. $cacheKey = "knowledge-point-children-{$kpCode}";
  133. return Cache::remember(
  134. key: $cacheKey,
  135. ttl: now()->addSeconds($this->cacheTtl),
  136. callback: function () use ($kpCode) {
  137. try {
  138. \Log::info("开始查找子节点: {$kpCode}");
  139. // 获取所有知识点
  140. $allPoints = $this->listKnowledgePoints();
  141. \Log::info("获取到知识点数量: " . $allPoints->count());
  142. // 查找以当前节点为父节点的知识点
  143. $children = $allPoints->filter(function ($point) use ($kpCode) {
  144. return in_array($kpCode, $point['parents'] ?? []);
  145. })->values()->toArray();
  146. \Log::info("找到子节点数量: " . count($children));
  147. return $children;
  148. } catch (\Exception $e) {
  149. \Log::error("查找子节点失败: " . $e->getMessage());
  150. \Log::error($e->getTraceAsString());
  151. return [];
  152. }
  153. }
  154. );
  155. }
  156. /**
  157. * @return Collection<int, array<string, mixed>>
  158. */
  159. public function listSkills(?string $kpCode = null, int $limit = 200): Collection
  160. {
  161. return Cache::remember(
  162. key: "knowledge-skills-{$kpCode}-{$limit}",
  163. ttl: now()->addSeconds($this->cacheTtl),
  164. callback: fn () => collect($this->request('GET', '/skills', array_filter([
  165. 'kp_code' => $kpCode,
  166. 'limit' => $limit,
  167. ]))),
  168. );
  169. }
  170. /**
  171. * Search knowledge points by keyword
  172. *
  173. * @return Collection<int, array<string, mixed>>
  174. */
  175. public function searchKnowledgePoints(string $keyword, int $limit = 50): Collection
  176. {
  177. // Ensure keyword is not empty
  178. $keyword = trim($keyword);
  179. if ($keyword === '') {
  180. return collect();
  181. }
  182. // Use query params array for proper encoding (no cache during debugging)
  183. try {
  184. $response = $this->request('GET', 'knowledge-points/search', [
  185. 'keyword' => $keyword,
  186. 'limit' => $limit,
  187. ]);
  188. } catch (\Exception $e) {
  189. // Log error but don't fail completely
  190. \Log::error('Search API error: ' . $e->getMessage());
  191. return collect();
  192. }
  193. return collect($response);
  194. }
  195. /**
  196. * @throws RequestException
  197. * @return mixed
  198. */
  199. protected function request(string $method, string $path, array $params = []): mixed
  200. {
  201. $response = $this->http()->send($method, ltrim($path, '/'), [
  202. 'query' => $params,
  203. ]);
  204. return $response->throw()->json();
  205. }
  206. protected function http(): PendingRequest
  207. {
  208. return Http::baseUrl($this->baseUrl)
  209. ->acceptJson()
  210. ->timeout($this->timeout)
  211. ->retry(2, 200)
  212. ->withHeaders([
  213. 'User-Agent' => 'Laravel/' . (app()->version()),
  214. 'Accept' => 'application/json',
  215. ]);
  216. }
  217. }