2026-06-17 20:49:17 +08:00
|
|
|
|
//! 前端(React)专用接口。挂 CORS,仅放行前端开发源。
|
|
|
|
|
|
|
2026-06-18 20:13:18 +08:00
|
|
|
|
use std::net::SocketAddr;
|
|
|
|
|
|
|
2026-06-18 00:00:38 +08:00
|
|
|
|
use axum::{
|
|
|
|
|
|
Json, Router,
|
2026-06-18 20:13:18 +08:00
|
|
|
|
extract::{ConnectInfo, State},
|
|
|
|
|
|
http::{HeaderMap, Method, StatusCode, header},
|
2026-06-18 00:00:38 +08:00
|
|
|
|
routing::{get, post},
|
|
|
|
|
|
};
|
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
2026-06-18 20:13:18 +08:00
|
|
|
|
use serde_json::{Value, json};
|
2026-06-17 20:49:17 +08:00
|
|
|
|
use tower_http::cors::{AllowOrigin, CorsLayer};
|
|
|
|
|
|
|
2026-06-18 20:13:18 +08:00
|
|
|
|
use crate::{monitor::Stats, state::AppState, weather::WeatherOutcome};
|
2026-06-17 20:49:17 +08:00
|
|
|
|
|
|
|
|
|
|
pub fn router(state: AppState) -> Router {
|
2026-06-18 20:13:18 +08:00
|
|
|
|
// CORS:只有浏览器会校验。放行前端开发源(Vite 默认 5173,被占用时会用 5174)。
|
2026-06-18 00:00:38 +08:00
|
|
|
|
// 登录是 POST + JSON,所以要额外放行 POST 方法与 Content-Type 请求头,
|
|
|
|
|
|
// 否则浏览器的预检(OPTIONS)会被拦下。
|
2026-06-17 20:49:17 +08:00
|
|
|
|
let cors = CorsLayer::new()
|
|
|
|
|
|
.allow_origin(AllowOrigin::list([
|
|
|
|
|
|
"http://localhost:5173".parse().unwrap(),
|
|
|
|
|
|
"http://127.0.0.1:5173".parse().unwrap(),
|
2026-06-18 20:13:18 +08:00
|
|
|
|
"http://localhost:5174".parse().unwrap(),
|
|
|
|
|
|
"http://127.0.0.1:5174".parse().unwrap(),
|
2026-06-17 20:49:17 +08:00
|
|
|
|
]))
|
2026-06-18 00:00:38 +08:00
|
|
|
|
.allow_methods([Method::GET, Method::POST])
|
|
|
|
|
|
.allow_headers([header::CONTENT_TYPE]);
|
2026-06-17 20:49:17 +08:00
|
|
|
|
|
|
|
|
|
|
Router::new()
|
|
|
|
|
|
.route("/api/web/stats", get(stats))
|
2026-06-18 00:00:38 +08:00
|
|
|
|
.route("/api/web/login", post(login))
|
2026-06-18 20:13:18 +08:00
|
|
|
|
.route("/api/web/weather", get(weather))
|
2026-06-17 20:49:17 +08:00
|
|
|
|
.layer(cors)
|
|
|
|
|
|
.with_state(state)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 返回最新系统状态快照。
|
|
|
|
|
|
async fn stats(State(state): State<AppState>) -> Json<Stats> {
|
|
|
|
|
|
let snapshot = state.snapshot.read().await.clone();
|
|
|
|
|
|
Json(snapshot)
|
|
|
|
|
|
}
|
2026-06-18 00:00:38 +08:00
|
|
|
|
|
|
|
|
|
|
/// 登录请求体:用户名 + 明文密码(开发期走 HTTP;生产应套 HTTPS)。
|
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
|
struct LoginRequest {
|
|
|
|
|
|
username: String,
|
|
|
|
|
|
password: String,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 登录成功响应。`token` 目前只是给前端持久化「登录态」用的标识,
|
|
|
|
|
|
/// 暂无受保护接口去校验它;将来加鉴权中间件时再赋予真实含义。
|
|
|
|
|
|
#[derive(Serialize)]
|
|
|
|
|
|
struct LoginResponse {
|
|
|
|
|
|
ok: bool,
|
|
|
|
|
|
username: String,
|
|
|
|
|
|
token: String,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 校验用户名/密码。成功 200 + token,失败 401。
|
|
|
|
|
|
async fn login(
|
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
|
Json(req): Json<LoginRequest>,
|
|
|
|
|
|
) -> Result<Json<LoginResponse>, StatusCode> {
|
|
|
|
|
|
if state.users.verify(&req.username, &req.password) {
|
|
|
|
|
|
Ok(Json(LoginResponse {
|
|
|
|
|
|
ok: true,
|
|
|
|
|
|
username: req.username,
|
|
|
|
|
|
token: crate::auth::issue_token(),
|
|
|
|
|
|
}))
|
|
|
|
|
|
} else {
|
|
|
|
|
|
Err(StatusCode::UNAUTHORIZED)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-18 20:13:18 +08:00
|
|
|
|
|
|
|
|
|
|
/// 天气 + 定位:高德按访问者 IP 定位取城市,和风取实时天气。
|
|
|
|
|
|
/// 内部自带每日限流(高德 300 / 和风 900,北京时间 0 点归零)与短期缓存。
|
|
|
|
|
|
async fn weather(
|
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
|
|
|
|
headers: HeaderMap,
|
|
|
|
|
|
) -> Json<Value> {
|
|
|
|
|
|
let ip = client_ip(&headers, addr);
|
|
|
|
|
|
let outcome = state.weather.get(&ip).await;
|
|
|
|
|
|
|
|
|
|
|
|
let q = state.weather.quota_snapshot();
|
|
|
|
|
|
let quota = json!({
|
|
|
|
|
|
"amap": { "remaining": q.amap_remaining, "limit": q.amap_limit },
|
|
|
|
|
|
"qweather": { "remaining": q.qweather_remaining, "limit": q.qweather_limit },
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
let body = match outcome {
|
|
|
|
|
|
WeatherOutcome::Fresh(d) => json!({
|
|
|
|
|
|
"ok": true, "cached": false,
|
|
|
|
|
|
"city": d.city, "text": d.text, "temp": d.temp, "icon": d.icon, "obsTime": d.obs_time,
|
|
|
|
|
|
"quota": quota,
|
|
|
|
|
|
}),
|
|
|
|
|
|
WeatherOutcome::Cached(d) => json!({
|
|
|
|
|
|
"ok": true, "cached": true,
|
|
|
|
|
|
"city": d.city, "text": d.text, "temp": d.temp, "icon": d.icon, "obsTime": d.obs_time,
|
|
|
|
|
|
"quota": quota,
|
|
|
|
|
|
}),
|
|
|
|
|
|
WeatherOutcome::QuotaExceeded { provider } => json!({
|
|
|
|
|
|
"ok": false, "reason": "quota_exceeded", "provider": provider,
|
|
|
|
|
|
"quota": quota,
|
|
|
|
|
|
}),
|
|
|
|
|
|
WeatherOutcome::NotConfigured => json!({
|
|
|
|
|
|
"ok": false, "reason": "not_configured",
|
|
|
|
|
|
}),
|
|
|
|
|
|
WeatherOutcome::Failed(message) => json!({
|
|
|
|
|
|
"ok": false, "reason": "failed", "message": message,
|
|
|
|
|
|
}),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
Json(body)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 取访问者真实 IP:优先反代透传的 `X-Forwarded-For` 首段,否则用对端 socket 地址。
|
|
|
|
|
|
fn client_ip(headers: &HeaderMap, addr: SocketAddr) -> String {
|
|
|
|
|
|
if let Some(xff) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) {
|
|
|
|
|
|
if let Some(first) = xff.split(',').next() {
|
|
|
|
|
|
let ip = first.trim();
|
|
|
|
|
|
if !ip.is_empty() {
|
|
|
|
|
|
return ip.to_string();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
addr.ip().to_string()
|
|
|
|
|
|
}
|