//! 天气与定位服务:高德(定位 + 城市名)+ 和风(实时天气)。 //! //! 设计要点: //! - **密钥只在后端**(环境变量读取),前端永远拿不到,也无法绕过限流。 //! - **每日限流**按服务商各自计数:高德 300/天、和风 900/天,北京时间 0 点归零。 //! 额度用完时返回 `QuotaExceeded`,由前端「表个态」。 //! - **短期缓存**(默认 10 分钟,按访问者 IP):前端轮询不会浪费上游额度。 //! - 城市名来自高德 IP 定位;和风只用来取天气文字/气温/图标码。 use std::collections::HashMap; use std::sync::Mutex; use std::time::{Duration, Instant}; use chrono::{Datelike, FixedOffset, Utc}; use jsonwebtoken::{Algorithm, EncodingKey, Header}; use serde::Serialize; /// 北京时区(UTC+8)下的“今天”序号(自公元元年的天数),跨 0 点即变化。 fn beijing_day() -> i32 { let tz = FixedOffset::east_opt(8 * 3600).expect("valid offset"); Utc::now().with_timezone(&tz).date_naive().num_days_from_ce() } /// 单服务商的每日额度计数器。 struct DailyQuota { limit: u32, used: u32, day: i32, } impl DailyQuota { fn new(limit: u32) -> Self { Self { limit, used: 0, day: beijing_day() } } /// 跨天则归零。 fn roll(&mut self) { let today = beijing_day(); if today != self.day { self.day = today; self.used = 0; } } fn has_budget(&mut self) -> bool { self.roll(); self.used < self.limit } fn consume(&mut self) { self.roll(); self.used = self.used.saturating_add(1); } fn remaining(&mut self) -> u32 { self.roll(); self.limit.saturating_sub(self.used) } } /// 对外的天气数据(城市来自高德,其余来自和风)。 #[derive(Clone)] pub struct WeatherData { pub city: String, pub text: String, pub temp: String, pub icon: String, pub obs_time: String, } /// 一次取数的结果。 pub enum WeatherOutcome { Fresh(WeatherData), Cached(WeatherData), /// 某服务商当日额度用尽。 QuotaExceeded { provider: &'static str }, /// 未配置密钥。 NotConfigured, /// 上游调用失败。 Failed(String), } /// 当日剩余额度快照(给前端展示/调试)。 pub struct QuotaSnapshot { pub amap_remaining: u32, pub amap_limit: u32, pub qweather_remaining: u32, pub qweather_limit: u32, } struct CacheEntry { at: Instant, data: WeatherData, } struct Inner { amap: DailyQuota, qweather: DailyQuota, cache: HashMap, } struct Located { city: String, /// "lon,lat" coord: String, } /// 和风 JWT 认证所需材料(控制台创建凭据时拿到)。 struct QWeatherAuth { /// Ed25519 私钥(PKCS#8 PEM)解析出的签名密钥 encoding_key: EncodingKey, /// 项目 ID → JWT 的 sub sub: String, /// 凭据 ID → JWT 头里的 kid kid: String, } /// 和风 JWT 载荷。 #[derive(Serialize)] struct QWeatherClaims { sub: String, iat: i64, exp: i64, } /// 用私钥签发一个短时效(15 分钟)的和风 JWT。 /// iat 回拨 30s 容忍时钟偏差。 fn sign_qweather_jwt(auth: &QWeatherAuth) -> Result { let now = Utc::now().timestamp(); let claims = QWeatherClaims { sub: auth.sub.clone(), iat: now - 30, exp: now + 900 }; let mut header = Header::new(Algorithm::EdDSA); header.kid = Some(auth.kid.clone()); jsonwebtoken::encode(&header, &claims, &auth.encoding_key).map_err(|e| e.to_string()) } /// 从环境变量装配和风 JWT 认证;任一缺失则返回 None(接口报 not_configured)。 fn load_qweather_auth() -> Option { let sub = std::env::var("QWEATHER_PROJECT_ID").ok().filter(|s| !s.is_empty())?; let kid = std::env::var("QWEATHER_KEY_ID").ok().filter(|s| !s.is_empty())?; let pem = qweather_private_key_pem()?; match EncodingKey::from_ed_pem(pem.as_bytes()) { Ok(encoding_key) => Some(QWeatherAuth { encoding_key, sub, kid }), Err(e) => { eprintln!("和风私钥解析失败: {e}"); None } } } /// 读取和风私钥:优先文件路径,其次直接给的 PEM 内容(支持 \n 转义)。 fn qweather_private_key_pem() -> Option { if let Some(path) = std::env::var("QWEATHER_PRIVATE_KEY_PATH").ok().filter(|s| !s.is_empty()) { return std::fs::read_to_string(path).ok(); } std::env::var("QWEATHER_PRIVATE_KEY") .ok() .filter(|s| !s.is_empty()) .map(|s| s.replace("\\n", "\n")) } struct Now { text: String, temp: String, icon: String, obs_time: String, } pub struct WeatherService { amap_key: Option, qweather: Option, qweather_host: String, /// 内网/本地 IP 高德无法定位时的兜底坐标 "lon,lat"。 default_location: Option, default_city: Option, cache_ttl: Duration, inner: Mutex, http: reqwest::Client, } fn env_u32(key: &str, default: u32) -> u32 { std::env::var(key).ok().and_then(|v| v.parse().ok()).unwrap_or(default) } impl WeatherService { pub fn from_env() -> Self { let amap_limit = env_u32("AMAP_DAILY_LIMIT", 300); let qweather_limit = env_u32("QWEATHER_DAILY_LIMIT", 900); let cache_secs = env_u32("WEATHER_CACHE_SECS", 600) as u64; Self { amap_key: std::env::var("AMAP_KEY").ok().filter(|s| !s.is_empty()), qweather: load_qweather_auth(), qweather_host: std::env::var("QWEATHER_HOST") .ok() .filter(|s| !s.is_empty()) .unwrap_or_else(|| "https://devapi.qweather.com".to_string()), default_location: std::env::var("WEATHER_DEFAULT_LOCATION").ok().filter(|s| !s.is_empty()), default_city: std::env::var("WEATHER_DEFAULT_CITY").ok().filter(|s| !s.is_empty()), cache_ttl: Duration::from_secs(cache_secs), inner: Mutex::new(Inner { amap: DailyQuota::new(amap_limit), qweather: DailyQuota::new(qweather_limit), cache: HashMap::new(), }), http: reqwest::Client::builder() .timeout(Duration::from_secs(8)) .build() .expect("build http client"), } } pub fn quota_snapshot(&self) -> QuotaSnapshot { let mut inner = self.inner.lock().unwrap(); QuotaSnapshot { amap_remaining: inner.amap.remaining(), amap_limit: inner.amap.limit, qweather_remaining: inner.qweather.remaining(), qweather_limit: inner.qweather.limit, } } /// 取天气:先查缓存,再查额度,最后才调上游。注意全程不跨 await 持锁。 pub async fn get(&self, ip: &str) -> WeatherOutcome { if self.amap_key.is_none() || self.qweather.is_none() { return WeatherOutcome::NotConfigured; } // 1) 命中新鲜缓存直接返回;否则预检两家额度(任一用尽即报)。 { let mut inner = self.inner.lock().unwrap(); if let Some(e) = inner.cache.get(ip) { if e.at.elapsed() < self.cache_ttl { return WeatherOutcome::Cached(e.data.clone()); } } if !inner.amap.has_budget() { return WeatherOutcome::QuotaExceeded { provider: "amap" }; } if !inner.qweather.has_budget() { return WeatherOutcome::QuotaExceeded { provider: "qweather" }; } } // 2) 高德定位(成功才计数) let loc = match self.locate(ip).await { Ok(l) => { self.inner.lock().unwrap().amap.consume(); l } Err(e) => return WeatherOutcome::Failed(format!("amap: {e}")), }; // 3) 和风实时天气(成功才计数) let now = match self.weather_now(&loc.coord).await { Ok(n) => { self.inner.lock().unwrap().qweather.consume(); n } Err(e) => return WeatherOutcome::Failed(format!("qweather: {e}")), }; let data = WeatherData { city: loc.city, text: now.text, temp: now.temp, icon: now.icon, obs_time: now.obs_time, }; self.inner .lock() .unwrap() .cache .insert(ip.to_string(), CacheEntry { at: Instant::now(), data: data.clone() }); WeatherOutcome::Fresh(data) } /// 高德 IP 定位:拿城市名 + 城市中心坐标。 /// 用 `serde_json::Value` 宽松解析(高德对空字段会返回 `[]` 而非字符串)。 async fn locate(&self, ip: &str) -> Result { let key = self.amap_key.as_ref().unwrap(); let url = format!("https://restapi.amap.com/v3/ip?key={key}&ip={ip}"); let v: serde_json::Value = self .http .get(&url) .send() .await .map_err(|e| e.to_string())? .json() .await .map_err(|e| e.to_string())?; let rect = v.get("rectangle").and_then(|x| x.as_str()).unwrap_or(""); let city = v.get("city").and_then(|x| x.as_str()).unwrap_or(""); let province = v.get("province").and_then(|x| x.as_str()).unwrap_or(""); if let Some(coord) = rectangle_center(rect) { let name = if !city.is_empty() { city.to_string() } else if !province.is_empty() { province.to_string() } else { "未知".to_string() }; return Ok(Located { city: name, coord }); } // 内网/本地 IP:高德定位为空 → 用兜底坐标(若配置了)。 if let Some(coord) = &self.default_location { return Ok(Located { city: self.default_city.clone().unwrap_or_else(|| "默认".to_string()), coord: coord.clone(), }); } Err("location unavailable (private IP? set WEATHER_DEFAULT_LOCATION)".to_string()) } /// 和风实时天气(JWT 认证:Authorization: Bearer)。 async fn weather_now(&self, coord: &str) -> Result { let auth = self.qweather.as_ref().unwrap(); let token = sign_qweather_jwt(auth)?; let host = self.qweather_host.trim_end_matches('/'); let url = format!("{host}/v7/weather/now?location={coord}"); let v: serde_json::Value = self .http .get(&url) .bearer_auth(token) .send() .await .map_err(|e| e.to_string())? .json() .await .map_err(|e| e.to_string())?; let code = v.get("code").and_then(|x| x.as_str()).unwrap_or(""); if code != "200" { return Err(format!("qweather code {code}")); } let now = v.get("now").ok_or("missing `now`")?; Ok(Now { text: now.get("text").and_then(|x| x.as_str()).unwrap_or("").to_string(), temp: now.get("temp").and_then(|x| x.as_str()).unwrap_or("").to_string(), icon: now.get("icon").and_then(|x| x.as_str()).unwrap_or("999").to_string(), obs_time: now.get("obsTime").and_then(|x| x.as_str()).unwrap_or("").to_string(), }) } } /// 把高德 rectangle "lon1,lat1;lon2,lat2" 求中心点,格式化成 "lon,lat"(两位小数)。 fn rectangle_center(rect: &str) -> Option { if rect.is_empty() { return None; } let mut lons = Vec::new(); let mut lats = Vec::new(); for corner in rect.split(';') { let mut it = corner.split(','); let lon: f64 = it.next()?.trim().parse().ok()?; let lat: f64 = it.next()?.trim().parse().ok()?; lons.push(lon); lats.push(lat); } if lons.is_empty() { return None; } let lon = lons.iter().sum::() / lons.len() as f64; let lat = lats.iter().sum::() / lats.len() as f64; Some(format!("{lon:.2},{lat:.2}")) }