//! 前端(React)专用接口。挂 CORS,仅放行前端开发源。 use std::net::SocketAddr; use axum::{ Json, Router, extract::{ConnectInfo, Query, State}, http::{HeaderMap, Method, StatusCode, header}, routing::{get, post}, }; use axum::extract::{DefaultBodyLimit, Path as AxPath}; use axum::response::{IntoResponse, Response}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use tower_http::cors::{AllowOrigin, CorsLayer}; use crate::photos::{SaveError, MAX_PHOTOS}; use crate::routes::extract::AuthUser; use crate::{ monitor::Stats, state::AppState, weather::{Located, WeatherOutcome}, }; pub fn router(state: AppState) -> Router { // CORS:只有浏览器会校验。放行前端开发源(Vite 默认 5173,被占用时会用 5174)。 // 登录是 POST + JSON,所以要额外放行 POST 方法与 Content-Type 请求头, // 否则浏览器的预检(OPTIONS)会被拦下。 let cors = CorsLayer::new() .allow_origin(AllowOrigin::list([ "http://localhost:5173".parse().unwrap(), "http://127.0.0.1:5173".parse().unwrap(), "http://localhost:5174".parse().unwrap(), "http://127.0.0.1:5174".parse().unwrap(), ])) .allow_methods([Method::GET, Method::POST, Method::DELETE]) .allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION]); Router::new() .route("/api/web/stats", get(stats)) .route("/api/web/gate", post(gate)) .route("/api/web/login", post(login)) .route("/api/web/weather", get(weather)) .route("/api/web/photos", get(list_photos).post(upload_photo)) .route("/api/web/photos/{id}", get(get_photo).delete(delete_photo)) .layer(DefaultBodyLimit::max(16 * 1024 * 1024)) .layer(cors) .with_state(state) } /// 返回最新系统状态快照。 async fn stats(State(state): State) -> Json { let snapshot = state.snapshot.read().await.clone(); Json(snapshot) } /// 入口门请求体:前端把 URL `#` 后那串 hash 发来校验。 #[derive(Deserialize)] struct GateRequest { hash: String, } /// 入口门响应。 #[derive(Serialize)] struct GateResponse { ok: bool, } /// 校验入口 hash 是否属于某个合法开发者。 /// /// 命中 200 + `{ok:true}`(前端据此放行到登录页);未命中返回 401。 /// 注意:这只决定“是否显示登录页”,**真正的身份校验仍在 `login`**。 async fn gate( State(state): State, Json(req): Json, ) -> Result, StatusCode> { if state.gate.allows(&req.hash).await { Ok(Json(GateResponse { ok: true })) } else { Err(StatusCode::UNAUTHORIZED) } } /// 登录请求体:用户名 + 明文密码(开发期走 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, Json(req): Json, ) -> Result, StatusCode> { if state.users.verify(&req.username, &req.password).await { Ok(Json(LoginResponse { ok: true, username: req.username.clone(), token: crate::auth::issue_token(&req.username), })) } else { Err(StatusCode::UNAUTHORIZED) } } /// 前端可选带上的定位参数:经纬度 + 城市名(来自 AMap.Geolocation 插件)。 /// 三者都缺省 → 后端退回高德 IP 定位兜底。 #[derive(Deserialize)] struct WeatherQuery { lon: Option, lat: Option, city: Option, } /// 天气 + 定位: /// - 前端带 `lon/lat`(AMap.Geolocation 定位结果)→ 直接用,只调和风。 /// - 未带 → 高德按访问者 IP 定位取城市作兜底。 /// /// 内部自带每日限流(高德 300 / 和风 900,北京时间 0 点归零)与短期缓存。 async fn weather( State(state): State, ConnectInfo(addr): ConnectInfo, headers: HeaderMap, Query(params): Query, ) -> Json { let ip = client_ip(&headers, addr); // 经纬度齐全才算前端定位成功;否则交给后端 IP 兜底。 let front = match (params.lon, params.lat) { (Some(lon), Some(lat)) => Some(Located::from_front(lon, lat, params.city)), _ => None, }; let outcome = state.weather.get(&ip, front).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() } /// 当前用户的图片 id 列表 + 数量上限。 #[derive(Serialize)] struct PhotoList { items: Vec, max: usize, } #[derive(Serialize)] struct PhotoItem { id: String, } async fn list_photos(AuthUser(user): AuthUser, State(state): State) -> Json { let items = state .photos .list(&user) .into_iter() .map(|id| PhotoItem { id }) .collect(); Json(PhotoList { items, max: MAX_PHOTOS, }) } /// 上传请求体:base64 data URL(前端 FileReader 读出的)。 #[derive(Deserialize)] struct UploadRequest { #[serde(rename = "dataUrl")] data_url: String, } #[derive(Serialize)] struct UploadResponse { id: String, } async fn upload_photo( AuthUser(user): AuthUser, State(state): State, Json(req): Json, ) -> Result<(StatusCode, Json), StatusCode> { match state.photos.save(&user, &req.data_url) { Ok(id) => Ok((StatusCode::CREATED, Json(UploadResponse { id }))), Err(SaveError::Full) => Err(StatusCode::CONFLICT), Err(SaveError::TooLarge) => Err(StatusCode::PAYLOAD_TOO_LARGE), Err(SaveError::BadType) => Err(StatusCode::UNSUPPORTED_MEDIA_TYPE), Err(SaveError::BadUser) => Err(StatusCode::BAD_REQUEST), Err(SaveError::Io) => Err(StatusCode::INTERNAL_SERVER_ERROR), } } async fn get_photo( AuthUser(user): AuthUser, State(state): State, AxPath(id): AxPath, ) -> Response { match state.photos.read(&user, &id) { Some((mime, bytes)) => ([(header::CONTENT_TYPE, mime)], bytes).into_response(), None => StatusCode::NOT_FOUND.into_response(), } } async fn delete_photo( AuthUser(user): AuthUser, State(state): State, AxPath(id): AxPath, ) -> StatusCode { if state.photos.delete(&user, &id) { StatusCode::NO_CONTENT } else { StatusCode::NOT_FOUND } }