Files
2026-08-11 22:07:55 +08:00

679 lines
24 KiB
Python
Raw Permalink 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.
"""QWeather client: JWT auth, real-time weather, city search, intelligent caching."""
import json
import requests
from datetime import datetime
from backend.time_util import now as tz_now, strftime as tz_strftime, strptime as tz_strptime, localize as tz_localize
from flask import current_app
from backend.models import get_db
from backend.jwt_util import sign_jwt, generate_key_pair
# QWeather API host (shared dev host, or user's dedicated host)
QWEATHER_API = "https://devapi.qweather.com"
QWEATHER_GEO_API = "https://geoapi.qweather.com"
# Cache TTL in seconds (default 30 min)
CACHE_TTL = 1800
# ─── Config ────────────────────────────────
def _uid() -> int:
"""Resolve the current user (request session or scheduler thread)."""
try:
from backend.auth import current_user_id
return current_user_id() or 0
except Exception:
return 0
def get_config(user_id: int = None) -> dict:
if user_id is None:
user_id = _uid()
db = get_db(current_app.config["DB_PATH"])
row = db.execute("SELECT * FROM weather_config WHERE user_id=?", (user_id,)).fetchone()
db.close()
return dict(row) if row else {"user_id": user_id}
def update_config(api_key: str = None,
city: str = None, lat: float = None, lon: float = None,
units: str = None, update_interval: int = None,
credential_id: str = None, project_id: str = None,
qweather_host: str = None, user_id: int = None):
if user_id is None:
user_id = _uid()
if qweather_host is not None and qweather_host.strip():
from backend.url_util import is_safe_https_host
if not is_safe_https_host(qweather_host):
raise ValueError("API Host 无效或不安全:仅允许公网域名(如 abc123.def.qweatherapi.com)")
db = get_db(current_app.config["DB_PATH"])
fields = []
vals = []
for key, val in [
("api_key", api_key),
("city", city), ("lat", lat), ("lon", lon),
("units", units), ("update_interval", update_interval),
("credential_id", credential_id), ("project_id", project_id),
("qweather_host", qweather_host),
]:
if val is not None:
fields.append(f"{key}=?")
vals.append(val)
row = db.execute("SELECT id FROM weather_config WHERE user_id=?", (user_id,)).fetchone()
if row is None:
db.execute("INSERT INTO weather_config (user_id) VALUES (?)", (user_id,))
if fields:
vals.append(user_id)
db.execute(f"UPDATE weather_config SET {', '.join(fields)} WHERE user_id=?", vals)
db.commit()
db.close()
def generate_weather_keys() -> dict:
"""Generate new Ed25519 key pair for QWeather JWT auth.
Returns:
dict with public_key (private key stored in DB)
"""
priv, pub = generate_key_pair()
user_id = _uid()
db = get_db(current_app.config["DB_PATH"])
row = db.execute("SELECT id FROM weather_config WHERE user_id=?", (user_id,)).fetchone()
if row is None:
db.execute("INSERT INTO weather_config (user_id) VALUES (?)", (user_id,))
db.execute(
"UPDATE weather_config SET jwt_private_key=?, jwt_public_key=? WHERE user_id=?",
(priv, pub, user_id)
)
db.commit()
db.close()
return {"public_key": pub}
# ─── JWT Token ─────────────────────────────
def _get_auth_header(cfg: dict) -> dict:
"""Build the Authorization header for QWeather API.
仅和风天气。优先 JWT(credential_id + project_id + jwt_private_key),
否则回退到和风旧版 API Key(X-QW-Api-Key)。
"""
priv_key = cfg.get("jwt_private_key", "")
kid = cfg.get("credential_id", "")
sub = cfg.get("project_id", "")
if priv_key and kid and sub:
token = sign_jwt(priv_key, kid, sub)
return {"Authorization": f"Bearer {token}"}
api_key = cfg.get("api_key", "")
if api_key:
return {"X-QW-Api-Key": api_key}
return {}
# ─── API Host ──────────────────────────────
def _api_host(cfg: dict) -> str:
host = cfg.get("qweather_host", "").strip()
from backend.url_util import is_safe_https_host
if host and is_safe_https_host(host):
return f"https://{host}"
return QWEATHER_API
def _geo_host(cfg: dict) -> str:
# If user has a dedicated host, GeoAPI is under the same host
host = cfg.get("qweather_host", "").strip()
from backend.url_util import is_safe_https_host
if host and is_safe_https_host(host):
return f"https://{host}"
return QWEATHER_GEO_API
# ─── City Lookup ───────────────────────────
def city_lookup(query: str, number: int = 10) -> list:
"""Search cities via QWeather GeoAPI.
Returns list of {name, id, lat, lon, adm1, adm2, country}
"""
cfg = get_config()
headers = _get_auth_header(cfg)
if not headers:
return []
host = _geo_host(cfg)
url = f"{host}/v2/city/lookup"
params = {
"location": query,
"number": min(number, 20),
"lang": "zh",
}
try:
resp = requests.get(url, params=params, headers=headers, timeout=8)
if resp.status_code != 200:
current_app.logger.error(f"City lookup failed: {resp.status_code} {resp.text}")
return []
data = resp.json()
if data.get("code") != "200":
return []
results = []
for loc in data.get("location", []):
results.append({
"name": loc.get("name", ""),
"id": loc.get("id", ""),
"lat": loc.get("lat", ""),
"lon": loc.get("lon", ""),
"adm1": loc.get("adm1", ""),
"adm2": loc.get("adm2", ""),
"country": loc.get("country", ""),
})
return results
except Exception as e:
current_app.logger.error(f"City lookup error: {e}")
return []
# ─── Weather Fetch ─────────────────────────
def fetch_weather(force: bool = False, user_id: int = None) -> dict:
"""Fetch real-time weather from QWeather and cache it.
Args:
force: If True, skip fallback cache on error — return error directly.
"""
if user_id is None:
user_id = _uid()
return _fetch_qweather(get_config(user_id), force=force, user_id=user_id)
def _fetch_qweather(cfg: dict, force: bool = False, user_id: int = None) -> dict:
"""Fetch weather from QWeather v7/weather/now."""
headers = _get_auth_header(cfg)
if not headers:
return {"error": "未配置 JWT 密钥或 API Key", "city": cfg.get("city", "")}
lat = cfg.get("lat") or 39.9042
lon = cfg.get("lon") or 116.4074
host = _api_host(cfg)
url = f"{host}/v7/weather/now"
params = {
"location": f"{lon:.2f},{lat:.2f}",
"lang": "zh",
}
try:
resp = requests.get(url, params=params, headers=headers, timeout=10)
if resp.status_code == 401:
current_app.logger.error("QWeather: JWT auth failed (401)")
return {"error": "JWT 认证失败,请检查密钥配置", "city": cfg.get("city", "")}
if resp.status_code == 403:
current_app.logger.error(f"QWeather: forbidden (403) body={resp.text[:200]}")
# Detect Invalid Host (shared dev domain deprecated)
if "invalid host" in resp.text.lower() or "unauthorized api host" in resp.text.lower():
return {"error": "API Host 无效:共享开发域名已弃用,请在控制端配置专属 API Host(和风控制台 → 产品 → API Host)", "city": cfg.get("city", "")}
return {"error": "请求被拒绝(403),请检查额度、权限或 API Host 配置", "city": cfg.get("city", "")}
if resp.status_code == 429:
msg = "请求太频繁,请稍后再试"
current_app.logger.warning(f"QWeather: rate limited (429)")
return {"error": msg, "city": cfg.get("city", "")} if force else _fallback_cache(cfg, user_id)
resp.raise_for_status()
data = resp.json()
if data.get("code") != "200":
err_msg = data.get("code", "unknown")
current_app.logger.error(f"QWeather API error code: {err_msg}")
return {"error": f"API 错误: {err_msg}", "city": cfg.get("city", "")}
except requests.exceptions.Timeout:
msg = "API 请求超时"
current_app.logger.error(f"QWeather: timeout")
return {"error": msg, "city": cfg.get("city", "")} if force else _fallback_cache(cfg, user_id)
except requests.exceptions.ConnectionError:
msg = "无法连接 API"
current_app.logger.error(f"QWeather: connection failed")
return {"error": msg, "city": cfg.get("city", "")} if force else _fallback_cache(cfg, user_id)
except Exception as e:
msg = str(e)
current_app.logger.error(f"QWeather error: {e}")
return {"error": msg, "city": cfg.get("city", "")} if force else _fallback_cache(cfg, user_id)
now_data = data.get("now", {})
# Map QWeather icon codes to something displayable
icon_code = now_data.get("icon", "999")
result = {
"city": cfg.get("city", ""),
"temp": float(now_data.get("temp", 0)),
"feels_like": float(now_data.get("feelsLike", 0)),
"humidity": int(now_data.get("humidity", 0)),
"pressure": int(now_data.get("pressure", 0)),
"description": now_data.get("text", ""),
"icon": icon_code,
"wind_speed": float(now_data.get("windSpeed", 0)),
"wind_dir": now_data.get("windDir", ""),
"wind_scale": int(now_data.get("windScale", 0)),
"visibility": now_data.get("vis", ""),
"precip": now_data.get("precip", "0"),
"cloud": now_data.get("cloud", ""),
"dew": now_data.get("dew", ""),
"obs_time": now_data.get("obsTime", ""),
"updated_at": tz_now().isoformat(),
"provider": "qweather",
}
_save_cache(result, user_id)
current_app.event_bus.publish("weather_updated", result, user_id=user_id)
return result
# ─── Cache ─────────────────────────────────
def _save_cache(data: dict, user_id: int = None):
"""Write weather data to the cache table, replacing any existing row."""
if user_id is None:
user_id = _uid()
now_str = tz_strftime()
db = get_db(current_app.config["DB_PATH"])
db.execute("DELETE FROM weather_cache WHERE user_id=?", (user_id,))
db.execute(
"INSERT INTO weather_cache (user_id, data, updated_at) VALUES (?, ?, ?)",
(user_id, json.dumps(data), now_str)
)
db.commit()
db.close()
def _fallback_cache(cfg: dict, user_id: int = None) -> dict:
"""Return cached data if available, else error."""
if user_id is None:
user_id = _uid()
db = get_db(current_app.config["DB_PATH"])
cache = db.execute(
"SELECT data FROM weather_cache WHERE user_id=?", (user_id,)
).fetchone()
db.close()
if cache:
cached = json.loads(cache["data"])
cached["cached"] = True
return cached
return {"error": "无法获取天气数据", "city": cfg.get("city", "")}
def get_cached_weather(user_id: int = None) -> dict:
"""Return cached weather or empty error."""
if user_id is None:
user_id = _uid()
db = get_db(current_app.config["DB_PATH"])
row = db.execute("SELECT * FROM weather_cache WHERE user_id=?", (user_id,)).fetchone()
db.close()
if row:
return json.loads(row["data"])
return {"error": "暂无天气数据"}
# ─── Next Update Schedule ─────────────────
def get_next_update_times(user_id: int = None) -> dict:
"""Compute next scheduled update times based on cron rules.
- Weather (current): every :00 and :30
- Hourly forecast: every :01 past the hour
"""
from datetime import timedelta
if user_id is None:
user_id = _uid()
now = tz_now()
# Next weather update: next :00 or :30
if now.minute < 30:
next_w = now.replace(minute=30, second=0, microsecond=0)
else:
next_w = (now + timedelta(hours=1)).replace(minute=0, second=0, microsecond=0)
# Next hourly update: next :01
if now.minute < 1:
next_h = now.replace(minute=1, second=0, microsecond=0)
else:
next_h = (now + timedelta(hours=1)).replace(minute=1, second=0, microsecond=0)
# Read last update times from cached JSON (local time) not SQLite datetime('now') which is UTC
db = get_db(current_app.config["DB_PATH"])
w_updated = None
w_row = db.execute("SELECT data FROM weather_cache WHERE user_id=?", (user_id,)).fetchone()
if w_row and w_row["data"]:
w_data = json.loads(w_row["data"])
w_updated = w_data.get("updated_at", None)
h_updated = None
h_row = db.execute("SELECT data FROM weather_hourly_cache WHERE user_id=?", (user_id,)).fetchone()
if h_row and h_row["data"]:
# Hourly data is a list; we store fetched_at separately, but for display
# use the local-computed schedule as last-update signal
h_row2 = db.execute("SELECT fetched_at FROM weather_hourly_cache WHERE user_id=?", (user_id,)).fetchone()
if h_row2 and h_row2[0]:
# Keep the UTC string but note it's UTC — we'll just pass it
h_updated = h_row2[0]
db.close()
return {
"weather_updated_at": w_updated,
"weather_next_update": next_w.strftime("%Y-%m-%d %H:%M:%S"),
"hourly_updated_at": h_updated,
"hourly_next_update": next_h.strftime("%Y-%m-%d %H:%M:%S"),
}
# ─── Hourly Forecast ──────────────────────
def fetch_hourly(user_id: int = None) -> list:
"""Fetch 24-hour forecast from QWeather, filter past hours, cache.
Returns filtered list of {time, temp, icon, text, pop, windSpeed, windDir}.
Handles cross-hour/cross-day: filters out fxTime < now, so stale cache
never shows expired hours. If filtered result is short, caller should
re-fetch (handled by scheduler every 60 min).
"""
if user_id is None:
user_id = _uid()
cfg = get_config(user_id)
headers = _get_auth_header(cfg)
if not headers:
return []
lat = cfg.get("lat") or 39.9042
lon = cfg.get("lon") or 116.4074
host = _api_host(cfg)
url = f"{host}/v7/weather/24h"
params = {"location": f"{lon:.2f},{lat:.2f}", "lang": "zh"}
try:
resp = requests.get(url, params=params, headers=headers, timeout=10)
if resp.status_code != 200:
current_app.logger.error(f"Hourly fetch failed: {resp.status_code}")
return _get_cached_hourly(user_id) or []
data = resp.json()
if data.get("code") != "200":
return _get_cached_hourly(user_id) or []
raw_list = data.get("hourly", [])
except Exception as e:
current_app.logger.error(f"Hourly fetch error: {e}")
return _get_cached_hourly(user_id) or []
now = tz_now()
today_date = now.strftime("%Y-%m-%d")
filtered = []
for h in raw_list:
fx_str = h.get("fxTime", "")
fx_time = _parse_qw_time(fx_str)
if not fx_time:
continue
# Unify to a local timezone-aware datetime via the time middleware.
# fxTime from QWeather may carry an offset (+08:00) or be naive;
# never strip tzinfo — that breaks comparison with tz_now().
fx_local = tz_localize(fx_time)
if fx_local >= now:
# Determine if this hour is today or tomorrow
fx_date = fx_local.strftime("%Y-%m-%d")
day_label = "明日" if fx_date > today_date else "今日"
filtered.append({
"time": fx_local.strftime("%H:%M"),
"day": day_label,
"temp": h.get("temp", ""),
"icon": h.get("icon", "999"),
"text": h.get("text", ""),
"pop": h.get("pop", "0"),
"wind_speed": h.get("windSpeed", "0"),
"wind_dir": h.get("windDir", ""),
})
_save_hourly_cache(filtered, user_id)
current_app.logger.info(f"Hourly: fetched {len(raw_list)} → {len(filtered)} future hours")
return filtered
def _parse_qw_time(s: str):
"""Parse QWeather ISO time string to datetime, or None."""
if not s:
return None
try:
# Format: "2026-07-14T15:00+08:00" or "2026-07-14T15:00+08:00"
return datetime.fromisoformat(s)
except Exception:
pass
try:
# Fallback: "2026-07-14T15:00"
return datetime.strptime(s[:16], "%Y-%m-%dT%H:%M")
except Exception:
return None
def get_cached_hourly(user_id: int = None) -> list:
"""Return cached hourly forecast with cross-time filtering.
Filters out past hours using proper time comparison (handles
cross-midnight correctly). If fewer than 12 remain, logs a warning
so the scheduler knows to refresh early.
"""
if user_id is None:
user_id = _uid()
data = _get_cached_hourly(user_id)
if not data:
return []
# Detect old cache format (missing "day" field) — force re-fetch
if not data[0].get("day"):
current_app.logger.info("Hourly: old cache format detected, force re-fetch")
return []
now = tz_now()
now_minutes = now.hour * 60 + now.minute
valid = []
for h in data:
parts = h.get("time", "00:00").split(":")
h_minutes = int(parts[0]) * 60 + int(parts[1])
day = h.get("day", "今日")
# Keep if: it's "今日" and time hasn't passed, OR it's "明日" (always future)
if (day == "今日" and h_minutes >= now_minutes) or day == "明日":
valid.append(h)
if len(valid) < 12:
current_app.logger.info(
f"Hourly cache: only {len(valid)} future hours left, will refresh soon"
)
return valid
def _save_hourly_cache(data: list, user_id: int = None):
"""Persist hourly data, replacing old cache."""
if user_id is None:
user_id = _uid()
now_str = tz_strftime()
db = get_db(current_app.config["DB_PATH"])
db.execute(
"DELETE FROM weather_hourly_cache WHERE user_id=?", (user_id,)
)
db.execute(
"INSERT INTO weather_hourly_cache (user_id, data, fetched_at) VALUES (?, ?, ?)",
(user_id, json.dumps(data), now_str)
)
db.commit()
db.close()
def _get_cached_hourly(user_id: int = None) -> list | None:
"""Read cached hourly data from DB."""
if user_id is None:
user_id = _uid()
db = get_db(current_app.config["DB_PATH"])
row = db.execute(
"SELECT data FROM weather_hourly_cache WHERE user_id=?", (user_id,)
).fetchone()
db.close()
if row and row["data"]:
return json.loads(row["data"])
return None
# ─── Minutely Forecast (manual, rate-limited) ─
_MINUTELY_COOLDOWN = 180 # 3 minutes in seconds
def fetch_minutely(user_id: int = None) -> dict:
"""Fetch minute-level precipitation forecast from QWeather v7/minutely/5m.
Rate-limited: once per 3 minutes. Returns cooldown info on rejection.
"""
if user_id is None:
user_id = _uid()
# Check cooldown
db = get_db(current_app.config["DB_PATH"])
row = db.execute(
"SELECT fetched_at FROM weather_minutely_cache WHERE user_id=?", (user_id,)
).fetchone()
if row and row["fetched_at"]:
try:
last = tz_strptime(row["fetched_at"], "%Y-%m-%d %H:%M:%S") if 'T' not in row["fetched_at"] else tz_strptime(row["fetched_at"][:19].replace('T', ' '), "%Y-%m-%d %H:%M:%S")
elapsed = (tz_now() - last).total_seconds()
if elapsed < _MINUTELY_COOLDOWN:
remaining = int(_MINUTELY_COOLDOWN - elapsed)
db.close()
return {"error": f"冷却中,剩余 {remaining} 秒", "cooldown": remaining}
except Exception:
pass
db.close()
cfg = get_config(user_id)
headers = _get_auth_header(cfg)
if not headers:
return {"error": "未配置认证"}
lat = cfg.get("lat") or 39.9042
lon = cfg.get("lon") or 116.4074
host = _api_host(cfg)
url = f"{host}/v7/minutely/5m"
params = {"location": f"{lon:.2f},{lat:.2f}", "lang": "zh"}
try:
resp = requests.get(url, params=params, headers=headers, timeout=10)
if resp.status_code != 200:
return {"error": f"API 返回 {resp.status_code}"}
data = resp.json()
if data.get("code") != "200":
return {"error": f"API 错误: {data.get('code')}"}
except Exception as e:
return {"error": str(e)}
now_str = tz_strftime()
result = {
"summary": data.get("summary", ""),
"minutely": data.get("minutely", []),
"fetched_at": now_str,
}
db = get_db(current_app.config["DB_PATH"])
db.execute("DELETE FROM weather_minutely_cache WHERE user_id=?", (user_id,))
db.execute(
"INSERT INTO weather_minutely_cache (user_id, data, fetched_at) VALUES (?, ?, ?)",
(user_id, json.dumps(result), now_str)
)
db.commit()
db.close()
return result
def get_cached_minutely(user_id: int = None) -> dict | None:
"""Return cached minutely forecast if available."""
if user_id is None:
user_id = _uid()
db = get_db(current_app.config["DB_PATH"])
row = db.execute("SELECT data FROM weather_minutely_cache WHERE user_id=?", (user_id,)).fetchone()
db.close()
if row and row["data"]:
return json.loads(row["data"])
return None
# ─── Weather Warnings (manual, rate-limited) ─
def fetch_warnings(user_id: int = None) -> dict:
"""Fetch weather warnings from QWeather v7/warning/now.
Rate-limited: once per 3 minutes. Returns cooldown info on rejection.
"""
if user_id is None:
user_id = _uid()
# Check cooldown
db = get_db(current_app.config["DB_PATH"])
row = db.execute(
"SELECT fetched_at FROM weather_warning_cache WHERE user_id=?", (user_id,)
).fetchone()
if row and row["fetched_at"]:
try:
last = tz_strptime(row["fetched_at"], "%Y-%m-%d %H:%M:%S") if 'T' not in row["fetched_at"] else tz_strptime(row["fetched_at"][:19].replace('T', ' '), "%Y-%m-%d %H:%M:%S")
elapsed = (tz_now() - last).total_seconds()
if elapsed < _MINUTELY_COOLDOWN:
remaining = int(_MINUTELY_COOLDOWN - elapsed)
db.close()
return {"error": f"冷却中,剩余 {remaining} 秒", "cooldown": remaining}
except Exception:
pass
db.close()
cfg = get_config(user_id)
headers = _get_auth_header(cfg)
if not headers:
return {"error": "未配置认证"}
lat = round(cfg.get("lat") or 39.9042, 2)
lon = round(cfg.get("lon") or 116.4074, 2)
host = _api_host(cfg)
url = f"{host}/weatheralert/v1/current/{lat}/{lon}"
params = {"lang": "zh"}
try:
resp = requests.get(url, params=params, headers=headers, timeout=10)
if resp.status_code == 403:
return {"error": "天气预警为付费功能,当前订阅不可用", "unavailable": True}
if resp.status_code != 200:
return {"error": f"API 返回 {resp.status_code}"}
data = resp.json()
alert_list = data.get("alerts", [])
except Exception as e:
return {"error": str(e)}
now_str = tz_strftime()
result = {
"warnings": alert_list,
"fetched_at": now_str,
"zero_result": data.get("metadata", {}).get("zeroResult", False),
}
db = get_db(current_app.config["DB_PATH"])
db.execute("DELETE FROM weather_warning_cache WHERE user_id=?", (user_id,))
db.execute(
"INSERT INTO weather_warning_cache (user_id, data, fetched_at) VALUES (?, ?, ?)",
(user_id, json.dumps(result), now_str)
)
db.commit()
db.close()
return result
def get_cached_warnings(user_id: int = None) -> dict | None:
"""Return cached warnings if available."""
if user_id is None:
user_id = _uid()
db = get_db(current_app.config["DB_PATH"])
row = db.execute("SELECT data FROM weather_warning_cache WHERE user_id=?", (user_id,)).fetchone()
db.close()
if row and row["data"]:
return json.loads(row["data"])
return None