| 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<String>, |
| codes: Option<String>, |
| limit: Option<usize>, |
| } |
|
|
| #[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#"<!doctype html> |
| <html lang="zh-CN"> |
| <head><meta charset="utf-8"><title>Sina Real Time API</title></head> |
| <body style="font-family: system-ui, sans-serif; line-height: 1.6; max-width: 880px; margin: 40px auto; padding: 0 20px;"> |
| <h1>Sina Real Time API</h1> |
| <p>服务启动后会持续连接新浪财经 WebSocket,并在内存中保留每只股票的最新行情。</p> |
| <h2>Endpoints</h2> |
| <ul> |
| <li><code>GET /health</code>:服务状态</li> |
| <li><code>GET /codes</code>:当前监听的股票代码</li> |
| <li><code>GET /latest?limit=100</code>:最新缓存行情列表</li> |
| <li><code>GET /latest?code=sh600519</code>:单只股票最新行情</li> |
| <li><code>GET /latest/sh600519</code>:单只股票最新行情</li> |
| <li><code>GET /batch?codes=sh600519,sz000001</code>:多只股票最新行情</li> |
| </ul> |
| </body> |
| </html>"#, |
| ) |
| } |
|
|
| async fn health(State(state): State<AppState>) -> Json<HealthResponse> { |
| 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<AppState>) -> Json<Vec<String>> { |
| Json((*state.codes).clone()) |
| } |
|
|
| async fn latest( |
| State(state): State<AppState>, |
| Query(params): Query<LatestQuery>, |
| ) -> 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<Quote> = 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<AppState>, |
| Path(code): Path<String>, |
| ) -> impl IntoResponse { |
| latest_response_for_codes(&state, vec![code], false).await |
| } |
|
|
| async fn batch(State(state): State<AppState>, Query(params): Query<BatchQuery>) -> impl IntoResponse { |
| latest_response_for_codes(&state, split_codes(¶ms.codes), true).await |
| } |
|
|
| async fn latest_response_for_codes( |
| state: &AppState, |
| codes: Vec<String>, |
| 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<String> { |
| input |
| .split(',') |
| .map(|s| s.trim().to_string()) |
| .filter(|s| !s.is_empty()) |
| .collect() |
| } |
|
|