Skip to main content

futu_gateway_core/bridge/
reload.rs

1use super::*;
2
3/// v1.4.106 codex 0554 F3 [P2]: ms 精度的 unix wall clock — `last_reload_refresh`
4/// 写 `started_ms` / `finished_ms` 用. 用 `SystemTime::UNIX_EPOCH` 直接派生,
5/// 不走 bridge server-clock runtime(reload 是真实墙钟事件 + status snapshot 给 ops 看,
6/// 不是 backend-time-aligned 业务路径, 不需 svr_time_offset 修正).
7fn now_ms() -> u64 {
8    crate::bridge::clock::wall_clock_since_unix_epoch_or_zero("admin reload refresh").as_millis()
9        as u64
10}
11
12/// v1.4.106 codex 0554 F3 [P2]: 后台 refresh 任务的纯逻辑 (剥离自 `reload()`),
13/// 便于单测. 不持 `&self`, 只接 `reload_state` clone (None = offline mode 无凭据).
14///
15/// 返回值是终态 (`Succeeded` / `Failed` / `Skipped` / `NotApplicable`) — caller
16/// 用 `*last_reload_refresh.write() = ...` 写回 RwLock.
17async fn run_reload_refresh_task(reload_state: Option<ReloadState>) -> ReloadRefreshStatus {
18    let Some(state) = reload_state else {
19        // offline mode 启动: 没有 reload_state, refresh 跳过
20        let finished_ms = now_ms();
21        let reason = "no reload_state (daemon started in offline mode); cipher \
22                      cache cleared but tgtgt not refreshable"
23            .to_string();
24        tracing::warn!(reason, "admin reload (async phase): refresh skipped");
25        return ReloadRefreshStatus::Skipped {
26            finished_ms,
27            reason,
28        };
29    };
30
31    match auth::refresh_credentials_on_disk(
32        &state.http,
33        &state.account,
34        &state.device_id,
35        state.client_type,
36        state.region_code.as_deref(),
37        state.attribution,
38    )
39    .await
40    {
41        Ok(report) => {
42            let finished_ms = now_ms();
43            if report.credentials_refreshed {
44                tracing::info!(
45                    "admin reload (async phase): credentials refreshed on disk \
46                     (new tgtgt picked up by next Platform reconnect)"
47                );
48                ReloadRefreshStatus::Succeeded {
49                    finished_ms,
50                    refreshed: true,
51                }
52            } else {
53                // refresh helper 报"already fresh" — 不算失败也不算"做了"
54                tracing::info!(
55                    "admin reload (async phase): credentials already fresh, \
56                     no refresh needed"
57                );
58                ReloadRefreshStatus::NotApplicable { finished_ms }
59            }
60        }
61        Err(e) => {
62            let finished_ms = now_ms();
63            // v1.4.42 (external reviewer v1.4.40 报告 P3.4 修): 专门针对 "no cached
64            // credentials" 场景加更 actionable 的 hint.
65            let err_str = format!("{e}");
66            let hinted = if err_str.contains("no cached credentials") {
67                format!(
68                    "{err_str}\n\
69                     【hint】如果这是 moomoo 账户,credentials 文件可能没被首登流程 \
70                     save (v1.4.42 已知 bug,根因待 raw response 分析)。workaround:\n\
71                     1. ./futu-opend --reset-device --setup-only \
72                        --login-account <account> --login-pwd <pwd> --platform moomoo\n\
73                     2. 走完 SMS 流程,daemon 早退出 (凭据已写盘)\n\
74                     3. 正式启 daemon; admin/reload 后应可正常 refresh"
75                )
76            } else {
77                err_str
78            };
79            tracing::warn!(
80                error = %hinted,
81                "admin reload (async phase): credentials refresh failed \
82                 (cipher cache still cleared; shutdown + restart opend if \
83                 Platform conn is stuck)"
84            );
85            ReloadRefreshStatus::Failed {
86                finished_ms,
87                error: hinted,
88            }
89        }
90    }
91}
92
93impl GatewayBridge {
94    /// v1.4.32+ 只读健康状态快照。
95    ///
96    /// 走 REST `/api/admin/status`(见 `futu_rest::routes::admin`),CLI 用
97    /// `futucli daemon-status` 查。不 mutate 任何 Bridge 状态,可任意时刻调。
98    ///
99    /// 暴露哪些字段由"运维真实想知道什么"决定:
100    /// - login: 登录是否成功、uid、归属地域(判断 auth 状态)
101    /// - platform: Platform 通道 connected 吗
102    /// - brokers: 每个 broker_id 的名字 + 是否已建 conn + 登录目标 addr
103    ///   + keep_alive(判断是不是某个 broker 掉线)
104    /// - cache: 是否解锁过、cipher 和 account 数(判断下单链路就绪度)
105    pub fn snapshot_status(&self) -> StatusSnapshot {
106        let auth_result = self.auth_runtime().auth_result();
107        let login_state = self.caches().login_cache.get_login_state();
108        let credential = login_state
109            .as_ref()
110            .and_then(|state| auth::credential_ticket_status(&state.login_account))
111            .map(|status| LoginCredentialStatus {
112                saved_at: status.saved_at,
113                age_days: status.age_days,
114                expires_in_days: status.expires_in_days,
115                expiry_warning: status.expiry_warning,
116            });
117        let auth_refresh = self.auth_runtime().client_sig_refresh_status();
118        let login = match &auth_result {
119            Some(auth) => LoginStatus {
120                online: true,
121                user_id: Some(auth.user_id),
122                // UserAttribution 已经 derive Debug(variant 名就是 "Cn"/"Hk"/...),
123                // 直接格式化成两字母 code,和 CLAUDE.md 里的 attribution 表一致。
124                attribution: Some(format!("{:?}", auth.user_attribution)),
125                credential,
126                auth_refresh: auth_refresh.clone(),
127            },
128            None => LoginStatus {
129                online: false,
130                user_id: None,
131                attribution: None,
132                credential: None,
133                auth_refresh,
134            },
135        };
136
137        let platform_connected = self.broker_runtime().platform_connected();
138        let platform = PlatformStatus {
139            connected: platform_connected,
140        };
141
142        // brokers 按 broker_id 排序输出。只列出有活动 conn 的(brokers map 里的),
143        // 加上 conn_meta 里的元数据(登录地址 + keep_alive)
144        let brokers = self.broker_runtime().broker_statuses();
145
146        let cipher_count = self.caches().trd_cache.ciphers.len();
147        let account_count = self.caches().trd_cache.get_accounts().len();
148        let stock_list_status = self.caches().static_cache.stock_list_sync_status();
149        let security_count = self.caches().static_cache.security_info_count();
150        let cache = CacheStats {
151            trade_unlocked: cipher_count > 0,
152            cached_cipher_count: cipher_count,
153            cached_account_count: account_count,
154            static_data: StaticCacheStats {
155                security_count,
156                stock_list_readiness: stock_list_status
157                    .readiness_for_security_count(security_count)
158                    .as_str(),
159                stock_list_first_sync_done: stock_list_status.first_sync_done,
160                stock_list_converged: stock_list_status.converged,
161                stock_list_sync_started_total: stock_list_status.started_total,
162                stock_list_sync_finished_total: stock_list_status.finished_total,
163                stock_list_sync_failed_total: stock_list_status.failed_total,
164                stock_list_sync_recoverable_retry_total: stock_list_status.recoverable_retry_total,
165                stock_list_zero_delta_total: stock_list_status.zero_delta_total,
166                stock_list_last_version: stock_list_status.last_version,
167                stock_list_last_total_stocks: stock_list_status.last_total_stocks,
168                stock_list_last_cached_count: stock_list_status.last_cached_count,
169                stock_list_last_finished_ms: stock_list_status.last_finished_ms,
170            },
171        };
172
173        // v1.4.106 codex 0554 F3 [P2]: clone last_reload_refresh status
174        // (RwLock guard 短生命周期, clone 出去避免锁住 snapshot).
175        let last_reload_refresh = self.auth_runtime().last_reload_refresh().read().clone();
176
177        StatusSnapshot {
178            version: env!("CARGO_PKG_VERSION"),
179            login,
180            platform,
181            brokers,
182            cache,
183            last_reload_refresh,
184        }
185    }
186
187    /// v1.4.32+ 重置 trade cipher 缓存(`/api/admin/reload`)。
188    ///
189    /// 当前 scope 故意小:**只清 cipher cache + 后台刷 credentials**,
190    /// 不重跑 HTTP auth 也不重建 broker TCP 连接。理由见
191    /// `routes/admin.rs::admin_reload` 的文档。
192    ///
193    /// 调用后(同步阶段, response return 前已生效):
194    /// - `cache.trade_unlocked` 变 false(cipher 全清)
195    /// - 各账户的 `cipher_state_version` 已 bump(v1.4.73 BUG-008 idem
196    ///   cache 失效)
197    /// - 客户端必须重新 `/api/unlock-trade` 才能下单
198    /// - broker TCP / Platform TCP / auth_result 全保持
199    ///
200    /// 后台阶段(spawn 跑, response return 后异步):
201    /// - v1.4.34: 跑一次 `remember_login` 刷新磁盘 credentials 文件
202    ///   tgtgt,下次 Platform 断线重连时自动用新 tgtgt。不动内存
203    ///   auth_result(不碰 Bridge 字段可变性,不主动断 TCP)。
204    /// - 状态写 auth runtime last-reload status,`/api/admin/status` 暴露
205    ///   `Running`/`Succeeded`/`Failed`/`Skipped`/`NotApplicable` 给 ops。
206    ///
207    /// **v1.4.106 codex 0554 F3 [P2] 拆两阶段**:
208    ///
209    /// 之前 `reload()` 整段 `await refresh_credentials_on_disk(...)`,
210    /// 网络 I/O 几秒, axum admin handler hang 期间 client 不知 cipher 已
211    /// 清没. 现在 sync clear 完立刻 return, ops 通过 `/api/admin/status`
212    /// 看 last_reload_refresh 进度. cipher 已清是 sync 保证, refresh 是
213    /// best-effort fallback (失败也不阻塞 reload 主目的).
214    pub fn reload(&self) -> ReloadReport {
215        // ── 同步阶段 (sync clear + bump) ──
216        // v1.4.106 codex 0554 F1 [P1]: 走 cache helper 同步清 cipher + bump
217        // 每个被清账户的 `cipher_state_version`,复用 v1.4.73 A2 BUG-008 的
218        // idem cache 失效语义。
219        //
220        // 之前直接清 `self.caches().trd_cache.ciphers` 会 silent skip bump →
221        // reload 后客户端再调 unlock-trade 同 body 时 idempotency cache 命中返
222        // stale "cached success" → cipher 仍空 → place-order `-401`。
223        //
224        // 禁止后续再用 `cache.ciphers.clear()` 直接清;所有 control-plane
225        // 路径必须走 helper 保证 bump。
226        let (ciphers_dropped, bumped_versions) = self
227            .caches()
228            .trd_cache
229            .clear_all_ciphers_and_bump_versions();
230        tracing::warn!(
231            ciphers_dropped,
232            ?bumped_versions,
233            "admin reload (sync phase): trade cipher cache cleared + \
234             cipher_state_version bumped (BUG-008 idem cache invalidation); \
235             clients must re-unlock"
236        );
237
238        // ── 后台阶段 (spawn refresh) ──
239        // 把状态置 Running, 然后 spawn 异步 task 跑 refresh_credentials_on_disk.
240        let started_ms = now_ms();
241        let last_status = self.auth_runtime().last_reload_refresh();
242        *last_status.write() = ReloadRefreshStatus::Running { started_ms };
243
244        let reload_state = self.auth_runtime().reload_state();
245        self.background_runtime().track(tokio::spawn(async move {
246            let final_status = run_reload_refresh_task(reload_state).await;
247            *last_status.write() = final_status;
248        }));
249
250        // 同步阶段已成 → 立即返
251        let message = if ciphers_dropped == 0 {
252            "no ciphers to drop; credentials refresh dispatched in background \
253             (see /api/admin/status → last_reload_refresh)"
254                .to_string()
255        } else {
256            format!(
257                "dropped {ciphers_dropped} trade cipher(s); credentials refresh \
258                 dispatched in background (see /api/admin/status → \
259                 last_reload_refresh); call /api/unlock-trade again to re-enable \
260                 order placement"
261            )
262        };
263
264        ReloadReport {
265            ok: true,
266            ciphers_dropped,
267            // v1.4.106 拆两阶段后, 同步路径不知道 refresh 结果. 留 None,
268            // 真值在 last_reload_refresh 里 ops 自己查 (向后兼容旧 client
269            // 期望存在 — 字段保留, 但语义变 "未知 / pending"). 历史 reviewer
270            // 写脚本读这两字段时也不会崩 (None 仍合法 JSON).
271            credentials_refreshed: false,
272            credentials_refresh_error: Some(
273                "refresh dispatched in background; query /api/admin/status \
274                 (last_reload_refresh) for outcome"
275                    .to_string(),
276            ),
277            message,
278        }
279    }
280}
281
282#[cfg(test)]
283mod reload_tests;