# Zendesk FAQ Keyword Search 基于 FastAPI 的 Zendesk Help Center FAQ 搜索代理,封装 Zendesk Help Center API,自动维护 FAQ Section 缓存并提供统一的关键词搜索接口。 ## 特性 - **FAQ Section 自动发现**:扫描 Zendesk Help Center 找出名字含 `faq` 且文章数 > 0 的 Section - **Redis 共享缓存**:多 worker 进程共享缓存,避免重复打 Zendesk - **Leader 选举**:仅一个 worker 负责刷新缓存,其余 worker 共享读取 - **每日定时刷新**:可配置时间(默认 0:00)自动刷新 sec_ids - **故障自愈**:leader 进程崩溃后,其他 worker 自动接管 - **统一响应格式**:所有接口返回 `{code, data, msg}` 信封 - **优雅降级**:Redis 不可用时返回空 sec_ids,搜索不带 section 过滤继续工作 ## 目录结构 ``` app/ ├── main.py # FastAPI 入口、lifespan、leader 选举、健康检查 ├── config.py # 配置加载(按 APP_ENV 切换 .env / .env.dev) ├── schemas.py # 请求/响应 Pydantic 模型 ├── response.py # 统一响应类 + 业务错误码 + BusinessError ├── exception_handlers.py # 全局异常处理器 ├── routers/ │ └── search.py # /search 接口 └── services/ ├── zendesk_client.py # 异步 Zendesk 客户端 ├── redis_client.py # Redis 连接 + 分布式锁原语 └── cache.py # 共享缓存读写 + leader 选举/心跳/定时刷新 ``` ## 快速开始 ### 1. 安装依赖 ```bash pip install -r requirements.txt ``` ### 2. 准备 Redis ```bash # Docker 一行起 docker run -d -p 6379:6379 --name redis redis:7 # 或用 brew (macOS) brew install redis && brew services start redis ``` ### 3. 配置环境 #### 生产环境 ```bash cp .env.example .env # 编辑 .env 填入真实 Zendesk 凭据和 Redis 密码 ``` #### 开发环境 ```bash cp .env.dev.example .env.dev # 编辑 .env.dev 填入开发凭据 # 注意:开发环境用 db=1 与生产隔离(REDIS_URL=redis://127.0.0.1:6379/1) ``` ### 4. 启动 ```bash # 开发环境(自动重载、单 worker) APP_ENV=dev uvicorn app.main:app --host 127.0.0.1 --port 8000 --reload # 生产环境(多 worker) APP_ENV=prod uvicorn app.main:app --host 0.0.0.0 --port 8025 --workers 3 # 或省略 APP_ENV(默认就是 prod) uvicorn app.main:app --host 0.0.0.0 --port 8025 --workers 3 ``` 启动日志中会看到 leader 选举: ``` [14408-abc] 当选 leader,开始首次刷新 [14408-abc] 开始刷新 FAQ 缓存… [14409-def] 不是 leader,等待缓存就绪… [14410-ghi] 不是 leader,等待缓存就绪… [14408-abc] FAQ 缓存刷新完成:sec_ids=N 条,下一次刷新 ... [14409-def] 共享缓存已就绪 [14410-ghi] 共享缓存已就绪 ``` ## 接口 所有接口返回统一格式: ```json { "code": 0, "data": {}, "msg": "成功" } ``` ### 1. 搜索(GET) ```bash curl "http://localhost:8000/search?query=Can%20I%20Add%20a%20PoE%20Switch" ``` ### 2. 搜索(POST) ```bash curl -X POST http://localhost:8000/search \ -H "Content-Type: application/json" \ -d '{"query": "Can I Add a PoE Switch", "page": 1, "per_page": 25}' ``` 成功响应: ```json { "code": 0, "data": { "query": "Can I Add a PoE Switch", "count": 3, "page": 1, "per_page": 25, "next_page": null, "sec_ids_used": [48252627506585, 47684283137817], "results": [{"title": "...", "html_url": "...", "snippet": "..."}] }, "msg": "成功" } ``` ### 3. 健康检查 ```bash curl http://localhost:8000/health # {"code": 0, "data": {"status": "ok"}, "msg": "成功"} ``` ### 4. 缓存状态 ```bash curl http://localhost:8000/health/cache ``` ```json { "code": 0, "data": { "sec_ids_count": 30, "last_updated_at": "2026-06-15T09:12:33", "next_refresh_at": "2026-06-16T00:00:00" }, "msg": "成功" } ``` ### 5. 手动刷新缓存 ```bash curl -X POST http://localhost:8000/admin/cache/refresh ``` ### 6. Swagger UI 打开 。 ## 错误码表 | code | 含义 | HTTP 状态 | |------|------|-----------| | 0 | 成功 | 200 | | 1001 | 参数验证失败(如 page=-1) | 422 | | 1002 | 请求格式不正确 | 400 | | 1003 | 请求方法不允许 | 405 | | 1004 | 资源不存在 | 404 | | 2001 | FAQ 缓存未就绪 | 200 | | 2002 | 业务资源不存在 | 200 | | 3001 | 上游服务调用失败(Zendesk) | 200 | | 3002 | 上游服务响应超时 | 200 | | 9999 | 服务器内部错误 | 500 | 参数错误响应示例(`?query=hi&page=-1`): ```json { "code": 1001, "data": [{"loc": ["query", "page"], "msg": "Input should be greater than or equal to 1", "type": "greater_than_equal"}], "msg": "参数验证失败" } ``` ## 配置项 通过 `APP_ENV` 选择加载哪个 env 文件: | `APP_ENV` | 加载文件 | |-----------|----------| | `dev` | `.env.dev` | | `prod`(默认) | `.env` | ### 完整配置项 | 变量 | 默认 | 说明 | | --- | --- | --- | | `ZENDESK_SUBDOMAIN` | — | Zendesk 子域名,例:`zositechhelp` | | `ZENDESK_EMAIL` | — | Zendesk 账号邮箱 | | `ZENDESK_API_TOKEN` | — | Zendesk API Token | | `DEFAULT_LOCALE` | `en-us` | 默认语言 | | `CACHE_REFRESH_HOUR` | `0` | 每日刷新小时(24 小时制,本地时区) | | `CACHE_REFRESH_MINUTE` | `0` | 每日刷新分钟 | | `HTTP_TIMEOUT` | `30` | httpx 超时秒数 | | `REDIS_URL` | `redis://127.0.0.1:6379/0` | Redis 连接 URL | | `REDIS_PASSWORD` | — | Redis 密码(如有) | | `REDIS_USERNAME` | — | Redis 用户名(ACL 模式才需要) | | `REDIS_KEY_PREFIX` | `faq_search` | 所有 Redis Key 的前缀 | | `LEADER_LOCK_TTL` | `60` | leader 锁 TTL,决定故障切换延迟 | | `CACHE_READY_WAIT` | `30` | follower 等待缓存就绪的最大秒数 | ### 环境隔离建议 | 项 | 生产 | 开发 | |----|------|------| | Redis db | `0` | `1`(避免污染生产数据) | | `REDIS_KEY_PREFIX` | `faq_search` | `faq_search_dev` | | 启动 | `--workers 3` | `--reload`(单 worker) | | Zendesk 凭据 | 生产账号 | sandbox / 测试账号 | ## 多 Worker 与 Leader 机制 启动 N 个 worker 时(`uvicorn --workers N`): 1. 每个 worker 独立尝试在 Redis 抢 `lock:leader`,**仅一个**抢到 2. Leader:拉 Zendesk 写入 Redis、跑 `scheduler_loop`(每天定时刷新)、跑 `leader_heartbeat`(每 `LEADER_LOCK_TTL/3` 秒续期锁) 3. Follower:仅读 Redis 缓存,不打 Zendesk 4. Leader 进程崩溃后,`LEADER_LOCK_TTL` 秒内剩余 worker 接管 **Redis Key**: ``` faq_search:cache:data JSON 缓存数据(共享) faq_search:lock:leader leader 选举锁 faq_search:lock:refresh 防并发刷新锁 ``` ## Redis 故障降级 - **Redis 完全挂掉**:`get_snapshot()` 返回空快照,搜索不带 section 过滤继续工作(结果可能包含非 FAQ 文章) - **Leader 长时间卡死**:`LEADER_LOCK_TTL` 后锁过期,其他 worker 抢锁接管 - **手动刷新**:`POST /admin/cache/refresh` 用 `force=True` 跳过去重锁,立即生效 ## 开发常见命令 ```bash # 仅启动开发环境 APP_ENV=dev uvicorn app.main:app --reload # 用 redis-cli 看缓存 redis-cli -n 1 get faq_search_dev:cache:data | python -m json.tool # 手动清空开发缓存 redis-cli -n 1 del faq_search_dev:cache:data faq_search_dev:lock:leader # 查看当前 leader 是哪个 worker redis-cli -n 1 get faq_search_dev:lock:leader ```