1d78567102
核心变更: - 删除 worker.rs + ipc.rs(废弃进程架构) - 删除 ias listen 路径(cmd_listen / listen_loop / ToolExecutor) - 合并 ias service → ias daemon --log-file - 修复 main.rs call_capability 不处理上下文工具的 bug MemoryStore 重构: - write_for 空内容防御 + 精确去重 - 缓存结构 Vec<String> → Vec<(i32, String)>(用数据库 id 标识) - 新增 delete_for / update_for 方法 + LLM 工具定义 - load_if_needed 避免每次消息查 DB - 缓存 TTL(1h) + 每用户 200 条上限 - read_for limit 参数(默认 30 条) daemon.rs: - execute_tool 统一为递归派发,消除 call_capability 重复路由 净减少约 1400 行代码,0 warning,20 测试通过
621 lines
20 KiB
Rust
621 lines
20 KiB
Rust
//! ## 和风天气查询工具(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<String, String> {
|
||
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<CityLocation>,
|
||
}
|
||
|
||
#[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<WeatherNow>,
|
||
}
|
||
|
||
#[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<DailyForecast>,
|
||
}
|
||
|
||
#[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<HourlyForecast>,
|
||
}
|
||
|
||
#[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<Mutex<HashMap<String, (String, Instant)>>> =
|
||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||
const CACHE_TTL: Duration = Duration::from_secs(3600);
|
||
|
||
fn get_cached_city(query: &str) -> Option<String> {
|
||
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<Self, String> {
|
||
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<String, String> {
|
||
// 检查缓存
|
||
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<serde_json::Value, String> {
|
||
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<serde_json::Value, String> {
|
||
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<serde_json::Value> = 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<serde_json::Value, String> {
|
||
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<serde_json::Value> = 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(), "应有预报");
|
||
}
|
||
}
|