Skip to main content

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;