sinastock / src /api.rs
Spooker's picture
Upload 16 files
7c3e988 verified
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(&params.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()
}