futu_grpc/server.rs
1//! gRPC 服务实现
2//!
3//! FutuOpenD 服务通过通用的 proto_id + body 方式,
4//! 将所有请求转发到现有的 RequestRouter。
5//! 支持流式推送:行情、交易、广播事件通过 SubscribePush 接口推送给客户端。
6//!
7//! ## v1.4.106 codex 0517 ζ25-redo F2: stateful QOT stable identity
8//!
9//! gRPC `request()` 与 `subscribe_push()` 共享同一身份派生函数
10//! [`auth::derive_grpc_conn_id`]:从 `(Bearer token, optional grpc-session-id
11//! metadata)` 派生 deterministic stable conn_id(在
12//! [`auth::GRPC_STABLE_CONN_NAMESPACE`] 即 bit 62 namespace 内)。
13//!
14//! 这让同一 caller 的连续 RPC 命中同一 SubscriptionManager / cache 状态:
15//! - subscribe → unsubscribe / get_sub_info / query_subscription 全对齐
16//! - quote cache 命中(per-conn cache 不再每次 RPC miss)
17//! - QOT push fanout per-conn filter 能找到 caller
18//!
19//! 不同 Bearer / 不同 session_id → 自然隔离(caller 之间互不影响)。
20//! Legacy mode(KeyStore 未配置 / Bearer 缺失)→ 共享一个固定 conn_id(
21//! 全 legacy caller 共享同 sub state,对齐"无鉴权配置 = 单租户"语义)。
22//!
23//! 历史:v1.4.105 之前用自增 `conn_id_counter`,每次 RPC 拿新 ID,
24//! 等价 REST v1.4.90 P0-B 之前的 quota 永久泄漏 bug。F2 修复对齐
25//! `REST_SHARED_CONN` 设计哲学,不同点是 gRPC 按 caller 隔离(REST 共享)。
26
27use std::sync::Arc;
28use std::sync::atomic::{AtomicU32, Ordering};
29
30use bytes::Bytes;
31use prost::Message as _;
32use tokio::sync::{broadcast, mpsc};
33use tokio_stream::wrappers::ReceiverStream;
34use tonic::{Request, Response, Status};
35
36use futu_auth::{KeyStore, RuntimeCounters, Scope};
37use futu_codec::header::ProtoFmtType;
38use futu_server::conn::IncomingRequest;
39use futu_server::router::RequestRouter;
40
41use crate::auth::{
42 derive_grpc_conn_id, extract_grpc_idempotency_key, extract_grpc_session_id, extract_grpc_token,
43 grpc_audit_context, grpc_status_for,
44};
45use crate::proto::futu_open_d_server::FutuOpenD;
46use crate::proto::{FutuRequest, FutuResponse, PushEvent, SubscribePushRequest};
47use futu_auth_pipeline::{
48 AuthDecision, AuthEnvelope, Credential, Endpoint, FilterRegistry, PushEventCtx, SurfaceId,
49 authenticate_request,
50};
51
52mod push;
53mod startup;
54pub use push::GrpcPushBroadcaster;
55pub use startup::{
56 build_service_with_auth, start, start_with_auth, start_with_auth_until_shutdown,
57};
58
59/// Explicit gRPC message boundary.
60///
61/// Native FTAPI frames are capped at 12 MiB; keeping gRPC at the same ceiling
62/// prevents relying on tonic defaults while preserving protocol-sized payloads.
63pub const GRPC_MAX_MESSAGE_SIZE_BYTES: usize = 12 * 1024 * 1024;
64
65/// gRPC 服务实现
66pub struct FutuGrpcService {
67 router: Arc<RequestRouter>,
68 push_broadcaster: Arc<GrpcPushBroadcaster>,
69 key_store: Arc<KeyStore>,
70 counters: Arc<RuntimeCounters>,
71 /// v1.4.104: response filter 注册中心 (proto 2001 = AccListFilter).
72 /// 加新 filter 在 `FilterRegistry::install_defaults` 注册一次, 4 surface
73 /// 自动生效.
74 filter_registry: Arc<FilterRegistry>,
75 /// v1.4.106 codex 0517 ζ25-redo F2: gRPC `conn_id_counter` 已删除 —
76 /// 自增 conn_id 让同一 caller 连续 RPC 拿不到同 sub state, 等价 REST
77 /// v1.4.90 P0-B 之前的 quota 永久泄漏 bug. 现在改用
78 /// [`auth::derive_grpc_conn_id`] 从 `(bearer, session_id)` 派生
79 /// deterministic stable conn_id, 同 caller 连续 RPC 命中同一 sub state.
80 serial_counter: AtomicU32,
81}
82
83impl FutuGrpcService {
84 pub fn new(router: Arc<RequestRouter>, push_broadcaster: Arc<GrpcPushBroadcaster>) -> Self {
85 Self::with_auth(
86 router,
87 push_broadcaster,
88 Arc::new(KeyStore::empty()),
89 Arc::new(RuntimeCounters::new()),
90 )
91 }
92
93 /// 完整构造:同时接 key_store + counters(v1.0 推荐入口)
94 ///
95 /// `counters` 应由 main 全进程共享:REST / gRPC / MCP 共用一个实例才能保证
96 /// rate limit / 日累计跨接口一致
97 pub fn with_auth(
98 router: Arc<RequestRouter>,
99 push_broadcaster: Arc<GrpcPushBroadcaster>,
100 key_store: Arc<KeyStore>,
101 counters: Arc<RuntimeCounters>,
102 ) -> Self {
103 Self {
104 router,
105 push_broadcaster,
106 key_store,
107 counters,
108 filter_registry: Arc::new(FilterRegistry::with_defaults()),
109 serial_counter: AtomicU32::new(1),
110 }
111 }
112
113 fn next_serial(&self) -> u32 {
114 self.serial_counter.fetch_add(1, Ordering::Relaxed)
115 }
116}
117
118#[tonic::async_trait]
119impl FutuOpenD for FutuGrpcService {
120 /// 通用请求-响应
121 async fn request(
122 &self,
123 request: Request<FutuRequest>,
124 ) -> Result<Response<FutuResponse>, Status> {
125 // v1.4.104: 走 futu_auth_pipeline::authenticate_request 单一函数,
126 // 不再 inline authenticate / check_scope / rate gate / body-aware /
127 // audit. surface adapter 极薄 — 仅 transport extract + RejectKind →
128 // Status 翻译.
129
130 let proto_id = request.get_ref().proto_id;
131 if proto_id == 0 {
132 return Err(Status::invalid_argument("proto_id is required"));
133 }
134 // v1.4.106 codex 0532 F3 (P2): daemon-internal proto_id (高位
135 // 0x8000_0000 bit) 绝不应从 gRPC 公开 surface 进入 — 仅 REST handler
136 // 内部合成给 router. 显式 reject + audit, 防探测 daemon 内部 routing.
137 if futu_auth::is_internal_proto_id(proto_id) {
138 tracing::warn!(
139 proto_id,
140 "rejecting daemon-internal proto_id at gRPC public surface (audit 0532 F3)"
141 );
142 return Err(Status::permission_denied(
143 "daemon-internal proto_id not allowed on public surface",
144 ));
145 }
146
147 // 1. extract token + session (owned String 避免 borrow vs move 冲突).
148 // v1.4.106 codex 0517 ζ25-redo F2: 同时拿 grpc-session-id metadata,
149 // 派生 stateful QOT stable conn_id (derive_grpc_conn_id).
150 let token = extract_grpc_token(&request);
151 let session_id = extract_grpc_session_id(&request);
152 let idempotency_key = extract_grpc_idempotency_key(&request);
153 let stable_conn_id = derive_grpc_conn_id(token.as_deref(), session_id.as_deref());
154 let audit_ctx = grpc_audit_context(&request, stable_conn_id);
155 // v1.4.110 Surface Spec v2: gRPC generic proto path is spec-owned.
156 // Unknown proto_id must not bypass EndpointSpec into auth/router.
157 let Some(spec) = futu_surface_spec::lookup_endpoint_by_proto_id(proto_id) else {
158 tracing::warn!(
159 proto_id,
160 "rejecting gRPC request for proto_id not declared in EndpointSpec"
161 );
162 return Err(Status::invalid_argument(
163 "proto_id is not declared in endpoint spec",
164 ));
165 };
166 match spec.grpc_exposure() {
167 futu_surface_spec::SurfaceExposure::Exposed(
168 futu_surface_spec::GrpcSurface::GenericProtoRequest,
169 ) => {}
170 futu_surface_spec::SurfaceExposure::NotExposed { reason } => {
171 tracing::warn!(
172 proto_id,
173 endpoint = spec.canonical_name,
174 reason,
175 "rejecting gRPC request for endpoint not exposed to gRPC"
176 );
177 return Err(Status::permission_denied(reason));
178 }
179 }
180 let needed_scope = Some(spec.runtime.scope);
181 let endpoint_name = spec.canonical_name;
182 tracing::debug!(
183 proto_id,
184 endpoint = endpoint_name,
185 "gRPC request dispatch (Layer 2 spec lookup)"
186 );
187 let req_inner = request.into_inner();
188 let request_body = normalize_grpc_history_query_body(proto_id, req_inner.body)?;
189
190 // 2. 构 credential + envelope, 调 pipeline
191 let credential = match token.as_deref() {
192 Some(t) => Credential::Bearer(t),
193 None => Credential::None,
194 };
195 let env = AuthEnvelope {
196 surface: SurfaceId::Grpc,
197 endpoint: Endpoint::Proto(proto_id),
198 needed_scope,
199 credential,
200 proto_id: Some(proto_id),
201 body: &request_body,
202 explicit_acc_id: None,
203 explicit_ctx: None,
204 commit_rate: true, // gRPC middleware 层 commit rate (trade:real)
205 audit_emit: true,
206 };
207 let (allowed_for_filter, caller_rec) =
208 match futu_auth::audit::with_context(audit_ctx.clone(), || {
209 authenticate_request(&self.key_store, &self.counters, env)
210 }) {
211 AuthDecision::Reject { kind, reason, .. } => {
212 return Err(grpc_status_for(kind, reason));
213 }
214 AuthDecision::Allow {
215 allowed_acc_ids,
216 rec,
217 ..
218 } => (allowed_acc_ids, rec),
219 };
220
221 // 3. dispatch + response filter
222 // v1.4.105 D2 T-A1 fix: caller_allowed_acc_ids 从 pipeline allow decision
223 // 真填进 IncomingRequest, 让 dispatch handler (e.g. SubAccPushHandler)
224 // 端 enforce per-acc whitelist defense-in-depth.
225 // codex 0522 F1 v1.4.106: 同步填 caller_key_id 让 cross-surface handler
226 // 能识别 gRPC caller.
227 let caller_allowed = allowed_for_filter
228 .as_ref()
229 .map(|s| std::sync::Arc::new(s.clone()));
230 let caller_key_id = caller_rec.as_ref().map(|r| r.id.clone());
231 let serial_no = self.next_serial();
232 let body = futu_server::trade_packet_id::fill_omitted_trade_packet_id_bytes(
233 proto_id,
234 request_body,
235 stable_conn_id,
236 serial_no,
237 )
238 .map_err(|e| Status::internal(format!("failed to prepare trade PacketID: {e}")))?;
239 let incoming = IncomingRequest::builder(
240 stable_conn_id,
241 proto_id,
242 serial_no,
243 ProtoFmtType::Protobuf,
244 Bytes::from(body),
245 )
246 .with_idempotency_key(idempotency_key)
247 .with_caller_scope(caller_allowed, caller_key_id)
248 .build();
249
250 match self.router.dispatch(incoming.conn_id, &incoming).await {
251 Some(resp_bytes) => {
252 // v1.4.104: 用 FilterRegistry (单一注册表) 替代 inline
253 // filter_acc_list_response. 加新 filter (e.g. cash-flow) 只
254 // 在 registry 注册一次, 4 surface 自动生效.
255 let filtered_body =
256 self.filter_registry
257 .apply(proto_id, resp_bytes, allowed_for_filter.as_ref());
258 Ok(Response::new(FutuResponse {
259 ret_type: 0,
260 ret_msg: String::new(),
261 proto_id,
262 body: filtered_body,
263 }))
264 }
265 None => Ok(Response::new(FutuResponse {
266 ret_type: -1,
267 ret_msg: "handler returned no response".to_string(),
268 proto_id,
269 body: Vec::new(),
270 })),
271 }
272 }
273
274 type SubscribePushStream = ReceiverStream<Result<PushEvent, Status>>;
275
276 /// 流式推送:客户端建立连接后持续接收行情、交易、广播推送
277 ///
278 /// v1.1:按订阅 key 的 scope 过滤推送 —— `qot:read`-only 的 key 不会收到
279 /// `trade` 类(账户交易回报),对齐 REST `/ws` v0.9.0 加的 push filter。
280 ///
281 /// ## v1.4.104 阶段 7-2: pipeline 委托
282 ///
283 /// **handshake**: pipeline 调一次 with `Endpoint::Event("subscribe_push")` +
284 /// `needed_scope=Some(qot:read)`. Allow → 拿 rec snapshot. Reject (Bearer
285 /// invalid / expired / no qot:read) → translate to gRPC Status.
286 ///
287 /// **per-event filter**: stream 内每 event 调一次 pipeline with
288 /// `Credential::PreVerified(rec)` + `Endpoint::Event(event_type)` +
289 /// `needed_scope=Some(scope_for_event(...))` + `explicit_acc_id` (trade event
290 /// 给 event.acc_id, 其他不传) + `audit_emit=false` (避免每 event 一条 audit
291 /// 把日志冲爆) + `commit_rate=false` (push 不计 rate). Reject → silent drop +
292 /// `metrics::bump_ws_filtered`. Allow → forward.
293 ///
294 /// `SubscribePush` 是 quote-first mixed push stream: handshake 必须有
295 /// qot:read;caller 若还持有 acc:read, stream 内 trade event 再由 per-event
296 /// scope match + trade acc_id whitelist 放行。acc:read-only key 不能打开
297 /// mixed stream。
298 async fn subscribe_push(
299 &self,
300 request: Request<SubscribePushRequest>,
301 ) -> Result<Response<Self::SubscribePushStream>, Status> {
302 // v1.4.106 codex 1125 F6 [P2]: 显式 notify subscription opt-in.
303 // 对齐 C++ raw TCP `IsConnSubRecvNotify` 语义 (broadcast push 必须显式
304 // sub 才下发).
305 let notify_subscribe = request.get_ref().notify_subscribe;
306
307 // ── handshake: pipeline 调一次拿 caller-key + audit 一次 ──────────────────
308 //
309 // Bearer 提取 / verify / expiry / qot:read scope 都走 pipeline
310 // (单一 source)。trade push 不是单独的 acc-only stream;caller 需要
311 // qot:read 打开 stream, 再靠 per-event acc:read 过滤接收 trade event.
312 //
313 // v1.4.106 codex 0517 ζ25-redo F2: 与 `request()` 共享 stable conn_id.
314 // 这让 ops/log 能把 push stream 和同 caller 的 subscribe request 串起来:
315 // grep `conn_id=<stable>` 看到 subscribe RPC + push event filter 同 id.
316 let token = extract_grpc_token(&request);
317 let session_id = extract_grpc_session_id(&request);
318 let stable_conn_id = derive_grpc_conn_id(token.as_deref(), session_id.as_deref());
319 let audit_ctx = grpc_audit_context(&request, stable_conn_id);
320 let credential = match token.as_deref() {
321 Some(t) => Credential::Bearer(t),
322 None => Credential::None,
323 };
324 let env = AuthEnvelope {
325 surface: SurfaceId::Grpc,
326 endpoint: Endpoint::Event("subscribe_push"),
327 needed_scope: Some(Scope::QotRead),
328 credential,
329 proto_id: None,
330 body: &[],
331 explicit_acc_id: None,
332 explicit_ctx: None,
333 commit_rate: false,
334 audit_emit: true, // handshake 一次 audit
335 };
336 let rec_snapshot = match futu_auth::audit::with_context(audit_ctx.clone(), || {
337 authenticate_request(&self.key_store, &self.counters, env)
338 }) {
339 AuthDecision::Reject { kind, reason, .. } => {
340 return Err(grpc_status_for(kind, reason));
341 }
342 AuthDecision::Allow { rec, .. } => rec,
343 };
344
345 let (scopes, key_id, allowed_acc_ids) = match rec_snapshot.as_ref() {
346 Some(rec) => (
347 rec.scopes.clone(),
348 rec.id.clone(),
349 rec.allowed_acc_ids.clone(),
350 ),
351 None => (
352 // legacy: 全 scope + 无 acc 限制
353 [
354 Scope::QotRead,
355 Scope::AccRead,
356 Scope::TradeSimulate,
357 Scope::TradeReal,
358 ]
359 .into_iter()
360 .collect::<std::collections::HashSet<Scope>>(),
361 "<none>".to_string(),
362 None,
363 ),
364 };
365
366 let (tx, rx) = mpsc::channel(256);
367 let mut push_rx = self.push_broadcaster.subscribe();
368
369 tracing::info!(
370 key_id = %key_id,
371 // v1.4.106 codex 0517 ζ25-redo F2: stable conn_id 让 subscribe RPC
372 // 和 push stream 在 log 里可关联.
373 conn_id = stable_conn_id,
374 scopes = ?scopes,
375 allowed_acc_ids = ?allowed_acc_ids.as_ref().map(|s| s.len()),
376 "gRPC client subscribed to push events",
377 );
378
379 // ── per-event filter: pipeline 二次调 (audit_emit=false 防日志冲爆) ───────
380 //
381 // 每 event 调一次 pipeline:
382 // - Credential::PreVerified(rec) 复用 handshake 已 verify 的 rec
383 // - Endpoint::Event(event_type) 给 audit label (虽然 audit_emit=false)
384 // - needed_scope = scope_for_event(event_type)
385 // - explicit_acc_id = trade event 给 event.acc_id, 其他 None
386 //
387 // legacy mode (rec_snapshot=None) 不调 pipeline (没必要, 全放行).
388 let key_store_arc = self.key_store.clone();
389 let counters_arc = self.counters.clone();
390 // v1.4.105 D3 (Phase 4): clone filter_registry for per-event Layer 3
391 // (allowed_markets) check + Layer 1/2 reuse.
392 let filter_registry_arc = self.filter_registry.clone();
393 tokio::spawn(async move {
394 loop {
395 match push_rx.recv().await {
396 Ok(event) => {
397 // legacy fast-path: rec=None → 全放行, scope check / acc_id
398 // whitelist 都不需要走 pipeline.
399 let allow_event = if let Some(rec) = rec_snapshot.as_ref() {
400 let needed = scope_for_event(&event.event_type);
401 let explicit_acc_id = if event.event_type == "trade" {
402 Some(event.acc_id)
403 } else {
404 None
405 };
406 let env = AuthEnvelope {
407 surface: SurfaceId::Grpc,
408 endpoint: Endpoint::Event(&event.event_type),
409 needed_scope: Some(needed),
410 credential: Credential::PreVerified(rec.clone()),
411 proto_id: None,
412 body: &[],
413 explicit_acc_id,
414 explicit_ctx: None,
415 commit_rate: false, // push 不计 rate
416 audit_emit: false, // per-event 不 audit, 避免冲爆
417 };
418 matches!(
419 authenticate_request(&key_store_arc, &counters_arc, env),
420 AuthDecision::Allow { .. }
421 )
422 } else {
423 true
424 };
425
426 if !allow_event {
427 // metric label 区分 trade acc_id reject vs scope reject
428 // (近似旧行为: trade 类 reject 标 "trade_acc_id" 仅当
429 // acc_id whitelist 非空; 否则按 event_type)
430 let label = if event.event_type == "trade"
431 && rec_snapshot
432 .as_ref()
433 .and_then(|r| r.allowed_acc_ids.as_ref())
434 .is_some_and(|s| !s.is_empty())
435 {
436 "trade_acc_id"
437 } else {
438 event.event_type.as_str()
439 };
440 futu_auth::metrics::bump_ws_filtered(label, &key_id);
441 continue;
442 }
443
444 // v1.4.105 D3 (Phase 4): FilterRegistry::should_drop_event
445 // Layer 3 (allowed_markets). Layer 1 (allowed_acc_ids) 已在
446 // 上面 pipeline body-aware 跑过, 此处串行 Layer 3 双重
447 // 防御 + 未来 Layer 扩展自动覆盖.
448 // ⚠️ UNVERIFIED — pending real-machine verify (HK+US 双
449 // 账户跨 market 推送). 这里是 backend-semantic 风险,
450 // 只能靠真机跨 market trade push 验证后再升 confidence.
451 //
452 // v1.4.105 T-B3: trd_market 改读 PushEvent.trd_market 字段
453 // (PushDispatcher 端一次 decode), 不再各 surface 独立
454 // decode body. 空字符串 = 非 trade event / decode 失败 /
455 // market 未知 → 转 None (Layer 3 不 trigger drop, 向后
456 // 兼容).
457 let event_trd_market =
458 if event.event_type == "trade" && !event.trd_market.is_empty() {
459 Some(event.trd_market.as_str())
460 } else {
461 None
462 };
463 let allowed_markets_for_filter = rec_snapshot
464 .as_ref()
465 .and_then(|r| r.allowed_markets.as_ref());
466 let push_ctx = PushEventCtx {
467 event_type: &event.event_type,
468 event_acc: if event.event_type == "trade" {
469 Some(event.acc_id)
470 } else {
471 None
472 },
473 // Layer 1 已 pipeline 跑, 此处传 None 防双重 reject (LE 之间 OR 短路)
474 allowed_acc_ids: None,
475 // gRPC 没 sub_state (REST 特有)
476 sub_state: None,
477 // Layer 3 — 新加
478 event_trd_market,
479 allowed_markets: allowed_markets_for_filter,
480 };
481 if filter_registry_arc.should_drop_event(&push_ctx) {
482 futu_auth::metrics::bump_ws_filtered("trade_market", &key_id);
483 continue;
484 }
485
486 // v1.4.106 codex 1125 F6 [P2]: notify_subscribe gate.
487 // 对齐 C++ `IsConnSubRecvNotify` (raw TCP). broadcast notify
488 // 类 push (e.g. price reminder) 必须 client 显式 sub 才下发.
489 if event.event_type == "notify" && !notify_subscribe {
490 futu_auth::metrics::bump_ws_filtered("notify_unsub", &key_id);
491 continue;
492 }
493
494 if tx.send(Ok(event)).await.is_err() {
495 break; // 客户端断开
496 }
497 }
498 Err(broadcast::error::RecvError::Lagged(n)) => {
499 tracing::warn!(skipped = n, "gRPC push client lagged, skipped events");
500 // 继续接收,不断开
501 }
502 Err(broadcast::error::RecvError::Closed) => {
503 break; // 广播器关闭
504 }
505 }
506 }
507 tracing::info!("gRPC push stream ended");
508 });
509
510 Ok(Response::new(ReceiverStream::new(rx)))
511 }
512}
513
514fn normalize_grpc_history_query_body(proto_id: u32, body: Vec<u8>) -> Result<Vec<u8>, Status> {
515 match proto_id {
516 futu_core::proto_id::TRD_GET_HISTORY_ORDER_LIST => {
517 normalize_history_order_list_filter_market(body)
518 }
519 futu_core::proto_id::TRD_GET_HISTORY_ORDER_FILL_LIST => {
520 normalize_history_order_fill_list_filter_market(body)
521 }
522 _ => Ok(body),
523 }
524}
525
526fn normalize_history_order_list_filter_market(body: Vec<u8>) -> Result<Vec<u8>, Status> {
527 let Ok(mut req) = futu_proto::trd_get_history_order_list::Request::decode(body.as_slice())
528 else {
529 return Ok(body);
530 };
531 if default_history_filter_market(
532 req.c2s.header.trd_market,
533 &mut req.c2s.filter_conditions.filter_market,
534 ) {
535 return Ok(req.encode_to_vec());
536 }
537 Ok(body)
538}
539
540fn normalize_history_order_fill_list_filter_market(body: Vec<u8>) -> Result<Vec<u8>, Status> {
541 let Ok(mut req) = futu_proto::trd_get_history_order_fill_list::Request::decode(body.as_slice())
542 else {
543 return Ok(body);
544 };
545 if default_history_filter_market(
546 req.c2s.header.trd_market,
547 &mut req.c2s.filter_conditions.filter_market,
548 ) {
549 return Ok(req.encode_to_vec());
550 }
551 Ok(body)
552}
553
554fn default_history_filter_market(header_market: i32, filter_market: &mut Option<i32>) -> bool {
555 // Ref: FutuOpenD/Src/APIServer/Business/Trade/APIServer_Trd_GetHistoryOrderList.cpp:48-55
556 // and _APIServer_Trd_Comm.cpp:3233-3247. C++ uses filterMarket both for
557 // time parsing and response market filtering; missing filterMarket means
558 // Unknown and passes every market. CLI/MCP/REST high-level paths already
559 // default it from header.trd_market, so generic gRPC must do the same.
560 if filter_market.is_none() && header_market != 0 {
561 *filter_market = Some(header_market);
562 return true;
563 }
564 false
565}
566
567// v1.4.104: `grpc_handler_full_check` 已删除 — 功能搬到
568// `futu_auth_pipeline::pipeline::authenticate_request` 内的 body-aware step.
569// gRPC `request()` 调一次 pipeline 即覆盖所有 (caller-key + scope + body-aware
570// + audit + rate). 4 surface 共用同一函数, 不再有 hand-copy diverge.
571
572/// gRPC PushEvent 的 event_type → 客户端必须持有的 scope (与 REST `/ws` 对齐).
573/// 用于 SubscribePush stream 内 per-event filter.
574fn scope_for_event(event_type: &str) -> Scope {
575 match event_type {
576 "trade" => Scope::AccRead, // 账户交易回报
577 _ => Scope::QotRead, // quote / notify / 未知都按行情门槛
578 }
579}
580
581// v1.4.105 D3 (Phase 4) T-B3: 旧 `extract_trd_market_from_trade_event` 已搬到
582// `futu_server::push::extract_trd_market_from_trade_body` + PushDispatcher 端
583// 一次 decode 后透传给 PushEvent.trd_market 字段, 4 surface (gRPC / REST WS /
584// MCP) 共用. gRPC subscribe_push 现读 event.trd_market 字段不再 decode body.
585//
586// 这里若想要静态 import 提示, 可保留:
587//
588// use futu_server::push::extract_trd_market_from_trade_body;
589//
590// 但目前 server.rs 不再调用, 全凭 PushEvent 字段透传, 故不 import.
591
592#[cfg(test)]
593mod tests;