Skip to main content

futu_rest/routes/trd/
unlock.rs

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