SystemApplication/Server/src/weather.rs
cc a185e56c22 feat: 导航起始页 + 天气/定位 + 天气动态效果
起始页(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>
2026-06-18 20:13:18 +08:00

374 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 天气与定位服务:高德(定位 + 城市名)+ 和风(实时天气)。
//!
//! 设计要点:
//! - **密钥只在后端**(环境变量读取),前端永远拿不到,也无法绕过限流。
//! - **每日限流**按服务商各自计数:高德 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}"))
}