Skip to main content

futu_mcp/
state.rs

1//! 共享状态:网关连接 + 订阅状态 + 授权
2
3use std::collections::HashMap;
4use std::fmt;
5use std::sync::Arc;
6use std::sync::atomic::Ordering;
7
8use anyhow::{Context, Result, anyhow};
9use futu_auth::{KeyRecord, KeyStore, RuntimeCounters};
10use futu_core::qot_symbol;
11use futu_net::client::{ClientConfig, FutuClient, ReconnectingClient};
12use futu_net::reconnect::ReconnectPolicy;
13use futu_qot::types::Security;
14use rmcp::{RoleServer, service::Peer};
15use tokio::sync::{Mutex, Semaphore};
16
17mod push_filter;
18mod push_subscribers;
19pub(crate) use push_subscribers::{LegacyPushServiceLease, PushDeliveryTarget};
20#[cfg(test)]
21pub(crate) use push_subscribers::{MAX_MODERN_PUSH_HANDLES, MODERN_PUSH_QUEUE_CAPACITY};
22pub(crate) use push_subscribers::{parse_push_resource_uri, push_resource_uri};
23#[cfg(test)]
24mod tests;
25
26use push_filter::{TradePushDecode, classify_trade_push, trd_market_int_to_str};
27#[cfg(test)]
28use push_filter::{
29    extract_acc_id_and_market_from_push, is_trade_push_proto_id, subscriber_should_receive,
30    subscriber_should_receive_with_market, subscriber_visible_to_caller,
31};
32use push_subscribers::{PushSubscriber, SubscriberDelivery};
33
34use crate::qot_sdk_adapter;
35
36const MCP_CONNECT_TOTAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
37const MCP_CONNECT_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(200);
38pub(crate) const LEGACY_PUSH_INFO_LEVEL_RANK: u8 = 1;
39
40/// Debug-only view for account collections at the logging boundary.
41///
42/// Account identifiers are financial PII. Expose only cardinality so adding a
43/// tracing field cannot accidentally serialize the underlying slice.
44struct RedactedAccountIdSet<'a> {
45    account_ids: &'a [u64],
46}
47
48impl<'a> RedactedAccountIdSet<'a> {
49    const fn new(account_ids: &'a [u64]) -> Self {
50        Self { account_ids }
51    }
52}
53
54impl fmt::Debug for RedactedAccountIdSet<'_> {
55    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
56        write!(formatter, "accounts(count={})", self.account_ids.len())
57    }
58}
59
60/// Fail-closed logging view for errors whose backend text may echo an account
61/// identifier or other request material.
62struct RedactedMcpError<'a> {
63    _error: &'a anyhow::Error,
64}
65
66impl<'a> RedactedMcpError<'a> {
67    const fn new(error: &'a anyhow::Error) -> Self {
68        Self { _error: error }
69    }
70}
71
72impl fmt::Debug for RedactedMcpError<'_> {
73    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
74        formatter.write_str("request_failed")
75    }
76}
77
78/// v1.4.38 Phase 5 helper: bytes → base64 (用于 push body 安全包进 JSON)
79fn base64_encode_bytes(bytes: &[u8]) -> String {
80    use base64::Engine as _;
81    base64::engine::general_purpose::STANDARD.encode(bytes)
82}
83
84struct PendingPushDelivery {
85    target: SubscriberDelivery,
86    data: serde_json::Value,
87    session_id: String,
88    owner_key_id: Option<String>,
89    proto_id: u32,
90}
91
92/// Single authorization admission shared by legacy logging and modern resource
93/// delivery. A caller must pass this function before either target is created,
94/// so decode-failed restricted trade bodies and current scope revocations can
95/// never enter a modern queue.
96fn push_delivery_is_authorized(
97    subscriber: &PushSubscriber,
98    key_store: &KeyStore,
99    filter_registry: &futu_auth_pipeline::FilterRegistry,
100    decode_result: &TradePushDecode,
101    event_type: &'static str,
102    push_acc_id: Option<u64>,
103    push_trd_market_str: Option<&'static str>,
104    proto_id: u32,
105) -> bool {
106    if event_type == "private_user"
107        && !private_user_push_owner_is_authorized(subscriber.owner_key_id.as_deref(), key_store)
108    {
109        return false;
110    }
111    let (allowed_acc_ids, allowed_markets) = match &subscriber.delivery {
112        SubscriberDelivery::LegacyPeer(_, _) => (
113            subscriber.allowed_acc_ids_snapshot.clone(),
114            subscriber.allowed_markets_snapshot.clone(),
115        ),
116        SubscriberDelivery::ModernResource(_) => {
117            let Some(owner) = subscriber.owner_key_id.as_deref() else {
118                return false;
119            };
120            let Some(current) = key_store
121                .get_by_id_for_current_machine(owner)
122                .filter(|record| {
123                    !record.is_expired(chrono::Utc::now())
124                        && record.scopes.contains(&futu_auth::Scope::AccRead)
125                })
126            else {
127                return false;
128            };
129            (
130                current.allowed_acc_ids.clone(),
131                current.allowed_markets.clone(),
132            )
133        }
134    };
135    if matches!(decode_result, TradePushDecode::DecodeFailed) {
136        let restricted = allowed_acc_ids
137            .as_ref()
138            .is_some_and(|allowed| !allowed.is_empty());
139        if restricted {
140            let key_id = subscriber.owner_key_id.as_deref().unwrap_or("<none>");
141            futu_auth::metrics::bump_ws_filtered("trade_decode_failed", key_id);
142            tracing::warn!(
143                proto_id,
144                key_id,
145                "MCP trade push body decode failed; dropped before legacy/modern delivery for restricted key"
146            );
147            return false;
148        }
149    }
150    let sub_state = (!subscriber.acc_ids.is_empty()).then_some(&subscriber.acc_ids);
151    let ctx = futu_auth_pipeline::PushEventCtx {
152        event_type,
153        event_acc: push_acc_id,
154        allowed_acc_ids: allowed_acc_ids.as_ref(),
155        sub_state,
156        event_trd_market: push_trd_market_str,
157        allowed_markets: allowed_markets.as_ref(),
158    };
159    if filter_registry.should_drop_event(&ctx) {
160        let key_id = subscriber.owner_key_id.as_deref().unwrap_or("<none>");
161        futu_auth::metrics::bump_ws_filtered("trade_market", key_id);
162        return false;
163    }
164    true
165}
166
167fn private_user_push_owner_is_authorized(owner_key_id: Option<&str>, key_store: &KeyStore) -> bool {
168    let Some(owner) = owner_key_id else {
169        return false;
170    };
171    key_store
172        .get_by_id_for_current_machine(owner)
173        .is_some_and(|record| {
174            !record.is_expired(chrono::Utc::now())
175                && record.scopes.contains(&futu_auth::Scope::AccRead)
176        })
177}
178
179#[allow(deprecated)]
180async fn notify_legacy_push(
181    peer: &Peer<RoleServer>,
182    minimum_level: &std::sync::atomic::AtomicU8,
183    data: serde_json::Value,
184) -> Result<(), rmcp::service::ServiceError> {
185    if minimum_level.load(Ordering::Acquire) > LEGACY_PUSH_INFO_LEVEL_RANK {
186        return Ok(());
187    }
188    let params =
189        rmcp::model::LoggingMessageNotificationParam::new(rmcp::model::LoggingLevel::Info, data)
190            .with_logger("futu_push");
191    peer.notify_logging_message(params).await
192}
193
194/// MCP server 运行时状态
195#[derive(Clone)]
196pub struct ServerState {
197    /// [`Inner`] 共享可变状态(gateway 地址 + 懒加载的 [`FutuClient`])
198    inner: Arc<Mutex<Inner>>,
199    /// 是否启用交易写工具(place/modify/cancel)。默认 false。旧开关,仅当
200    /// `key_store.is_configured() == false` 时生效。
201    enable_trading: bool,
202    /// 是否允许对 real 环境下单。默认 false。旧开关,同上。
203    allow_real_trading: bool,
204    /// keys.json 加载的 KeyStore。`is_configured()` 为 true 时走 scope 授权模式。
205    key_store: Arc<KeyStore>,
206    /// 调用方传入的 API Key 对应的记录;None 表示未提供 key。
207    authed_key: Option<Arc<KeyRecord>>,
208    opend_rest_url: Option<String>,
209    opend_api_key: Option<String>,
210    /// 交易密码所属登录账号。用于 `futu_unlock_trade` 从账号级 keychain
211    /// `trade-password.<login-account>` 读取密码;None 时走 legacy/global/env 兼容路径。
212    trade_pwd_account: Option<String>,
213    /// 限额运行时(日累计计数器)
214    counters: Arc<RuntimeCounters>,
215    /// v1.4.38 Phase 5: MCP push 订阅者注册表(session_uuid → subscriber)。
216    /// `futu_sub_acc_push` 工具在 HTTP 模式下调用时注册当前 session,daemon
217    /// push 到 MCP 后按 acc_id filter 向注册的 peer 发
218    /// `notify_logging_message`(server-initiated notification)。
219    push_subscribers: Arc<Mutex<HashMap<String, PushSubscriber>>>,
220    /// Modern resources retain bounded queues for up to 4h. A semaphore makes
221    /// handle cardinality admission atomic and lets the tool fail before any
222    /// daemon side effect. The fixed local limit is a memory-safety boundary,
223    /// not backend/config data; raise it only after measuring worst-case push
224    /// payload memory and concurrent client demand.
225    modern_push_slots: Arc<Semaphore>,
226    /// issue #54:gateway 重连单飞门。建连期间持有(不持 `inner` 锁),并发
227    /// caller 在此排队,拿到门后复检缓存直接复用赢家结果 → 一次断连只开一条新 TCP。
228    connect_gate: Arc<Mutex<()>>,
229    /// 陈旧订阅者 purge task 已 spawn 次数;`compare_exchange(0, 1)` 保证整个
230    /// [`ServerState`] 生命周期只 spawn 一次(重连不重复 spawn)。
231    purge_task_spawns: Arc<std::sync::atomic::AtomicU64>,
232    /// issue #54 Reviewer M-5: gateway 重连后 `TRD_SUB_ACC_PUSH` 重注册失败次数。
233    /// 失败只记日志 + 计数,不阻塞工具调用;调用方需重新 `futu_sub_acc_push`。
234    push_reregister_failures: Arc<std::sync::atomic::AtomicU64>,
235    /// issue #54 Reviewer I-2: set when the post-reconnect push re-registration
236    /// failed; the next `client()` that finds a live connection retries once.
237    push_registration_dirty: Arc<std::sync::atomic::AtomicBool>,
238}
239
240/// ServerState 内部可变部分,加锁存放 gateway 地址 + 懒加载的 [`FutuClient`]。
241struct Inner {
242    /// 网关 TCP 地址(如 `127.0.0.1:11111`)
243    gateway: String,
244    /// 懒加载的底层连接;首次调用 [`ServerState::client`] 时建立,后续复用
245    client: Option<Arc<FutuClient>>,
246    /// 连接代数:每次成功安装新 client +1(首连 = 1)。
247    generation: u64,
248}
249
250impl ServerState {
251    /// 创建默认 state:`enable_trading=false` / `allow_real_trading=false` /
252    /// 空 [`KeyStore`] / 无 authed_key。使用 `with_*` 链式方法注入额外能力。
253    pub fn new(gateway: String) -> Self {
254        Self {
255            inner: Arc::new(Mutex::new(Inner {
256                gateway,
257                client: None,
258                generation: 0,
259            })),
260            enable_trading: false,
261            allow_real_trading: false,
262            key_store: Arc::new(KeyStore::empty()),
263            authed_key: None,
264            opend_rest_url: None,
265            opend_api_key: None,
266            trade_pwd_account: None,
267            counters: Arc::new(RuntimeCounters::new()),
268            push_subscribers: Arc::new(Mutex::new(HashMap::new())),
269            modern_push_slots: Arc::new(Semaphore::new(push_subscribers::MAX_MODERN_PUSH_HANDLES)),
270            connect_gate: Arc::new(Mutex::new(())),
271            purge_task_spawns: Arc::new(std::sync::atomic::AtomicU64::new(0)),
272            push_reregister_failures: Arc::new(std::sync::atomic::AtomicU64::new(0)),
273            push_registration_dirty: Arc::new(std::sync::atomic::AtomicBool::new(false)),
274        }
275    }
276
277    /// 当前 gateway client 代数(0 = 尚未连接)。
278    #[cfg(test)]
279    pub(crate) async fn client_generation_for_test(&self) -> u64 {
280        self.inner.lock().await.generation
281    }
282
283    /// 陈旧订阅者 purge task 已 spawn 次数。
284    #[cfg(test)]
285    pub(crate) fn purge_task_spawn_count_for_test(&self) -> u64 {
286        self.purge_task_spawns.load(Ordering::Acquire)
287    }
288
289    /// 启用交易写工具(构造器式链式设置)
290    pub fn with_trading(mut self, enable_trading: bool, allow_real_trading: bool) -> Self {
291        self.enable_trading = enable_trading;
292        self.allow_real_trading = allow_real_trading;
293        self
294    }
295
296    /// 设置 KeyStore(新授权模式)
297    pub fn with_key_store(mut self, store: Arc<KeyStore>) -> Self {
298        self.key_store = store;
299        self
300    }
301
302    /// 设置已通过验证的 API Key 记录
303    pub fn with_authed_key(mut self, key: Option<Arc<KeyRecord>>) -> Self {
304        self.authed_key = key;
305        self
306    }
307
308    pub fn with_opend_rest(mut self, base_url: Option<String>, api_key: Option<String>) -> Self {
309        self.opend_rest_url = base_url.map(|value| value.trim_end_matches('/').to_string());
310        self.opend_api_key = api_key.filter(|value| !value.is_empty());
311        self
312    }
313
314    pub fn opend_rest_url(&self) -> Option<&str> {
315        self.opend_rest_url.as_deref()
316    }
317
318    pub fn opend_api_key(&self) -> Option<&str> {
319        self.opend_api_key.as_deref()
320    }
321
322    /// 设置交易密码所属登录账号(MCP 只连 gateway,本身无法可靠推断 daemon
323    /// 的 login account;由 CLI/env/config 显式注入)。
324    pub fn with_trade_pwd_account(mut self, account: Option<String>) -> Self {
325        self.trade_pwd_account = account;
326        self
327    }
328
329    /// 是否启用了 scope 授权模式
330    pub fn is_scope_mode(&self) -> bool {
331        self.key_store.is_configured()
332    }
333
334    /// 交易写工具开关(legacy mode)。
335    pub fn enable_trading(&self) -> bool {
336        self.enable_trading
337    }
338
339    /// real 环境交易写工具开关(legacy mode)。
340    pub fn allow_real_trading(&self) -> bool {
341        self.allow_real_trading
342    }
343
344    /// 当前 MCP API key store。返回共享引用,避免调用方替换 runtime storage。
345    pub fn key_store(&self) -> &Arc<KeyStore> {
346        &self.key_store
347    }
348
349    /// startup 阶段验证过的 key 快照;调用方需要 fresh record 时仍应按 id 回查 key store。
350    pub fn authed_key(&self) -> Option<Arc<KeyRecord>> {
351        self.authed_key.clone()
352    }
353
354    /// 交易密码所属登录账号。
355    pub fn trade_pwd_account(&self) -> Option<&str> {
356        self.trade_pwd_account.as_deref()
357    }
358
359    /// 限额运行时计数器。返回共享引用,避免调用方替换 runtime storage。
360    pub fn counters(&self) -> &Arc<RuntimeCounters> {
361        &self.counters
362    }
363
364    /// 当前配置的 gateway 地址。
365    pub async fn gateway(&self) -> String {
366        self.inner.lock().await.gateway.clone()
367    }
368
369    /// 获取(或懒加载)网关客户端。
370    ///
371    /// issue #54:gateway 重启后旧 client 的 event loop 已退出
372    /// ([`FutuClient::is_closed`]),缓存视为失效并重建;并发调用经 `connect_gate`
373    /// 单飞,只开一条新连接;重连成功后重发本地 push 订阅
374    /// ([`Self::restore_push_registration`])。建连期间不持 `inner` 锁。
375    pub async fn client(&self) -> Result<Arc<FutuClient>> {
376        let (gateway, stale) = {
377            let guard = self.inner.lock().await;
378            match &guard.client {
379                Some(c) if !c.is_closed() => {
380                    let alive = c.clone();
381                    drop(guard);
382                    // Reviewer I-2: a failed post-reconnect push re-registration must
383                    // not stay silently broken; retry once on the next live call.
384                    if self
385                        .push_registration_dirty
386                        .swap(false, std::sync::atomic::Ordering::AcqRel)
387                    {
388                        self.restore_push_registration(&alive).await;
389                    }
390                    return Ok(alive);
391                }
392                cached => (guard.gateway.clone(), cached.clone()),
393            }
394        };
395
396        // 单飞:先到者建连,后到者在门上等待;拿到门后复检,赢家已装好则直接复用。
397        // Reviewer M-5:门在建连(最长 MCP_CONNECT_TOTAL_TIMEOUT=3s)期间持有,gateway
398        // 宕机时 N 个并发调用会串行各等一次超时;这是单飞的代价,换来只开一条 TCP。
399        // push 重注册(最长一次请求超时)已移到门外,见下方 `drop(_connect_gate)`。
400        let _connect_gate = self.connect_gate.lock().await;
401        {
402            let guard = self.inner.lock().await;
403            if let Some(c) = &guard.client
404                && !c.is_closed()
405            {
406                return Ok(c.clone());
407            }
408        }
409
410        let config = ClientConfig {
411            addr: gateway.clone(),
412            client_ver: env!("CARGO_PKG_VERSION").to_string(),
413            client_id: "futu-mcp".to_string(),
414            recv_notify: false,
415            rsa_key: None,
416        };
417        let policy =
418            ReconnectPolicy::new(MCP_CONNECT_RETRY_DELAY, MCP_CONNECT_RETRY_DELAY, Some(1));
419        let mut reconnector = ReconnectingClient::new(config).with_policy(policy);
420        let connect_result =
421            tokio::time::timeout(MCP_CONNECT_TOTAL_TIMEOUT, reconnector.connect()).await;
422        let (client, mut push_rx, info) = match connect_result {
423            Ok(result) => {
424                result.with_context(|| format!("connect to futu gateway at {gateway}"))?
425            }
426            Err(_) => {
427                return Err(anyhow!(
428                    "connect to futu gateway at {gateway} timed out after {}s",
429                    MCP_CONNECT_TOTAL_TIMEOUT.as_secs()
430                ));
431            }
432        };
433
434        let arc = Arc::new(client);
435        let (generation, previous_conn_id) = {
436            let mut guard = self.inner.lock().await;
437            // CAS 语义:只替换"空 / 已 closed / 与本次观察到的同一个 stale Arc"的缓存。
438            // 若缓存里已是另一个存活 client(理论上被 gate 串行化排除),沿用它,
439            // 本次新建的 client 随 `arc` drop 自行关闭 event loop。
440            if let Some(existing) = &guard.client
441                && !existing.is_closed()
442                && !stale.as_ref().is_some_and(|s| Arc::ptr_eq(s, existing))
443            {
444                return Ok(existing.clone());
445            }
446            let previous_conn_id = guard.client.as_ref().and_then(|c| c.conn_id());
447            guard.client = Some(arc.clone());
448            guard.generation += 1;
449            (guard.generation, previous_conn_id)
450        };
451        if generation > 1 {
452            tracing::info!(
453                generation,
454                old_conn_id = previous_conn_id.unwrap_or(0),
455                new_conn_id = info.conn_id,
456                "mcp gateway client reconnected"
457            );
458        }
459
460        // v1.4.38 Phase 5 (100%): 按 acc_ids 过滤的 push broadcast
461        //
462        // 流程:
463        // 1. push_rx 收 daemon 转发的 push
464        // 2. 对 TRD_UPDATE_ORDER (2208) / TRD_UPDATE_ORDER_FILL (2218) 解包
465        //    提取 acc_id
466        // 3. 遍历订阅者,**只推给 acc_ids 匹配的**(或订阅者 acc_ids 空 = 不
467        //    过滤,所有 acc 都收)
468        // 4. 行情 push(QOT_UPDATE_*)无 acc_id 语义,广播给所有订阅者
469        //
470        // Per-session 独立 spawn notify,避免一个慢 session 阻塞其他
471        let subs_for_push = Arc::downgrade(&self.push_subscribers);
472        let key_store_for_push = Arc::downgrade(&self.key_store);
473        // v1.4.105 F5 fix (codex review C4 USER_ACK B): MCP push filter 改用
474        // FilterRegistry::should_drop_event 单一注册中心 (跟 4 surface 一致),
475        // 替代之前 inline subscriber_should_receive_with_market. 防 sibling-route
476        // bypass — 加新 push event filter 维度只在 install_defaults 注册一次,
477        // MCP 自动覆盖.
478        let filter_registry =
479            std::sync::Arc::new(futu_auth_pipeline::FilterRegistry::with_defaults());
480        tokio::spawn(async move {
481            while let Some(push) = push_rx.recv().await {
482                let Some(subs_for_push) = subs_for_push.upgrade() else {
483                    break;
484                };
485                let Some(key_store_for_push) = key_store_for_push.upgrade() else {
486                    break;
487                };
488                let subscribers = {
489                    let subs = subs_for_push.lock().await;
490                    if subs.is_empty() {
491                        Vec::new()
492                    } else {
493                        subs.iter()
494                            .map(|(session_id, sub)| (session_id.clone(), sub.clone()))
495                            .collect::<Vec<_>>()
496                    }
497                };
498                if subscribers.is_empty() {
499                    continue; // fast path: no listeners, drop
500                }
501                // v1.4.105 T-C2 + v1.4.106 codex 0932 F6/F7: classify push by proto_id
502                // (set membership), 不再靠 body decode 成功推断. trade body decode
503                // 失败现在归 TradePushDecode::DecodeFailed (event_type="trade",
504                // 无 acc/market gate 信息) — restricted key 应 drop, unrestricted
505                // 透传带 decode_status="failed".
506                let decode_result = classify_trade_push(push.proto_id, &push.body);
507                let (push_acc_id, push_trd_market, decode_status, event_type) = match &decode_result
508                {
509                    TradePushDecode::NotTrade
510                        if push.proto_id == futu_core::proto_id::QOT_UPDATE_STOCK_NOTE =>
511                    {
512                        (None, None, "ok", "private_user")
513                    }
514                    TradePushDecode::NotTrade => (None, None, "ok", "quote"),
515                    TradePushDecode::Decoded { acc_id, trd_market } => {
516                        (Some(*acc_id), Some(*trd_market), "ok", "trade")
517                    }
518                    TradePushDecode::DecodeFailed => (None, None, "failed", "trade"),
519                };
520                let push_trd_market_str = push_trd_market.map(trd_market_int_to_str);
521                // v1.4.106 codex 0932 F7 [P3]: payload 加 event_type / trd_market /
522                // decode_status — 让客户端不需要按 proto_id 自己 derive (4 surface 一致).
523                // body_base64 后向兼容保留.
524                let payload = serde_json::json!({
525                    "kind": "futu_push",
526                    "proto_id": push.proto_id,
527                    "acc_id": push_acc_id,
528                    "event_type": event_type,
529                    "trd_market": push_trd_market_str,
530                    "decode_status": decode_status,
531                    "body_base64": base64_encode_bytes(&push.body),
532                });
533                let deliveries = {
534                    let mut deliveries = Vec::with_capacity(subscribers.len());
535                    for (session_id, sub) in subscribers.iter() {
536                        if !push_delivery_is_authorized(
537                            sub,
538                            &key_store_for_push,
539                            &filter_registry,
540                            &decode_result,
541                            event_type,
542                            push_acc_id,
543                            push_trd_market_str,
544                            push.proto_id,
545                        ) {
546                            continue;
547                        }
548                        deliveries.push(PendingPushDelivery {
549                            target: sub.delivery.clone(),
550                            data: payload.clone(),
551                            session_id: session_id.clone(),
552                            owner_key_id: sub.owner_key_id.clone(),
553                            proto_id: push.proto_id,
554                        });
555                    }
556                    deliveries
557                };
558                for delivery in deliveries {
559                    match delivery.target {
560                        SubscriberDelivery::LegacyPeer(peer, minimum_level) => {
561                            tokio::spawn(async move {
562                                let result =
563                                    notify_legacy_push(&peer, &minimum_level, delivery.data)
564                                        .await
565                                        .map_err(|error| error.to_string());
566                                if let Err(err) = result {
567                                    tracing::warn!(
568                                        proto_id = delivery.proto_id,
569                                        session_id = delivery.session_id,
570                                        owner_key_id = delivery.owner_key_id.as_deref().unwrap_or("<none>"),
571                                        error = %err,
572                                        "mcp push notification send failed"
573                                    );
574                                }
575                            });
576                        }
577                        SubscriberDelivery::ModernResource(resource) => {
578                            if let Some(work) = resource.enqueue(delivery.data) {
579                                tokio::spawn(async move {
580                                    resource.send_pending_notification(work).await;
581                                });
582                            }
583                        }
584                    }
585                }
586            }
587        });
588
589        // v1.4.39 Phase 5 stale cleanup: 5 分钟跑一次,移除 registered_at > 4h
590        // 的订阅者。避免长跑 daemon 累积陈旧 subscriber(客户端断开 /  rmcp
591        // session gone 但没显式 unregister 的情况)。
592        // issue #54:purge task 与连接代数无关,整个 ServerState 只 spawn 一次。
593        let subs_for_purge = Arc::downgrade(&self.push_subscribers);
594        if self
595            .purge_task_spawns
596            .compare_exchange(0, 1, Ordering::AcqRel, Ordering::Acquire)
597            .is_ok()
598        {
599            tokio::spawn(async move {
600                use std::time::Duration;
601                const PURGE_INTERVAL: Duration = Duration::from_secs(5 * 60);
602                let mut ticker = tokio::time::interval(PURGE_INTERVAL);
603                ticker.tick().await; // skip the immediate first tick
604                loop {
605                    ticker.tick().await;
606                    let Some(subs_for_purge) = subs_for_purge.upgrade() else {
607                        break;
608                    };
609                    let now = std::time::Instant::now();
610                    let removed = {
611                        let mut subs = subs_for_purge.lock().await;
612                        let expired = subs
613                            .iter()
614                            .filter_map(|(handle, sub)| {
615                                now.checked_duration_since(sub.registered_at)
616                                    .is_some_and(|age| {
617                                        age >= push_subscribers::PUSH_SUBSCRIBER_MAX_AGE
618                                    })
619                                    .then_some(handle.clone())
620                            })
621                            .collect::<Vec<_>>();
622                        expired
623                            .into_iter()
624                            .filter_map(|handle| subs.remove(&handle))
625                            .collect::<Vec<_>>()
626                    };
627                    let purged = removed.len();
628                    for subscriber in removed {
629                        subscriber.close_modern_resource();
630                    }
631                    if purged > 0 {
632                        let remaining = subs_for_purge.lock().await.len();
633                        tracing::info!(
634                            purged,
635                            remaining,
636                            max_age_secs = push_subscribers::PUSH_SUBSCRIBER_MAX_AGE.as_secs(),
637                            "v1.4.39 Phase 5: purged stale push subscribers (> 4h registered)"
638                        );
639                    }
640                }
641            });
642        }
643
644        // Reviewer M-4: release the single-flight gate before the network
645        // re-registration so concurrent callers are not serialised behind it;
646        // `restore_push_registration` is idempotent.
647        drop(_connect_gate);
648        if generation > 1 {
649            self.restore_push_registration(&arc).await;
650        }
651
652        Ok(arc)
653    }
654
655    /// Whether a failed post-reconnect push re-registration is pending a retry.
656    #[cfg(test)]
657    pub fn push_registration_dirty(&self) -> bool {
658        self.push_registration_dirty
659            .load(std::sync::atomic::Ordering::Acquire)
660    }
661
662    /// Number of failed post-reconnect `TRD_SUB_ACC_PUSH` re-registrations
663    /// (production path logs each failure at warn; the counter is a test seam).
664    #[cfg(test)]
665    pub fn push_reregister_failures(&self) -> u64 {
666        self.push_reregister_failures
667            .load(std::sync::atomic::Ordering::Relaxed)
668    }
669
670    /// issue #54:daemon 侧 push 订阅按 conn_id 存,旧连接断开时已被 daemon
671    /// `on_disconnect(conn_id)` 清空;重连后把本地仍在册订阅者的 acc_ids 并集
672    /// 重发 `TRD_SUB_ACC_PUSH`(2008),否则 subscriber 留在本地却收不到任何 push。
673    /// 失败只 warn,不阻塞 `client()` 返回(调用方可重新 `futu_sub_acc_push`)。
674    /// QOT 行情订阅 MCP 没有本地记录,不在此恢复。
675    async fn restore_push_registration(&self, client: &Arc<FutuClient>) {
676        let acc_ids = {
677            let subs = self.push_subscribers.lock().await;
678            if subs.is_empty() {
679                return;
680            }
681            let mut acc_ids = subs
682                .values()
683                .flat_map(|sub| sub.acc_ids.iter().copied())
684                .collect::<Vec<u64>>();
685            acc_ids.sort_unstable();
686            acc_ids.dedup();
687            acc_ids
688        };
689        match crate::handlers::trade::sub_acc_push(client, &acc_ids).await {
690            Ok(_) => {
691                self.push_registration_dirty
692                    .store(false, std::sync::atomic::Ordering::Release);
693                tracing::info!(
694                    accounts = ?RedactedAccountIdSet::new(&acc_ids),
695                    "mcp push subscription re-registered"
696                );
697            }
698            Err(err) => {
699                self.push_registration_dirty
700                    .store(true, std::sync::atomic::Ordering::Release);
701                self.push_reregister_failures
702                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
703                tracing::warn!(
704                    error = ?RedactedMcpError::new(&err),
705                    accounts = ?RedactedAccountIdSet::new(&acc_ids),
706                    "mcp push subscription re-register failed after gateway reconnect; \
707                     callers must re-run futu_sub_acc_push"
708                );
709            }
710        }
711    }
712
713    /// Open a transient C++-compatible internal-UI transport for the pre-login
714    /// Verification tool. The ordinary cached MCP client must keep normal
715    /// InitConnect semantics and wait for a real login identity.
716    pub async fn verification_client(&self) -> Result<Arc<FutuClient>> {
717        let gateway = self.gateway().await;
718        let config = ClientConfig {
719            addr: gateway.clone(),
720            client_ver: env!("CARGO_PKG_VERSION").to_string(),
721            client_id: futu_core::INTERNAL_UI_CLIENT_ID.to_string(),
722            recv_notify: false,
723            rsa_key: None,
724        };
725        let policy =
726            ReconnectPolicy::new(MCP_CONNECT_RETRY_DELAY, MCP_CONNECT_RETRY_DELAY, Some(1));
727        let mut reconnector = ReconnectingClient::new(config).with_policy(policy);
728        let connect_result =
729            tokio::time::timeout(MCP_CONNECT_TOTAL_TIMEOUT, reconnector.connect()).await;
730        let (client, _push_rx, _info) = match connect_result {
731            Ok(result) => result.with_context(|| {
732                format!("connect Verification transport to futu gateway at {gateway}")
733            })?,
734            Err(_) => {
735                return Err(anyhow!(
736                    "connect Verification transport to futu gateway at {gateway} timed out after {}s",
737                    MCP_CONNECT_TOTAL_TIMEOUT.as_secs()
738                ));
739            }
740        };
741        Ok(Arc::new(client))
742    }
743}
744
745// ========== symbol 解析 ==========
746
747pub fn parse_symbol(s: &str) -> Result<Security> {
748    let parsed = qot_symbol::parse_qot_symbol_parts(s).map_err(|err| anyhow!("{err}"))?;
749    Ok(qot_sdk_adapter::security_from_parsed_symbol(parsed))
750}
751
752/// 格式化 Security 为 "MARKET.CODE"
753pub fn format_symbol(sec: &Security) -> String {
754    qot_symbol::format_qot_symbol(sec.market as i32, &sec.code)
755}
756
757/// v1.4.90 P2-C: audit log Option<T> 序列化助手。
758///
759/// **背景**:之前 audit log 把 `Option<f64>` 用 `?req.price`(tracing 的 Debug
760/// shorthand)记录,渲染成 JSON 字符串 `"Some(400.0)"` / `"None"`,下游 jq /
761/// DuckDB 数值聚合炸(aggregator 期望 `400.0` number 或 `null`)。
762///
763/// **修法**:用 NaN sentinel 把 `Option<f64>` flatten 成 `f64`,tracing-subscriber
764/// 的 JSON formatter 内部走 `serde_json::Value::from(f64::NAN)` →
765/// `Number::from_f64(NaN) = None` → `Value::Null`。
766/// 整数 / 字符串同理(i32 → f64 NaN sentinel;&str → "" 哨兵)。
767///
768/// 验证依据:
769/// - `tracing_subscriber::fmt::format::json` line 501 `record_f64` 直接调
770///   `serde_json::Value::from(value)`
771/// - `serde_json::Value::from(f64)` impl: `Number::from_f64(f).map_or(Value::Null, Value::Number)`
772pub mod audit_fmt {
773    /// `Option<f64>` → `f64`(None → NaN)。tracing JSON 渲染 NaN 为 `null`。
774    #[inline]
775    pub fn opt_f64(v: Option<f64>) -> f64 {
776        v.unwrap_or(f64::NAN)
777    }
778
779    /// `Option<i32>` → `f64`(None → NaN,Some(n) → n as f64)。
780    /// i32 ≤ 2^31 < 2^52 mantissa,无精度损失。
781    #[inline]
782    pub fn opt_i32(v: Option<i32>) -> f64 {
783        v.map(f64::from).unwrap_or(f64::NAN)
784    }
785
786    /// `Option<&str>` → `&str`(None → "")。"" 哨兵在 audit 上下文里足以区分
787    /// 不传 vs 传空(因为 Symbol / owner 等业务字段不会是空字符串)。
788    #[inline]
789    pub fn opt_str(v: Option<&str>) -> &str {
790        v.unwrap_or("")
791    }
792}