@
将 web/routes.py 拆分为模块化 router + service 架构
- 新增 web/app_factory.py 集中注册 7 个域名 router
- 新增 web/routers/ 薄壳路由层(auth/city/system/scan/ops/payments/analytics)
- 新增 web/services/ 业务函数下沉(每域独立 service 文件)
- web/routes.py 缩减为 city_runtime 的兼容重导出 facade
- analysis_service.py/app.py 适配新入口并清理冗余导入
Scope-risk: LOW — 全量 170 测试通过,router 注册顺序与原路由一致
Tested: python -m pytest -q (170 passed), ruff check . (All checks passed)
@
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
"""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
|
||||
)
|
||||
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"),
|
||||
"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,
|
||||
}
|
||||
Reference in New Issue
Block a user