瀏覽代碼

增加文档的统一格式和安全性(需要用户名和密码访问)

liujintao 1 月之前
父節點
當前提交
71b89779fe
共有 9 個文件被更改,包括 320 次插入30 次删除
  1. 5 5
      README.md
  2. 4 0
      app/config.py
  3. 102 0
      app/docs_auth.py
  4. 1 0
      app/exception_handlers.py
  5. 22 4
      app/main.py
  6. 163 0
      app/openapi.py
  7. 3 0
      app/response.py
  8. 19 20
      app/routers/search.py
  9. 1 1
      app/schemas.py

+ 5 - 5
README.md

@@ -134,14 +134,14 @@ curl -X POST http://localhost:8000/search \
 ### 3. 健康检查
 
 ```bash
-curl http://localhost:8000/health
+curl http://localhost:8000/faq/health
 # {"code": 0, "data": {"status": "ok"}, "msg": "成功"}
 ```
 
 ### 4. 缓存状态
 
 ```bash
-curl http://localhost:8000/health/cache
+curl http://localhost:8000/faq/health/cache
 ```
 
 ```json
@@ -159,12 +159,12 @@ curl http://localhost:8000/health/cache
 ### 5. 手动刷新缓存
 
 ```bash
-curl -X POST http://localhost:8000/admin/cache/refresh
+curl -X POST http://localhost:8000/faq/admin/cache/refresh
 ```
 
 ### 6. Swagger UI
 
-打开 <http://localhost:8000/docs>。
+打开 <http://localhost:8000/faq/docs>。
 
 ## 错误码表
 
@@ -248,7 +248,7 @@ faq_search:lock:refresh     防并发刷新锁
 
 - **Redis 完全挂掉**:`get_snapshot()` 返回空快照,搜索不带 section 过滤继续工作(结果可能包含非 FAQ 文章)
 - **Leader 长时间卡死**:`LEADER_LOCK_TTL` 后锁过期,其他 worker 抢锁接管
-- **手动刷新**:`POST /admin/cache/refresh` 用 `force=True` 跳过去重锁,立即生效
+- **手动刷新**:`POST /faq/admin/cache/refresh` 用 `force=True` 跳过去重锁,立即生效
 
 ## 开发常见命令
 

+ 4 - 0
app/config.py

@@ -58,6 +58,10 @@ class Settings(BaseSettings):
     # follower 等待 leader 首次写入缓存的最大秒数
     cache_ready_wait: int = 180
 
+    # API 文档(Swagger / ReDoc)访问凭据 — 统一开启,需 HTTP Basic 认证
+    docs_username: str = "admin"
+    docs_password: str = "asj@123456"
+
     @property
     def zendesk_base_url(self) -> str:
         return f"https://{self.zendesk_subdomain}.zendesk.com/api/v2/help_center"

+ 102 - 0
app/docs_auth.py

@@ -0,0 +1,102 @@
+"""API 文档(Swagger UI / ReDoc)的访问保护。
+
+默认 FastAPI 暴露 docs_url / redoc_url / openapi_url 是公开的。
+本模块关闭这些公开路由,改为注册受 HTTP Basic 认证保护的等价路由。
+
+用法(在 app/main.py 中):
+    from app.docs_auth import setup_protected_docs
+
+    app = FastAPI(
+        ...
+        docs_url=None,
+        redoc_url=None,
+        openapi_url=None,
+    )
+    setup_protected_docs(app)
+"""
+from __future__ import annotations
+
+import secrets
+
+from fastapi import Depends, FastAPI, HTTPException, status
+from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
+from fastapi.security import HTTPBasic, HTTPBasicCredentials
+
+from app.config import settings
+
+# auto_error=False 让我们自己处理无凭据的情况,
+# 否则 FastAPI 抛出的 401 不带 WWW-Authenticate 头,浏览器不会弹登录框
+_security = HTTPBasic(auto_error=False)
+
+# 受保护后的实际访问路径(修改这里同时改变所有三个文档路径)
+DOCS_URL = "/faq/docs"
+REDOC_URL = "/faq/redoc"
+OPENAPI_URL = "/faq/openapi.json"
+
+
+def verify_docs_auth(
+    credentials: HTTPBasicCredentials | None = Depends(_security),
+) -> str:
+    """校验 HTTP Basic 凭据。
+
+    无凭据 / 凭据错误时统一返回 401 + WWW-Authenticate: Basic,
+    浏览器看到此头会弹出登录框(首次访问 / 凭据错误均如此)。
+
+    使用 secrets.compare_digest 进行常量时间比较,防止时序攻击。
+    """
+    if credentials is None:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail="需要 API 文档访问凭据",
+            headers={"WWW-Authenticate": "Basic"},
+        )
+
+    expected_user = settings.docs_username.encode("utf-8")
+    expected_pass = settings.docs_password.encode("utf-8")
+    actual_user = credentials.username.encode("utf-8")
+    actual_pass = credentials.password.encode("utf-8")
+
+    user_ok = secrets.compare_digest(actual_user, expected_user)
+    pass_ok = secrets.compare_digest(actual_pass, expected_pass)
+
+    if not (user_ok and pass_ok):
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail="API 文档访问凭据错误",
+            headers={"WWW-Authenticate": "Basic"},
+        )
+    return credentials.username
+
+
+def setup_protected_docs(app: FastAPI) -> None:
+    """注册受 HTTP Basic 认证保护的 Swagger / ReDoc / OpenAPI 路由。
+
+    必须在 FastAPI 实例化时关闭默认 docs(docs_url=None / redoc_url=None / openapi_url=None),
+    否则会与本模块注册的同名路径冲突。
+
+    本函数应在 setup_custom_openapi 之后调用,确保读取到的 schema 是覆写过的。
+    """
+
+    @app.get(OPENAPI_URL, include_in_schema=False)
+    async def protected_openapi(
+        _user: str = Depends(verify_docs_auth),
+    ) -> dict:
+        return app.openapi()
+
+    @app.get(DOCS_URL, include_in_schema=False)
+    async def protected_swagger(
+        _user: str = Depends(verify_docs_auth),
+    ):
+        return get_swagger_ui_html(
+            openapi_url=OPENAPI_URL,
+            title=f"{app.title} - Swagger UI",
+        )
+
+    @app.get(REDOC_URL, include_in_schema=False)
+    async def protected_redoc(
+        _user: str = Depends(verify_docs_auth),
+    ):
+        return get_redoc_html(
+            openapi_url=OPENAPI_URL,
+            title=f"{app.title} - ReDoc",
+        )

+ 1 - 0
app/exception_handlers.py

@@ -73,6 +73,7 @@ async def http_exception_handler(
         code=code,
         msg=str(exc.detail) if exc.detail else None,
         http_status=status_code,
+        headers=exc.headers,
     )
 
 

+ 22 - 4
app/main.py

@@ -8,7 +8,10 @@ from typing import AsyncIterator
 
 from fastapi import FastAPI
 
+from app.config import settings
+from app.docs_auth import setup_protected_docs
 from app.exception_handlers import register_exception_handlers
+from app.openapi import setup_custom_openapi
 from app.response import ApiResponse
 from app.routers import search
 from app.schemas import CacheRefreshResult, CacheStatus
@@ -78,22 +81,35 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
         await close_redis()
 
 
+# 仅开发环境暴露 API 文档;生产环境一律关闭,避免泄露接口结构
+_docs_enabled = settings.app_env.lower() == "dev"
+
 app = FastAPI(
     title="Zendesk FAQ Keyword Search",
-    description="封装 Zendesk Help Center 搜索;FAQ sec_ids 共享缓存(Redis),每日定时刷新。",
+    description="封装 Zendesk Help Center 搜索;FAQ",
     version="1.0.0",
     lifespan=lifespan,
+    # 关闭默认 docs,由 setup_protected_docs 注册受 HTTP Basic 保护的等价路由
+    docs_url=None,
+    redoc_url=None,
+    openapi_url=None,
 )
 
 # 注册全局异常处理器(统一错误响应格式 {code, data, msg})
 register_exception_handlers(app)
 
+# 覆写 OpenAPI 文档:让所有错误响应(4xx/5xx)使用统一信封格式
+setup_custom_openapi(app)
+
+# 注册受 HTTP Basic 认证保护的 /faq/docs、/faq/redoc、/faq/openapi.json
+setup_protected_docs(app)
+
 # 路由
 app.include_router(search.router)
 
 
 @app.get(
-    "/health",
+    "/faq/health",
     response_model=ApiResponse[dict[str, str]],
     tags=["meta"],
 )
@@ -102,8 +118,9 @@ async def health() -> ApiResponse[dict[str, str]]:
     return ApiResponse.ok(data={"status": "ok"})
 
 
+
 @app.get(
-    "/health/cache",
+    "/faq/health/cache",
     response_model=ApiResponse[CacheStatus],
     tags=["meta"],
 )
@@ -119,8 +136,9 @@ async def cache_status() -> ApiResponse[CacheStatus]:
     )
 
 
+
 @app.post(
-    "/admin/cache/refresh",
+    "/faq/admin/cache/refresh",
     response_model=ApiResponse[CacheRefreshResult],
     tags=["meta"],
 )

+ 163 - 0
app/openapi.py

@@ -0,0 +1,163 @@
+"""自定义 OpenAPI Schema 生成。
+
+FastAPI 默认根据路由参数自动生成 OpenAPI 时,不会感知 exception_handlers,
+导致文档里的错误响应(422 等)使用内置 HTTPValidationError 模板,
+与项目实际返回的统一信封 {code, data, msg} 不一致。
+
+本模块在应用初始化阶段覆写 app.openapi 函数,让所有错误响应文档与运行时返回保持一致。
+"""
+from __future__ import annotations
+
+from typing import Any
+
+from fastapi import FastAPI
+from fastapi.openapi.utils import get_openapi
+
+from app.response import ResponseCode
+
+# 需要在 OpenAPI 文档中替换的错误状态码 → (业务码, 描述, data 示例)
+# 与 app/exception_handlers.py 中的处理器返回值保持一致
+_ERROR_STATUS_MAP: dict[str, tuple[ResponseCode, str, Any]] = {
+    "400": (
+        ResponseCode.REQUEST_INVALID,
+        "请求格式不正确",
+        None,
+    ),
+    "404": (
+        ResponseCode.NOT_FOUND,
+        "资源不存在",
+        None,
+    ),
+    "405": (
+        ResponseCode.METHOD_NOT_ALLOWED,
+        "请求方法不允许",
+        None,
+    ),
+    "422": (
+        ResponseCode.PARAM_INVALID,
+        "参数验证失败",
+        [
+            {
+                "type": "greater_than_equal",
+                "loc": ["query", "page"],
+                "msg": "Input should be greater than or equal to 1",
+                "input": "-1",
+                "ctx": {"ge": 1},
+            }
+        ],
+    ),
+    "500": (
+        ResponseCode.INTERNAL_ERROR,
+        "服务器内部错误",
+        {"request_id": "uuid-for-tracing"},
+    ),
+    "502": (
+        ResponseCode.UPSTREAM_ERROR,
+        "上游服务调用失败(Zendesk 等)",
+        None,
+    ),
+    "504": (
+        ResponseCode.UPSTREAM_TIMEOUT,
+        "上游服务响应超时",
+        None,
+    ),
+}
+
+
+def _build_error_schema_component() -> dict[str, Any]:
+    """构造统一错误响应的 OpenAPI schema 组件。"""
+    return {
+        "type": "object",
+        "properties": {
+            "code": {
+                "type": "integer",
+                "description": "业务错误码(非 0 表示失败)",
+                "example": ResponseCode.PARAM_INVALID.code,
+            },
+            "data": {
+                "description": (
+                    "失败时的附加数据:参数验证失败时为错误字段列表,"
+                    "服务器内部错误时含 request_id,其他错误一般为 null"
+                ),
+                "nullable": True,
+                "example": [
+                    {
+                        "type": "greater_than_equal",
+                        "loc": ["query", "page"],
+                        "msg": "Input should be greater than or equal to 1",
+                        "input": "-1",
+                        "ctx": {"ge": 1},
+                    }
+                ],
+            },
+            "msg": {
+                "type": "string",
+                "description": "错误描述",
+                "example": ResponseCode.PARAM_INVALID.msg,
+            },
+        },
+        "required": ["code", "msg"],
+        "title": "ApiErrorResponse",
+    }
+
+
+def _build_response_for(
+    code: ResponseCode, description: str, data_example: Any
+) -> dict[str, Any]:
+    """为某个具体错误状态码构造 OpenAPI 响应对象(带 example,便于 ReDoc 展示)。"""
+    return {
+        "description": f"{description}(业务码 {code.code})",
+        "content": {
+            "application/json": {
+                "schema": {"$ref": "#/components/schemas/ApiErrorResponse"},
+                "example": {
+                    "code": code.code,
+                    "data": data_example,
+                    "msg": code.msg,
+                },
+            }
+        },
+    }
+
+
+def setup_custom_openapi(app: FastAPI) -> None:
+    """覆写 app.openapi,让所有错误响应文档使用统一信封格式。
+
+    应在应用初始化阶段调用一次(main.py 中 register_exception_handlers 之后)。
+    """
+
+    def custom_openapi() -> dict[str, Any]:
+        if app.openapi_schema:
+            return app.openapi_schema
+
+        schema = get_openapi(
+            title=app.title,
+            version=app.version,
+            description=app.description,
+            routes=app.routes,
+        )
+
+        # 注册统一错误响应组件
+        components = schema.setdefault("components", {}).setdefault("schemas", {})
+        components["ApiErrorResponse"] = _build_error_schema_component()
+
+        # 替换所有路径下的错误响应
+        for path_item in schema.get("paths", {}).values():
+            for operation in path_item.values():
+                if not isinstance(operation, dict):
+                    continue
+                responses = operation.get("responses", {})
+                for status_code, (
+                    code,
+                    description,
+                    data_example,
+                ) in _ERROR_STATUS_MAP.items():
+                    if status_code in responses:
+                        responses[status_code] = _build_response_for(
+                            code, description, data_example
+                        )
+
+        app.openapi_schema = schema
+        return schema
+
+    app.openapi = custom_openapi  # type: ignore[method-assign]

+ 3 - 0
app/response.py

@@ -131,6 +131,7 @@ def error_response(
     msg: str | None = None,
     data: Any = None,
     http_status: int = 200,
+    headers: dict[str, str] | None = None,
 ) -> JSONResponse:
     """构造失败的 JSONResponse(适用于异常处理器、中间件等场景)。
 
@@ -139,6 +140,7 @@ def error_response(
         msg:  错误提示,为空时使用错误码自带的默认提示
         data: 附加数据(如参数验证错误的详细字段列表)
         http_status: HTTP 状态码,默认 200(业务错误也走 200,由 code 区分成败)
+        headers: 额外响应头(如 401 必须带 WWW-Authenticate 才能让浏览器弹登录框)
 
     返回示例:
         {"code": 1001, "data": [...], "msg": "参数验证失败"}
@@ -150,6 +152,7 @@ def error_response(
             "data": data,
             "msg": msg if msg is not None else code.msg,
         },
+        headers=headers,
     )
 
 

+ 19 - 20
app/routers/search.py

@@ -12,7 +12,7 @@ from app.services.zendesk_client import ZendeskError, search_articles
 
 logger = logging.getLogger(__name__)
 
-router = APIRouter(tags=["search"])
+router = APIRouter(prefix="/faq",tags=["search"])
 
 
 async def _do_search(req: SearchRequest) -> ApiResponse[SearchData]:
@@ -45,25 +45,6 @@ async def _do_search(req: SearchRequest) -> ApiResponse[SearchData]:
     )
 
 
-@router.get(
-    "/search",
-    response_model=ApiResponse[SearchData],
-    summary="按 QUERY 搜索 FAQ 文章",
-)
-async def search_get(
-    query: str = Query(..., min_length=1, description="搜索关键词"),
-    locale: str | None = Query(default=None),
-    page: int = Query(default=1, ge=1),
-    per_page: int = Query(default=25, ge=1, le=100),
-) -> ApiResponse[SearchData]:
-    """GET 版本,便于浏览器直接测试。"""
-    return await _do_search(
-        SearchRequest(
-            query=query, locale=locale, page=page, per_page=per_page
-        )
-    )
-
-
 @router.post(
     "/search",
     response_model=ApiResponse[SearchData],
@@ -72,3 +53,21 @@ async def search_get(
 async def search_post(req: SearchRequest) -> ApiResponse[SearchData]:
     """POST 版本,请求体:{"query": "...", "locale": "...", ...}。"""
     return await _do_search(req)
+
+
+# @router.get(
+#     "/search",
+#     response_model=ApiResponse[SearchData],
+#     summary="按 QUERY 搜索 FAQ 文章",
+# )
+# async def search_get(
+#     query: str = Query(..., min_length=1, description="搜索关键词"),
+#     locale: str | None = Query(default=None),
+#     page: int = Query(default=1, ge=1),
+#     per_page: int = Query(default=25, ge=1, le=100),
+# ) -> ApiResponse[SearchData]:
+#     return await _do_search(
+#         SearchRequest(
+#             query=query, locale=locale, page=page, per_page=per_page
+#         )
+#     )

+ 1 - 1
app/schemas.py

@@ -12,7 +12,7 @@ class SearchRequest(BaseModel):
     """搜索请求体(POST 用)。"""
 
     query: str = Field(..., min_length=1, description="搜索关键词")
-    locale: str | None = Field(default=None, description="语言代码,留空使用默认")
+    locale: str | None = Field(default="en-us", description="语言代码,留空使用默认")
     page: int = Field(default=1, ge=1)
     per_page: int = Field(default=25, ge=1, le=100)