use crate::models::{AppState, Quote}; use axum::extract::{Path, Query, State}; use axum::http::StatusCode; use axum::response::{Html, IntoResponse}; use axum::routing::get; use axum::{Json, Router}; use serde::{Deserialize, Serialize}; use serde_json::json; use tower_http::cors::CorsLayer; #[derive(Serialize)] struct HealthResponse { ok: bool, started_at: String, watched_codes: usize, cached_quotes: usize, } #[derive(Deserialize)] struct LatestQuery { code: Option, codes: Option, limit: Option, } #[derive(Deserialize)] struct BatchQuery { codes: String, } pub fn build_router(state: AppState) -> Router { Router::new() .route("/", get(index)) .route("/health", get(health)) .route("/codes", get(codes)) .route("/latest", get(latest)) .route("/latest/:code", get(latest_by_code)) .route("/batch", get(batch)) .with_state(state) .layer(CorsLayer::permissive()) } async fn index() -> Html<&'static str> { Html( r#" Sina Real Time API

Sina Real Time API

服务启动后会持续连接新浪财经 WebSocket,并在内存中保留每只股票的最新行情。

Endpoints

  • GET /health:服务状态
  • GET /codes:当前监听的股票代码
  • GET /latest?limit=100:最新缓存行情列表
  • GET /latest?code=sh600519:单只股票最新行情
  • GET /latest/sh600519:单只股票最新行情
  • GET /batch?codes=sh600519,sz000001:多只股票最新行情
"#, ) } async fn health(State(state): State) -> Json { let latest = state.latest.read().await; Json(HealthResponse { ok: true, started_at: state.started_at.clone(), watched_codes: state.codes.len(), cached_quotes: latest.len(), }) } async fn codes(State(state): State) -> Json> { Json((*state.codes).clone()) } async fn latest( State(state): State, Query(params): Query, ) -> impl IntoResponse { if let Some(code) = params.code { return latest_response_for_codes(&state, vec![code], false).await; } if let Some(codes) = params.codes { let codes = split_codes(&codes); return latest_response_for_codes(&state, codes, false).await; } let limit = params.limit.unwrap_or(100).min(10_000); let latest = state.latest.read().await; let mut rows: Vec = latest.values().cloned().collect(); rows.sort_by(|a, b| a.code.cmp(&b.code)); rows.truncate(limit); Json(rows).into_response() } async fn latest_by_code( State(state): State, Path(code): Path, ) -> impl IntoResponse { latest_response_for_codes(&state, vec![code], false).await } async fn batch(State(state): State, Query(params): Query) -> impl IntoResponse { latest_response_for_codes(&state, split_codes(¶ms.codes), true).await } async fn latest_response_for_codes( state: &AppState, codes: Vec, always_array: bool, ) -> axum::response::Response { let latest = state.latest.read().await; let mut found = Vec::new(); let mut missing = Vec::new(); for code in codes { match latest.get(&code) { Some(quote) => found.push(quote.clone()), None => missing.push(code), } } if found.is_empty() { return ( StatusCode::NOT_FOUND, Json(json!({ "error": "quote_not_ready", "message": "还没有收到这些代码的实时行情,可能是非交易时段、代码未监听,或上游暂未推送。", "missing": missing, })), ) .into_response(); } if always_array || found.len() > 1 { Json(json!({ "data": found, "missing": missing })).into_response() } else { Json(found.remove(0)).into_response() } } fn split_codes(input: &str) -> Vec { input .split(',') .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect() }