futu_backend/auth/refresh.rs
1//! v1.4.92 P1-D Tier A1: in-process relogin trigger.
2//!
3//! 用途: 让 gateway 的 `qot_logined` self-heal loop (`run_qot_login_health_loop`,
4//! 30s tick) 在 `should_attempt_relogin()` 触发时**真**调一次 in-process relogin
5//! 而不只 loud warn (v1.4.91 observability-only) — 与 v1.4.34 daemon-reload
6//! 路径同语义.
7//!
8//! **设计**: 抽象成 `AuthRefresher` trait + `DefaultAuthRefresher` 默认实装,
9//! 这样 unit test 可以 mock (success / HTTP fail / TCP fail / cipher reject)
10//! 而 production 复用现有 [`crate::auth::refresh_credentials_on_disk`] 的 HTTP
11//! /authority/ POST → remember-login → save credentials 路径.
12//!
13//! **不做 (v1.4.92 边界)**:
14//! - 不重建 TCP — 现有连接复用 (CMD 6001 InitConnect 留作未来真 TCP 重建场景)
15//! - 不刷 per-broker cipher (CMD 2900) — 那个绑用户显式 `unlock-trade` action
16//! - 不做交互式 SMS — daemon 运行时不可能交互, refresh 失败必 fall-through
17//! 到 v1.4.91 行为 (loud warn + counter), 等 supervisor restart 介入
18//!
19//! **错误语义**: 任何步骤失败 (HTTP 超时 / tgtgt 过期 / backend 拒) → 返
20//! `Err`, 调用方 (`run_qot_login_health_loop`) 按 `record_relogin_failure()`
21//! 推 backoff. 连续 5 次失败后 (Tier A2 circuit-breaker) 跳 10 min.
22//!
23//! **超时**: 整个 `refresh_qot_login()` honor 10 秒预算
24//! ([`REFRESH_TIMEOUT`]) — 不能阻塞 polling loop.
25
26use std::sync::Arc;
27use std::time::Duration;
28
29use anyhow::{Context, Result};
30use futu_cache::login_cache::{LoginCache, LoginState};
31use tokio::time::timeout;
32
33use crate::auth::UserAttribution;
34
35/// 整个 `refresh_qot_login` 的超时预算 (10 秒). 超时即视为失败.
36pub const REFRESH_TIMEOUT: Duration = Duration::from_secs(10);
37
38/// In-process qot login refresh 抽象.
39///
40/// 实装方负责: HTTP /authority/ POST 刷 tgtgt → 写回 disk credentials →
41/// 把 [`LoginCache`] 置为 `is_logged_in=true`.
42///
43/// 调用方 (`run_qot_login_health_loop`) 会在调本 fn 之外维护
44/// `record_relogin_attempt()` / `record_relogin_success()` /
45/// `record_relogin_failure()` (per QotLoginHealth API).
46///
47/// **MUST**:
48/// - honor 10s 超时, 用 [`tokio::time::timeout`] 包裹各 network step
49/// - **不**要 mutate per-broker cipher 状态 (绑用户显式 `unlock-trade`)
50/// - 失败 loud `Err` (caller 推 backoff)
51#[async_trait::async_trait]
52pub trait AuthRefresher: Send + Sync + std::fmt::Debug {
53 /// 尝试 refresh qot login. 成功 → `Ok(())`, 失败 → `Err`.
54 async fn refresh_qot_login(&self) -> Result<()>;
55}
56
57/// Production 实装 — 通过 `refresh_credentials_on_disk` 走 v1.4.34 daemon-reload
58/// 的 HTTP /authority/ POST 路径.
59///
60/// 字段持有 `Arc<...>` 以方便从 `bridge::ReloadState` 派生克隆 (constructor
61/// 不耦合 bridge 内部生命期).
62///
63/// **Note**: `Debug` 手工实装而非 derive — `LoginCache` 没 derive Debug
64/// (含 `parking_lot::RwLock` / atomic 等无 Debug 字段), derive 会报 E0277.
65pub struct DefaultAuthRefresher {
66 /// 复用 bridge 创建的 reqwest::Client (含 webpki-roots TLS 配置 + 15s 默认 timeout).
67 pub http: reqwest::Client,
68 /// 归一化后的 account 字符串 (无区号前缀, e.g. "13900000000" 而非 "+86-13900000000").
69 pub account: String,
70 /// device_id (16-hex hash, 来自磁盘文件).
71 pub device_id: String,
72 /// region_code, 手机号账号有, 邮箱/数字 ID 是 None.
73 pub region_code: Option<String>,
74 /// user_attribution — 决定派生 auth domain (auth.futunn.com vs auth.moomoo.com).
75 pub attribution: UserAttribution,
76 /// LoginCache 共享指针 — refresh 成功后写 LoginState.is_logged_in=true.
77 pub login_cache: Arc<LoginCache>,
78 /// region (来自 LoginState — refresh 后写回保持一致).
79 pub region: String,
80 /// server_addr (来自 LoginState — refresh 后写回保持一致).
81 pub server_addr: String,
82 /// user_id (来自 LoginState — refresh 后写回保持一致).
83 pub user_id: u32,
84}
85
86impl std::fmt::Debug for DefaultAuthRefresher {
87 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88 f.debug_struct("DefaultAuthRefresher")
89 .field("account", &self.account)
90 .field("device_id", &self.device_id)
91 .field("region_code", &self.region_code)
92 .field("attribution", &self.attribution)
93 .field("region", &self.region)
94 .field("server_addr", &self.server_addr)
95 .field("user_id", &self.user_id)
96 .finish_non_exhaustive()
97 }
98}
99
100#[async_trait::async_trait]
101impl AuthRefresher for DefaultAuthRefresher {
102 async fn refresh_qot_login(&self) -> Result<()> {
103 // Step 1: HTTP /authority/ POST — refresh disk credentials.
104 //
105 // refresh_credentials_on_disk 内部:
106 // - load_credentials → 读磁盘 tgtgt + rand_key + device_sig
107 // - remember_login → POST /authority/ with cached tgtgt
108 // - save_credentials_from_response → 写回新 tgtgt + rand_key_new
109 //
110 // 失败 (tgtgt 过期 / 服务端拒 / SMS required) → 返 Err.
111 //
112 // **不做 password fallback** — daemon 运行时不可能交互输入 SMS.
113 let report = timeout(
114 REFRESH_TIMEOUT,
115 crate::auth::refresh_credentials_on_disk(
116 &self.http,
117 &self.account,
118 &self.device_id,
119 self.region_code.as_deref(),
120 self.attribution,
121 ),
122 )
123 .await
124 .context("v1.4.92 P1-D: refresh_credentials_on_disk timed out (10s budget)")?
125 .context("v1.4.92 P1-D: refresh_credentials_on_disk failed")?;
126
127 // v1.4.106 codex 0558 F2+F3: log fingerprint 替代 raw account/uid
128 tracing::info!(
129 account_fp = %crate::auth::redact::account_log_fingerprint(&self.account),
130 uid_fp = %crate::auth::redact::uid_log_fingerprint(report.uid),
131 credentials_refreshed = report.credentials_refreshed,
132 "v1.4.92 P1-D: disk credentials refreshed"
133 );
134
135 // Step 2: mark LoginCache state as healthy.
136 //
137 // **设计选择**: 即使 `credentials_refreshed=false` (服务端说"老凭据仍
138 // 有效, 没下发新的") 也算成功. 唯一 hard fail = HTTP error / load_credentials
139 // 找不到 disk file. 这两个都已经 ? 上抛了.
140 //
141 // 复用 LoginState 字段 (region / server_addr / user_id) 来自 constructor
142 // 时从 bridge LoginCache get_login_state() 拿到的快照 — 这些字段在
143 // refresh 路径**不会变** (uid 服务端 sanity check 保证一致, region
144 // 是用户启动时设的, server_addr 是当前 TCP 连接).
145 self.login_cache.set_login_state(LoginState {
146 user_id: self.user_id,
147 is_logged_in: true,
148 login_account: self.account.clone(),
149 region: self.region.clone(),
150 user_attribution: Some(self.attribution as i32),
151 server_addr: self.server_addr.clone(),
152 });
153
154 Ok(())
155 }
156}
157
158// ============================================================================
159// Test-only AuthRefresher impls — let unit test exercise success/fail paths
160// without standing up real HTTP server / TCP backend.
161// ============================================================================
162
163#[cfg(any(test, feature = "test-util"))]
164pub mod test_util {
165 //! Test fixtures for v1.4.92 P1-D AuthRefresher.
166 //!
167 //! Used by `gateway/src/bridge/push_health.rs` tests + future
168 //! `tests/integration_qot_login_health.rs`. Gated behind `test-util`
169 //! feature so production builds don't pull these in.
170
171 use std::sync::Arc;
172 use std::sync::atomic::{AtomicU64, Ordering};
173
174 use anyhow::{Result, anyhow};
175
176 /// Always-success refresher — increments a counter so test can assert
177 /// "refresh_qot_login was called N times".
178 #[derive(Debug, Default)]
179 pub struct AlwaysOkRefresher {
180 pub call_count: AtomicU64,
181 }
182
183 impl AlwaysOkRefresher {
184 pub fn new() -> Arc<Self> {
185 Arc::new(Self::default())
186 }
187
188 pub fn count(&self) -> u64 {
189 self.call_count.load(Ordering::Relaxed)
190 }
191 }
192
193 #[async_trait::async_trait]
194 impl super::AuthRefresher for AlwaysOkRefresher {
195 async fn refresh_qot_login(&self) -> Result<()> {
196 self.call_count.fetch_add(1, Ordering::Relaxed);
197 Ok(())
198 }
199 }
200
201 /// Always-fail refresher — increments counter + returns canned error.
202 #[derive(Debug, Default)]
203 pub struct AlwaysErrRefresher {
204 pub call_count: AtomicU64,
205 }
206
207 impl AlwaysErrRefresher {
208 pub fn new() -> Arc<Self> {
209 Arc::new(Self::default())
210 }
211
212 pub fn count(&self) -> u64 {
213 self.call_count.load(Ordering::Relaxed)
214 }
215 }
216
217 #[async_trait::async_trait]
218 impl super::AuthRefresher for AlwaysErrRefresher {
219 async fn refresh_qot_login(&self) -> Result<()> {
220 self.call_count.fetch_add(1, Ordering::Relaxed);
221 Err(anyhow!("v1.4.92 test: simulated refresh failure"))
222 }
223 }
224
225 /// Slow refresher — sleeps before returning. Use to test overlapping
226 /// `try_begin_attempt()` race protection.
227 #[derive(Debug)]
228 pub struct SlowOkRefresher {
229 pub call_count: AtomicU64,
230 pub sleep_ms: u64,
231 }
232
233 impl SlowOkRefresher {
234 pub fn new(sleep_ms: u64) -> Arc<Self> {
235 Arc::new(Self {
236 call_count: AtomicU64::new(0),
237 sleep_ms,
238 })
239 }
240
241 pub fn count(&self) -> u64 {
242 self.call_count.load(Ordering::Relaxed)
243 }
244 }
245
246 #[async_trait::async_trait]
247 impl super::AuthRefresher for SlowOkRefresher {
248 async fn refresh_qot_login(&self) -> Result<()> {
249 self.call_count.fetch_add(1, Ordering::Relaxed);
250 tokio::time::sleep(std::time::Duration::from_millis(self.sleep_ms)).await;
251 Ok(())
252 }
253 }
254}
255
256#[cfg(test)]
257mod tests;