后端服务:REST 契约、类型校验与幂等键
同一个 client_id 提交三次,持仓只增加 1000 股——幂等键挡住了重复委托
把 p8.1 的 Gateway 包成 HTTP 服务,只需要三条要求——而每一条都在防一类事故:
① 请求与响应都用 pydantic 模型 → 类型错在进门时就被挡住
② 自动文档(/docs、/openapi.json)→ 契约就是文档,不会漂
③ 每个写操作都要幂等键 → 网络重试不该产生两笔委托
第 ③ 条最容易被忽略。实测:同一个 client_id 连续提交三次
第 1 次:{'accepted': True, 'filled_qty': 1000, 'price': 122.9043}
第 2 次:{'accepted': True, 'filled_qty': 1000, 'price': 122.9043}
第 3 次:{'accepted': True, 'filled_qty': 1000, 'price': 122.9043}
账户持仓:sh.600000 × 1000 股
三次响应都说"成交 1000 股",而持仓只增加了 1000 股。
这不是 bug,这正是幂等该有的样子。
你在 ATM 取 1000 元,按下确认后屏幕卡住了。
你会不会再按一次?
幂等键就是那张凭条编号:银行认的是编号,不是你按了几次。
没有幂等键的下单接口,等于一台按几次就吐几次钱的 ATM。
一、类型校验:错在进门时就被挡住
四种坏请求,全部在撮合之前返回 422:
| 坏请求 | 返回 | 命中字段 |
|---|---|---|
side: "LONG"(不在枚举里) | 422 | side |
qty: -100 | 422 | qty |
client_id: "ab"(太短) | 422 | client_id |
缺 client_id | 422 | client_id |
class OrderIn(BaseModel):
symbol: str
side: str = Field(..., pattern="^(BUY|SELL)$")
qty: int = Field(..., gt=0)
client_id: str = Field(..., min_length=4) # 幂等键
422 是在撮合之前返回的——脏数据没有进入系统。
对比一下没有类型层会发生什么:
side="LONG"会一路走到撮合,然后落到else分支被当成卖出。它不会报错,只会做错。
二、自动文档:契约就是文档
GET /openapi.json 200 端点 4 个:['/account', '/health', '/orders', '/strategies']
/docs 由同一份 pydantic 模型生成——改了模型,文档立刻跟着变,不会漂。
这与 p5.1 的规格书是同一个思路:把约定写成可执行的东西,而不是写成文档。
文档会过期,类型不会。
🔧 动手做:同一笔委托提交三次,看持仓会不会翻三倍(5 分钟)
跑后端服务的例子,用同一个 client_id 提交同一笔买单三次(模拟网络重试)。
你会看到(实测):持仓只增加了 1000 股,不是 3000 股——后两次被幂等键挡下了。
想明白:网络会超时,客户端会重试,而"下单"这个动作重复执行就是灾难(一笔变三笔)。解法是给每笔委托一个 client_id,服务端记住"这个 id 处理过了",重复提交直接返回上次结果。这和 p1.5 的数据幂等是同一条纪律:任何有副作用的操作,都必须能安全地重试。
三、幂等键:网络重试不该产生两笔委托
为什么必须有:客户端超时重试是常态,而不是异常。
网络抖动、网关超时、客户端崩溃重启——这些都会让同一笔委托被提交多次。
实现只有三行:
seen = STATE.setdefault("_seen", {})
if body.client_id in seen:
return seen[body.client_id] # 直接返回上次的结果
...
seen[body.client_id] = out
幂等的正确语义是"返回上次的结果",不是"拒绝第二次"。
如果第二次返回 409 或报错,客户端会以为下单失败,然后可能换个 id 再下一单——那才是真正的重复下单。
幂等接口应该让重试变得安全,而不是让重试变得危险。
同理:幂等键必须由客户端生成并在重试时保持不变。服务端生成的 id 起不到任何作用。
四、策略注册也要幂等 + 版本冲突检测
注册 v1.0.0 → 200 {'fingerprint': '072ef770c2ea'}
同规格重复注册 → 200(幂等)
改了规格不升版本 → 409
版本冲突:ma@1.0.0 已存在且指纹不同(072ef770c2ea vs b096925661c1)
"同规格重复注册"返回 200,"改了规格不升版本"返回 409。
这两者的区别就是 p5.1 的规格指纹:指纹相同 = 同一件事,指纹不同 = 不同的事。
五、这个服务里没有什么
| 有 | 没有 |
|---|---|
/health /account /orders /strategies | 任何真实券商下单端点 |
| 模拟撮合(SimMatcher) | 任何交易通道凭证 |
mode: "simulation-only" | 资金划转、开户、绑卡 |
GET /health 的响应里写着 mode: "simulation-only"——这不是装饰,是给运维和审计看的:任何时候都能确认这套系统在跑什么。
📌 免责:本课为技术教学,服务只暴露模拟撮合,没有任何真实下单端点;不构成投资建议。
四件事:
- 三条 REST 契约要求:pydantic 模型(类型错在进门时被挡)· 自动文档(契约就是文档,不会漂)· 幂等键
- 类型校验实测:四种坏请求全部在撮合之前返回 422。没有类型层时,
side="LONG"会一路走到撮合被当成卖出——它不会报错,只会做错 - 幂等实测:同一
client_id提交三次,三次都返回"成交 1000 股",而持仓只增加 1000 股。语义是"返回上次的结果",不是"拒绝第二次"——返回错误会让客户端换个 id 再下一单,那才是真正的重复下单 - 策略注册同样幂等:同规格重复注册 200,改了规格不升版本 409——区别就是 p5.1 的规格指纹
服务里没有任何真实券商下单端点;/health 的响应写着 mode: "simulation-only",这是给运维和审计看的。
下一节讲数据这一层:落盘优先、任务化、增量——以及为什么分析脚本永远不该自己联网。
🔎 来源与核验· 3 条,点开核对
