← 返回目录
p8.2动手⏱ 约 15 分钟

后端服务:REST 契约、类型校验与幂等键

同一个 client_id 提交三次,持仓只增加 1000 股——幂等键挡住了重复委托

🔎 最后验证 2026-08📚 来源:FastAPI 服务的契约、校验与幂等实测,2026-08-01 于 ECS 实测,输出见 code/outputs/stdout.txt🧰 FastAPI 0.141.1、pydantic 2.13.4、uvicorn 0.52.0、httpx 0.28.1
为什么学这个

把 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"(不在枚举里)422side
qty: -100422qty
client_id: "ab"(太短)422client_id
client_id422client_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.0200  {'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"——这不是装饰,是给运维和审计看的:任何时候都能确认这套系统在跑什么。

❓ 测验
你的下单接口收到重复的 client_id,第二次应该返回什么?
✏️ 填空
幂等的正确语义是「返回上次的结果」,而不是「___ 第二次」。

📌 免责:本课为技术教学,服务只暴露模拟撮合,没有任何真实下单端点;不构成投资建议。

✅ 小结

四件事:

  1. 三条 REST 契约要求:pydantic 模型(类型错在进门时被挡)· 自动文档(契约就是文档,不会漂幂等键
  2. 类型校验实测:四种坏请求全部在撮合之前返回 422。没有类型层时,side="LONG" 会一路走到撮合被当成卖出——它不会报错,只会做错
  3. 幂等实测:同一 client_id 提交三次,三次都返回"成交 1000 股",而持仓只增加 1000 股。语义是"返回上次的结果",不是"拒绝第二次"——返回错误会让客户端换个 id 再下一单,那才是真正的重复下单
  4. 策略注册同样幂等:同规格重复注册 200,改了规格不升版本 409——区别就是 p5.1 的规格指纹

服务里没有任何真实券商下单端点;/health 的响应写着 mode: "simulation-only",这是给运维和审计看的

下一节讲数据这一层:落盘优先、任务化、增量——以及为什么分析脚本永远不该自己联网。

下一节 → 数据管理服务:落盘、任务与增量
🔎 来源与核验· 3 条,点开核对
本节每个关键论断都对应一个可追溯的来源 —— 这是本课程"靠谱、不过时"的底线。
「FastAPI 服务实测:/openapi.json 返回 4 个端点;四种非法请求(side 越界/qty≤0/client_id 过短/缺 client_id)全部返回 422 并命中对应字段」
📚 本节 code/p82_api_demo.py,2026-08-01 于 ECS 实测,输出见 code/outputs/stdout.txt✓ 已核验 2026-08
「幂等实测:同一 client_id 提交 3 次,均返回 filled_qty=1000、price=122.9043,而账户持仓仅增加 1000 股」
📚 同上实测✓ 已核验 2026-08
「策略注册:同规格重复注册返回 200(幂等);改规格不升版本返回 409,提示指纹 072ef770c2ea vs b096925661c1」
📚 同上实测✓ 已核验 2026-08
服务仅暴露模拟撮合相关端点,不含任何真实券商下单通道;/health 返回 mode=simulation-only。
智图软件的赞赏码
都看到这了,打个赏呗!
接下来 · p8.3
数据管理服务:落盘优先、任务化、增量
继续读下一节 →