First Push
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user