//! ## 和风天气查询工具(Rust 原生实现) //! //! 通过和风天气 API 查询实时天气、每日预报和逐小时预报。 //! //! ### API 端点 //! - 城市查询: `GET /geo/v2/city/lookup?location=城市名` → 获取 city_id //! - 实时天气: `GET /v7/weather/now?location=city_id` //! - 每日预报: `GET /v7/weather/{3,7,10,15,30}d?location=id` //! - 逐时预报: `GET /v7/weather/{24,72,168}h?location=id` //! //! ### 认证方式 //! 使用 EdDSA (Ed25519) JWT 进行 API 认证。 //! 密钥对存放在 `qweather/` 目录下。 //! //! ### 环境变量 //! - `QWEATHER_API_HOST` — API 地址(默认 `api.qweather.com`) //! - `QWEATHER_JWT_KEY_ID` — JWT Key ID //! - `QWEATHER_JWT_PROJECT_ID` — 项目 ID //! - `QWEATHER_JWT_PRIVATE_KEY_FILE` — Ed25519 私钥路径 //! //! ### 缓存策略 //! city_id 在内存中缓存 1 小时(TTL),减少重复查询。 use base64::{Engine as _, engine::general_purpose}; use chrono::Utc; use ed25519_dalek::pkcs8::DecodePrivateKey; use ed25519_dalek::{Signer, SigningKey}; use reqwest::Client; use serde::Deserialize; use std::collections::HashMap; use std::sync::Mutex; use std::time::{Duration, Instant}; use crate::tools::types::{RiskLevel, SkillResult, SkillSpec}; // ═══════════════════════════════════════════════ // JWT 认证 // ═══════════════════════════════════════════════ fn base64url(data: &[u8]) -> String { general_purpose::URL_SAFE_NO_PAD.encode(data) } fn generate_jwt() -> Result { let key_file = std::env::var("QWEATHER_JWT_PRIVATE_KEY_FILE").unwrap_or_else(|_| { dirs::home_dir() .unwrap_or_else(|| std::path::PathBuf::from(".")) .join(".ias") .join("qweather") .join("ed25519-private.pem") .to_string_lossy() .to_string() }); let pem = std::fs::read_to_string(&key_file) .map_err(|e| format!("读取密钥文件 {} 失败: {}", key_file, e))?; let signing_key = SigningKey::from_pkcs8_pem(&pem) .map_err(|e| format!("解析 Ed25519 密钥失败: {e}"))?; let key_id = std::env::var("QWEATHER_JWT_KEY_ID").unwrap_or_default(); let project_id = std::env::var("QWEATHER_JWT_PROJECT_ID").unwrap_or_default(); let ttl: i64 = std::env::var("QWEATHER_JWT_TTL_SECONDS") .ok() .and_then(|s| s.parse().ok()) .unwrap_or(3600); let now = Utc::now().timestamp(); // Header let header = serde_json::json!({"alg": "EdDSA", "kid": key_id}); let header_b64 = base64url(header.to_string().as_bytes()); // Payload let payload = serde_json::json!({ "sub": project_id, "iat": now - 30, "exp": now + ttl, }); let payload_b64 = base64url(payload.to_string().as_bytes()); // Sign let message = format!("{}.{}", header_b64, payload_b64); let signature = signing_key .try_sign(message.as_bytes()) .map_err(|e| format!("JWT 签名失败: {e}"))?; Ok(format!( "{}.{}.{}", header_b64, payload_b64, base64url(&signature.to_bytes()) )) } // ═══════════════════════════════════════════════ // API 响应类型 // ═══════════════════════════════════════════════ #[derive(Deserialize)] struct CityLookupResponse { code: String, #[serde(default)] location: Vec, } #[derive(Deserialize)] #[allow(dead_code)] struct CityLocation { id: String, name: String, #[serde(default)] adm1: String, #[serde(default)] adm2: String, } #[derive(Deserialize)] struct WeatherNowResponse { code: String, #[serde(default)] now: Option, } #[derive(Deserialize)] struct WeatherNow { #[serde(default)] temp: String, #[serde(rename = "feelsLike", default)] feels_like: String, #[serde(default)] text: String, #[serde(rename = "windDir", default)] wind_dir: String, #[serde(rename = "windScale", default)] wind_scale: String, #[serde(rename = "windSpeed", default)] wind_speed: String, #[serde(default)] humidity: String, #[serde(default)] precip: String, #[serde(default)] pressure: String, #[serde(default)] vis: String, } #[derive(Deserialize)] struct DailyForecastResponse { code: String, #[serde(default)] daily: Vec, } #[derive(Deserialize)] struct DailyForecast { #[serde(rename = "fxDate", default)] fx_date: String, #[serde(rename = "tempMax", default)] temp_max: String, #[serde(rename = "tempMin", default)] temp_min: String, #[serde(rename = "textDay", default)] text_day: String, #[serde(rename = "textNight", default)] text_night: String, #[serde(rename = "windDirDay", default)] wind_dir_day: String, #[serde(rename = "windScaleDay", default)] wind_scale_day: String, #[serde(default)] humidity: String, #[serde(rename = "uvIndex", default)] uv_index: String, } #[derive(Deserialize)] struct HourlyForecastResponse { code: String, #[serde(default)] hourly: Vec, } #[derive(Deserialize)] struct HourlyForecast { #[serde(rename = "fxTime", default)] fx_time: String, #[serde(default)] temp: String, #[serde(default)] text: String, #[serde(rename = "windDir", default)] wind_dir: String, #[serde(rename = "windScale", default)] wind_scale: String, #[serde(default)] humidity: String, #[serde(default)] pop: String, #[serde(default)] precip: String, } // ═══════════════════════════════════════════════ // 城市 ID 缓存 // ═══════════════════════════════════════════════ /// 持久化 city_id 映射,减少 API 调用 use std::sync::LazyLock; static CITY_CACHE: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); const CACHE_TTL: Duration = Duration::from_secs(3600); fn get_cached_city(query: &str) -> Option { let cache = CITY_CACHE.lock().unwrap(); cache.get(query).and_then(|(id, ts)| { if ts.elapsed() < CACHE_TTL { Some(id.clone()) } else { None } }) } fn cache_city(query: String, city_id: String) { let mut cache = CITY_CACHE.lock().unwrap(); cache.insert(query, (city_id, Instant::now())); } // ═══════════════════════════════════════════════ // HTTP 客户端 // ═══════════════════════════════════════════════ struct QWeatherClient { http: Client, jwt: String, api_host: String, } impl QWeatherClient { async fn new() -> Result { let jwt = generate_jwt()?; let api_host = std::env::var("QWEATHER_API_HOST").unwrap_or_else(|_| "api.qweather.com".into()); Ok(Self { http: Client::builder() .timeout(Duration::from_secs(10)) .build() .map_err(|e| format!("创建 HTTP client 失败: {}", e))?, jwt, api_host, }) } /// 城市查询 → LocationID async fn lookup_city(&self, location: &str) -> Result { // 检查缓存 if let Some(id) = get_cached_city(location) { tracing::debug!("🏙️ 城市缓存命中: {} → {}", location, id); return Ok(id); } let resp: CityLookupResponse = self .http .get("https://geoapi.qweather.com/v2/city/lookup") .query(&[("location", location), ("number", "1")]) .header("Authorization", format!("Bearer {}", self.jwt)) .send() .await .map_err(|e| format!("城市查询请求失败: {}", e))? .json() .await .map_err(|e| format!("解析城市查询响应失败: {}", e))?; if resp.code != "200" { return Err(format!("城市查询 API 返回: {}", resp.code)); } let city = resp .location .first() .ok_or_else(|| format!("未找到城市: {}", location))?; let id = city.id.clone(); cache_city(location.to_string(), id.clone()); tracing::debug!("🏙️ 城市查询: {} → {} ({})", location, city.name, id); Ok(id) } /// 实时天气 async fn weather_now(&self, city_id: &str) -> Result { let resp: WeatherNowResponse = self .http .get(format!("https://{}/v7/weather/now", self.api_host)) .query(&[("location", city_id), ("lang", "zh"), ("unit", "m")]) .header("Authorization", format!("Bearer {}", self.jwt)) .send() .await .map_err(|e| format!("实时天气请求失败: {}", e))? .json() .await .map_err(|e| format!("解析实时天气响应失败: {}", e))?; if resp.code != "200" { return Err(format!("实时天气 API 返回: {}", resp.code)); } let now = resp.now.ok_or("实时天气数据为空")?; Ok(serde_json::json!({ "temp": now.temp, "feelsLike": now.feels_like, "text": now.text, "windDir": now.wind_dir, "windScale": now.wind_scale, "windSpeed": now.wind_speed, "humidity": now.humidity, "precip": now.precip, "pressure": now.pressure, "vis": now.vis, })) } /// 每日预报 async fn daily_forecast(&self, city_id: &str, days: u32) -> Result { let d = match days { 0..=3 => "3d", 4..=7 => "7d", 8..=10 => "10d", 11..=15 => "15d", _ => "30d", }; let resp: DailyForecastResponse = self .http .get(format!("https://{}/v7/weather/{}", self.api_host, d)) .query(&[("location", city_id), ("lang", "zh"), ("unit", "m")]) .header("Authorization", format!("Bearer {}", self.jwt)) .send() .await .map_err(|e| format!("每日预报请求失败: {}", e))? .json() .await .map_err(|e| format!("解析每日预报响应失败: {}", e))?; if resp.code != "200" { return Err(format!("每日预报 API 返回: {}", resp.code)); } let forecast: Vec = resp .daily .into_iter() .take(days as usize) .map(|d| { serde_json::json!({ "date": d.fx_date, "tempMax": d.temp_max, "tempMin": d.temp_min, "textDay": d.text_day, "textNight": d.text_night, "windDirDay": d.wind_dir_day, "windScaleDay": d.wind_scale_day, "humidity": d.humidity, "uvIndex": d.uv_index, }) }) .collect(); Ok(serde_json::Value::Array(forecast)) } /// 逐时预报 async fn hourly_forecast( &self, city_id: &str, hours: u32, ) -> Result { let h = match hours { 0..=24 => "24h", 25..=72 => "72h", _ => "168h", }; let resp: HourlyForecastResponse = self .http .get(format!("https://{}/v7/weather/{}", self.api_host, h)) .query(&[("location", city_id), ("lang", "zh"), ("unit", "m")]) .header("Authorization", format!("Bearer {}", self.jwt)) .send() .await .map_err(|e| format!("逐时预报请求失败: {}", e))? .json() .await .map_err(|e| format!("解析逐时预报响应失败: {}", e))?; if resp.code != "200" { return Err(format!("逐时预报 API 返回: {}", resp.code)); } let forecast: Vec = resp .hourly .into_iter() .take(hours as usize) .map(|h| { serde_json::json!({ "time": h.fx_time, "temp": h.temp, "text": h.text, "windDir": h.wind_dir, "windScale": h.wind_scale, "humidity": h.humidity, "pop": h.pop, "precip": h.precip, }) }) .collect(); Ok(serde_json::Value::Array(forecast)) } } // ═══════════════════════════════════════════════ // 公开接口 — 3 个独立工具 // ═══════════════════════════════════════════════ /// 1. weather_now — 实时天气 pub fn now_spec() -> SkillSpec { SkillSpec { name: "weather_now".into(), description: "查询指定城市的实时天气(温度、体感温度、天气状况、风向风力、湿度、降水量、气压、能见度)。参数 location 为城市名".into(), risk_level: RiskLevel::Low, parameters: serde_json::json!({ "type": "object", "properties": { "location": {"type": "string", "description": "城市名,如 北京、上海"} }, "required": ["location"] }), timeout_secs: 10, } } pub async fn now_execute(params: serde_json::Value) -> SkillResult { let location = params .get("location") .and_then(|v| v.as_str()) .unwrap_or(""); if location.is_empty() { return SkillResult::error("请提供 location(城市名)"); } let client = match QWeatherClient::new().await { Ok(c) => c, Err(e) => return SkillResult::error(format!("初始化天气客户端失败: {}", e)), }; let city_id = match client.lookup_city(location).await { Ok(id) => id, Err(e) => return SkillResult::error(e), }; match client.weather_now(&city_id).await { Ok(data) => { let output = serde_json::json!({"location": location, "now": data}); SkillResult::ok_with_data(serde_json::to_string(&output).unwrap_or_default(), output) } Err(e) => SkillResult::error(e), } } /// 2. weather_forecast — 每日预报 pub fn forecast_spec() -> SkillSpec { SkillSpec { name: "weather_forecast".into(), description: "查询指定城市未来几天的天气预报(最高/最低温度、白天/夜间天气状况、风向风力、湿度、紫外线指数)。参数 location 为城市名,days 为预报天数(默认3,最大30)".into(), risk_level: RiskLevel::Low, parameters: serde_json::json!({ "type": "object", "properties": { "location": {"type": "string", "description": "城市名,如 北京、上海"}, "days": {"type": "integer", "description": "预报天数,默认3,最大30"} }, "required": ["location"] }), timeout_secs: 10, } } pub async fn forecast_execute(params: serde_json::Value) -> SkillResult { let location = params .get("location") .and_then(|v| v.as_str()) .unwrap_or(""); if location.is_empty() { return SkillResult::error("请提供 location(城市名)"); } let days = params .get("days") .and_then(|v| v.as_u64()) .unwrap_or(3) .min(30) .max(1) as u32; let client = match QWeatherClient::new().await { Ok(c) => c, Err(e) => return SkillResult::error(format!("初始化天气客户端失败: {}", e)), }; let city_id = match client.lookup_city(location).await { Ok(id) => id, Err(e) => return SkillResult::error(e), }; match client.daily_forecast(&city_id, days).await { Ok(data) => { let output = serde_json::json!({"location": location, "forecast": data}); SkillResult::ok_with_data(serde_json::to_string(&output).unwrap_or_default(), output) } Err(e) => SkillResult::error(e), } } /// 3. weather_hourly — 逐时预报 pub fn hourly_spec() -> SkillSpec { SkillSpec { name: "weather_hourly".into(), description: "查询指定城市未来逐小时的天气预报(温度、天气状况、风向风力、湿度、降水概率、降水量)。参数 location 为城市名,hours 为预报小时数(默认24,最大168)".into(), risk_level: RiskLevel::Low, parameters: serde_json::json!({ "type": "object", "properties": { "location": {"type": "string", "description": "城市名,如 北京、上海"}, "hours": {"type": "integer", "description": "预报小时数,默认24,最大168"} }, "required": ["location"] }), timeout_secs: 10, } } pub async fn hourly_execute(params: serde_json::Value) -> SkillResult { let location = params .get("location") .and_then(|v| v.as_str()) .unwrap_or(""); if location.is_empty() { return SkillResult::error("请提供 location(城市名)"); } let hours = params .get("hours") .and_then(|v| v.as_u64()) .unwrap_or(24) .min(168) .max(1) as u32; let client = match QWeatherClient::new().await { Ok(c) => c, Err(e) => return SkillResult::error(format!("初始化天气客户端失败: {}", e)), }; let city_id = match client.lookup_city(location).await { Ok(id) => id, Err(e) => return SkillResult::error(e), }; match client.hourly_forecast(&city_id, hours).await { Ok(data) => { let output = serde_json::json!({"location": location, "hourly": data}); SkillResult::ok_with_data(serde_json::to_string(&output).unwrap_or_default(), output) } Err(e) => SkillResult::error(e), } } #[cfg(test)] mod tests { use super::*; #[test] fn test_jwt_generation() { // 仅验证能生成非空 token(需要有效的密钥文件) let result = generate_jwt(); if result.is_ok() { let jwt = result.unwrap(); let parts: Vec<&str> = jwt.split('.').collect(); assert_eq!(parts.len(), 3, "JWT 应该有 3 部分"); assert!(!parts[0].is_empty()); assert!(!parts[1].is_empty()); assert!(!parts[2].is_empty()); } } /// 端到端测试:需要有效的 QWeather 环境变量 #[tokio::test] async fn test_now_execute_integration() { if std::env::var("QWEATHER_JWT_KEY_ID").is_err() { eprintln!("跳过集成测试: 未设置 QWeather 环境变量"); return; } let result = super::now_execute(serde_json::json!({"location": "合肥"})).await; assert!(result.success, "查询失败: {}", result.output); let data = result.data.expect("应有 data 字段"); assert_eq!(data["location"], "合肥"); assert!(data["now"].is_object(), "应有实时天气"); } #[tokio::test] async fn test_forecast_execute_integration() { if std::env::var("QWEATHER_JWT_KEY_ID").is_err() { eprintln!("跳过集成测试: 未设置 QWeather 环境变量"); return; } let result = super::forecast_execute(serde_json::json!({"location": "合肥", "days": 3})).await; assert!(result.success, "查询失败: {}", result.output); let data = result.data.expect("应有 data 字段"); assert_eq!(data["location"], "合肥"); assert!(data["forecast"].is_array(), "应有预报"); } }