futu_backend/auth/repull.rs
1//! v1.4.93 G2 (CLAUDE.md C4 audit): 实装 POST `/authority/repull_auth_code`,
2//! 对齐 C++ FTLogin `auth_impl.cpp:715-754` `RepullAuthCode` +
3//! `auth_impl.cpp:3308-3376` `ParseRepullAuthCodeResponse`。
4//!
5//! ## 触发场景
6//!
7//! - **broker auth_code 过期**:[`super::BrokerAuthCode::invalid_time`] 在认证
8//! 响应里给的 expiry。daemon 长跑(典型 30 天)触发,原本 broker channel
9//! 失效不 self-heal、必须重启 daemon —— 本 fn 让 `bridge` 拉新 auth_code
10//! 重做 `broker_auth` HTTP + CMD 1001 重登 broker, **避免重启**。
11//! - **broker `kAuthNoValidCid` (error_code=20029)**:C++ 在
12//! `ParseRepullAuthCodeResponse` 见此码会 `ClearBrokerAccountInfo` 然后重新
13//! 走整 RepullAuthCode 流程 —— 本 fn 仅做"拉新 auth_code"那一步,
14//! `ClearBrokerAccountInfo` 等价物(清 cipher / customer_id)由 caller 决定
15//! 要不要做(v1.4.93 不主动清,v1.4.94+ 视真机行为再决定)。
16//!
17//! ## 协议层 (对齐 C++)
18//!
19//! ```text
20//! POST https://{auth_domain}/authority/repull_auth_code
21//! body = {
22//! "uid": <u64>, // 当前账户 uid
23//! "device_id": "<16-hex>", // 设备 ID (持久化)
24//! "web_sig": "<str>", // /authority/ 响应里 web_sig_new (持久化)
25//! "broker_id": <i32> // 单 broker
26//! }
27//! ```
28//!
29//! Response result 单 broker:
30//! ```text
31//! { "result": { "uid":<u64>, "broker_id":<i32>, "auth_code":"<str>",
32//! "invalid_time":<u64> } }
33//! ```
34//!
35//! 错误响应 result 缺失,error.error_code 给具体错码。
36//! `error_code=20029` (`kAuthNoValidCid`) 是特定可识别状态。
37//!
38//! ## Failure fallback
39//!
40//! - `web_sig` 空(v1.4.92 凭据 / device-verify shell 没此字段)→ caller 跳过
41//! repull,fallback 走 platform refresh(重 POST /authority/)然后再 retry
42//! - HTTP 失败 / web_sig 过期 → caller log + 不重试本轮 → 等下次 broker
43//! reconnect 触发或 platform refresh
44//!
45//! ## CLAUDE.md pitfalls 关联
46//!
47//! - **#34** Agent 调研结论 ≠ 真机正确性: 本实装基于 C++ 源码 `auth_impl.cpp`
48//! 完整对照, 但 backend 实际 wire (是否 reject 'web_sig over-frequent
49//! refresh', error_code 准确含义) 仍需真机 verify
50//! - **#42** Backend-semantic 风险: error_code=20029 是否真触发 + repull 后
51//! 的 broker channel 重建是否 work, 需真机
52//! - **#45** Silent-success: 函数返 `Ok(BrokerAuthCode)` 必须基于响应
53//! `result.auth_code` + `invalid_time` 都非空, 否则返 Err
54
55use futu_core::error::{FutuError, Result};
56
57use super::{BrokerAuthCode, UserAttribution};
58
59/// `RepullAuthCode` URL 路径常量, 对齐 C++ `auth_impl.cpp:28`
60/// `AUTH_REPULL_AUTHCODE = "/authority/repull_auth_code"`.
61const REPULL_AUTH_CODE_PATH: &str = "/authority/repull_auth_code";
62
63/// C++ `kAuthNoValidCid` 错码(broker cid 失效)。当 backend 返此码时,
64/// 调用方应当清 broker cipher cache + 触发 broker channel 重建(C++ 行为
65/// `ClearBrokerAccountInfo`)。本 fn 不做副作用,只透传给 caller 决定。
66pub const ERROR_CODE_NO_VALID_CID: i64 = 20029;
67
68/// 请求新的 broker auth_code,对齐 C++ `RepullAuthCode`.
69///
70/// # 参数
71///
72/// - `http`: 复用 bridge 创建的 reqwest::Client(含 webpki-roots TLS 配置)
73/// - `attribution`: 当前账户 user_attribution (决定 auth_domain)
74/// - `uid`: 当前账户 uid (== AuthResult.user_id)
75/// - `web_sig`: 持久化的 web_sig (来自 SavedCredentials.web_sig 或
76/// AuthResult.web_sig)。**空字符串 → 直接 Err**(向后兼容旧凭据无此字段
77/// 的场景,调用方应跳过 repull、fallback 走 platform refresh)。
78/// - `device_id`: 设备 ID (16-hex)
79/// - `broker_id`: 目标 broker (1001 / 1007 / 1008 / 1009 / 1012 / 1017 / 1019)
80///
81/// # 返回
82///
83/// 成功: `BrokerAuthCode { broker_id, auth_code, invalid_time }` —— 与
84/// `parse_auth_code_list` 解出的元素同结构, caller 可直接走 `broker_auth`
85/// HTTP + `broker_tcp_login` 流程。
86///
87/// 失败: `Err(FutuError::*)`. 见模块文档 fallback 策略.
88pub async fn repull_auth_code(
89 http: &reqwest::Client,
90 attribution: UserAttribution,
91 uid: u64,
92 web_sig: &str,
93 device_id: &str,
94 broker_id: u32,
95) -> Result<BrokerAuthCode> {
96 // Backward-compatible public wrapper: callers created before v1.4.112 did
97 // not pass OpenD's app client type. Internal bridge code should call the
98 // precise helper below because it already owns `AuthState.client_type`.
99 let client_type = match attribution {
100 UserAttribution::Cn | UserAttribution::Hk => 40,
101 _ => 60,
102 };
103 repull_auth_code_with_client_type(
104 http,
105 client_type,
106 attribution,
107 uid,
108 web_sig,
109 device_id,
110 broker_id,
111 )
112 .await
113}
114
115/// 请求新的 broker auth_code, 显式使用 OpenD client_type 构造 C++ 形态
116/// FTAuthImpl business headers。
117pub async fn repull_auth_code_with_client_type(
118 http: &reqwest::Client,
119 client_type: u8,
120 attribution: UserAttribution,
121 uid: u64,
122 web_sig: &str,
123 device_id: &str,
124 broker_id: u32,
125) -> Result<BrokerAuthCode> {
126 // 早期 reject: web_sig 空(向后兼容)
127 if web_sig.is_empty() {
128 return Err(FutuError::Codec(
129 "repull_auth_code: web_sig empty (legacy credentials before v1.4.93 G3 \
130 or device-verify shell path) — caller should fallback to platform refresh"
131 .into(),
132 ));
133 }
134 if uid == 0 {
135 return Err(FutuError::Codec(
136 "repull_auth_code: uid is 0 (invalid)".into(),
137 ));
138 }
139 if super::broker_config(broker_id).is_none() {
140 return Err(FutuError::Codec(format!(
141 "repull_auth_code: unknown broker_id {broker_id}"
142 )));
143 }
144
145 // 构造 URL: https://{auth_domain}/authority/repull_auth_code
146 // auth_domain 按 user_attribution 派生 (CN/HK→auth.futunn.com,
147 // US/SG/AU/JP→auth.moomoo.com)
148 let auth_domain = attribution.auth_domain();
149 let url = format!("https://{auth_domain}{REPULL_AUTH_CODE_PATH}");
150
151 let body = serde_json::json!({
152 "uid": uid,
153 "device_id": device_id,
154 "web_sig": web_sig,
155 "broker_id": broker_id,
156 });
157
158 tracing::info!(
159 broker_id,
160 uid,
161 url = %url,
162 attribution = ?attribution,
163 "v1.4.93 G2: POST /authority/repull_auth_code (broker auth_code refresh)"
164 );
165
166 // 注意: 不打印 body (含 web_sig) — 走 redact_auth_body 才能 log,
167 // 这里只 info url + broker_id; 失败场景下走 error/warn 仍只透出 ret_type
168 let headers = super::http_client::auth_business_headers(client_type, device_id)?;
169 let resp: serde_json::Value = http
170 .post(&url)
171 .headers(headers)
172 .json(&body)
173 .send()
174 .await
175 .map_err(|e| FutuError::Network(std::io::Error::other(e.to_string())))?
176 .json()
177 .await
178 .map_err(|e| FutuError::Codec(format!("repull_auth_code: response not JSON: {e}")))?;
179
180 // 错误分支 (对齐 C++ ParseRepullAuthCodeResponse:3340-3358)
181 if let Some(err) = resp.get("error").and_then(|e| e.as_object()) {
182 let code = err.get("error_code").and_then(|v| v.as_i64()).unwrap_or(-1);
183 let msg = err
184 .get("error_msg")
185 .and_then(|v| v.as_str())
186 .unwrap_or("unknown");
187 if code != 0 {
188 tracing::warn!(
189 broker_id,
190 uid,
191 error_code = code,
192 error_msg = %msg,
193 no_valid_cid = code == ERROR_CODE_NO_VALID_CID,
194 "v1.4.93 G2: RepullAuthCode failed"
195 );
196 return Err(FutuError::ServerError {
197 ret_type: code as i32,
198 msg: format!("repull_auth_code broker_id={broker_id}: {msg}"),
199 });
200 }
201 }
202
203 let result = resp
204 .get("result")
205 .and_then(|r| r.as_object())
206 .ok_or_else(|| {
207 FutuError::Codec("repull_auth_code: missing result + missing error".into())
208 })?;
209
210 parse_repull_success_response(result, uid, broker_id)
211}
212
213fn parse_repull_success_response(
214 result: &serde_json::Map<String, serde_json::Value>,
215 uid: u64,
216 broker_id: u32,
217) -> Result<BrokerAuthCode> {
218 // 对齐 C++ ParseRepullAuthCodeResponse:3325-3338 字段抽取 + 校验
219 let resp_uid = result.get("uid").and_then(|v| v.as_u64()).unwrap_or(0);
220 let resp_broker_id_raw = result
221 .get("broker_id")
222 .and_then(|v| v.as_u64())
223 .ok_or_else(|| FutuError::Codec("repull_auth_code: response broker_id invalid".into()))?;
224 let resp_broker_id = u32::try_from(resp_broker_id_raw).map_err(|_| {
225 FutuError::Codec(format!(
226 "repull_auth_code: response broker_id invalid (got {resp_broker_id_raw})"
227 ))
228 })?;
229 let auth_code = result
230 .get("auth_code")
231 .and_then(|v| v.as_str())
232 .unwrap_or("")
233 .to_string();
234 let invalid_time = result
235 .get("invalid_time")
236 .and_then(|v| v.as_u64())
237 .unwrap_or(0);
238
239 // C++ 校验 1: uid + broker_id 必须 match
240 if resp_uid != uid {
241 return Err(FutuError::Codec(format!(
242 "repull_auth_code: response uid mismatch (expected {uid}, got {resp_uid})"
243 )));
244 }
245 if resp_broker_id != broker_id {
246 return Err(FutuError::Codec(format!(
247 "repull_auth_code: response broker_id mismatch (expected {broker_id}, \
248 got {resp_broker_id})"
249 )));
250 }
251 // C++ 校验 2: auth_code 非空 + invalid_time 非 0
252 if auth_code.is_empty() || invalid_time == 0 {
253 return Err(FutuError::Codec(format!(
254 "repull_auth_code: empty auth_code or invalid_time (auth_code_len={}, \
255 invalid_time={invalid_time})",
256 auth_code.len()
257 )));
258 }
259
260 tracing::info!(
261 broker_id,
262 uid,
263 invalid_time,
264 auth_code_len = auth_code.len(),
265 "v1.4.93 G2: RepullAuthCode success — broker auth_code refreshed"
266 );
267
268 Ok(BrokerAuthCode {
269 broker_id,
270 auth_code,
271 invalid_time,
272 })
273}
274
275#[cfg(test)]
276mod tests;