507 lines
19 KiB
Python
507 lines
19 KiB
Python
"""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, requests as _r, 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", 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}
|