futu_rest/routes/trd/cancel_all.rs
1//! REST `/api/cancel-all-order` 便捷写入口。
2//!
3//! 该 route 是 `ModifyOrder(Cancel)` 的 REST convenience wrapper,负责 flat body
4//! promote、强制 cancel-all 字段和写路径限额检查;通用交易 route 不再承载这些局部细节。
5
6use std::sync::Arc;
7
8use axum::Json;
9use axum::extract::{Extension, State};
10use axum::http::HeaderMap;
11use serde_json::Value;
12
13use futu_auth::{CheckCtx, KeyRecord};
14use futu_core::proto_id;
15use futu_proto::trd_modify_order;
16
17use crate::adapter::{self, RestState};
18
19use super::ApiResult;
20use super::card_num::normalize_and_resolve_card_num_for_route;
21use super::validation::{authorize_trade_write, trd_market_str, validate_header_trd_market_write};
22use super::write_pipeline::{
23 idempotency_key_from_headers, limit_reject_response, surface_spec_or_internal_error,
24};
25
26/// v1.4.96 BUG #006 helper (external reviewer 6-round cross-verify, real env data corruption risk fix).
27///
28/// REST `/api/cancel-all-order` 支持两种 body 格式: c2s wrapper 和 flat.
29/// flat body (`{"acc_id":..., "trd_market":1}`) 之前直接传给 adapter, 跳过
30/// handler 的 `c2s.insert("modify_order_op", 2)` 强制覆盖 → daemon 收到
31/// modify_op=0 (ModifyOrder) → real env 真 modify 匹配订单风险.
32///
33/// 本 helper 检测 flat body (顶层有 `acc_id` / `trd_market` / `header` 等
34/// 业务字段, 但无 `c2s` key), 把它包装成 `{"c2s": {"header": {...原字段}}}`
35/// 让强制字段 override 走原 c2s 路径, **避免重复 fix path / silent regression**.
36///
37/// 不动已 c2s wrapper 的 body (`{"c2s": {...}}`).
38///
39/// ## 与 `adapter::maybe_wrap_flat_body_as_c2s` 的关系 (audit follow-up):
40///
41/// `crates/futu-rest/src/adapter.rs::maybe_wrap_flat_body_as_c2s` 已经在
42/// adapter `proto_request` 入口做 flat → c2s wrap, 所以理论上 promote 是
43/// 重复. **但**: cancel-all-order handler 在 `adapter::proto_request` **之前**
44/// 跑 `c2s.insert("modify_order_op", 2)` 强制覆盖, 此时 c2s 必须已存在.
45/// 顺序: handler → `promote_flat_body_to_c2s` → `c2s.insert(...)` →
46/// `adapter::proto_request(body, ...)` → `maybe_wrap_flat_body_as_c2s` (已经
47/// 是 c2s wrapper, no-op).
48///
49/// 因此 `promote_flat_body_to_c2s` 是 cancel-all-order handler 局部需要的,
50/// 不是 adapter 通用 wrap 的替代.
51pub(crate) fn promote_flat_body_to_c2s(body: &mut Value) {
52 // 已有 c2s 则直接返回 (尊重显式 wrapper)
53 if body.get("c2s").is_some() {
54 return;
55 }
56 let Some(map) = body.as_object_mut() else {
57 return;
58 };
59 if map.is_empty() {
60 return;
61 }
62 // 把所有字段下沉到 c2s.header (业务字段 acc_id / trd_market / 等都属
63 // header). 注意: c2s 自己不能在 map 里, 上面已 guard.
64 let header: serde_json::Map<String, Value> = std::mem::take(map).into_iter().collect();
65 let mut c2s = serde_json::Map::new();
66 c2s.insert("header".to_string(), Value::Object(header));
67 map.insert("c2s".to_string(), Value::Object(c2s));
68}
69
70/// POST /api/cancel-all-order — 全部撤单(v1.4.30 新加)
71///
72/// 这是 `/api/modify-order` 的便捷端点,内部强制 `forAll=true` +
73/// `modifyOrderOp=2 (Cancel)` + `orderID=0`,用户只需传 `trdHeader` 和
74/// 可选的 `trdMarket`(不指定市场时撤整个账户全部)。
75///
76/// 风险提示:**对真实账户下发后立即撤销该账户指定市场所有 pending 订单**,
77/// 不可恢复。scope 要求 `trade:real`(同 modify-order),限额按 market
78/// 白名单 + rate 检查。
79pub async fn cancel_all_order(
80 State(state): State<RestState>,
81 rec: Option<Extension<Arc<KeyRecord>>>,
82 headers: HeaderMap,
83 Json(mut body): Json<Value>,
84) -> ApiResult {
85 crate::adapter::normalize_json_keys_snake_case(&mut body);
86 authorize_trade_write(
87 &state,
88 rec.as_ref().map(|Extension(rec)| rec.as_ref()),
89 &body,
90 "/api/cancel-all-order",
91 )?;
92 // v1.4.45: normalize camelCase → snake_case(在强制字段覆盖前做)
93 // v1.4.105 D12 (Phase 2): card_num → acc_id 解析 (与 place_order 一致).
94 // 注意: cancel_all_order 走 flat-body promote 路径, helper 在 normalize 后
95 // 提取 card_num + 写 acc_id 到 top-level (header 不存在则 fallback top),
96 // promote_flat 之后下沉到 c2s.header — 顺序兼容.
97 // v1.4.105 D12 contract-hardening 补丁: 同 place_order, 加 rec 做 string-level
98 // allowed_card_nums whitelist 校验.
99 normalize_and_resolve_card_num_for_route(&state, &rec, &mut body, "/api/cancel-all-order")?;
100 // v1.4.96 BUG #003 hotfix (external reviewer matrix-double-confirmed): trd_market=999
101 // silent accept 200 OK 已修. 在 c2s 强制字段覆盖前 validate, 让用户知道
102 // trd_market 不合法.
103 // v1.4.102 codex 26 F1 (P1): write 路径用更窄 allowlist (无 fund markets)
104 validate_header_trd_market_write(&body, "/api/cancel-all-order")?;
105 // v1.4.96 BUG #006 hotfix (external reviewer 6-round cross-verify 2026-04-26):
106 // flat body (无 c2s wrapper) → 之前 c2s.insert 不命中 → daemon 收到
107 // modify_op=0 (ModifyOrder) 而非 2 (Cancel) → real env 风险真 modify
108 // 匹配的订单. fix: 检测 flat body, 若 acc_id 在顶层而 c2s 不存在, 把
109 // header 字段下沉到 c2s.header 让 adapter 看到统一形态. 然后正常跑
110 // 原有 c2s.insert 强制字段覆盖逻辑.
111 promote_flat_body_to_c2s(&mut body);
112 // 覆盖 c2s 里的强制字段(这里用 snake_case,normalize 后和原代码的 camelCase 等价)
113 if let Some(c2s) = body.get_mut("c2s").and_then(|c| c.as_object_mut()) {
114 c2s.insert("order_id".to_string(), Value::from(0u64));
115 // v1.4.47 P0.2 修(external reviewer 验收报告 §2 跨 5 版未修):modify_order_op 应为 2 (Cancel)
116 // 不是 4。Trd_Common.ModifyOrderOp: 1=Normal, 2=Cancel, 3=Disable, 4=Enable, 5=DeleteOutofdate.
117 // 之前写 4 (Enable) → handler `if modify_op == 2` 不命中 → 走 ReplaceOrder 分支 →
118 // 错误文案硬编码 "ModifyOrder:"(不是 "CancelAllOrder:")。修为 2 后 handler 正确
119 // 命中 cancel_all 分支 → op_name = "CancelAllOrder"。
120 c2s.insert("modify_order_op".to_string(), Value::from(2));
121 c2s.insert("for_all".to_string(), Value::from(true));
122 } else {
123 // v1.4.96 BUG #006: 经 promote_flat_body_to_c2s 后 c2s **必须**存在.
124 // 若仍不存在 = body 完全 empty (如 `{}`), 显式构造让强制字段生效.
125 let mut c2s = serde_json::Map::new();
126 c2s.insert("order_id".to_string(), Value::from(0u64));
127 c2s.insert("modify_order_op".to_string(), Value::from(2));
128 c2s.insert("for_all".to_string(), Value::from(true));
129 if let Some(map) = body.as_object_mut() {
130 map.insert("c2s".to_string(), Value::Object(c2s));
131 }
132 }
133
134 // 限额检查(symbol/value/side 空,仅 market 白名单 + rate)
135 if let Some(Extension(rec)) = rec
136 && let Ok(parsed) = serde_json::from_value::<trd_modify_order::Request>(body.clone())
137 {
138 let market = trd_market_str(parsed.c2s.header.trd_market);
139 let ctx = CheckCtx {
140 market: market.to_string(),
141 symbol: String::new(),
142 order_value: None,
143 trd_side: None,
144 acc_id: Some(parsed.c2s.header.acc_id), // v1.4.35
145 mutation_no_exposure: false,
146 currency: None,
147 };
148 let now = chrono::Utc::now();
149 // v1.4.36 Bug #1:Whitelist → 403
150 let outcome = state
151 .counters
152 .check_full_skip_rate(&rec.id, rec.as_ref(), &ctx, now);
153 if let Some(reason) = outcome.reason() {
154 return Err(limit_reject_response(
155 "/api/cancel-all-order",
156 &rec,
157 &reason,
158 outcome.http_status_code(),
159 ));
160 }
161 }
162
163 let cancel_all_spec =
164 surface_spec_or_internal_error("/api/cancel-all-order", "CancelAllOrder")?;
165 let idem_key = idempotency_key_from_headers(&headers);
166 adapter::proto_request_with_surface_spec_and_idempotency::<
167 trd_modify_order::Request,
168 trd_modify_order::Response,
169 >(
170 &state,
171 proto_id::TRD_MODIFY_ORDER,
172 Some(body),
173 idem_key,
174 cancel_all_spec,
175 )
176 .await
177}
178
179#[cfg(test)]
180mod flat_body_promotion_tests;