起始页(StartPage) - 极简玻璃风导航页作未登录首页:时段问候、Bing 默认可切换搜索、10 个快捷位、右下齿轮进登录 - 昼夜背景自动切换(light/dark),按天气图标码 + 本地时间判断 - 天气动态效果(WeatherFx canvas):雨/雪/雷,克制风格,尊重 reduced-motion 天气与定位(前后端) - 后端 /api/web/weather:高德 IP 定位(城市+坐标) + 和风实时天气 - 和风用 JWT(EdDSA/Ed25519)认证,密钥走环境变量、仅后端持有 - 每日限流(高德 300、和风 900,北京 0 点归零) + 10 分钟按 IP 缓存 - 前端 useWeather + WeatherIcon(7 类极简 SVG) 导航与登录 - 浏览器返回键支持(History API);返回即登出 - 记住我:勾选存 localStorage、不勾存 sessionStorage,默认不勾 其他 - .gitignore 忽略 *.pem/*.env 等密钥 - 新增文档 docs/startpage-weather-location.md 及部署 spec/plan Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
374 lines
12 KiB
Rust
374 lines
12 KiB
Rust
//! 天气与定位服务:高德(定位 + 城市名)+ 和风(实时天气)。
|
||
//!
|
||
//! 设计要点:
|
||
//! - **密钥只在后端**(环境变量读取),前端永远拿不到,也无法绕过限流。
|
||
//! - **每日限流**按服务商各自计数:高德 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<String, CacheEntry>,
|
||
}
|
||
|
||
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<String, String> {
|
||
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<QWeatherAuth> {
|
||
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<String> {
|
||
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<String>,
|
||
qweather: Option<QWeatherAuth>,
|
||
qweather_host: String,
|
||
/// 内网/本地 IP 高德无法定位时的兜底坐标 "lon,lat"。
|
||
default_location: Option<String>,
|
||
default_city: Option<String>,
|
||
cache_ttl: Duration,
|
||
inner: Mutex<Inner>,
|
||
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<Located, String> {
|
||
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<Now, String> {
|
||
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<String> {
|
||
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::<f64>() / lons.len() as f64;
|
||
let lat = lats.iter().sum::<f64>() / lats.len() as f64;
|
||
Some(format!("{lon:.2},{lat:.2}"))
|
||
}
|