"""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