1mod bundle;
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::path::Path;
5
6use anyhow::{Context, Result};
7use futu_core::diagnostic_text::update_check_unavailable_action;
8use futu_core::market::qot_market_state_label as market_state_label;
9use serde::Serialize;
10use serde_json::{Value, json};
11
12use bundle::write_doctor_bundle;
13#[cfg(test)]
14pub(super) use bundle::{redact_sensitive_json_for_bundle, redact_sensitive_text_for_bundle};
15
16use crate::cmd::static_diag::summarize_static_status;
17use crate::cmd::version_check::{self, UpdateDecision, UpdateLevel};
18use crate::output::OutputFormat;
19
20pub async fn run_doctor(
22 rest_url: Option<&str>,
23 api_key: Option<&str>,
24 symbol: Option<&str>,
25 bundle_dir: Option<&Path>,
26 check_update: bool,
27 update_url: Option<&str>,
28 update_timeout_ms: u64,
29 output: OutputFormat,
30) -> Result<()> {
31 let base = super::resolve_rest_url(rest_url);
32 let client = futu_core::http_client::webpki_builder()
33 .timeout(std::time::Duration::from_secs(5))
34 .build()
35 .context("build reqwest client")?;
36 let mut endpoints = Vec::new();
37 endpoints.push(
38 fetch_doctor_endpoint(
39 &client,
40 &base,
41 "readyz",
42 reqwest::Method::GET,
43 "/readyz",
44 api_key,
45 None,
46 )
47 .await,
48 );
49 endpoints.push(
50 fetch_doctor_endpoint(
51 &client,
52 &base,
53 "admin-status",
54 reqwest::Method::GET,
55 "/api/admin/status",
56 api_key,
57 None,
58 )
59 .await,
60 );
61 endpoints.push(
62 fetch_doctor_endpoint(
63 &client,
64 &base,
65 "push-subscriber-info",
66 reqwest::Method::GET,
67 "/api/push-subscriber-info",
68 api_key,
69 None,
70 )
71 .await,
72 );
73 endpoints.push(
74 fetch_doctor_endpoint(
75 &client,
76 &base,
77 "sub-info",
78 reqwest::Method::GET,
79 "/api/sub-info",
80 api_key,
81 None,
82 )
83 .await,
84 );
85 endpoints.push(
86 fetch_doctor_endpoint(
87 &client,
88 &base,
89 "used-quota",
90 reqwest::Method::POST,
91 "/api/used-quota",
92 api_key,
93 Some(json!({})),
94 )
95 .await,
96 );
97 endpoints.push(
98 fetch_doctor_endpoint(
99 &client,
100 &base,
101 "history-kl-quota",
102 reqwest::Method::POST,
103 "/api/history-kl-quota",
104 api_key,
105 Some(json!({"c2s": {"b_get_detail": false}})),
106 )
107 .await,
108 );
109 endpoints.push(
110 fetch_doctor_endpoint(
111 &client,
112 &base,
113 "metrics",
114 reqwest::Method::GET,
115 "/metrics",
116 api_key,
117 None,
118 )
119 .await,
120 );
121 if let Some(symbol) = symbol {
122 endpoints.push(
123 fetch_doctor_endpoint(
124 &client,
125 &base,
126 "market-state",
127 reqwest::Method::POST,
128 "/api/market-state",
129 api_key,
130 Some(json!({"symbols": [symbol]})),
131 )
132 .await,
133 );
134 endpoints.push(
135 fetch_doctor_endpoint(
136 &client,
137 &base,
138 "quote-capability",
139 reqwest::Method::GET,
140 &format!("/api/quote-capability?symbol={symbol}"),
141 api_key,
142 None,
143 )
144 .await,
145 );
146 }
147
148 let mut findings = analyze_doctor(&endpoints, symbol);
149 if check_update {
150 findings.extend(fetch_update_check_finding(update_url, update_timeout_ms).await);
151 }
152 let report = DoctorReport {
153 rest_url: base,
154 symbol: symbol.map(str::to_string),
155 endpoints,
156 findings,
157 };
158 if let Some(dir) = bundle_dir {
159 write_doctor_bundle(&report, dir)?;
160 eprintln!("# doctor bundle written to {}", dir.display());
161 }
162 print_doctor_report(&report, output)
163}
164
165async fn fetch_update_check_finding(url: Option<&str>, timeout_ms: u64) -> Option<DoctorFinding> {
166 match version_check::check_for_update(env!("CARGO_PKG_VERSION"), url, timeout_ms).await {
167 Ok(decision) => update_check_finding_from_decision(&decision),
168 Err(err) => Some(DoctorFinding {
169 level: "INFO",
170 area: "update",
171 message: "version update check unavailable".to_string(),
172 action: format!(
173 "{err}; {}",
174 update_check_unavailable_action().text_with_code()
175 ),
176 }),
177 }
178}
179
180#[derive(Debug, Serialize)]
181pub(super) struct DoctorReport {
182 rest_url: String,
183 #[serde(skip_serializing_if = "Option::is_none")]
184 symbol: Option<String>,
185 endpoints: Vec<DoctorEndpoint>,
186 findings: Vec<DoctorFinding>,
187}
188
189#[derive(Debug, Serialize)]
190pub(super) struct DoctorEndpoint {
191 pub(super) name: String,
192 pub(super) method: String,
193 pub(super) path: String,
194 pub(super) ok: bool,
195 #[serde(skip_serializing_if = "Option::is_none")]
196 pub(super) http_status: Option<u16>,
197 #[serde(skip_serializing_if = "Option::is_none")]
198 pub(super) json: Option<Value>,
199 #[serde(skip_serializing_if = "Option::is_none")]
200 pub(super) text_preview: Option<String>,
201 #[serde(skip_serializing_if = "Option::is_none")]
202 pub(super) error: Option<String>,
203}
204
205#[derive(Debug, Serialize)]
206pub(super) struct DoctorFinding {
207 pub(super) level: &'static str,
208 pub(super) area: &'static str,
209 pub(super) message: String,
210 pub(super) action: String,
211}
212
213async fn fetch_doctor_endpoint(
214 client: &reqwest::Client,
215 base: &str,
216 name: &str,
217 method: reqwest::Method,
218 path: &str,
219 api_key: Option<&str>,
220 json_body: Option<Value>,
221) -> DoctorEndpoint {
222 let method_text = method.as_str().to_string();
223 match super::request_raw(client, base, method, path, api_key, json_body).await {
224 Ok((status, body)) => {
225 let json = serde_json::from_str::<Value>(&body).ok();
226 let text_preview = if path == "/metrics" {
227 Some(select_metrics_preview(&body))
228 } else if json.is_none() {
229 Some(preview_text(&body, 1200))
230 } else {
231 None
232 };
233 DoctorEndpoint {
234 name: name.to_string(),
235 method: method_text,
236 path: path.to_string(),
237 ok: (200..300).contains(&status),
238 http_status: Some(status),
239 json,
240 text_preview,
241 error: None,
242 }
243 }
244 Err(err) => DoctorEndpoint {
245 name: name.to_string(),
246 method: method_text,
247 path: path.to_string(),
248 ok: false,
249 http_status: None,
250 json: None,
251 text_preview: None,
252 error: Some(err.to_string()),
253 },
254 }
255}
256
257pub(super) fn analyze_doctor(
258 endpoints: &[DoctorEndpoint],
259 symbol: Option<&str>,
260) -> Vec<DoctorFinding> {
261 let mut findings = Vec::new();
262 for ep in endpoints {
263 if !ep.ok {
264 findings.push(DoctorFinding {
265 level: if matches!(
266 ep.name.as_str(),
267 "metrics" | "used-quota" | "history-kl-quota"
268 ) {
269 "WARN"
270 } else {
271 "ERROR"
272 },
273 area: "endpoint",
274 message: format!(
275 "{} {} unavailable{}",
276 ep.method,
277 ep.path,
278 ep.http_status
279 .map(|s| format!(" (HTTP {s})"))
280 .unwrap_or_default()
281 ),
282 action: ep
283 .error
284 .clone()
285 .unwrap_or_else(|| "查看 daemon 日志与 API key scope".to_string()),
286 });
287 }
288 }
289
290 if endpoint(endpoints, "readyz").is_some_and(|ep| ep.ok) {
291 findings.push(DoctorFinding {
292 level: "OK",
293 area: "readyz",
294 message: "REST readiness endpoint is reachable".to_string(),
295 action: "gateway dispatch path is ready enough for REST diagnostics".to_string(),
296 });
297 }
298
299 let market_state = endpoint_json(endpoints, "market-state").and_then(first_market_state);
300 let market_active = market_state.map(is_market_state_active);
301 if let Some(state) = market_state {
302 findings.push(DoctorFinding {
303 level: "INFO",
304 area: "market-state",
305 message: format!(
306 "{} market_state={} ({})",
307 symbol.unwrap_or("<symbol>"),
308 market_state_label(state),
309 state
310 ),
311 action: "用该状态辅助判断订阅无新 push 是闭市正常还是开市断流".to_string(),
312 });
313 }
314
315 if let Some(push_json) = endpoint_json(endpoints, "push-subscriber-info")
316 && let Some(push_health) = push_json.get("push_health")
317 {
318 if push_health
319 .get("push_stream_healthy")
320 .and_then(Value::as_bool)
321 == Some(false)
322 {
323 findings.push(DoctorFinding {
324 level: "ERROR",
325 area: "push",
326 message: "push_stream_healthy=false".to_string(),
327 action:
328 "先看 consecutive_parse_errors / circuit breaker;必要时抓 daemon debug log"
329 .to_string(),
330 });
331 }
332 if push_health
333 .get("backend_connected")
334 .and_then(Value::as_bool)
335 == Some(false)
336 {
337 findings.push(DoctorFinding {
338 level: "ERROR",
339 area: "push",
340 message: "backend_connected=false".to_string(),
341 action: "request path TCP 未连接;先确认 broker runtime 重连状态".to_string(),
342 });
343 }
344 if push_health
345 .get("last_push_received_at_ms")
346 .and_then(Value::as_i64)
347 == Some(0)
348 {
349 findings.push(DoctorFinding {
350 level: "WARN",
351 area: "push",
352 message: "daemon has not observed any backend push yet".to_string(),
353 action: "确认已订阅行情或交易推送;仅启动未订阅时可正常为 0".to_string(),
354 });
355 }
356 }
357
358 if let Some(admin_json) = endpoint_json(endpoints, "admin-status")
359 && let Some(warning) = admin_json
360 .get("login")
361 .and_then(|login| login.get("credential"))
362 .and_then(|credential| credential.get("expiry_warning"))
363 .and_then(Value::as_str)
364 {
365 findings.push(DoctorFinding {
366 level: "WARN",
367 area: "auth",
368 message: warning.to_string(),
369 action: "优先执行 futucli daemon-reload;若已过期则用密码/SMS 重新认证".to_string(),
370 });
371 }
372
373 if let Some(admin_json) = endpoint_json(endpoints, "admin-status")
374 && admin_json
375 .get("cache")
376 .and_then(|cache| cache.get("static_data"))
377 .is_some()
378 && let Ok(report) = summarize_static_status(admin_json)
379 && (!report.ready
380 || report.stock_list_sync_failed_total > 0
381 || report.stock_list_sync_recoverable_retry_total > 0)
382 {
383 let level = if report.ready { "INFO" } else { "WARN" };
384 findings.push(DoctorFinding {
385 level,
386 area: "static-data",
387 message: format!(
388 "stock-list readiness={} ready={} reason={}",
389 report.stock_list_readiness, report.ready, report.reason
390 ),
391 action: format!("{}; inspect with futucli static-status", report.action),
392 });
393 }
394
395 if let Some(admin_json) = endpoint_json(endpoints, "admin-status")
396 && let Some(auth_refresh) = admin_json
397 .get("login")
398 .and_then(|login| login.get("auth_refresh"))
399 {
400 let proactive = auth_refresh
401 .get("client_sig_proactive_refresh_enabled")
402 .and_then(Value::as_bool)
403 .unwrap_or(false);
404 let reactive = auth_refresh
405 .get("client_sig_reactive_refresh_enabled")
406 .and_then(Value::as_bool)
407 .unwrap_or(false);
408 if proactive || reactive {
409 findings.push(DoctorFinding {
410 level: "INFO",
411 area: "auth",
412 message: format!(
413 "client_sig refresh opt-in enabled: proactive={proactive}, reactive={reactive}"
414 ),
415 action: "确认这是有意开启的长跑恢复配置;默认关闭,建议先用非关键账户验证"
416 .to_string(),
417 });
418 }
419 }
420
421 let subscribed_sub_types = endpoint_json(endpoints, "sub-info")
422 .map(collect_sub_types)
423 .unwrap_or_default();
424 let push_watermarks = endpoint_json(endpoints, "push-subscriber-info")
425 .and_then(|v| v.get("push_health"))
426 .map(collect_sub_type_watermarks)
427 .unwrap_or_default();
428 if subscribed_sub_types.is_empty() {
429 findings.push(DoctorFinding {
430 level: "INFO",
431 area: "subscription",
432 message: "no subscribed SubType found in /api/sub-info".to_string(),
433 action: "若期待行情 push,先执行 futucli sub 或 REST /api/subscribe".to_string(),
434 });
435 } else {
436 for sub_type in subscribed_sub_types {
437 if !push_watermarks.contains_key(&sub_type) {
438 findings.push(missing_sub_type_finding(sub_type, market_active, symbol));
439 }
440 }
441 }
442
443 if let Some(metrics) = endpoint(endpoints, "metrics")
444 && metrics.ok
445 && !metrics
446 .text_preview
447 .as_deref()
448 .unwrap_or_default()
449 .contains("futu_gateway_push_health_stream_healthy")
450 {
451 findings.push(DoctorFinding {
452 level: "WARN",
453 area: "metrics",
454 message: "/metrics reachable but push health metrics were not found".to_string(),
455 action: "确认 daemon 版本包含 v1.4.113 push-health Prometheus renderer".to_string(),
456 });
457 }
458
459 if let Some(capability_json) = endpoint_json(endpoints, "quote-capability") {
460 findings.extend(quote_capability_decision_findings(capability_json));
461 }
462
463 findings
464}
465
466pub(super) fn update_check_finding_from_decision(
467 decision: &UpdateDecision,
468) -> Option<DoctorFinding> {
469 match decision.level {
470 UpdateLevel::UpToDate => None,
471 UpdateLevel::Info => Some(DoctorFinding {
472 level: "INFO",
473 area: "update",
474 message: decision.message.clone(),
475 action: decision.action.clone(),
476 }),
477 UpdateLevel::Recommended => Some(DoctorFinding {
478 level: "WARN",
479 area: "update",
480 message: decision.message.clone(),
481 action: decision.action.clone(),
482 }),
483 UpdateLevel::Critical => Some(DoctorFinding {
484 level: "ERROR",
485 area: "update",
486 message: decision.message.clone(),
487 action: decision.action.clone(),
488 }),
489 }
490}
491
492fn endpoint<'a>(endpoints: &'a [DoctorEndpoint], name: &str) -> Option<&'a DoctorEndpoint> {
493 endpoints.iter().find(|ep| ep.name == name)
494}
495
496fn endpoint_json<'a>(endpoints: &'a [DoctorEndpoint], name: &str) -> Option<&'a Value> {
497 endpoint(endpoints, name).and_then(|ep| ep.json.as_ref())
498}
499
500fn missing_sub_type_finding(
501 sub_type: i64,
502 market_active: Option<bool>,
503 symbol: Option<&str>,
504) -> DoctorFinding {
505 match market_active {
506 Some(true) => DoctorFinding {
507 level: "WARN",
508 area: "subscription",
509 message: format!(
510 "SubType {sub_type} is subscribed but has no regular push watermark while {} is active",
511 symbol.unwrap_or("the reference symbol")
512 ),
513 action: "开市仍无该类型 push:确认订阅参数、backend push 通道;必要时 /api/admin/reload 后重订阅".to_string(),
514 },
515 Some(false) => DoctorFinding {
516 level: "INFO",
517 area: "subscription",
518 message: format!("SubType {sub_type} is subscribed but has no regular push watermark"),
519 action: "参考 symbol 当前非连续交易时段;闭市/午休/盘前结束时部分 SubType 无新数据通常正常".to_string(),
520 },
521 None => DoctorFinding {
522 level: "INFO",
523 area: "subscription",
524 message: format!("SubType {sub_type} is subscribed but has no regular push watermark"),
525 action: "未传 --symbol,无法区分闭市正常与开市断流;可加 --symbol HK.00700 或查 /api/market-state".to_string(),
526 },
527 }
528}
529
530fn collect_sub_types(value: &Value) -> BTreeSet<i64> {
531 let mut out = BTreeSet::new();
532 collect_i64_key(value, "sub_type", &mut out);
533 out
534}
535
536fn collect_sub_type_watermarks(push_health: &Value) -> BTreeMap<i64, i64> {
537 let mut out = BTreeMap::new();
538 if let Some(items) = push_health
539 .get("last_push_by_sub_type")
540 .and_then(Value::as_array)
541 {
542 for item in items {
543 if let (Some(sub_type), Some(at_ms)) = (
544 item.get("sub_type").and_then(Value::as_i64),
545 item.get("last_push_received_at_ms").and_then(Value::as_i64),
546 ) {
547 out.insert(sub_type, at_ms);
548 }
549 }
550 }
551 out
552}
553
554fn collect_i64_key(value: &Value, key: &str, out: &mut BTreeSet<i64>) {
555 match value {
556 Value::Object(map) => {
557 if let Some(v) = map.get(key).and_then(Value::as_i64) {
558 out.insert(v);
559 }
560 for child in map.values() {
561 collect_i64_key(child, key, out);
562 }
563 }
564 Value::Array(items) => {
565 for child in items {
566 collect_i64_key(child, key, out);
567 }
568 }
569 _ => {}
570 }
571}
572
573fn quote_capability_decision_findings(value: &Value) -> Vec<DoctorFinding> {
574 let Some(decisions) = value.get("decisions").and_then(Value::as_array) else {
575 return Vec::new();
576 };
577 decisions
578 .iter()
579 .filter_map(|decision| {
580 let allowed = decision.get("allowed").and_then(Value::as_bool)?;
581 if allowed {
582 return None;
583 }
584 let topic = decision
585 .get("topic")
586 .and_then(Value::as_str)
587 .unwrap_or("unknown");
588 let reason = decision
589 .get("reason")
590 .and_then(Value::as_str)
591 .unwrap_or("blocked by quote capability decision");
592 let source = decision
593 .get("source")
594 .and_then(Value::as_str)
595 .unwrap_or("unknown");
596 let freshness = decision
597 .get("freshness")
598 .and_then(Value::as_str)
599 .unwrap_or("unknown");
600 let action = decision
601 .get("action")
602 .and_then(Value::as_str)
603 .unwrap_or("inspect futucli quote-rights and quote-capability")
604 .to_string();
605 Some(DoctorFinding {
606 level: "WARN",
607 area: "quote-capability",
608 message: format!(
609 "{topic} blocked: {reason} (source={source}, freshness={freshness})"
610 ),
611 action,
612 })
613 })
614 .collect()
615}
616
617fn first_market_state(value: &Value) -> Option<i32> {
618 let mut values = BTreeSet::new();
619 collect_i64_key(value, "market_state", &mut values);
620 values
621 .into_iter()
622 .next()
623 .and_then(|v| i32::try_from(v).ok())
624}
625
626fn is_market_state_active(state: i32) -> bool {
627 matches!(
628 state,
629 1 | 3 | 5 | 8 | 10 | 13 | 15 | 19 | 21 | 23 | 25 | 28 | 32 | 33 | 35 | 36 | 37
630 )
631}
632
633fn print_doctor_report(report: &DoctorReport, output: OutputFormat) -> Result<()> {
634 match output {
635 OutputFormat::Json => {
636 println!("{}", serde_json::to_string_pretty(report)?);
637 }
638 OutputFormat::Jsonl => {
639 println!("{}", serde_json::to_string(report)?);
640 }
641 OutputFormat::Table | OutputFormat::Markdown => {
642 println!("futucli doctor");
643 println!("REST: {}", report.rest_url);
644 if let Some(symbol) = &report.symbol {
645 println!("Symbol: {symbol}");
646 }
647 println!();
648 println!("Endpoints:");
649 for ep in &report.endpoints {
650 let status = ep
651 .http_status
652 .map(|s| s.to_string())
653 .unwrap_or_else(|| "-".to_string());
654 let mark = if ep.ok { "OK" } else { "FAIL" };
655 println!("- [{mark}] {} {} HTTP {}", ep.method, ep.path, status);
656 if let Some(error) = &ep.error {
657 println!(" error: {error}");
658 }
659 }
660 println!();
661 println!("Findings:");
662 if report.findings.is_empty() {
663 println!("- [OK] no actionable findings");
664 } else {
665 for finding in &report.findings {
666 println!(
667 "- [{}] {}: {}",
668 finding.level, finding.area, finding.message
669 );
670 println!(" next: {}", finding.action);
671 }
672 }
673 }
674 }
675 Ok(())
676}
677
678fn select_metrics_preview(body: &str) -> String {
679 let selected: Vec<&str> = body
680 .lines()
681 .filter(|line| {
682 line.contains("futu_gateway_push_health")
683 || line.contains("futu_gateway_broker_tcp")
684 || line.contains("futu_metrics_registry")
685 })
686 .collect();
687 if selected.is_empty() {
688 preview_text(body, 1200)
689 } else {
690 selected.join("\n")
691 }
692}
693
694fn preview_text(body: &str, max_chars: usize) -> String {
695 body.chars().take(max_chars).collect()
696}