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