zendesk_client.py 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  1. """Zendesk Help Center 异步客户端。
  2. 封装两类调用:
  3. - list_all_faq_sections:扫描 sections 找出名字含 "faq" 且文章数 > 0 的 sec_id
  4. - search_articles:按 section 列表搜索文章
  5. """
  6. from __future__ import annotations
  7. import asyncio
  8. import logging
  9. from typing import Any
  10. import httpx
  11. from app.config import settings
  12. logger = logging.getLogger(__name__)
  13. # Zendesk 接口请求最大重试次数(包含首次请求)
  14. MAX_RETRIES = 3
  15. # 重试退避基数(秒):第 n 次重试等待 RETRY_BACKOFF_BASE * n 秒
  16. RETRY_BACKOFF_BASE = 1.0
  17. class ZendeskError(Exception):
  18. """Zendesk API 调用失败统一异常。"""
  19. async def _get_article_count(
  20. client: httpx.AsyncClient, section_id: int, locale: str
  21. ) -> int:
  22. """获取某 section 的文章数(与 test2.py 中 get_article_count 等价)。"""
  23. url = (
  24. f"{settings.zendesk_base_url}/{locale}/sections/"
  25. f"{section_id}/articles.json"
  26. )
  27. try:
  28. resp = await client.get(url, params={"per_page": 1})
  29. if resp.status_code == 200:
  30. return int(resp.json().get("count", 0))
  31. except httpx.HTTPError as exc:
  32. logger.warning("get_article_count 失败 sec_id=%s: %s", section_id, exc)
  33. return 0
  34. async def list_all_faq_sections(
  35. locales: list[str] | None = None,
  36. ) -> tuple[list[int], list[dict[str, Any]]]:
  37. """完整列出所有 FAQ 相关 Section(异步版本,等价于 test2.list_all_sections_full)。
  38. 返回:
  39. (sec_ids, faq_sections):
  40. sec_ids — 含有文章 (count>0) 的 FAQ Section ID 列表
  41. faq_sections — 详细元数据列表
  42. """
  43. locales = locales or [settings.default_locale]
  44. faq_sections: list[dict[str, Any]] = []
  45. sec_ids: list[int] = []
  46. timeout = httpx.Timeout(settings.http_timeout)
  47. async with httpx.AsyncClient(
  48. auth=settings.zendesk_auth, timeout=timeout
  49. ) as client:
  50. for locale in locales:
  51. url: str | None = (
  52. f"{settings.zendesk_base_url}/{locale}/sections.json"
  53. )
  54. page = 1
  55. logger.info("扫描 Locale: %s", locale)
  56. while url:
  57. params: dict[str, Any] = {"per_page": 100, "page": page} if "page" in url else {"per_page": 100}
  58. # 最多重试 MAX_RETRIES 次(含首次),全部失败才放弃当前 locale
  59. resp: httpx.Response | None = None
  60. last_error: str | None = None
  61. for attempt in range(1, MAX_RETRIES + 1):
  62. try:
  63. resp = await client.get(url, params=params)
  64. if resp.status_code == 200:
  65. break
  66. last_error = f"status={resp.status_code}"
  67. logger.warning(
  68. "sections 请求失败 locale=%s page=%s attempt=%s/%s %s",
  69. locale, page, attempt, MAX_RETRIES, last_error,
  70. )
  71. except httpx.HTTPError as exc:
  72. last_error = str(exc)
  73. logger.warning(
  74. "sections 请求异常 locale=%s page=%s attempt=%s/%s: %s",
  75. locale, page, attempt, MAX_RETRIES, exc,
  76. )
  77. resp = None
  78. # 不是最后一次,等待后再试(指数退避)
  79. if attempt < MAX_RETRIES:
  80. await asyncio.sleep(RETRY_BACKOFF_BASE * attempt)
  81. if resp is None or resp.status_code != 200:
  82. logger.error(
  83. "sections 重试 %s 次仍失败 locale=%s page=%s: %s",
  84. MAX_RETRIES, locale, page, last_error,
  85. )
  86. break
  87. data = resp.json()
  88. sections = data.get("sections", [])
  89. logger.info("第 %s 页,共 %s 个 Section", page, len(sections))
  90. for sec in sections:
  91. name = (sec.get("name") or "").strip()
  92. sec_id = sec.get("id")
  93. cat_id = sec.get("category_id")
  94. if "faq" in name.lower():
  95. count = await _get_article_count(client, sec_id, locale)
  96. logger.info(
  97. "✅ FAQ Section id=%s count=%s name=%s cat=%s",
  98. sec_id,
  99. count,
  100. name,
  101. cat_id,
  102. )
  103. if count > 0:
  104. sec_ids.append(sec_id)
  105. faq_sections.append(
  106. {
  107. "id": sec_id,
  108. "name": name,
  109. "category_id": cat_id,
  110. "locale": locale,
  111. "article_count": count,
  112. }
  113. )
  114. url = data.get("next_page")
  115. page += 1
  116. logger.info("总共发现 %s 个 FAQ 相关 Section", len(faq_sections))
  117. return sec_ids, faq_sections
  118. async def search_articles(
  119. query: str,
  120. section_ids: list[int],
  121. locale: str | None = None,
  122. page: int = 1,
  123. per_page: int = 25,
  124. **extra: Any,
  125. ) -> dict[str, Any]:
  126. """搜索 Help Center 文章(异步版本,等价于 test1.search_articles)。
  127. section_ids 为空列表时,不带 section 过滤直接搜索全部。
  128. """
  129. locale = locale or settings.default_locale
  130. url = f"{settings.zendesk_base_url}/articles/search"
  131. params: dict[str, Any] = {
  132. "query": query,
  133. "locale": locale,
  134. "page": page,
  135. "per_page": per_page,
  136. }
  137. if section_ids:
  138. params["section"] = ",".join(map(str, section_ids))
  139. params.update({k: v for k, v in extra.items() if v is not None})
  140. timeout = httpx.Timeout(settings.http_timeout)
  141. async with httpx.AsyncClient(
  142. auth=settings.zendesk_auth, timeout=timeout
  143. ) as client:
  144. try:
  145. resp = await client.get(url, params=params)
  146. resp.raise_for_status()
  147. return resp.json()
  148. except httpx.HTTPStatusError as exc:
  149. logger.error(
  150. "search_articles HTTP %s: %s",
  151. exc.response.status_code,
  152. exc.response.text,
  153. )
  154. raise ZendeskError(
  155. f"Zendesk 返回 {exc.response.status_code}"
  156. ) from exc
  157. except httpx.HTTPError as exc:
  158. logger.error("search_articles 网络错误: %s", exc)
  159. raise ZendeskError(str(exc)) from exc