First Push

This commit is contained in:
喆OvO
2026-08-06 11:11:11 +08:00
commit 0fef309972
551 changed files with 15793 additions and 0 deletions
+680
View File
@@ -0,0 +1,680 @@
"""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, today_str as tz_today_str, 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"]:
h_data = json.loads(h_row["data"])
# Hourly data is a list; we store fetched_at separately, but for display
# use the SQLite fetched_at (converted) or just show the schedule
# Let's 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