Skip to main content

futu_rest/routes/trd/
unlock.rs

1//! REST trade unlock helper functions.
2
3use std::sync::Arc;
4
5use axum::extract::{Extension, Json, State};
6use axum::http::StatusCode;
7use serde_json::Value;
8
9use futu_auth::KeyRecord;
10use futu_core::proto_id;
11use futu_proto::trd_unlock_trade;
12
13use super::ApiResult;
14use crate::adapter::{self, RestState};
15
16/// POST /api/unlock-trade — 解锁交易
17///
18/// v1.4.27 修(BUG-1):服务端返回"交易密码输入错误"时,自动在 `ret_msg`
19/// 追加一句提醒"交易密码 ≠ 登录密码",避免用户因为不知道这个差异而连续
20/// 错 10 次导致账户被锁(需联系券商客服恢复)。加拿大同事 v1.4.26 回归
21/// 测试时踩到这个坑。
22///
23/// v1.4.96 BUG #008 hotfix (eli double-tester report 2026-04-26): MCP tool
24/// schema 的 OTP 字段叫 `otp` (别名 `token` / `one_time_password`), REST
25/// 只认 `sec_otp`. 用户按 MCP doc 调 REST 时 silent drop, daemon log
26/// `has_otp=false`, 用户误以为账户 2FA 没绑反复排查 (实际 daemon schema 错).
27/// 本版加 REST 端 alias 兼容: `otp` / `token` / `one_time_password` → `sec_otp`.
28pub async fn unlock_trade(
29    State(state): State<RestState>,
30    rec: Option<Extension<Arc<KeyRecord>>>,
31    Json(mut body): Json<Value>,
32) -> ApiResult {
33    // BUG #008 alias: `otp` / `token` / `one_time_password` → `sec_otp`
34    apply_unlock_trade_otp_aliases(&mut body);
35
36    // v1.4.104 codex round 1 F1 (P1) fix: REST handler-level acc_ids
37    // whitelist enforcement (受限 key 不能解锁 unauthorized acc_ids; 空
38    // acc_ids + 受限 key = ambiguous unlock-all → fail-closed reject).
39    //
40    // 为什么不在 middleware/pipeline body_aware 跑: REST middleware 阶段
41    // body 还是 raw JSON, proto_id=None 不跑 body_aware. handler 这里
42    // body 已 parse 但还没 proto encode, 取 c2s.acc_ids 即可. 与 v1.4.104
43    // 阶段 7-5 MCP futu_unlock_trade special path 行为对齐.
44    if let Some(Extension(rec_ref)) = rec.as_ref()
45        && let Some(allowed) = &rec_ref.allowed_acc_ids
46        && !allowed.is_empty()
47    {
48        let acc_ids = match extract_unlock_trade_acc_ids(&body) {
49            Ok(Some(acc_ids)) => acc_ids,
50            Ok(None) => Vec::new(),
51            Err(reason) => {
52                return Err((
53                    StatusCode::BAD_REQUEST,
54                    Json(serde_json::json!({
55                        "error": format!("/api/unlock-trade: {reason}")
56                    })),
57                ));
58            }
59        };
60        if acc_ids.is_empty() {
61            // ambiguous unlock-all under restricted key → reject fail-closed
62            return Err((
63                StatusCode::FORBIDDEN,
64                Json(serde_json::json!({
65                    "error": format!(
66                        "API key {:?} has allowed_acc_ids restriction but \
67                         acc_ids not specified. Restricted keys must explicitly \
68                         pass acc_ids; unlock-all is rejected to prevent unauthorized \
69                         broker unlock side effects.",
70                        rec_ref.id
71                    ),
72                    "hint": "pass acc_ids: [<your-allowed-acc-id>] in request body c2s",
73                })),
74            ));
75        }
76        for id in &acc_ids {
77            if !allowed.contains(id) {
78                futu_auth::audit::reject(
79                    "rest",
80                    "/api/unlock-trade",
81                    &rec_ref.id,
82                    &format!("acc_id {id} not in allowed list"),
83                );
84                return Err((
85                    StatusCode::FORBIDDEN,
86                    Json(serde_json::json!({ "error": "forbidden" })),
87                ));
88            }
89        }
90    }
91
92    let resp = adapter::proto_request::<trd_unlock_trade::Request, trd_unlock_trade::Response>(
93        &state,
94        proto_id::TRD_UNLOCK_TRADE,
95        Some(body),
96    )
97    .await?;
98
99    // 拆出 JSON 看 ret_msg 是否含"交易密码"关键词 → 追加引导提示
100    let Json(mut v) = resp;
101    let is_err = v
102        .get("ret_type")
103        .and_then(|t| t.as_i64())
104        .map(|t| t != 0)
105        .unwrap_or(false);
106    if is_err && let Some(msg) = v.get("ret_msg").and_then(|m| m.as_str()) {
107        // 服务端中文 msg 里密码错误一般含 "交易密码" 或 "密码错误"
108        if msg.contains("交易密码") || msg.contains("密码错误") || msg.contains("密码输入")
109        {
110            let hint = "[提示] 交易密码与登录密码独立;如未设置,先用 \
111                            `futucli set-trade-pwd` 存入 OS keychain,重复错密码后券商会锁账户。";
112            let new_msg = format!("{msg} {hint}");
113            if let Some(obj) = v.as_object_mut() {
114                obj.insert("ret_msg".to_string(), Value::String(new_msg));
115            }
116        }
117    }
118    Ok(Json(v))
119}
120
121fn extract_unlock_trade_acc_ids(body: &Value) -> Result<Option<Vec<u64>>, String> {
122    let Some(c2s) = body.get("c2s") else {
123        return Ok(None);
124    };
125    let Some(raw) = c2s.get("acc_ids").or_else(|| c2s.get("accIds")) else {
126        return Ok(None);
127    };
128    let Some(items) = raw.as_array() else {
129        return Err("c2s.acc_ids must be an array of positive integer acc_id values".to_string());
130    };
131
132    let mut acc_ids = Vec::with_capacity(items.len());
133    for (idx, value) in items.iter().enumerate() {
134        let Some(acc_id) = value.as_u64() else {
135            return Err(format!(
136                "c2s.acc_ids[{idx}] must be a positive integer acc_id"
137            ));
138        };
139        if acc_id == 0 {
140            return Err(format!(
141                "c2s.acc_ids[{idx}] contains zero — call /api/accounts to discover real acc_id values"
142            ));
143        }
144        acc_ids.push(acc_id);
145    }
146    Ok(Some(acc_ids))
147}
148
149/// v1.4.96 BUG #008 helper (eli MCP/REST OTP 字段不一致 silent drop fix).
150///
151/// MCP tool schema 用 `otp` / `token` / `one_time_password` 作为 OTP 字段名
152/// (3 alias). REST `/api/unlock-trade` 只认 `sec_otp` / `secOtp`. 用户按 MCP
153/// doc 调 REST 时 silent drop, 用户误以为账户 2FA 没绑.
154///
155/// 本 helper 把 body 顶层 (含 `c2s` 嵌套) 的 alias key (`otp` / `token` /
156/// `one_time_password` / `oneTimePassword`) rename 为 `sec_otp`. 优先级:
157/// 用户显式传 `sec_otp` 或 `secOtp` (任一 canonical form) 都优先, alias 仅作
158/// strip — 不覆盖.
159///
160/// v1.4.97 codex P2 #2 fix (audit feedback 2026-04-27): 之前 helper 只把
161/// `sec_otp` (snake_case) 当 canonical, 把 `secOtp` (camelCase) 当未知字段.
162/// 用户传 `{secOtp: "explicit", otp: "alias"}` 会被 helper promote
163/// `otp → sec_otp`, 之后 normalize_json_keys_snake_case 把 secOtp 也转成
164/// sec_otp 与 alias-promoted 值碰撞 — 后者 wins, 显式 secOtp 被 alias 覆盖,
165/// 违反 "explicit field 优先, alias 仅作兼容" 契约.
166///
167/// 修后规则: `sec_otp` OR `secOtp` 任一存在 → 仅 strip alias, 保留 explicit;
168/// 同样新增 `oneTimePassword` (camelCase) 作为 alias (与 `one_time_password`
169/// 等价).
170pub(crate) fn apply_unlock_trade_otp_aliases(body: &mut Value) {
171    /// alias key 全清单. snake_case + camelCase 双写 (helper 在 normalize 之前
172    /// 跑, 所以 camelCase form 必须显式 enum). canonical form (`sec_otp` /
173    /// `secOtp`) 不在此列表 — 它们走 has_canonical 分支保留.
174    const ALIAS_KEYS: &[&str] = &["otp", "token", "one_time_password", "oneTimePassword"];
175
176    fn rename_alias_in_object(map: &mut serde_json::Map<String, Value>) {
177        // v1.4.97 codex P2 #2: 同时识别 `sec_otp` (snake_case) AND `secOtp`
178        // (camelCase) 两种 canonical form. 任一存在即 "用户显式给了 OTP",
179        // 仅 strip alias key, 不 promote (避免后续 normalize 阶段碰撞覆盖).
180        let has_canonical = map.contains_key("sec_otp") || map.contains_key("secOtp");
181        if has_canonical {
182            // 仅 strip alias keys, 保留用户显式 canonical (sec_otp / secOtp)
183            for alias in ALIAS_KEYS {
184                map.remove(*alias);
185            }
186            return;
187        }
188        // 没显式 canonical: 取首个非空 alias rename 为 sec_otp, strip 其余
189        let mut renamed = false;
190        for alias in ALIAS_KEYS {
191            if let Some(v) = map.remove(*alias)
192                && !renamed
193            {
194                map.insert("sec_otp".to_string(), v);
195                renamed = true;
196            }
197            // 若 renamed 已 true, 当前 alias (若存在) 已 remove, 不重新插入
198        }
199    }
200    if let Some(map) = body.as_object_mut() {
201        rename_alias_in_object(map);
202    }
203    if let Some(c2s) = body.get_mut("c2s").and_then(|c| c.as_object_mut()) {
204        rename_alias_in_object(c2s);
205    }
206}
207
208#[cfg(test)]
209mod acc_ids_tests;
210
211#[cfg(test)]
212mod tests_v1_4_96_bug_008_otp_aliases;