1use std::sync::Arc;
4
5use axum::Extension;
6use axum::extract::{Json, Query, State};
7use axum::http::StatusCode;
8use bytes::Bytes;
9use futu_codec::header::ProtoFmtType;
10use futu_server::conn::IncomingRequest;
11use prost::Message;
12use serde::Deserialize;
13use serde_json::Value;
14
15use futu_core::proto_id;
16use futu_proto::get_delay_statistics;
17use futu_proto::get_global_state;
18use futu_proto::get_user_info;
19use futu_proto::test_cmd;
20use futu_surface_spec::endpoints::get_delay_statistics::default_request_body_json;
21use futu_backend::proto_internal::futu_token_state;
23use futu_qot::quote_rights::{SYS_QUERY_GET_QUOTE_CAPABILITY, SYS_QUERY_GET_QUOTE_RIGHTS_PROFILE};
24
25use crate::adapter::{self, RestState};
26
27type ApiResult = Result<Json<Value>, (StatusCode, Json<Value>)>;
28type RawApiResult = Result<adapter::RawJson, (StatusCode, Json<Value>)>;
29
30pub async fn get_global_state(State(state): State<RestState>) -> RawApiResult {
35 adapter::proto_request_raw::<get_global_state::Request, get_global_state::Response>(
36 &state,
37 proto_id::GET_GLOBAL_STATE,
38 None,
39 )
40 .await
41}
42
43pub async fn get_user_info(State(state): State<RestState>) -> RawApiResult {
45 adapter::proto_request_raw::<get_user_info::Request, get_user_info::Response>(
46 &state,
47 proto_id::GET_USER_INFO,
48 None,
49 )
50 .await
51}
52
53#[derive(Debug, Deserialize)]
54#[serde(deny_unknown_fields)]
55pub struct QuoteRightsQuery {
56 refresh: Option<bool>,
57}
58
59#[derive(Debug, Deserialize)]
60#[serde(deny_unknown_fields)]
61pub struct QuoteCapabilityQuery {
62 symbol: Option<String>,
63 market: Option<String>,
64 code: Option<String>,
65}
66
67pub async fn get_quote_rights(
69 State(state): State<RestState>,
70 Query(query): Query<QuoteRightsQuery>,
71) -> ApiResult {
72 if query.refresh.unwrap_or(false) {
73 let req = test_cmd::Request {
74 c2s: test_cmd::C2s {
75 cmd: "request_highest_quote_right".to_string(),
76 param_str: None,
77 param_bytes: None,
78 },
79 };
80 let resp: test_cmd::Response = dispatch_proto(
81 &state,
82 proto_id::TEST_CMD,
83 req,
84 "request_highest_quote_right",
85 )
86 .await?;
87 if resp.ret_type != 0 {
88 return Err(api_error(
89 StatusCode::BAD_GATEWAY,
90 format_sys_command_error_message(
91 "request_highest_quote_right",
92 resp.ret_type,
93 resp.ret_msg.as_deref(),
94 ),
95 ));
96 }
97 }
98
99 let req = test_cmd::Request {
100 c2s: test_cmd::C2s {
101 cmd: SYS_QUERY_GET_QUOTE_RIGHTS_PROFILE.to_string(),
102 param_str: None,
103 param_bytes: None,
104 },
105 };
106 let resp: test_cmd::Response = dispatch_proto(
107 &state,
108 proto_id::TEST_CMD,
109 req,
110 SYS_QUERY_GET_QUOTE_RIGHTS_PROFILE,
111 )
112 .await?;
113 if resp.ret_type != 0 {
114 return Err(api_error(
115 StatusCode::BAD_GATEWAY,
116 format_sys_command_error_message(
117 SYS_QUERY_GET_QUOTE_RIGHTS_PROFILE,
118 resp.ret_type,
119 resp.ret_msg.as_deref(),
120 ),
121 ));
122 }
123 let json = resp.s2c.and_then(|s| s.result_str).ok_or_else(|| {
124 api_error(
125 StatusCode::BAD_GATEWAY,
126 format!("{SYS_QUERY_GET_QUOTE_RIGHTS_PROFILE}: missing result_str"),
127 )
128 })?;
129 serde_json::from_str::<Value>(&json).map(Json).map_err(|e| {
130 api_error(
131 StatusCode::INTERNAL_SERVER_ERROR,
132 format!("parse {SYS_QUERY_GET_QUOTE_RIGHTS_PROFILE}: {e}"),
133 )
134 })
135}
136
137pub async fn get_quote_capability(
139 State(state): State<RestState>,
140 Query(query): Query<QuoteCapabilityQuery>,
141) -> ApiResult {
142 let symbol = quote_capability_symbol(query)?;
143 let req = test_cmd::Request {
144 c2s: test_cmd::C2s {
145 cmd: SYS_QUERY_GET_QUOTE_CAPABILITY.to_string(),
146 param_str: Some(symbol),
147 param_bytes: None,
148 },
149 };
150 let resp: test_cmd::Response = dispatch_proto(
151 &state,
152 proto_id::TEST_CMD,
153 req,
154 SYS_QUERY_GET_QUOTE_CAPABILITY,
155 )
156 .await?;
157 if resp.ret_type != 0 {
158 return Err(api_error(
159 StatusCode::BAD_GATEWAY,
160 format_sys_command_error_message(
161 SYS_QUERY_GET_QUOTE_CAPABILITY,
162 resp.ret_type,
163 resp.ret_msg.as_deref(),
164 ),
165 ));
166 }
167 let json = resp.s2c.and_then(|s| s.result_str).ok_or_else(|| {
168 api_error(
169 StatusCode::BAD_GATEWAY,
170 format!("{SYS_QUERY_GET_QUOTE_CAPABILITY}: missing result_str"),
171 )
172 })?;
173 serde_json::from_str::<Value>(&json).map(Json).map_err(|e| {
174 api_error(
175 StatusCode::INTERNAL_SERVER_ERROR,
176 format!("parse {SYS_QUERY_GET_QUOTE_CAPABILITY}: {e}"),
177 )
178 })
179}
180
181fn quote_capability_symbol(
182 query: QuoteCapabilityQuery,
183) -> Result<String, (StatusCode, Json<Value>)> {
184 let has_symbol = query
185 .symbol
186 .as_ref()
187 .is_some_and(|symbol| !symbol.trim().is_empty());
188 let has_pair = query.market.as_ref().is_some_and(|m| !m.trim().is_empty())
189 || query.code.as_ref().is_some_and(|c| !c.trim().is_empty());
190 if has_symbol && has_pair {
191 return Err(api_error(
192 StatusCode::BAD_REQUEST,
193 "quote-capability accepts either symbol or market+code, not both".to_string(),
194 ));
195 }
196 if let Some(symbol) = query.symbol {
197 let symbol = symbol.trim().to_string();
198 futu_qot::symbol::parse_symbol_parts(&symbol).map_err(|e| {
199 api_error(
200 StatusCode::BAD_REQUEST,
201 format!("invalid quote-capability symbol: {e}"),
202 )
203 })?;
204 return Ok(symbol);
205 }
206
207 let market = query
208 .market
209 .as_deref()
210 .map(str::trim)
211 .filter(|v| !v.is_empty());
212 let code = query
213 .code
214 .as_deref()
215 .map(str::trim)
216 .filter(|v| !v.is_empty());
217 let (Some(market), Some(code)) = (market, code) else {
218 return Err(api_error(
219 StatusCode::BAD_REQUEST,
220 "quote-capability requires symbol=MARKET.CODE or market+code".to_string(),
221 ));
222 };
223
224 let symbol = if let Ok(raw_market) = market.parse::<i32>() {
225 let qot_market = futu_qot::QotMarket::from_i32(raw_market);
226 if qot_market == futu_qot::QotMarket::Unknown {
227 return Err(api_error(
228 StatusCode::BAD_REQUEST,
229 format!("invalid quote-capability market {raw_market}"),
230 ));
231 }
232 futu_qot::symbol::format_symbol(&futu_qot::Security::new(qot_market, code.to_string()))
233 } else {
234 format!("{}.{}", market.to_ascii_uppercase(), code)
235 };
236 futu_qot::symbol::parse_symbol_parts(&symbol).map_err(|e| {
237 api_error(
238 StatusCode::BAD_REQUEST,
239 format!("invalid quote-capability market/code: {e}"),
240 )
241 })?;
242 Ok(symbol)
243}
244
245fn format_sys_command_error_message(label: &str, ret_type: i32, ret_msg: Option<&str>) -> String {
246 let ret_msg = ret_msg
247 .filter(|msg| !msg.is_empty())
248 .unwrap_or("<missing ret_msg>");
249 format!("{label} ret_type={ret_type} msg={ret_msg}")
250}
251
252async fn dispatch_proto<Req, Rsp>(
253 state: &RestState,
254 proto_id: u32,
255 req: Req,
256 label: &str,
257) -> Result<Rsp, (StatusCode, Json<Value>)>
258where
259 Req: Message,
260 Rsp: Message + Default,
261{
262 let incoming = IncomingRequest::builder(
263 state.next_conn_id(),
264 proto_id,
265 state.next_serial(),
266 ProtoFmtType::Protobuf,
267 Bytes::from(req.encode_to_vec()),
268 )
269 .build();
270 let resp_bytes = state
271 .router
272 .dispatch(incoming.conn_id, &incoming)
273 .await
274 .ok_or_else(|| {
275 api_error(
276 StatusCode::INTERNAL_SERVER_ERROR,
277 format!("{label}: handler returned no response"),
278 )
279 })?;
280 Rsp::decode(Bytes::from(resp_bytes)).map_err(|e| {
281 api_error(
282 StatusCode::INTERNAL_SERVER_ERROR,
283 format!("decode {label}: {e}"),
284 )
285 })
286}
287
288fn api_error(status: StatusCode, message: String) -> (StatusCode, Json<Value>) {
289 (
290 status,
291 Json(serde_json::json!({
292 "ret_type": -1,
293 "ret_msg": message,
294 })),
295 )
296}
297
298pub async fn get_delay_statistics(State(state): State<RestState>) -> RawApiResult {
300 let body = default_request_body_json();
301 adapter::proto_request_raw::<get_delay_statistics::Request, get_delay_statistics::Response>(
302 &state,
303 proto_id::GET_DELAY_STATISTICS,
304 Some(body),
305 )
306 .await
307}
308
309pub async fn get_delay_statistics_post(
315 State(state): State<RestState>,
316 Json(body): Json<Value>,
317) -> RawApiResult {
318 adapter::proto_request_raw::<get_delay_statistics::Request, get_delay_statistics::Response>(
319 &state,
320 proto_id::GET_DELAY_STATISTICS,
321 Some(body),
322 )
323 .await
324}
325
326pub async fn ping(State(state): State<RestState>) -> ApiResult {
334 let ok = adapter::proto_request::<get_global_state::Request, get_global_state::Response>(
337 &state,
338 proto_id::GET_GLOBAL_STATE,
339 None,
340 )
341 .await
342 .is_ok();
343
344 Ok(Json(serde_json::json!({
345 "ok": ok,
346 "version": env!("CARGO_PKG_VERSION"),
347 "gateway": "futu-opend-rs",
348 })))
349}
350
351pub async fn push_subscriber_info(
374 State(state): State<RestState>,
375) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
376 if let Some(ref provider) = state.push_health_snapshot_provider {
378 let health = provider();
379 return Ok(Json(serde_json::json!({
380 "ret_type": 0,
381 "ret_msg": "success",
382 "push_health": health,
383 "recommendations": [
384 {
385 "purpose": "查订阅列表 + 全局 quota",
386 "endpoint": "POST /api/query-subscription -d '{}'",
387 "note": "v1.4.83 起默认 all-conn 视图"
388 },
389 {
390 "purpose": "接收 push 数据(quote / tick / orderbook 等)",
391 "endpoint": "WebSocket /ws (支持 Bearer Token 握手)"
392 }
393 ],
394 })));
395 }
396 Err((
400 StatusCode::SERVICE_UNAVAILABLE,
401 Json(serde_json::json!({
402 "ret_type": -1,
403 "ret_msg": "push health snapshot provider not wired (internal setup bug)",
404 "recommendations": [
405 {
406 "purpose": "查订阅列表 + 全局 quota",
407 "endpoint": "POST /api/query-subscription -d '{}'"
408 },
409 {
410 "purpose": "接收 push 数据",
411 "endpoint": "WebSocket /ws"
412 }
413 ],
414 })),
415 ))
416}
417
418pub async fn unsub_acc_push(
434 State(state): State<RestState>,
435 rec: Option<Extension<Arc<futu_auth::KeyRecord>>>,
436 Json(mut body): Json<Value>,
437) -> ApiResult {
438 if rec.is_none() {
442 futu_auth::audit::reject(
443 "rest",
444 "/api/unsub-acc-push",
445 "<legacy>",
446 "unsub-acc-push not supported in legacy mode (no keys.json)",
447 );
448 return Err((
449 axum::http::StatusCode::FORBIDDEN,
450 Json(serde_json::json!({
451 "error": "/api/unsub-acc-push: legacy mode (no keys.json) does not support per-key sub state. \
452 Configure keys.json and pass Bearer token to enable.",
453 "ret_type": -1,
454 "hint": "v1.4.103 B9: legacy mode previously returned silent success without revoking. Now loud-reject to surface the limitation."
455 })),
456 ));
457 }
458 crate::adapter::normalize_json_keys_snake_case(&mut body);
460
461 let acc_ids = match crate::routes::trd::extract_acc_id_list(&body) {
463 Ok(acc_ids) => acc_ids,
464 Err(reason) => {
465 let key_id = rec
466 .as_deref()
467 .map(|r| r.as_ref().id.clone())
468 .unwrap_or_else(|| "<legacy>".to_string());
469 futu_auth::audit::reject("rest", "/api/unsub-acc-push", &key_id, &reason);
470 return Err((
471 axum::http::StatusCode::BAD_REQUEST,
472 Json(serde_json::json!({
473 "error": format!("/api/unsub-acc-push: {reason}")
474 })),
475 ));
476 }
477 };
478 if let Err(reason) = crate::routes::trd::validate_sub_acc_push_acc_ids(&acc_ids) {
479 let key_id = rec
480 .as_deref()
481 .map(|r| r.as_ref().id.clone())
482 .unwrap_or_else(|| "<legacy>".to_string());
483 futu_auth::audit::reject("rest", "/api/unsub-acc-push", &key_id, reason);
484 return Err((
485 axum::http::StatusCode::BAD_REQUEST,
486 Json(serde_json::json!({
487 "error": format!("/api/unsub-acc-push: {reason}")
488 })),
489 ));
490 }
491
492 crate::routes::trd::check_per_acc_rate_for_caller(
497 &state.counters,
498 rec.as_deref().map(|r| r.as_ref()),
499 &acc_ids,
500 "/api/unsub-acc-push",
501 )?;
502
503 let daemon_resp: Json<serde_json::Value> = Json(serde_json::json!({
508 "ret_type": 0,
509 "ret_msg": serde_json::Value::Null,
510 "err_code": serde_json::Value::Null,
511 "s2c": {}
512 }));
513
514 if let Some(rec_ref) = rec.as_deref() {
520 let key_id = rec_ref.as_ref().id.clone();
521 crate::adapter::with_rest_acc_subscriptions_write(&state.rest_acc_subscriptions, |subs| {
522 let entry = subs.entry(key_id).or_default();
525 for &acc_id in &acc_ids {
526 entry.remove(&acc_id);
527 }
528 });
530 }
531
532 Ok(daemon_resp)
533}
534
535#[derive(Debug, Deserialize, Default)]
559#[serde(deny_unknown_fields)]
560pub struct TokenStateQuery {
561 pub app_id: Option<String>,
563}
564
565pub async fn get_token_state(
566 State(state): State<RestState>,
567 Query(q): Query<TokenStateQuery>,
568 body: Option<Json<Value>>,
569) -> RawApiResult {
570 let body_val = match body {
572 Some(Json(mut v)) => {
573 if let Some(qs_app) = q.app_id.as_ref()
575 && let Some(map) = v.as_object_mut()
576 {
577 let c2s_has = map
578 .get("c2s")
579 .and_then(|c| c.as_object())
580 .is_some_and(|c| c.contains_key("app_id") || c.contains_key("appId"));
581 let top_has = map.contains_key("app_id") || map.contains_key("appId");
582 if !c2s_has && !top_has {
583 map.entry("c2s".to_string())
584 .or_insert_with(|| serde_json::json!({}))
585 .as_object_mut()
586 .map(|c| {
587 c.insert(
588 "app_id".to_string(),
589 serde_json::Value::String(qs_app.clone()),
590 )
591 });
592 }
593 }
594 Some(v)
595 }
596 None => {
597 q.app_id
599 .as_ref()
600 .map(|app_id| serde_json::json!({"c2s": {"app_id": app_id}}))
601 }
602 };
603 adapter::proto_request_raw::<
604 futu_token_state::DaemonGetTokenStateReq,
605 futu_token_state::DaemonGetTokenStateRsp,
606 >(&state, proto_id::GET_TOKEN_STATE, body_val)
607 .await
608}
609
610#[cfg(test)]
611mod tests;