Skip to main content

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;