| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188 |
- """Zendesk Help Center 异步客户端。
- 封装两类调用:
- - list_all_faq_sections:扫描 sections 找出名字含 "faq" 且文章数 > 0 的 sec_id
- - search_articles:按 section 列表搜索文章
- """
- from __future__ import annotations
- import asyncio
- import logging
- from typing import Any
- import httpx
- from app.core.config import settings
- logger = logging.getLogger(__name__)
- # Zendesk 接口请求最大重试次数(包含首次请求)
- MAX_RETRIES = 3
- # 重试退避基数(秒):第 n 次重试等待 RETRY_BACKOFF_BASE * n 秒
- RETRY_BACKOFF_BASE = 1.0
- class ZendeskError(Exception):
- """Zendesk API 调用失败统一异常。"""
- async def _get_article_count(
- client: httpx.AsyncClient, section_id: int, locale: str
- ) -> int:
- """获取某 section 的文章数(与 test2.py 中 get_article_count 等价)。"""
- url = (
- f"{settings.zendesk_base_url}/{locale}/sections/"
- f"{section_id}/articles.json"
- )
- try:
- resp = await client.get(url, params={"per_page": 1})
- if resp.status_code == 200:
- return int(resp.json().get("count", 0))
- except httpx.HTTPError as exc:
- logger.warning("get_article_count 失败 sec_id=%s: %s", section_id, exc)
- return 0
- async def list_all_faq_sections(
- locales: list[str] | None = None,
- ) -> tuple[list[int], list[dict[str, Any]]]:
- """完整列出所有 FAQ 相关 Section(异步版本,等价于 test2.list_all_sections_full)。
- 返回:
- (sec_ids, faq_sections):
- sec_ids — 含有文章 (count>0) 的 FAQ Section ID 列表
- faq_sections — 详细元数据列表
- """
- locales = locales or [settings.default_locale]
- faq_sections: list[dict[str, Any]] = []
- sec_ids: list[int] = []
- timeout = httpx.Timeout(settings.http_timeout)
- async with httpx.AsyncClient(
- auth=settings.zendesk_auth, timeout=timeout
- ) as client:
- for locale in locales:
- url: str | None = (
- f"{settings.zendesk_base_url}/{locale}/sections.json"
- )
- page = 1
- logger.info("扫描 Locale: %s", locale)
- while url:
- params: dict[str, Any] = {"per_page": 100, "page": page} if "page" in url else {"per_page": 100}
- # 最多重试 MAX_RETRIES 次(含首次),全部失败才放弃当前 locale
- resp: httpx.Response | None = None
- last_error: str | None = None
- for attempt in range(1, MAX_RETRIES + 1):
- try:
- resp = await client.get(url, params=params)
- if resp.status_code == 200:
- break
- last_error = f"status={resp.status_code}"
- logger.warning(
- "sections 请求失败 locale=%s page=%s attempt=%s/%s %s",
- locale, page, attempt, MAX_RETRIES, last_error,
- )
- except httpx.HTTPError as exc:
- last_error = str(exc)
- logger.warning(
- "sections 请求异常 locale=%s page=%s attempt=%s/%s: %s",
- locale, page, attempt, MAX_RETRIES, exc,
- )
- resp = None
- # 不是最后一次,等待后再试(指数退避)
- if attempt < MAX_RETRIES:
- await asyncio.sleep(RETRY_BACKOFF_BASE * attempt)
- if resp is None or resp.status_code != 200:
- logger.error(
- "sections 重试 %s 次仍失败 locale=%s page=%s: %s",
- MAX_RETRIES, locale, page, last_error,
- )
- break
- data = resp.json()
- sections = data.get("sections", [])
- logger.info("第 %s 页,共 %s 个 Section", page, len(sections))
- for sec in sections:
- name = (sec.get("name") or "").strip()
- sec_id = sec.get("id")
- cat_id = sec.get("category_id")
- if "faq" in name.lower():
- count = await _get_article_count(client, sec_id, locale)
- logger.info(
- "✅ FAQ Section id=%s count=%s name=%s cat=%s",
- sec_id,
- count,
- name,
- cat_id,
- )
- if count > 0:
- sec_ids.append(sec_id)
- faq_sections.append(
- {
- "id": sec_id,
- "name": name,
- "category_id": cat_id,
- "locale": locale,
- "article_count": count,
- }
- )
- url = data.get("next_page")
- page += 1
- logger.info("总共发现 %s 个 FAQ 相关 Section", len(faq_sections))
- return sec_ids, faq_sections
- async def search_articles(
- query: str,
- section_ids: list[int],
- locale: str | None = None,
- page: int = 1,
- per_page: int = 25,
- **extra: Any,
- ) -> dict[str, Any]:
- """搜索 Help Center 文章(异步版本,等价于 test1.search_articles)。
- section_ids 为空列表时,不带 section 过滤直接搜索全部。
- """
- locale = locale or settings.default_locale
- url = f"{settings.zendesk_base_url}/articles/search"
- params: dict[str, Any] = {
- "query": query,
- "locale": locale,
- "page": page,
- "per_page": per_page,
- }
- if section_ids:
- params["section"] = ",".join(map(str, section_ids))
- params.update({k: v for k, v in extra.items() if v is not None})
- timeout = httpx.Timeout(settings.http_timeout)
- async with httpx.AsyncClient(
- auth=settings.zendesk_auth, timeout=timeout
- ) as client:
- try:
- resp = await client.get(url, params=params)
- resp.raise_for_status()
- return resp.json()
- except httpx.HTTPStatusError as exc:
- logger.error(
- "search_articles HTTP %s: %s",
- exc.response.status_code,
- exc.response.text,
- )
- raise ZendeskError(
- f"Zendesk 返回 {exc.response.status_code}"
- ) from exc
- except httpx.HTTPError as exc:
- logger.error("search_articles 网络错误: %s", exc)
- raise ZendeskError(str(exc)) from exc
|