File size: 4,529 Bytes
7c3e988
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
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()
}