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