"""Operations/admin API service functions.""" from __future__ import annotations from typing import Any, Dict from fastapi import HTTPException, Request from src.database.db_manager import DBManager from web.core import GrantPointsRequest import web.routes as legacy_routes def _require_ops(request: Request) -> Dict[str, Any] | None: legacy_routes._assert_entitlement(request) return legacy_routes._require_ops_admin(request) def search_ops_users(request: Request, q: str = "", limit: int = 20) -> Dict[str, Any]: _require_ops(request) db = DBManager() return {"users": db.search_users(q, limit=limit)} def get_ops_weekly_leaderboard(request: Request, limit: int = 20) -> Dict[str, Any]: _require_ops(request) db = DBManager() return {"leaderboard": db.get_weekly_leaderboard(limit=limit)} def list_ops_memberships(request: Request, limit: int = 200) -> Dict[str, Any]: _require_ops(request) db = DBManager() if getattr(legacy_routes.PAYMENT_CHECKOUT, "enabled", False): try: legacy_routes.PAYMENT_CHECKOUT.reconcile_recent_intents( limit=min(max(int(limit or 200), 20), 200) ) except Exception: pass subscriptions = legacy_routes.SUPABASE_ENTITLEMENT.list_active_subscriptions(limit=limit) subscription_user_ids = [str(item.get("user_id") or "") for item in subscriptions] user_map = db.get_users_by_supabase_user_ids(subscription_user_ids) unresolved_user_ids = [ user_id for user_id in subscription_user_ids if str(user_id or "").strip().lower() and not str( (user_map.get(str(user_id).strip().lower(), {}) or {}).get("supabase_email") or "" ).strip() ] auth_user_map = legacy_routes.SUPABASE_ENTITLEMENT.get_auth_users(unresolved_user_ids) deduped: dict[str, dict] = {} for item in subscriptions: user_id = str(item.get("user_id") or "").strip().lower() local_user = user_map.get(user_id, {}) auth_user = auth_user_map.get(user_id, {}) subscription_window = legacy_routes.SUPABASE_ENTITLEMENT.get_subscription_window( user_id, respect_requirement=False, bypass_cache=True, ) current_expires_at = item.get("expires_at") total_expires_at = ( subscription_window.get("total_expires_at") if isinstance(subscription_window, dict) else None ) queued_days = ( int(subscription_window.get("queued_days") or 0) if isinstance(subscription_window, dict) else 0 ) queued_count = ( int(subscription_window.get("queued_count") or 0) if isinstance(subscription_window, dict) else 0 ) source = str(item.get("source") or "") is_trial = source == "signup_trial" or str(item.get("plan_code") or "").startswith("signup_trial") row = { "user_id": user_id, "email": str(auth_user.get("email") or local_user.get("supabase_email") or ""), "telegram_id": local_user.get("telegram_id"), "username": local_user.get("username"), "registered_at": local_user.get("created_at") or auth_user.get("created_at"), "plan_code": item.get("plan_code"), "source": source, "is_trial": is_trial, "starts_at": item.get("starts_at"), "current_expires_at": current_expires_at, "total_expires_at": total_expires_at or current_expires_at, "expires_at": total_expires_at or current_expires_at, "queued_days": queued_days, "queued_count": queued_count, } existing = deduped.get(user_id) existing_expires = str(existing.get("expires_at") or "") if existing else "" current_expires = str(row.get("expires_at") or "") if existing is None or current_expires > existing_expires: deduped[user_id] = row rows = sorted( deduped.values(), key=lambda item: str(item.get("expires_at") or ""), ) return {"memberships": rows} def list_ops_payment_incidents( request: Request, limit: int = 50, reason: str = "", include_resolved: bool = False, ) -> Dict[str, Any]: _require_ops(request) db = DBManager() incidents = db.list_payment_audit_events( limit=max(1, min(int(limit or 50), 200)), event_type="payment_intent_failed", ) normalized_reason = str(reason or "").strip().lower() filtered = [] for item in incidents: payload = item.get("payload") if isinstance(item, dict) else {} payload = payload if isinstance(payload, dict) else {} item_reason = str(payload.get("reason") or "").strip().lower() resolved_at = str(payload.get("resolved_at") or "").strip() if normalized_reason and item_reason != normalized_reason: continue if not include_resolved and resolved_at: continue filtered.append(item) return {"incidents": filtered} def resolve_ops_payment_incident(request: Request, event_id: int) -> Dict[str, Any]: admin = _require_ops(request) or {} db = DBManager() resolved = db.mark_payment_audit_event_resolved(event_id, str(admin.get("email") or "")) if not resolved: raise HTTPException(status_code=404, detail="payment_incident_not_found") return {"ok": True, "incident": resolved} def grant_ops_points(request: Request, body: GrantPointsRequest) -> Dict[str, Any]: admin = _require_ops(request) or {} db = DBManager() result = db.grant_points_by_supabase_email(body.email, body.points) result["operator_email"] = admin.get("email") if not result.get("ok"): reason = str(result.get("reason") or "grant_points_failed") status_code = 404 if reason == "user_not_found" else 400 raise HTTPException(status_code=status_code, detail=result) return result def get_ops_analytics_funnel(request: Request, days: int = 30) -> Dict[str, Any]: _require_ops(request) db = DBManager() return db.get_app_analytics_funnel_summary(days=days) def get_ops_truth_history( request: Request, city: str = "", date_from: str = "", date_to: str = "", limit: int = 200, ) -> Dict[str, Any]: _require_ops(request) truth_history = legacy_routes.TruthRecordRepository().load_all() normalized_city = str(city or "").strip().lower() normalized_from = str(date_from or "").strip() normalized_to = str(date_to or "").strip() max_limit = max(1, min(int(limit or 200), 1000)) rows = [] for row_city, by_date in truth_history.items(): if normalized_city and row_city != normalized_city: continue if not isinstance(by_date, dict): continue for target_date, payload in by_date.items(): if normalized_from and str(target_date) < normalized_from: continue if normalized_to and str(target_date) > normalized_to: continue if not isinstance(payload, dict): continue rows.append( { "city": row_city, "display_name": str( (legacy_routes.CITY_REGISTRY.get(row_city) or {}).get("name") or row_city ), "target_date": str(target_date), "actual_high": payload.get("actual_high"), "settlement_source": payload.get("settlement_source"), "settlement_station_code": payload.get("settlement_station_code"), "settlement_station_label": payload.get("settlement_station_label"), "truth_version": payload.get("truth_version"), "updated_by": payload.get("updated_by"), "truth_updated_at": payload.get("truth_updated_at"), "is_final": payload.get("is_final"), } ) rows.sort(key=lambda item: (str(item["target_date"]), str(item["city"])), reverse=True) filtered_count = len(rows) rows = rows[:max_limit] available_cities = [ { "city": city_id, "name": str(info.get("name") or city_id), } for city_id, info in sorted( legacy_routes.CITY_REGISTRY.items(), key=lambda item: str(item[1].get("name") or item[0]), ) ] return { "items": rows, "available_cities": available_cities, "filters": { "city": normalized_city or None, "date_from": normalized_from or None, "date_to": normalized_to or None, "limit": max_limit, }, "filtered_count": filtered_count, } # ── Config ────────────────────────────────────────────────────────── _EDITABLE_CONFIG_KEYS: dict[str, str] = { "POLYWEATHER_AUTH_REQUIRED": "是否强制要求 Supabase 登录访问 API", "POLYWEATHER_PAYMENT_ENABLED": "是否启用支付功能", "POLYWEATHER_PAYMENT_POINTS_ENABLED": "是否启用积分抵扣", "POLYWEATHER_TELEGRAM_ALERT_PUSH_ENABLED": "是否启用 Telegram 告警推送", "POLYWEATHER_GROUP_MEMBER_PRICE_USDC": "群成员月费 (USDC)", "POLYWEATHER_PUBLIC_PRICE_USDC": "公开月费 (USDC)", "POLYWEATHER_PAYMENT_POINTS_PER_USDC": "积分兑换汇率 (积分/USDC)", "POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC": "积分最高抵扣金额 (USDC)", } def get_ops_config(request: Request) -> dict[str, Any]: _require_ops(request) import os configs: list[dict[str, str]] = [] for key, desc in _EDITABLE_CONFIG_KEYS.items(): configs.append({ "key": key, "value": os.getenv(key) or "", "description": desc, }) return {"configs": configs} def update_ops_config(request: Request, key: str, value: str) -> dict[str, Any]: _require_ops(request) import os if key not in _EDITABLE_CONFIG_KEYS: raise HTTPException(status_code=400, detail=f"config key '{key}' is not editable") os.environ[key] = str(value) return {"key": key, "value": value, "ok": True} # ── Subscriptions ─────────────────────────────────────────────────── def grant_ops_subscription( request: Request, email: str, plan_code: str = "pro_monthly", days: int = 30, ) -> dict[str, Any]: _require_ops(request) import os from datetime import datetime, timedelta import requests as _requests supabase_url = str(os.getenv("SUPABASE_URL") or "").strip().rstrip("/") service_role_key = str(os.getenv("SUPABASE_SERVICE_ROLE_KEY") or "").strip() if not supabase_url or not service_role_key: raise HTTPException(status_code=503, detail="Supabase not configured") allowed_plans = {"pro_monthly", "pro_quarterly", "pro_yearly"} if plan_code not in allowed_plans: raise HTTPException(status_code=400, detail=f"invalid plan_code, allowed: {allowed_plans}") safe_days = max(1, min(365, int(days or 30))) normalized_email = str(email or "").strip().lower() if not normalized_email: raise HTTPException(status_code=400, detail="email is required") headers = { "apikey": service_role_key, "Authorization": f"Bearer {service_role_key}", "Content-Type": "application/json", "Prefer": "return=representation", } # Look up user by email user_resp = _requests.get( f"{supabase_url}/auth/v1/admin/users", headers=headers, params={"filter": f"email.eq.{normalized_email}"}, timeout=10, ) users = user_resp.json().get("users", []) if user_resp.ok else [] user_id = users[0].get("id") if users else None if not user_id: raise HTTPException(status_code=404, detail=f"user not found: {normalized_email}") now = datetime.utcnow() starts_at = now.isoformat() + "Z" expires_at = (now + timedelta(days=safe_days)).isoformat() + "Z" payload = { "user_id": user_id, "email": normalized_email, "plan_code": plan_code, "starts_at": starts_at, "expires_at": expires_at, "source": "ops_manual_grant", "created_at": now.isoformat() + "Z", } resp = _requests.post( f"{supabase_url}/rest/v1/subscriptions", headers=headers, json=payload, timeout=10, ) if resp.ok: return {"ok": True, "user_id": user_id, "plan_code": plan_code, "days": safe_days, "expires_at": expires_at} raise HTTPException(status_code=500, detail=f"Supabase insert failed: {resp.text[:200]}") def extend_ops_subscription( request: Request, email: str, additional_days: int = 30, ) -> dict[str, Any]: _require_ops(request) import os from datetime import datetime, timedelta import requests as _requests supabase_url = str(os.getenv("SUPABASE_URL") or "").strip().rstrip("/") service_role_key = str(os.getenv("SUPABASE_SERVICE_ROLE_KEY") or "").strip() if not supabase_url or not service_role_key: raise HTTPException(status_code=503, detail="Supabase not configured") safe_days = max(1, min(365, int(additional_days or 30))) normalized_email = str(email or "").strip().lower() if not normalized_email: raise HTTPException(status_code=400, detail="email is required") headers = { "apikey": service_role_key, "Authorization": f"Bearer {service_role_key}", "Content-Type": "application/json", "Prefer": "return=representation", } # Find latest active subscription subs_resp = _requests.get( f"{supabase_url}/rest/v1/subscriptions", headers=headers, params={ "select": "*", "email": f"eq.{normalized_email}", "order": "expires_at.desc", "limit": "1", }, timeout=10, ) subs = subs_resp.json() if subs_resp.ok else [] if not subs: raise HTTPException(status_code=404, detail=f"no subscription found for {normalized_email}") sub = subs[0] current_expiry = sub.get("expires_at", "") try: dt = datetime.fromisoformat(current_expiry.replace("Z", "+00:00")) new_expiry = (dt + timedelta(days=safe_days)).isoformat() except Exception: new_expiry = (datetime.utcnow() + timedelta(days=safe_days)).isoformat() + "Z" patch_resp = _requests.patch( f"{supabase_url}/rest/v1/subscriptions?id=eq.{sub['id']}", headers=headers, json={"expires_at": new_expiry}, timeout=10, ) if patch_resp.ok: return {"ok": True, "email": normalized_email, "additional_days": safe_days, "new_expires_at": new_expiry} raise HTTPException(status_code=500, detail=f"Supabase update failed: {patch_resp.text[:200]}") # ── Logs ──────────────────────────────────────────────────────────── def get_ops_logs( request: Request, level: str = "", lines: int = 100, ) -> dict[str, Any]: _require_ops(request) import subprocess safe_lines = max(10, min(1000, int(lines or 100))) try: # Read from Docker logs result = subprocess.run( ["docker", "logs", "--tail", str(safe_lines), "polyweather_bot"], capture_output=True, text=True, timeout=10, ) log_text = result.stdout or result.stderr or "" except Exception: log_text = "" log_lines = log_text.strip().split("\n") if log_text.strip() else [] if level: level_upper = level.upper() log_lines = [line for line in log_lines if level_upper in line.upper()] return { "lines": log_lines[-safe_lines:], "total": len(log_lines), } def get_ops_health_check(request: Request) -> dict[str, Any]: _require_ops(request) import os import requests as _r import time as _time results: dict[str, dict] = {} timeout = 3 # Supabase supabase_url = str(os.getenv("SUPABASE_URL") or "").strip().rstrip("/") supabase_key = str(os.getenv("SUPABASE_SERVICE_ROLE_KEY") or "").strip() if supabase_url and supabase_key: try: r = _r.get(f"{supabase_url}/rest/v1/", headers={"apikey": supabase_key, "Authorization": f"Bearer {supabase_key}"}, timeout=timeout) results["supabase"] = {"ok": r.ok, "status": r.status_code, "latency_ms": round(r.elapsed.total_seconds() * 1000)} except Exception as e: results["supabase"] = {"ok": False, "error": str(e)[:100]} else: results["supabase"] = {"ok": False, "error": "not configured"} # Open-Meteo try: t0 = _time.perf_counter() r = _r.get("https://api.open-meteo.com/v1/forecast?latitude=52.52&longitude=13.41&daily=temperature_2m_max&timezone=auto&forecast_days=1", timeout=timeout) results["open_meteo"] = {"ok": r.ok, "status": r.status_code, "latency_ms": round((_time.perf_counter() - t0) * 1000)} except Exception as e: results["open_meteo"] = {"ok": False, "error": str(e)[:100]} # METAR (aviationweather) try: t0 = _time.perf_counter() r = _r.get("https://aviationweather.gov/api/data/metar?ids=KJFK&format=json", timeout=timeout) results["metar"] = {"ok": r.ok, "status": r.status_code, "latency_ms": round((_time.perf_counter() - t0) * 1000)} except Exception as e: results["metar"] = {"ok": False, "error": str(e)[:100]} # KNMI knmi_key = str(os.getenv("KNMI_API_KEY") or "").strip() if knmi_key: try: t0 = _time.perf_counter() r = _r.get("https://api.dataplatform.knmi.nl/open-data/v1/datasets/10-minute-in-situ-meteorological-observations/versions/1.0/files?maxKeys=1", headers={"Authorization": knmi_key}, timeout=timeout) results["knmi"] = {"ok": r.ok, "status": r.status_code, "latency_ms": round((_time.perf_counter() - t0) * 1000)} except Exception as e: results["knmi"] = {"ok": False, "error": str(e)[:100]} else: results["knmi"] = {"ok": False, "error": "not configured"} # MADIS (NOAA) try: t0 = _time.perf_counter() r = _r.get("https://madis-data.ncep.noaa.gov/madisPublic1/data/LDAD/hfmetar/netCDF/", timeout=timeout) results["madis"] = {"ok": r.ok, "status": r.status_code, "latency_ms": round((_time.perf_counter() - t0) * 1000)} except Exception as e: results["madis"] = {"ok": False, "error": str(e)[:100]} # Telegram Bot bot_token = str(os.getenv("TELEGRAM_BOT_TOKEN") or "").strip() if bot_token: try: t0 = _time.perf_counter() r = _r.get(f"https://api.telegram.org/bot{bot_token}/getMe", timeout=timeout) results["telegram"] = {"ok": r.ok, "status": r.status_code, "latency_ms": round((_time.perf_counter() - t0) * 1000)} except Exception as e: results["telegram"] = {"ok": False, "error": str(e)[:100]} else: results["telegram"] = {"ok": False, "error": "not configured"} all_ok = all(v.get("ok") for v in results.values()) return {"ok": all_ok, "checked_at": __import__("datetime").datetime.utcnow().isoformat() + "Z", "services": results}