补齐三个成功响应缺失的 meta 信封;信封实现抽为公共(docs/05 §3.3)

"缺 meta"很容易被误判成"有":X-Trace-ID 是**响应头**(中间件加),和 body 里的
meta.trace_id 是两件事;错误响应一直有 meta(异常处理器统一加),漏的只有成功路径。

核到三个端点在把 service 的内部结构直接当响应体返回:

- GET /conversations/{session_id}/messages → 裸 {"data": [...]},meta 整个缺失,
  游标也没地方放(§3.3 要求列表的 data 为纯数组、next_cursor/has_more 进 meta)
- POST /conversation-messages/{id}/feedback → 同样没有 meta
- GET /knowledge-references/{token} → 直接返回资源对象

改动:

- 新增 app/api/views/envelope.py,把 envelope / list_envelope 抽成一份公共实现,
  风控链路改为复用它 —— 同一份契约写两遍的结果就是其中一处漏了 meta。
- ConversationService.messages 改为返回内部结构 {items, next_cursor, has_more},
  用 limit + 1 判断 has_more:只看"取满没取满"会把恰好等于 limit 的最后一页说成
  还有下一页。next_cursor 取本页最后一条的 message_id —— 游标语义是"取更旧的一页",
  天然可续,集成测试本来就是这么翻页的。
- Controller 统一套信封,data 仍是数组、字段名不变,前端不需要改。

测试:新增 tests/unit/api/test_response_envelope.py,断言 set(body) == {"data","meta"}
(多或少一个顶层字段都会红),并覆盖 has_more / next_cursor / trace_id;
另更新两处既有断言(limit 20→21、feedback 返回裸对象)。

门禁:ruff 干净 / mypy 138 文件 / 696 unit+contract / 33 integration。
This commit is contained in:
2026-09-11 15:21:36 +08:00
parent 575b4c2baa
commit 8d79bd9767
7 changed files with 212 additions and 36 deletions
+40
View File
@@ -0,0 +1,40 @@
"""统一响应信封(`docs/05` §3.3)。
§3.3 规定成功响应是 `{data, meta}`,列表的 `data` 为**纯数组**、游标与 `has_more` 放在
`meta` 里,并且明确「业务接口不得增加其他顶层字段」。
放在这里而不是各 Controller 各写一份:同一个偏差已经出现过两次 —— service 返回
`{items, next_cursor, has_more}`(或干脆只有 `data`)之后被直接当响应体返回,于是
`meta` 要么只剩 trace_id、要么整个缺失。同一份契约不该有多份实现。
"""
from __future__ import annotations
from typing import Any
from app.core.contracts import RequestContext
def envelope(data: object, context: RequestContext) -> dict[str, object]:
"""单资源 / 单对象的信封。"""
return {
"data": data,
"meta": {"trace_id": context.trace_id},
}
def list_envelope(page: dict[str, Any], context: RequestContext) -> dict[str, object]:
"""列表资源的信封:`data` 只放数组,分页元数据进 `meta`。
`page` 是 service 的内部结构 `{items, next_cursor, has_more}` —— service 不必改,
只是不再把它整体当 `data` 用。缺失的键按空值处理,因此只返回 `{"data": [...]}`
的旧 service 也不会炸。
"""
return {
"data": page.get("items") or [],
"meta": {
"trace_id": context.trace_id,
"next_cursor": page.get("next_cursor"),
"has_more": bool(page.get("has_more")),
},
}