From a8bde505497601ab1cce8cc80dca510be1d36a53 Mon Sep 17 00:00:00 2001 From: StephenQiu30 <89136224+StephenQiu30@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:43:42 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E5=88=9B=E5=BB=BA=20PRD07=20=E5=85=A8?= =?UTF-8?q?=E5=B1=80=E5=93=8D=E5=BA=94=E6=A0=BC=E5=BC=8F=E4=B8=8E=E5=BC=82?= =?UTF-8?q?=E5=B8=B8=E5=A4=84=E7=90=86=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 创建 PRD07:定义 failure envelope 契约、错误码分类、日志脱敏要求 - 创建 PLAN12:全局响应与异常处理实施计划 - 创建 OpenSpec change artifacts:proposal, design, specs, tasks Closes STE-310 --- ...04\347\220\206\350\256\241\345\210\222.md" | 180 ++++++++++++++++ ...02\345\270\270\345\244\204\347\220\206.md" | 199 ++++++++++++++++++ .../global-response-error-handling/design.md | 76 +++++++ .../proposal.md | 37 ++++ .../specs/exception-handlers/spec.md | 177 ++++++++++++++++ .../specs/global-error-model/spec.md | 119 +++++++++++ .../specs/request-id/spec.md | 144 +++++++++++++ .../global-response-error-handling/tasks.md | 51 +++++ 8 files changed, 983 insertions(+) create mode 100644 "docs/plans/12-\345\205\250\345\261\200\345\223\215\345\272\224\344\270\216\345\274\202\345\270\270\345\244\204\347\220\206\350\256\241\345\210\222.md" create mode 100644 "docs/prd/07-\345\205\250\345\261\200\345\223\215\345\272\224\346\240\274\345\274\217\344\270\216\345\274\202\345\270\270\345\244\204\347\220\206.md" create mode 100644 openspec/changes/global-response-error-handling/design.md create mode 100644 openspec/changes/global-response-error-handling/proposal.md create mode 100644 openspec/changes/global-response-error-handling/specs/exception-handlers/spec.md create mode 100644 openspec/changes/global-response-error-handling/specs/global-error-model/spec.md create mode 100644 openspec/changes/global-response-error-handling/specs/request-id/spec.md create mode 100644 openspec/changes/global-response-error-handling/tasks.md diff --git "a/docs/plans/12-\345\205\250\345\261\200\345\223\215\345\272\224\344\270\216\345\274\202\345\270\270\345\244\204\347\220\206\350\256\241\345\210\222.md" "b/docs/plans/12-\345\205\250\345\261\200\345\223\215\345\272\224\344\270\216\345\274\202\345\270\270\345\244\204\347\220\206\350\256\241\345\210\222.md" new file mode 100644 index 0000000..f9cedb9 --- /dev/null +++ "b/docs/plans/12-\345\205\250\345\261\200\345\223\215\345\272\224\344\270\216\345\274\202\345\270\270\345\244\204\347\220\206\350\256\241\345\210\222.md" @@ -0,0 +1,180 @@ +--- +layer: Plan +doc_no: "12" +audience: + - Dev + - QA +feature_area: global-response-error-handling +purpose: "实现 PRD07 中的全局 failure envelope、exception handlers、request id 集成和契约测试。" +canonical_path: "docs/plans/12-全局响应与异常处理计划.md" +status: draft +version: "1.0.0" +owner: "StephenQiu30" +inputs: + - "docs/prd/07-全局响应格式与异常处理.md" + - "apps/api/app/core/errors.py" + - "apps/api/app/core/responses.py" + - "apps/api/app/middleware/request_context.py" +outputs: + - "全局响应与异常处理计划" +triggers: + - "需要规范化全局错误处理" + - "需要集成 request id 到错误响应" +downstream: + - "openspec/changes/global-response-error-handling/" +--- + +# PLAN12 全局响应与异常处理 + +## 1. 背景 + +PRD07 定义了全局响应格式和异常处理的需求边界。当前已有初步实现(`AppError`、`ErrorCode`、`failure_response`),但缺少 request id 集成和统一契约测试。本计划将补全这些缺失,确保全局错误处理具备完整的可追踪性和可测试性。 + +## 2. 目标 + +1. 错误响应包含 `request_id` 字段,支持问题追踪。 +2. `request_id` 存储在 `request.state` 中,便于全局访问。 +3. 所有异常类型(`AppError`、`HTTPException`、`RequestValidationError`、未知异常)遵循统一 failure envelope。 +4. 契约测试覆盖所有错误路径。 + +## 3. 非目标 + +- 不在本计划中迁移已有成功响应格式。 +- 不在本计划中添加新的错误码(仅使用已有 `ErrorCode`)。 +- 不在本计划中修改日志脱敏规则(已在 `unhandled_exception_handler` 中实现基础脱敏)。 + +## 4. 核心内容 + +### 4.1 Request ID 集成 + +**修改文件:** `apps/api/app/middleware/request_context.py` + +**变更内容:** + +1. 将 `request_id` 存储到 `request.state.request_id` +2. 保持现有响应头 `X-Request-ID` 逻辑不变 + +**实现步骤:** + +```python +# 在 call_next 之前添加 +request.state.request_id = request_id +``` + +### 4.2 Failure Envelope 增强 + +**修改文件:** `apps/api/app/core/responses.py` + +**变更内容:** + +1. `failure_response` 函数新增 `request_id` 参数 +2. 错误响应结构包含 `request_id` 字段 + +**实现步骤:** + +```python +def failure_response(code: str, message: str, details: Any = None, request_id: str | None = None) -> dict[str, Any]: + error = { + "code": code, + "message": message, + "details": details, + } + if request_id: + error["request_id"] = request_id + return { + "success": False, + "error": error, + } +``` + +### 4.3 Exception Handlers 更新 + +**修改文件:** `apps/api/app/core/errors.py` + +**变更内容:** + +1. 所有 exception handler 从 `request.state` 获取 `request_id` +2. 将 `request_id` 传递给 `failure_response` + +**实现步骤:** + +1. `app_error_handler`:获取 `request_id` 并传递 +2. `http_exception_handler`:获取 `request_id` 并传递 +3. `validation_exception_handler`:获取 `request_id` 并传递 +4. `unhandled_exception_handler`:获取 `request_id` 并传递 + +**示例:** + +```python +async def app_error_handler(request: Request, exc: AppError) -> JSONResponse: + request_id = getattr(request.state, "request_id", None) + return JSONResponse( + status_code=exc.status_code, + content=failure_response(exc.code, exc.message, exc.details, request_id), + ) +``` + +### 4.4 契约测试 + +**新增文件:** `apps/api/tests/test_error_contract.py` + +**测试用例:** + +1. **AppError 测试** + - 验证 `AppError` 响应包含 `success: false` + - 验证响应包含 `error.code` 和 `error.message` + - 验证响应包含 `request_id` + +2. **HTTPException 测试** + - 验证 401/403/404/5xx 映射为标准错误码 + - 验证响应包含 `request_id` + +3. **RequestValidationError 测试** + - 验证参数校验错误返回 `validation_error` + - 验证 `details` 包含校验错误详情 + - 验证响应包含 `request_id` + +4. **未知异常测试** + - 验证未知异常返回 `internal_error` + - 验证不暴露内部堆栈 + - 验证响应包含 `request_id` + +5. **Request ID 来源测试** + - 验证优先使用客户端传入的 `X-Request-ID` + - 验证未传入时自动生成 UUID + +## 5. 任务拆解 + +### 5.1 实施任务 + +| 序号 | 任务 | 文件 | 说明 | +| --- | --- | --- | --- | +| 1 | Request ID 存储到 request.state | `middleware/request_context.py` | 在 middleware 中存储 request_id | +| 2 | failure_response 增加 request_id | `core/responses.py` | 函数签名和实现更新 | +| 3 | Exception handlers 传递 request_id | `core/errors.py` | 4 个 handler 更新 | +| 4 | 契约测试 | `tests/test_error_contract.py` | 新增测试文件 | + +### 5.2 验证任务 + +| 序号 | 验证项 | 命令 | 预期结果 | +| --- | --- | --- | --- | +| 1 | 单元测试 | `pytest apps/api/tests/test_error_contract.py -v` | 全部通过 | +| 2 | 集成测试 | `pytest apps/api/tests/ -v` | 无回归 | +| 3 | 仓库验证 | `bash scripts/validate-repository.sh` | 通过 | + +## 6. 风险与边界 + +- **风险**:修改 `failure_response` 签名可能影响已有调用 → **缓解**:`request_id` 参数默认值为 `None`,保持向后兼容 +- **风险**:`request.state` 在某些异常场景下可能不存在 → **缓解**:使用 `getattr(request.state, "request_id", None)` 安全访问 + +## 7. 依赖关系 + +- 依赖 `apps/api/app/core/errors.py` 已有实现 +- 依赖 `apps/api/app/core/responses.py` 已有实现 +- 依赖 `apps/api/app/middleware/request_context.py` 已有实现 + +## 8. 变更记录 + +| 日期 | 作者 | 版本 | 变更说明 | +| --- | --- | --- | --- | +| 2026-06-16 | StephenQiu30 | 1.0.0 | 创建 PLAN12:全局响应与异常处理计划 | diff --git "a/docs/prd/07-\345\205\250\345\261\200\345\223\215\345\272\224\346\240\274\345\274\217\344\270\216\345\274\202\345\270\270\345\244\204\347\220\206.md" "b/docs/prd/07-\345\205\250\345\261\200\345\223\215\345\272\224\346\240\274\345\274\217\344\270\216\345\274\202\345\270\270\345\244\204\347\220\206.md" new file mode 100644 index 0000000..98df239 --- /dev/null +++ "b/docs/prd/07-\345\205\250\345\261\200\345\223\215\345\272\224\346\240\274\345\274\217\344\270\216\345\274\202\345\270\270\345\244\204\347\220\206.md" @@ -0,0 +1,199 @@ +--- +layer: PRD +doc_no: "07" +audience: + - PM + - Dev + - QA +feature_area: global-response-error-handling +purpose: "定义 video-server 后端全局响应格式和异常处理需求边界,使 API 错误响应、错误码、request id 和日志脱敏具备统一契约。" +canonical_path: "docs/prd/07-全局响应格式与异常处理.md" +status: draft +version: "1.0.0" +owner: "StephenQiu30" +inputs: + - "docs/prd/01-解析入口与URL安全.md" + - "docs/prd/03-异步下载任务主链路.md" + - "apps/api/app/core/errors.py" + - "apps/api/app/core/responses.py" +outputs: + - "全局 failure envelope 契约" + - "异常分类与错误码规范" + - "日志脱敏要求" +triggers: + - "新增错误码" + - "调整 failure envelope 结构" + - "修改异常处理逻辑" + - "调整日志脱敏规则" +downstream: + - "docs/plans/12-全局响应与异常处理计划.md" + - "openspec/changes/global-response-error-handling/" +--- + +# PRD07 全局响应格式与异常处理 + +## 1. 背景 + +video-server 后端已有初步的错误处理实现(`AppError`、`ErrorCode`、`failure_response`),但缺少统一的需求边界定义。当前问题: + +1. 错误响应格式虽已存在,但缺少明确的契约文档。 +2. Request ID 已在 middleware 中生成,但未包含在错误响应中,难以追踪。 +3. 日志脱敏规则未明确定义,存在泄露敏感信息风险。 +4. 成功响应格式不统一,部分接口直接返回数据,部分使用 envelope。 + +## 2. 目标 + +1. 建立统一的 failure envelope 契约,所有错误响应遵循相同结构。 +2. 定义错误码分类和使用规范,确保错误语义稳定、可机器读取。 +3. 将 Request ID 集成到错误响应中,支持问题追踪。 +4. 定义日志脱敏规则,防止泄露 token、cookie、secret、密码等敏感信息。 +5. 明确成功响应迁移原则,保持向后兼容。 + +```gherkin +Given API 遇到任何错误情况 +When 系统生成错误响应 +Then 响应遵循统一 failure envelope 结构 +And 包含 request_id 用于追踪 +And 日志中不包含敏感信息 +``` + +## 3. 非目标 + +- 不在本 PRD 中一次性 envelope 化所有既有成功响应。 +- 不在本 PRD 中定义前端错误展示逻辑。 +- 不在本 PRD 中重写任务状态机。 +- 不向用户暴露内部异常堆栈、token、cookie、secret、密码或完整预签名 URL。 + +## 4. 核心内容 + +### 4.1 Failure Envelope 契约 + +所有错误响应必须遵循以下结构: + +```json +{ + "success": false, + "error": { + "code": "error_code", + "message": "用户可读的错误描述", + "details": {}, + "request_id": "abc123" + } +} +``` + +**字段说明:** + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `success` | boolean | 是 | 固定为 `false` | +| `error.code` | string | 是 | 机器可读错误码,使用 snake_case | +| `error.message` | string | 是 | 用户可读的错误描述,中文 | +| `error.details` | object/array/null | 否 | 额外错误详情,用于参数校验等场景 | +| `error.request_id` | string | 是 | 请求追踪 ID,来自 `X-Request-ID` | + +### 4.2 成功响应迁移原则 + +**向后兼容策略:** + +1. 新接口必须使用统一 envelope:`{"success": true, "data": {...}}` +2. 已有接口保持原格式,不在本 PRD 中强制迁移 +3. 后续 PRD/PLAN 可按需逐步迁移已有接口 + +**统一成功 envelope 结构:** + +```json +{ + "success": true, + "data": {} +} +``` + +### 4.3 异常分类与错误码 + +**错误码分类:** + +| 分类 | 前缀 | 示例 | 说明 | +| --- | --- | --- | --- | +| 入口校验 | - | `invalid_url`, `unsafe_url` | URL 格式和安全校验 | +| 平台相关 | - | `parse_failed`, `platform_restricted` | 平台识别和解析 | +| 基础设施 | - | `engine_unavailable`, `queue_unavailable` | 依赖服务不可用 | +| 认证授权 | - | `invalid_credentials`, `user_disabled` | 用户认证和权限 | +| 任务生命周期 | - | `invalid_state`, `not_found` | 任务状态和查找 | +| 通用 | - | `rate_limited`, `validation_error` | 通用错误 | + +**异常处理分类:** + +| 异常类型 | HTTP 状态码 | 处理方式 | +| --- | --- | --- | +| `AppError` | 自定义 | 使用 `error.code` 和 `error.message` | +| `HTTPException` | 原状态码 | 映射为标准错误码 | +| `RequestValidationError` | 422 | `validation_error` + 详细校验信息 | +| 未知异常 | 500 | `internal_error` + 通用提示 | + +### 4.4 Request ID 集成 + +**要求:** + +1. 每个请求必须有唯一 `request_id`。 +2. 优先使用客户端传入的 `X-Request-ID`,否则生成 UUID。 +3. `request_id` 必须存储在 `request.state` 中,便于全局访问。 +4. 错误响应必须包含 `request_id`。 +5. 响应头必须包含 `X-Request-ID`。 + +### 4.5 日志脱敏要求 + +**禁止记录的敏感信息:** + +1. 用户 token、session、cookie +2. 密码、密钥、secret +3. 完整预签名 URL(包含签名参数) +4. 用户个人信息(邮箱、手机号等) + +**日志格式要求:** + +1. 异常日志必须包含 `request_id`、`path`、`method` +2. 未知异常必须记录完整堆栈(`exc_info=exc`) +3. 已知业务异常只记录错误码和消息,不记录堆栈 + +## 5. 关联文档 + +### 5.1 输入文档 + +1. `docs/prd/01-解析入口与URL安全.md` +2. `docs/prd/03-异步下载任务主链路.md` +3. `apps/api/app/core/errors.py` +4. `apps/api/app/core/responses.py` + +### 5.2 输出文档 + +1. `docs/plans/12-全局响应与异常处理计划.md` +2. `openspec/changes/global-response-error-handling/` + +### 5.3 下游文档 + +1. 后续 PRD/PLAN 可基于本 PRD 扩展错误码和 envelope 使用 + +## 6. 验收门禁 + +- 所有错误响应遵循统一 failure envelope 结构。 +- 错误响应包含 `request_id` 字段。 +- 未知异常不暴露内部堆栈、token、cookie、secret。 +- 日志中不包含敏感信息。 +- `AppError`、参数校验错误和未知异常都有统一处理。 + +## 7. 风险与边界 + +- **风险**:修改 failure envelope 结构可能影响已有客户端 → **缓解**:保持 `success` 和 `error` 字段兼容,仅新增 `request_id` +- **风险**:日志脱敏过严可能影响排障 → **缓解**:保留 request_id 和错误码,便于关联分析 + +## 8. 待确认问题 + +- 是否需要为不同环境(dev/staging/prod)配置不同的日志详细程度? +- 是否需要支持错误码国际化? + +## 9. 变更记录 + +| 日期 | 作者 | 版本 | 变更说明 | +| --- | --- | --- | --- | +| 2026-06-16 | StephenQiu30 | 1.0.0 | 创建 PRD07:全局响应格式与异常处理 | diff --git a/openspec/changes/global-response-error-handling/design.md b/openspec/changes/global-response-error-handling/design.md new file mode 100644 index 0000000..008207f --- /dev/null +++ b/openspec/changes/global-response-error-handling/design.md @@ -0,0 +1,76 @@ +## Context + +video-server 后端使用 FastAPI 框架,已有初步的错误处理实现: + +- **异常类**:`AppError` — 自定义业务异常,包含 `code`、`message`、`status_code`、`details` +- **错误码**:`ErrorCode` — StrEnum 枚举,分类定义错误码 +- **响应格式**:`failure_response` — 生成统一错误响应结构 +- **异常处理器**: + - `app_error_handler` — 处理 `AppError` + - `http_exception_handler` — 处理 `HTTPException` 和 `StarletteHTTPException` + - `validation_exception_handler` — 处理 `RequestValidationError` + - `unhandled_exception_handler` — 处理未知异常 +- **Request Context**:`request_context_middleware` — 生成/提取 request_id,添加响应头 + +**当前缺失:** + +1. request_id 未存储到 `request.state`,exception handler 无法访问 +2. 错误响应未包含 request_id +3. 缺少契约测试覆盖所有错误路径 + +## Goals / Non-Goals + +**Goals:** + +- 错误响应包含 request_id,支持问题追踪 +- request_id 存储在 request.state 中,便于全局访问 +- 所有异常类型遵循统一 failure envelope +- 契约测试覆盖所有错误路径 + +**Non-Goals:** + +- 不迁移已有成功响应格式 +- 不添加新的错误码 +- 不修改日志脱敏规则(已实现基础脱敏) + +## Decisions + +### 1. Request ID 存储在 request.state + +**选择**:在 `request_context_middleware` 中将 `request_id` 存储到 `request.state.request_id`。 + +**理由**:`request.state` 是 FastAPI/Starlette 提供的请求级状态存储,exception handler 可以通过 `request` 参数访问。 + +**替代方案**: +- 使用 contextvars:增加复杂度,且 exception handler 已有 `request` 参数 +- 在 exception handler 中重新生成 request_id:丢失客户端传入的 request_id + +### 2. failure_response 函数增加 request_id 参数 + +**选择**:`failure_response(code, message, details=None, request_id=None)`,request_id 默认值为 None。 + +**理由**: +- 保持向后兼容,已有调用无需修改 +- 显式传递 request_id,避免函数内部访问 request.state(违反单一职责) + +### 3. Exception handler 从 request.state 获取 request_id + +**选择**:使用 `getattr(request.state, "request_id", None)` 安全访问。 + +**理由**: +- `request.state` 在某些异常场景下可能不存在属性 +- 安全访问避免二次异常 + +### 4. 错误响应结构保持向后兼容 + +**选择**:在 `error` 对象中新增 `request_id` 字段,不修改已有字段。 + +**理由**: +- 已有客户端依赖 `success`、`error.code`、`error.message`、`error.details` +- 新增字段向后兼容,客户端可选择性使用 + +## Risks / Trade-offs + +- **风险**:修改 `failure_response` 签名可能影响已有调用 → **缓解**:`request_id` 参数默认值为 `None`,保持向后兼容 +- **风险**:`request.state` 在某些异常场景下可能不存在 → **缓解**:使用 `getattr` 安全访问 +- **风险**:契约测试可能与实际实现不一致 → **缓解**:测试直接调用 exception handler,验证实际响应 diff --git a/openspec/changes/global-response-error-handling/proposal.md b/openspec/changes/global-response-error-handling/proposal.md new file mode 100644 index 0000000..5bab060 --- /dev/null +++ b/openspec/changes/global-response-error-handling/proposal.md @@ -0,0 +1,37 @@ +## Why + +video-server 后端已有初步的错误处理实现(`AppError`、`ErrorCode`、`failure_response`),但缺少统一的契约定义和 request id 集成。当前问题: + +1. 错误响应格式虽已存在,但缺少明确的契约文档和 OpenSpec 规范。 +2. Request ID 已在 middleware 中生成,但未包含在错误响应中,难以追踪。 +3. 缺少契约测试覆盖所有错误路径。 + +需要通过 OpenSpec 将全局错误模型、exception handlers、request id 集成规范化,为后续 API 稳定性和可观测性提供基础。 + +## What Changes + +- 新增 OpenSpec spec `global-error-model`:定义 failure envelope 契约、错误码分类、成功响应迁移原则 +- 新增 OpenSpec spec `exception-handlers`:定义各类异常的处理方式和响应格式 +- 新增 OpenSpec spec `request-id`:定义 request id 的生成、存储、传递和使用规范 +- 修改 `apps/api/app/middleware/request_context.py`:将 request_id 存储到 request.state +- 修改 `apps/api/app/core/responses.py`:failure_response 函数增加 request_id 参数 +- 修改 `apps/api/app/core/errors.py`:所有 exception handler 传递 request_id +- 新增 `apps/api/tests/test_error_contract.py`:契约测试覆盖所有错误路径 + +## Capabilities + +### New Capabilities + +- `global-error-model`: 全局错误模型规范,覆盖 failure envelope 结构、错误码分类、成功响应迁移原则 +- `exception-handlers`: 异常处理器规范,覆盖 AppError、HTTPException、RequestValidationError、未知异常的处理方式 +- `request-id`: Request ID 规范,覆盖生成、存储、传递、响应包含和日志记录 + +### Modified Capabilities + +(无已有 spec 需要修改) + +## Impact + +- 受影响代码:`apps/api/app/middleware/request_context.py`、`apps/api/app/core/responses.py`、`apps/api/app/core/errors.py` +- 受影响测试:`apps/api/tests/test_error_contract.py`(新增) +- 受影响文档:`docs/prd/07-全局响应格式与异常处理.md`、`docs/plans/12-全局响应与异常处理计划.md` diff --git a/openspec/changes/global-response-error-handling/specs/exception-handlers/spec.md b/openspec/changes/global-response-error-handling/specs/exception-handlers/spec.md new file mode 100644 index 0000000..9a46bfc --- /dev/null +++ b/openspec/changes/global-response-error-handling/specs/exception-handlers/spec.md @@ -0,0 +1,177 @@ +--- +layer: Spec +spec_id: exception-handlers +audience: + - Dev + - QA +purpose: "定义各类异常的处理方式和响应格式,确保所有异常遵循统一 failure envelope。" +canonical_path: "openspec/changes/global-response-error-handling/specs/exception-handlers/spec.md" +status: draft +version: "1.0.0" +owner: "StephenQiu30" +inputs: + - "docs/prd/07-全局响应格式与异常处理.md" + - "apps/api/app/core/errors.py" +outputs: + - "异常处理器规范" +triggers: + - "新增异常类型" + - "调整异常处理逻辑" +--- + +# Spec: exception-handlers + +## 1. 异常处理分类 + +### 1.1 AppError 处理 + +**触发条件**:业务逻辑抛出 `AppError` 异常 + +**处理方式**: + +```python +async def app_error_handler(request: Request, exc: AppError) -> JSONResponse: + request_id = getattr(request.state, "request_id", None) + return JSONResponse( + status_code=exc.status_code, + content=failure_response(exc.code, exc.message, exc.details, request_id), + ) +``` + +**响应格式**: + +```json +{ + "success": false, + "error": { + "code": "app_error_code", + "message": "业务错误描述", + "details": {}, + "request_id": "abc123" + } +} +``` + +**约束**: +- 使用 `exc.code` 作为 `error.code` +- 使用 `exc.message` 作为 `error.message` +- 使用 `exc.details` 作为 `error.details` +- 使用 `exc.status_code` 作为 HTTP 状态码 + +### 1.2 HTTPException 处理 + +**触发条件**:FastAPI 或 Starlette 抛出 `HTTPException` + +**处理方式**: + +```python +async def http_exception_handler(request: Request, exc: HTTPException | StarletteHTTPException) -> JSONResponse: + request_id = getattr(request.state, "request_id", None) + code, message = _http_error_contract(exc) + return JSONResponse(status_code=exc.status_code, content=failure_response(code, message, request_id=request_id)) +``` + +**错误码映射**: + +| HTTP 状态码 | error.code | error.message | +| --- | --- | --- | +| 401 | `unauthorized` | 请先登录后再继续操作 | +| 403 | `forbidden` | 当前账号没有权限执行该操作 | +| 404 | `not_found` | 资源不存在 | +| 5xx | `internal_error` | 服务暂时不可用,请稍后重试 | +| 其他 | `http_error` | 原 detail 内容或 "请求处理失败" | + +### 1.3 RequestValidationError 处理 + +**触发条件**:FastAPI 参数校验失败 + +**处理方式**: + +```python +async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse: + request_id = getattr(request.state, "request_id", None) + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content=failure_response("validation_error", "请求参数不符合要求", exc.errors(), request_id), + ) +``` + +**响应格式**: + +```json +{ + "success": false, + "error": { + "code": "validation_error", + "message": "请求参数不符合要求", + "details": [ + { + "loc": ["body", "field_name"], + "msg": "field required", + "type": "value_error" + } + ], + "request_id": "abc123" + } +} +``` + +### 1.4 未知异常处理 + +**触发条件**:未被捕获的异常 + +**处理方式**: + +```python +async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse: + request_id = getattr(request.state, "request_id", None) + logger.exception( + "Unhandled API exception path=%s method=%s request_id=%s", + request.url.path, + request.method, + request_id, + exc_info=exc, + ) + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content=failure_response("internal_error", "服务暂时不可用,请稍后重试", request_id=request_id), + ) +``` + +**响应格式**: + +```json +{ + "success": false, + "error": { + "code": "internal_error", + "message": "服务暂时不可用,请稍后重试", + "request_id": "abc123" + } +} +``` + +**约束**: +- 不得在响应中暴露内部异常堆栈 +- 必须记录完整堆栈到日志(`exc_info=exc`) +- 日志必须包含 `request_id`、`path`、`method` + +## 2. 实现要求 + +### 2.1 所有 handler 必须从 request.state 获取 request_id + +```python +request_id = getattr(request.state, "request_id", None) +``` + +### 2.2 所有 handler 必须传递 request_id 给 failure_response + +```python +content=failure_response(code, message, details, request_id) +``` + +### 2.3 约束 + +- 不得修改已有错误码映射逻辑 +- 不得修改已有 HTTP 状态码逻辑 +- 不得在响应中暴露敏感信息 diff --git a/openspec/changes/global-response-error-handling/specs/global-error-model/spec.md b/openspec/changes/global-response-error-handling/specs/global-error-model/spec.md new file mode 100644 index 0000000..41af5ea --- /dev/null +++ b/openspec/changes/global-response-error-handling/specs/global-error-model/spec.md @@ -0,0 +1,119 @@ +--- +layer: Spec +spec_id: global-error-model +audience: + - Dev + - QA +purpose: "定义全局错误模型规范,覆盖 failure envelope 结构、错误码分类、成功响应迁移原则。" +canonical_path: "openspec/changes/global-response-error-handling/specs/global-error-model/spec.md" +status: draft +version: "1.0.0" +owner: "StephenQiu30" +inputs: + - "docs/prd/07-全局响应格式与异常处理.md" +outputs: + - "全局错误模型规范" +triggers: + - "新增错误码" + - "调整 failure envelope 结构" +--- + +# Spec: global-error-model + +## 1. Failure Envelope 结构 + +所有错误响应必须遵循以下结构: + +```json +{ + "success": false, + "error": { + "code": "error_code", + "message": "用户可读的错误描述", + "details": {}, + "request_id": "abc123" + } +} +``` + +### 字段定义 + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `success` | boolean | 是 | 固定为 `false` | +| `error` | object | 是 | 错误详情对象 | +| `error.code` | string | 是 | 机器可读错误码,使用 snake_case | +| `error.message` | string | 是 | 用户可读的错误描述,中文 | +| `error.details` | object/array/null | 否 | 额外错误详情,用于参数校验等场景 | +| `error.request_id` | string | 是 | 请求追踪 ID,来自 `X-Request-ID` | + +## 2. 错误码分类 + +| 分类 | 错误码 | HTTP 状态码 | 说明 | +| --- | --- | --- | --- | +| 入口校验 | `invalid_url` | 400 | URL 格式错误 | +| 入口校验 | `unsafe_url` | 400 | URL 不安全(SSRF 风险) | +| 平台相关 | `parse_failed` | 400 | 平台解析失败 | +| 平台相关 | `platform_restricted` | 403 | 平台限制 | +| 平台相关 | `platform_rate_limited` | 429 | 平台限流 | +| 平台相关 | `unsupported_platform` | 400 | 不支持的平台 | +| 平台相关 | `platform_unavailable` | 503 | 平台不可用 | +| 基础设施 | `engine_unavailable` | 503 | 下载引擎不可用 | +| 基础设施 | `queue_unavailable` | 503 | 队列不可用 | +| 基础设施 | `storage_unavailable` | 503 | 存储不可用 | +| 认证授权 | `invalid_credentials` | 401 | 凭据错误 | +| 认证授权 | `user_disabled` | 403 | 用户已禁用 | +| 认证授权 | `auth_locked` | 403 | 认证锁定 | +| 认证授权 | `registration_disabled` | 403 | 注册未开放 | +| 认证授权 | `registration_failed` | 400 | 注册失败 | +| 任务生命周期 | `invalid_state` | 409/422 | 状态无效 | +| 任务生命周期 | `limit_exceeded` | 429 | 超出限制 | +| 任务生命周期 | `not_found` | 404 | 资源不存在 | +| 任务生命周期 | `retention_expired` | 410 | 保留时间已过期 | +| 任务生命周期 | `retry_superseded` | 409 | 重试已被取代 | +| 通用 | `rate_limited` | 429 | 请求限流 | +| 通用 | `validation_error` | 422 | 参数校验错误 | +| 通用 | `internal_error` | 500 | 内部错误 | + +## 3. 成功响应迁移原则 + +### 向后兼容策略 + +1. 新接口必须使用统一 envelope:`{"success": true, "data": {...}}` +2. 已有接口保持原格式,不强制迁移 +3. 后续可按需逐步迁移已有接口 + +### 统一成功 envelope 结构 + +```json +{ + "success": true, + "data": {} +} +``` + +## 4. 实现要求 + +### 4.1 failure_response 函数 + +```python +def failure_response(code: str, message: str, details: Any = None, request_id: str | None = None) -> dict[str, Any]: + error = { + "code": code, + "message": message, + "details": details, + } + if request_id: + error["request_id"] = request_id + return { + "success": False, + "error": error, + } +``` + +### 4.2 约束 + +- `error.code` 必须使用 snake_case +- `error.message` 必须是用户可读的中文描述 +- `error.request_id` 必须包含在所有错误响应中 +- 不得在 `error.details` 中暴露内部异常堆栈、token、cookie、secret、密码 diff --git a/openspec/changes/global-response-error-handling/specs/request-id/spec.md b/openspec/changes/global-response-error-handling/specs/request-id/spec.md new file mode 100644 index 0000000..b3b23ad --- /dev/null +++ b/openspec/changes/global-response-error-handling/specs/request-id/spec.md @@ -0,0 +1,144 @@ +--- +layer: Spec +spec_id: request-id +audience: + - Dev + - QA +purpose: "定义 request id 的生成、存储、传递和使用规范,确保请求追踪的完整性和一致性。" +canonical_path: "openspec/changes/global-response-error-handling/specs/request-id/spec.md" +status: draft +version: "1.0.0" +owner: "StephenQiu30" +inputs: + - "docs/prd/07-全局响应格式与异常处理.md" + - "apps/api/app/middleware/request_context.py" +outputs: + - "Request ID 规范" +triggers: + - "调整 request id 生成逻辑" + - "修改 request id 传递方式" +--- + +# Spec: request-id + +## 1. Request ID 生命周期 + +### 1.1 生成 + +**来源优先级**: + +1. 客户端传入的 `X-Request-ID` 请求头 +2. 自动生成的 UUID(`uuid4().hex`) + +**实现**: + +```python +request_id = request.headers.get("X-Request-ID") or uuid4().hex +``` + +**约束**: +- 客户端传入的 `X-Request-ID` 必须是非空字符串 +- 自动生成的 UUID 使用 `uuid4().hex` 格式(32 位十六进制,无连字符) + +### 1.2 存储 + +**存储位置**:`request.state.request_id` + +**实现**: + +```python +request.state.request_id = request_id +``` + +**约束**: +- 必须在 `call_next` 之前存储 +- 存储后可在整个请求生命周期内访问 + +### 1.3 传递 + +**响应头**:`X-Request-ID` + +**实现**: + +```python +response.headers["X-Request-ID"] = request_id +``` + +**约束**: +- 所有响应必须包含 `X-Request-ID` 响应头 +- 响应头值必须与请求处理使用的 request_id 一致 + +### 1.4 错误响应包含 + +**位置**:`error.request_id` + +**实现**: + +```python +failure_response(code, message, details, request_id) +``` + +**约束**: +- 所有错误响应必须包含 `request_id` 字段 +- `request_id` 位于 `error` 对象内 + +## 2. 使用场景 + +### 2.1 异常处理 + +所有 exception handler 必须从 `request.state` 获取 `request_id`: + +```python +request_id = getattr(request.state, "request_id", None) +``` + +### 2.2 日志记录 + +异常日志必须包含 `request_id`: + +```python +logger.exception( + "Unhandled API exception path=%s method=%s request_id=%s", + request.url.path, + request.method, + request_id, + exc_info=exc, +) +``` + +### 2.3 业务逻辑 + +业务逻辑可通过 `request.state.request_id` 访问 request_id,用于日志记录或追踪。 + +## 3. 实现要求 + +### 3.1 Middleware 修改 + +**文件**:`apps/api/app/middleware/request_context.py` + +**变更**: + +```python +async def request_context_middleware( + request: Request, + call_next: Callable[[Request], Awaitable[Response]], +) -> Response: + request_id = request.headers.get("X-Request-ID") or uuid4().hex + request.state.request_id = request_id # 新增 + start = time.perf_counter() + response = await call_next(request) + duration_ms = int((time.perf_counter() - start) * 1000) + + response.headers["X-Request-ID"] = request_id + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["Referrer-Policy"] = "no-referrer" + response.headers["X-Request-Duration-Ms"] = str(duration_ms) + return response +``` + +### 3.2 约束 + +- 不得修改 request_id 生成逻辑 +- 不得修改响应头设置逻辑 +- 不得在 request_id 中包含敏感信息 diff --git a/openspec/changes/global-response-error-handling/tasks.md b/openspec/changes/global-response-error-handling/tasks.md new file mode 100644 index 0000000..b1b7e4e --- /dev/null +++ b/openspec/changes/global-response-error-handling/tasks.md @@ -0,0 +1,51 @@ +## 1. OpenSpec Change Artifacts + +- [ ] 1.1 创建 `openspec/changes/global-response-error-handling/proposal.md` +- [ ] 1.2 创建 `openspec/changes/global-response-error-handling/design.md` +- [ ] 1.3 创建 `openspec/changes/global-response-error-handling/specs/global-error-model/spec.md` +- [ ] 1.4 创建 `openspec/changes/global-response-error-handling/specs/exception-handlers/spec.md` +- [ ] 1.5 创建 `openspec/changes/global-response-error-handling/specs/request-id/spec.md` +- [ ] 1.6 创建 `openspec/changes/global-response-error-handling/tasks.md` + +## 2. Request ID 集成 + +- [ ] 2.1 修改 `apps/api/app/middleware/request_context.py`,将 request_id 存储到 request.state +- [ ] 2.2 验证 request_id 在 request.state 中可用 + +## 3. Failure Envelope 增强 + +- [ ] 3.1 修改 `apps/api/app/core/responses.py`,failure_response 函数增加 request_id 参数 +- [ ] 3.2 验证 failure_response 返回包含 request_id 的响应 + +## 4. Exception Handlers 更新 + +- [ ] 4.1 修改 `app_error_handler`,从 request.state 获取 request_id 并传递 +- [ ] 4.2 修改 `http_exception_handler`,从 request.state 获取 request_id 并传递 +- [ ] 4.3 修改 `validation_exception_handler`,从 request.state 获取 request_id 并传递 +- [ ] 4.4 修改 `unhandled_exception_handler`,从 request.state 获取 request_id 并传递 + +## 5. 契约测试 + +- [ ] 5.1 创建 `apps/api/tests/test_error_contract.py` +- [ ] 5.2 编写 AppError 契约测试 +- [ ] 5.3 编写 HTTPException 契约测试 +- [ ] 5.4 编写 RequestValidationError 契约测试 +- [ ] 5.5 编写未知异常契约测试 +- [ ] 5.6 编写 Request ID 来源测试 + +## 6. 推广 Specs 到 Baseline + +- [ ] 6.1 推广 `global-error-model` spec 到 `openspec/specs/global-error-model/spec.md` +- [ ] 6.2 推广 `exception-handlers` spec 到 `openspec/specs/exception-handlers/spec.md` +- [ ] 6.3 推广 `request-id` spec 到 `openspec/specs/request-id/spec.md` + +## 7. 验证 + +- [ ] 7.1 运行 `pytest apps/api/tests/test_error_contract.py -v`,全部通过 +- [ ] 7.2 运行 `pytest apps/api/tests/ -v`,无回归 +- [ ] 7.3 运行 `bash scripts/validate-repository.sh`,通过 +- [ ] 7.4 审阅 PRD07、PLAN12 与设计文档,确认一致性 + +## 8. 文档更新 + +- [ ] 8.1 更新 `docs/plans/12-全局响应与异常处理计划.md` 状态从 `draft` 到 `accepted`