相对第一版 46fc976 的完整变更。组员迁移对照表见 docs/20。
一、对外契约对齐 docs/05(破坏性,共 4 处,组员需按 docs/20 调整)
1) 配置发布端点改为文档规定的复数资源名:submit→validations、
approve→reviews(需 body decision)、activate→activations、
rollback→rollbacks;第一版这 4 个动词式路径 docs/05 从未定义过。
2) 错误码由 8 个笼统码改为 15 个具体语义码(FORBIDDEN→AGENT_PERMISSION_DENIED、
UNAUTHORIZED→AUTHENTICATION_REQUIRED、CONFLICT→RESOURCE_VERSION_CONFLICT、
RESOURCE_NOT_FOUND→RUN_NOT_FOUND/SESSION_NOT_FOUND 等),
输入类错误状态码 400→422。
3) POST /api/v1/agent-runs 与 GET /api/v1/agent-runs/{run_id} 统一为
{data, meta} 信封(data 内字段名与语义未变)。
4) 错误响应体统一为 {error:{code,message,retryable,field_errors}, meta:{trace_id}},
不再返回 FastAPI 默认的 {"detail": ...}。
二、数据库基线与约束
新增 39 张表的基线迁移(链根)与联合唯一键纠偏(4 张表、删 8 增 4,幂等收敛);
撤下 config_release 的双人复核 CHECK(应用层已允许自审,审核节点保留,
自审如实写入 reviewer_id);记忆 active key 生成列与唯一键;
activate 开始记录 supersedes_release_id 使版本链可追溯。
docs/00 基线未修改,未重命名或删除任何表与字段。
三、修复会静默出错或无报错的缺陷
- 跑完集成测试后平台会静默失去生效配置:清理只删自己创建的版本,却没有恢复被它
顶成 superseded 的原生效版本,且审计一并删除因而完全无痕,表现为所有工具被拒
但没有任何报错。已修清理逻辑并加恢复。
- Worker 单轮异常导致进程退出;记忆抽取调用方的“事务已开始”异常;
召回缓存丢失 degraded 标记;连接时区未生效导致 created_at/updated_at 差 8 小时;
.env 与 os.getenv 密钥来源分裂导致“没有可用的已批准模型端点”。
- 记忆信号识别漏判与跨键误命中;SSE 未带 Accept 的协商行为。
四、功能补齐
记忆链路 P1/P2/P3(抽取、受控词表、召回与缓存、生命周期级联及投影事件)、
fin_* 场内交易只读 ORM 层、agent_intent_config 状态流转并在运行期真正生效、
限流(Redis 固定窗口、故障一律放行)、游标校验、trace_id 中间件、
示例业务 Agent fund_query_demo 与一键端到端验证脚本,以及审计/指纹/迁移状态工具。
五、文档与验证
新增 docs/19(业务 Agent 接入实操)、docs/20(第一版迁移指南)与 docs/evidence 证据;
docs/01/02/06/08/09/17 同步实现现状。
验证结果:ruff 通过、mypy 103 文件无错、unit+contract 447 passed、
integration 29 passed、acceptance_check --production 7 PASS、
demo_agent_e2e 9/9 PASS(含失败关闭反证)。
52 lines
2.9 KiB
Python
52 lines
2.9 KiB
Python
"""游标分页的输入校验(文档 §3.8、§16.1)。
|
||
|
||
文档 §3.8 规定列表接口统一使用 `cursor` + `limit`,§16.1 要求"分页游标绑定过滤
|
||
条件,非法游标返回 `400 INVALID_CURSOR`"。当前实现的游标就是**记录 ID 边界**
|
||
(`id < cursor`,取更旧的一页),本次**不引入新的不透明编码机制**:这里的职责只有
|
||
一件事——把"能安全当边界用"的游标筛出来,其余一律 400,不再静默忽略。
|
||
|
||
为什么非法游标必须报错而不是当成"没有游标":静默忽略会把客户端的笔误(`abc`、
|
||
`-1`、超范围 ID)变成"返回第一页且没有更多数据",客户端据此认为已经翻到底,
|
||
数据丢失无法察觉;而规范化成"从头开始"又会造成重复读取。两种都比 400 更难排查。
|
||
|
||
取值范围的依据(上界与下界都不是随手取的):
|
||
|
||
- 下界 `1`:游标列是自增 BIGINT 主键,从 1 起;`0`/负数只可能来自客户端计算错误,
|
||
作为 `id < 0` 的边界会让列表恒为空。
|
||
- 上界 `2**63 - 1`:与基线 BIGINT 有符号上界一致。更大的值不可能是真实 ID,
|
||
只会在 SQL 里退化成一次无意义的全范围比较。
|
||
- 只接受纯 ASCII 十进制数字:`+1`、`-1`、`1.0`、`0x10`、`1e3`、"1 2" 以及各种
|
||
Unicode 数字(`str.isdigit()` 对它们也为真)一律拒绝——`int()` 与不同数据库方言
|
||
对同一字符串的解释并不一致,输入层只放行一种唯一解释。
|
||
|
||
错误消息只说明原因与期望格式,**不回显客户端原始取值**:避免把客户端可控内容
|
||
(可能含不可见控制字符,文档 §3.7)搬进错误响应体。
|
||
"""
|
||
|
||
from app.core.errors import InvalidCursorError
|
||
|
||
# BIGINT 有符号上界,与 `docs/00-新数据库基线设计.md` 的 id 列类型一致。
|
||
MAX_CURSOR_VALUE = 2**63 - 1
|
||
|
||
|
||
def parse_cursor(raw: str | None, *, field: str = "cursor") -> int | None:
|
||
"""把查询串里的游标解析成记录 ID 边界;`None` 与空白串都表示不翻页。
|
||
|
||
`?cursor=`(空值)在实际客户端里是"没有更多页"的常见写法,把它当非法会让正常
|
||
客户端翻到最后一页时突然收到 400;因此空白串按"未携带游标"处理,其余一律严格校验。
|
||
非法取值抛 `InvalidCursorError`(`400 INVALID_CURSOR`,文档 §3.6)。
|
||
"""
|
||
if raw is None:
|
||
return None
|
||
value = raw.strip()
|
||
if not value:
|
||
return None
|
||
if not value.isascii() or not value.isdigit():
|
||
raise InvalidCursorError(f"{field} 必须是十进制数字(记录 ID),不允许符号、小数或十六进制")
|
||
parsed = int(value)
|
||
if parsed < 1:
|
||
raise InvalidCursorError(f"{field} 必须为正整数(记录 ID 从 1 开始)")
|
||
if parsed > MAX_CURSOR_VALUE:
|
||
raise InvalidCursorError(f"{field} 超出记录 ID 的取值范围(最大 {MAX_CURSOR_VALUE})")
|
||
return parsed
|