679 lines
24 KiB
Python
679 lines
24 KiB
Python
"""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
|