1164 lines
43 KiB
Python
1164 lines
43 KiB
Python
"""Ops payments / billing / membership service functions."""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from collections import defaultdict
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
from fastapi import HTTPException, Request
|
|
|
|
from src.database.db_manager import DBManager # type hints
|
|
import web.routes as legacy_routes
|
|
|
|
|
|
def _get_db():
|
|
from src.database.db_manager import DBManager as _DBManager
|
|
return _DBManager()
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════
|
|
# Shared helpers
|
|
# ═══════════════════════════════════════════════════════════════════════
|
|
|
|
def _require_ops(request: Request) -> Dict[str, Any] | None:
|
|
from web.services.ops_api import _require_ops as _real
|
|
return _real(request)
|
|
|
|
|
|
def _parse_iso_datetime(value: Any) -> Optional[datetime]:
|
|
text = str(value or "").strip()
|
|
if not text:
|
|
return None
|
|
try:
|
|
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
|
except Exception:
|
|
return None
|
|
if parsed.tzinfo is None:
|
|
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
return parsed.astimezone(timezone.utc)
|
|
|
|
|
|
def _to_utc_iso(value: datetime) -> str:
|
|
if value.tzinfo is None:
|
|
value = value.replace(tzinfo=timezone.utc)
|
|
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
|
|
|
|
def _supabase_rest_rows(
|
|
table: str,
|
|
params: Dict[str, Any],
|
|
*,
|
|
timeout: int = 10,
|
|
) -> List[Dict[str, Any]]:
|
|
from web.services.ops_api import _supabase_rest_rows as _real
|
|
|
|
return _real(table, params, timeout=timeout)
|
|
|
|
|
|
def _app_analytics_actor_key(row: Dict[str, Any]) -> str:
|
|
payload = row.get("payload")
|
|
payload = payload if isinstance(payload, dict) else {}
|
|
user_id = str(row.get("user_id") or payload.get("user_id") or "").strip().lower()
|
|
client_id = str(row.get("client_id") or "").strip()
|
|
session_id = str(row.get("session_id") or "").strip()
|
|
if user_id:
|
|
return f"user:{user_id}"
|
|
if client_id:
|
|
return f"client:{client_id}"
|
|
if session_id:
|
|
return f"session:{session_id}"
|
|
return f"event:{row.get('id')}"
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════
|
|
# Payment helpers
|
|
# ═══════════════════════════════════════════════════════════════════════
|
|
|
|
def _payment_explorer_url(chain: Any, tx_hash: Any) -> str:
|
|
tx = str(tx_hash or "").strip()
|
|
if not tx:
|
|
return ""
|
|
chain_text = str(chain or "").strip().lower()
|
|
base = "https://etherscan.io" if "eth" in chain_text else "https://polygonscan.com"
|
|
return f"{base}/tx/{tx}"
|
|
|
|
|
|
def _risk_issue(
|
|
*,
|
|
category: str,
|
|
severity: str,
|
|
title: str,
|
|
detail: str,
|
|
user_id: Any = "",
|
|
created_at: Any = "",
|
|
reference: Any = "",
|
|
payload: Optional[Dict[str, Any]] = None,
|
|
) -> Dict[str, Any]:
|
|
return {
|
|
"category": category,
|
|
"severity": severity,
|
|
"title": title,
|
|
"detail": detail,
|
|
"user_id": str(user_id or ""),
|
|
"created_at": str(created_at or ""),
|
|
"reference": str(reference or ""),
|
|
"payload": payload or {},
|
|
}
|
|
|
|
|
|
def _normalize_payment_incident(item: Dict[str, Any]) -> Dict[str, Any]:
|
|
payload = item.get("payload") if isinstance(item, dict) else {}
|
|
payload = payload if isinstance(payload, dict) else {}
|
|
confirm_failure = (
|
|
payload.get("confirm_failure")
|
|
if isinstance(payload.get("confirm_failure"), dict)
|
|
else {}
|
|
)
|
|
reason = str(
|
|
payload.get("reason")
|
|
or confirm_failure.get("reason")
|
|
or payload.get("error")
|
|
or "unknown"
|
|
).strip().lower()
|
|
detail = str(
|
|
payload.get("detail")
|
|
or confirm_failure.get("detail")
|
|
or payload.get("message")
|
|
or payload.get("error")
|
|
or ""
|
|
).strip()
|
|
resolved_at = str(payload.get("resolved_at") or "").strip()
|
|
return {
|
|
**item,
|
|
"payload": payload,
|
|
"reason": reason or "unknown",
|
|
"detail": detail,
|
|
"intent_id": str(
|
|
payload.get("intent_id")
|
|
or payload.get("payment_intent_id")
|
|
or confirm_failure.get("intent_id")
|
|
or ""
|
|
).strip(),
|
|
"user_id": str(payload.get("user_id") or "").strip(),
|
|
"tx_hash": str(
|
|
payload.get("tx_hash")
|
|
or confirm_failure.get("tx_hash")
|
|
or ""
|
|
).strip(),
|
|
"refund_case_id": payload.get("refund_case_id"),
|
|
"refund_status": str(payload.get("refund_status") or "").strip(),
|
|
"resolved": bool(resolved_at),
|
|
"resolved_at": resolved_at,
|
|
"resolved_by": str(payload.get("resolved_by") or "").strip(),
|
|
}
|
|
|
|
|
|
def _payment_incident_group_key(item: Dict[str, Any]) -> Tuple[str, str, str, str]:
|
|
reason = str(item.get("reason") or "unknown").strip().lower()
|
|
intent_id = str(item.get("intent_id") or "").strip().lower()
|
|
tx_hash = str(item.get("tx_hash") or "").strip().lower()
|
|
user_id = str(item.get("user_id") or "").strip().lower()
|
|
if intent_id or tx_hash:
|
|
return reason, user_id, intent_id, tx_hash
|
|
return reason, user_id, f"event:{item.get('id')}", ""
|
|
|
|
|
|
def _group_payment_incidents(
|
|
incidents: List[Dict[str, Any]],
|
|
*,
|
|
reason: str = "",
|
|
include_resolved: bool = False,
|
|
) -> Dict[str, Any]:
|
|
normalized_reason = str(reason or "").strip().lower()
|
|
groups: Dict[Tuple[str, str, str, str], Dict[str, Any]] = {}
|
|
raw_total = 0
|
|
|
|
for item in incidents:
|
|
normalized_item = _normalize_payment_incident(item)
|
|
item_reason = str(normalized_item.get("reason") or "").strip().lower()
|
|
resolved = bool(normalized_item.get("resolved"))
|
|
if normalized_reason and item_reason != normalized_reason:
|
|
continue
|
|
if not include_resolved and resolved:
|
|
continue
|
|
raw_total += 1
|
|
|
|
key = _payment_incident_group_key(normalized_item)
|
|
created_at = str(normalized_item.get("created_at") or "").strip()
|
|
event_id = int(normalized_item.get("id") or 0)
|
|
existing = groups.get(key)
|
|
if existing is None:
|
|
grouped = {
|
|
**normalized_item,
|
|
"occurrence_count": 1,
|
|
"event_ids": [event_id] if event_id > 0 else [],
|
|
"first_seen_at": created_at,
|
|
"last_seen_at": created_at,
|
|
}
|
|
groups[key] = grouped
|
|
continue
|
|
|
|
existing["occurrence_count"] = int(existing.get("occurrence_count") or 1) + 1
|
|
if event_id > 0:
|
|
existing.setdefault("event_ids", []).append(event_id)
|
|
first_seen = str(existing.get("first_seen_at") or "").strip()
|
|
last_seen = str(existing.get("last_seen_at") or "").strip()
|
|
if created_at and (not first_seen or created_at < first_seen):
|
|
existing["first_seen_at"] = created_at
|
|
if created_at and (not last_seen or created_at > last_seen):
|
|
existing["last_seen_at"] = created_at
|
|
|
|
grouped_items = list(groups.values())
|
|
grouped_items.sort(
|
|
key=lambda item: (
|
|
str(item.get("last_seen_at") or item.get("created_at") or ""),
|
|
int(item.get("id") or 0),
|
|
),
|
|
reverse=True,
|
|
)
|
|
return {
|
|
"incidents": grouped_items,
|
|
"raw_total": raw_total,
|
|
"total": len(grouped_items),
|
|
}
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════
|
|
# Payment incident API
|
|
# ═══════════════════════════════════════════════════════════════════════
|
|
|
|
def list_ops_payment_incidents(
|
|
request: Request,
|
|
limit: int = 50,
|
|
reason: str = "",
|
|
include_resolved: bool = False,
|
|
) -> Dict[str, Any]:
|
|
_require_ops(request)
|
|
db = _get_db()
|
|
safe_limit = max(1, min(int(limit or 50), 200))
|
|
incidents: List[Dict[str, Any]] = []
|
|
for event_type in ("payment_intent_failed", "payment_refund_required"):
|
|
try:
|
|
rows = db.list_payment_audit_events(
|
|
limit=max(safe_limit, 500),
|
|
event_type=event_type,
|
|
)
|
|
incidents.extend(
|
|
row for row in rows
|
|
if str(row.get("event_type") or "").strip().lower() == event_type
|
|
)
|
|
except Exception:
|
|
continue
|
|
terminal_refund_statuses = {"refunded", "rejected", "closed"}
|
|
list_refund_cases = getattr(db, "list_refund_cases", None)
|
|
if callable(list_refund_cases):
|
|
try:
|
|
refund_cases = list_refund_cases(limit=max(safe_limit, 500))
|
|
except Exception:
|
|
refund_cases = []
|
|
for case in refund_cases:
|
|
if not isinstance(case, dict):
|
|
continue
|
|
status = str(case.get("status") or "").strip().lower()
|
|
if not include_resolved and status in terminal_refund_statuses:
|
|
continue
|
|
incidents.append(
|
|
{
|
|
"id": int(case.get("id") or 0),
|
|
"event_type": "payment_refund_case",
|
|
"payload": {
|
|
"reason": str(case.get("reason") or "refund_required"),
|
|
"intent_id": case.get("intent_id"),
|
|
"user_id": case.get("user_id"),
|
|
"tx_hash": case.get("tx_hash"),
|
|
"refund_case_id": case.get("id"),
|
|
"refund_status": status,
|
|
},
|
|
"created_at": case.get("created_at"),
|
|
}
|
|
)
|
|
grouped = _group_payment_incidents(
|
|
incidents,
|
|
reason=reason,
|
|
include_resolved=include_resolved,
|
|
)
|
|
return {
|
|
**grouped,
|
|
"incidents": grouped["incidents"][:safe_limit],
|
|
}
|
|
|
|
|
|
def list_ops_refund_cases(
|
|
request: Request,
|
|
limit: int = 50,
|
|
status: str = "",
|
|
) -> Dict[str, Any]:
|
|
_require_ops(request)
|
|
db = _get_db()
|
|
safe_limit = max(1, min(int(limit or 50), 200))
|
|
return {
|
|
"refunds": db.list_refund_cases(
|
|
limit=safe_limit,
|
|
status=str(status or "").strip().lower() or None,
|
|
)
|
|
}
|
|
|
|
|
|
def create_ops_refund_case(
|
|
request: Request,
|
|
*,
|
|
reason: str,
|
|
intent_id: str = "",
|
|
tx_hash: str = "",
|
|
user_id: str = "",
|
|
amount_usdc: str = "",
|
|
note: str = "",
|
|
) -> Dict[str, Any]:
|
|
admin = _require_ops(request) or {}
|
|
actor_email = str(admin.get("email") or "").strip().lower()
|
|
db = _get_db()
|
|
created = db.create_refund_case(
|
|
reason=reason,
|
|
intent_id=intent_id,
|
|
tx_hash=tx_hash,
|
|
user_id=user_id,
|
|
amount_usdc=amount_usdc,
|
|
created_by=actor_email,
|
|
note=note,
|
|
)
|
|
if not created or created.get("ok") is False:
|
|
raise HTTPException(status_code=400, detail=created or "refund_case_failed")
|
|
db.append_ops_audit_event(
|
|
action="refund_case_create",
|
|
actor_email=actor_email,
|
|
target_user_id=str(user_id or ""),
|
|
target_type="refund_case",
|
|
target_id=str(created.get("id") or ""),
|
|
payload={
|
|
"reason": reason,
|
|
"intent_id": intent_id,
|
|
"tx_hash": tx_hash,
|
|
"amount_usdc": amount_usdc,
|
|
},
|
|
)
|
|
db.append_payment_audit_event(
|
|
"payment_refund_required",
|
|
{
|
|
"reason": str(reason or "refund_required").strip().lower(),
|
|
"intent_id": intent_id,
|
|
"user_id": user_id,
|
|
"tx_hash": tx_hash,
|
|
"refund_case_id": created.get("id"),
|
|
},
|
|
)
|
|
return {"ok": True, "refund": created}
|
|
|
|
|
|
def update_ops_refund_case(
|
|
request: Request,
|
|
*,
|
|
case_id: int,
|
|
status: str,
|
|
note: str = "",
|
|
) -> Dict[str, Any]:
|
|
admin = _require_ops(request) or {}
|
|
actor_email = str(admin.get("email") or "").strip().lower()
|
|
db = _get_db()
|
|
updated = db.update_refund_case(
|
|
case_id,
|
|
status=status,
|
|
handled_by=actor_email,
|
|
note=note,
|
|
)
|
|
if not updated:
|
|
raise HTTPException(status_code=404, detail="refund_case_not_found")
|
|
db.append_ops_audit_event(
|
|
action="refund_case_update",
|
|
actor_email=actor_email,
|
|
target_user_id=str(updated.get("user_id") or ""),
|
|
target_type="refund_case",
|
|
target_id=str(case_id),
|
|
payload={
|
|
"status": status,
|
|
"note": note,
|
|
"intent_id": updated.get("intent_id"),
|
|
"tx_hash": updated.get("tx_hash"),
|
|
},
|
|
)
|
|
return {"ok": True, "refund": updated}
|
|
|
|
|
|
def resolve_ops_payment_incident(request: Request, event_id: int) -> Dict[str, Any]:
|
|
admin = _require_ops(request) or {}
|
|
db = _get_db()
|
|
resolved_group = db.mark_related_payment_audit_events_resolved(
|
|
event_id, str(admin.get("email") or "")
|
|
)
|
|
if not resolved_group:
|
|
raise HTTPException(status_code=404, detail="payment_incident_not_found")
|
|
return {
|
|
"ok": True,
|
|
"incident": resolved_group[0],
|
|
"resolved_count": len(resolved_group),
|
|
"resolved_event_ids": [int(item.get("id") or 0) for item in resolved_group],
|
|
}
|
|
|
|
|
|
def list_ops_payments(
|
|
request: Request,
|
|
limit: int = 50,
|
|
) -> Dict[str, Any]:
|
|
"""List successful payment records from Supabase."""
|
|
_require_ops(request)
|
|
safe_limit = max(1, min(int(limit or 50), 200))
|
|
rows = _supabase_rest_rows(
|
|
"payments",
|
|
{
|
|
"select": "id,user_id,amount,currency,chain,tx_hash,status,created_at",
|
|
"order": "created_at.desc",
|
|
"limit": str(safe_limit),
|
|
},
|
|
)
|
|
return {"payments": rows, "total": len(rows)}
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════
|
|
# Billing risk
|
|
# ═══════════════════════════════════════════════════════════════════════
|
|
|
|
def get_ops_billing_risk(
|
|
request: Request,
|
|
days: int = 30,
|
|
limit: int = 80,
|
|
) -> Dict[str, Any]:
|
|
"""Summarize trial, payment, referral, and points risk signals for ops."""
|
|
_require_ops(request)
|
|
db = _get_db()
|
|
now = datetime.now(timezone.utc)
|
|
safe_days = max(1, min(int(days or 30), 120))
|
|
safe_limit = max(10, min(int(limit or 80), 200))
|
|
since_dt = now - timedelta(days=safe_days)
|
|
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
|
|
|
query_errors: List[Dict[str, str]] = []
|
|
|
|
def collect(table: str, params: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
try:
|
|
return _supabase_rest_rows(table, params)
|
|
except Exception as exc:
|
|
query_errors.append({"table": table, "error": str(exc)[:180]})
|
|
return []
|
|
|
|
intents = collect(
|
|
"payment_intents",
|
|
{
|
|
"select": (
|
|
"id,user_id,plan_code,chain_id,status,expires_at,tx_hash,"
|
|
"metadata,created_at,updated_at"
|
|
),
|
|
"order": "updated_at.desc",
|
|
"limit": str(max(safe_limit * 3, 100)),
|
|
},
|
|
)
|
|
referral_attributions = collect(
|
|
"referral_attributions",
|
|
{
|
|
"select": (
|
|
"id,referrer_user_id,referred_user_id,code,status,"
|
|
"converted_payment_intent_id,converted_tx_hash,converted_at,"
|
|
"created_at,updated_at"
|
|
),
|
|
"order": "updated_at.desc",
|
|
"limit": str(safe_limit),
|
|
},
|
|
)
|
|
referral_rewards = collect(
|
|
"referral_rewards",
|
|
{
|
|
"select": (
|
|
"id,referral_attribution_id,referrer_user_id,referred_user_id,"
|
|
"payment_intent_id,tx_hash,reward_days,reward_points,created_at"
|
|
),
|
|
"order": "created_at.desc",
|
|
"limit": str(safe_limit),
|
|
},
|
|
)
|
|
trial_claims = collect(
|
|
"trial_claims",
|
|
{
|
|
"select": "id,user_id,email,telegram_user_id,claimed_at,created_at",
|
|
"order": "created_at.desc",
|
|
"limit": str(max(safe_limit * 10, 500)),
|
|
},
|
|
)
|
|
trial_subscription_rows = collect(
|
|
"subscriptions",
|
|
{
|
|
"select": (
|
|
"id,user_id,plan_code,source,status,starts_at,expires_at,"
|
|
"created_at,updated_at"
|
|
),
|
|
"or": "(source.eq.signup_trial,plan_code.eq.signup_trial_3d)",
|
|
"order": "created_at.desc",
|
|
"limit": str(max(safe_limit * 20, 1000)),
|
|
},
|
|
)
|
|
active_subscription_rows = collect(
|
|
"subscriptions",
|
|
{
|
|
"select": (
|
|
"id,user_id,plan_code,source,status,starts_at,expires_at,"
|
|
"created_at,updated_at"
|
|
),
|
|
"status": "eq.active",
|
|
"order": "created_at.desc",
|
|
"limit": str(max(safe_limit * 20, 1000)),
|
|
},
|
|
)
|
|
subscription_rows: List[Dict[str, Any]] = []
|
|
seen_subscription_keys: set[str] = set()
|
|
for row in [*trial_subscription_rows, *active_subscription_rows]:
|
|
key = str(row.get("id") or "").strip() or (
|
|
f"{row.get('user_id')}:{row.get('plan_code')}:{row.get('source')}:"
|
|
f"{row.get('starts_at')}:{row.get('expires_at')}"
|
|
)
|
|
if key in seen_subscription_keys:
|
|
continue
|
|
seen_subscription_keys.add(key)
|
|
subscription_rows.append(row)
|
|
entitlement_trial_events = collect(
|
|
"entitlement_events",
|
|
{
|
|
"select": "id,user_id,action,payload,created_at",
|
|
"action": "in.(signup_trial_claimed,signup_trial_granted)",
|
|
"order": "created_at.desc",
|
|
"limit": str(max(safe_limit * 10, 500)),
|
|
},
|
|
)
|
|
|
|
issues: List[Dict[str, Any]] = []
|
|
stuck_intents: List[Dict[str, Any]] = []
|
|
points_issues: List[Dict[str, Any]] = []
|
|
|
|
for intent in intents:
|
|
status = str(intent.get("status") or "").strip().lower()
|
|
updated_at = _parse_iso_datetime(intent.get("updated_at"))
|
|
created_at = _parse_iso_datetime(intent.get("created_at"))
|
|
expires_at = _parse_iso_datetime(intent.get("expires_at"))
|
|
age_min = (
|
|
int((now - (updated_at or created_at or now)).total_seconds() // 60)
|
|
if (updated_at or created_at)
|
|
else 0
|
|
)
|
|
intent_id = str(intent.get("id") or "")
|
|
user_id = str(intent.get("user_id") or "")
|
|
metadata = intent.get("metadata")
|
|
metadata = metadata if isinstance(metadata, dict) else {}
|
|
redemption = metadata.get("points_redemption")
|
|
redemption = redemption if isinstance(redemption, dict) else {}
|
|
|
|
if status == "submitted" and age_min >= 10:
|
|
row = {
|
|
"id": intent_id,
|
|
"user_id": user_id,
|
|
"plan_code": intent.get("plan_code"),
|
|
"status": status,
|
|
"age_min": age_min,
|
|
"tx_hash": intent.get("tx_hash"),
|
|
"updated_at": intent.get("updated_at"),
|
|
}
|
|
stuck_intents.append(row)
|
|
issues.append(
|
|
_risk_issue(
|
|
category="payment_intent",
|
|
severity="high",
|
|
title="Submitted intent 超过 10 分钟未确认",
|
|
detail=f"{intent_id} 已提交 {age_min} 分钟,可能需要补单或检查确认循环。",
|
|
user_id=user_id,
|
|
created_at=intent.get("updated_at") or intent.get("created_at"),
|
|
reference=intent_id,
|
|
payload=row,
|
|
)
|
|
)
|
|
elif status == "created" and expires_at and expires_at < now:
|
|
row = {
|
|
"id": intent_id,
|
|
"user_id": user_id,
|
|
"plan_code": intent.get("plan_code"),
|
|
"status": status,
|
|
"expires_at": intent.get("expires_at"),
|
|
"age_min": age_min,
|
|
}
|
|
stuck_intents.append(row)
|
|
issues.append(
|
|
_risk_issue(
|
|
category="payment_intent",
|
|
severity="medium",
|
|
title="Created intent 已过期但未关闭",
|
|
detail=f"{intent_id} 已过期,用户可能离开支付流程。",
|
|
user_id=user_id,
|
|
created_at=intent.get("created_at"),
|
|
reference=intent_id,
|
|
payload=row,
|
|
)
|
|
)
|
|
|
|
if bool(redemption.get("applied")):
|
|
planned = int(redemption.get("points_to_consume") or 0)
|
|
consumed = bool(redemption.get("consumed"))
|
|
consumed_points = int(redemption.get("consumed_points") or 0)
|
|
if status == "confirmed" and not consumed:
|
|
row = {
|
|
"intent_id": intent_id,
|
|
"user_id": user_id,
|
|
"status": status,
|
|
"planned_points": planned,
|
|
"consumed_points": consumed_points,
|
|
"updated_at": intent.get("updated_at"),
|
|
}
|
|
points_issues.append(row)
|
|
issues.append(
|
|
_risk_issue(
|
|
category="points_redemption",
|
|
severity="high",
|
|
title="订单已确认但积分未扣减",
|
|
detail=f"{intent_id} 标记使用积分,但 confirmed metadata 未显示 consumed。",
|
|
user_id=user_id,
|
|
created_at=intent.get("updated_at") or intent.get("created_at"),
|
|
reference=intent_id,
|
|
payload=row,
|
|
)
|
|
)
|
|
elif status == "confirmed" and planned > 0 and 0 < consumed_points < planned:
|
|
row = {
|
|
"intent_id": intent_id,
|
|
"user_id": user_id,
|
|
"status": status,
|
|
"planned_points": planned,
|
|
"consumed_points": consumed_points,
|
|
"updated_at": intent.get("updated_at"),
|
|
}
|
|
points_issues.append(row)
|
|
issues.append(
|
|
_risk_issue(
|
|
category="points_redemption",
|
|
severity="medium",
|
|
title="积分抵扣只扣了部分积分",
|
|
detail=f"{intent_id} 计划扣 {planned},实际扣 {consumed_points}。",
|
|
user_id=user_id,
|
|
created_at=intent.get("updated_at") or intent.get("created_at"),
|
|
reference=intent_id,
|
|
payload=row,
|
|
)
|
|
)
|
|
|
|
reward_by_attribution = {
|
|
str(row.get("referral_attribution_id") or ""): row
|
|
for row in referral_rewards
|
|
if row.get("referral_attribution_id") is not None
|
|
}
|
|
monthly_cap_hits: List[Dict[str, Any]] = []
|
|
referral_settlement_issues: List[Dict[str, Any]] = []
|
|
|
|
for attribution in referral_attributions:
|
|
status = str(attribution.get("status") or "").strip().lower()
|
|
attribution_id = str(attribution.get("id") or "")
|
|
updated_at = _parse_iso_datetime(
|
|
attribution.get("updated_at") or attribution.get("converted_at") or attribution.get("created_at")
|
|
)
|
|
if status == "capped" and (not updated_at or updated_at >= month_start):
|
|
row = {
|
|
"id": attribution_id,
|
|
"code": attribution.get("code"),
|
|
"referrer_user_id": attribution.get("referrer_user_id"),
|
|
"referred_user_id": attribution.get("referred_user_id"),
|
|
"updated_at": attribution.get("updated_at"),
|
|
}
|
|
monthly_cap_hits.append(row)
|
|
issues.append(
|
|
_risk_issue(
|
|
category="referral",
|
|
severity="medium",
|
|
title="邀请奖励月度上限命中",
|
|
detail=f"邀请码 {attribution.get('code') or ''} 的推荐奖励已被月度上限拦截。",
|
|
user_id=attribution.get("referrer_user_id"),
|
|
created_at=attribution.get("updated_at") or attribution.get("created_at"),
|
|
reference=attribution_id,
|
|
payload=row,
|
|
)
|
|
)
|
|
if status == "converted" and attribution_id not in reward_by_attribution:
|
|
row = {
|
|
"id": attribution_id,
|
|
"code": attribution.get("code"),
|
|
"referrer_user_id": attribution.get("referrer_user_id"),
|
|
"referred_user_id": attribution.get("referred_user_id"),
|
|
"converted_payment_intent_id": attribution.get("converted_payment_intent_id"),
|
|
"converted_at": attribution.get("converted_at"),
|
|
}
|
|
referral_settlement_issues.append(row)
|
|
issues.append(
|
|
_risk_issue(
|
|
category="referral",
|
|
severity="high",
|
|
title="推荐已转化但没有奖励记录",
|
|
detail=f"归因 {attribution_id} 已 converted,但 referral_rewards 未找到对应记录。",
|
|
user_id=attribution.get("referrer_user_id"),
|
|
created_at=attribution.get("converted_at") or attribution.get("updated_at"),
|
|
reference=attribution_id,
|
|
payload=row,
|
|
)
|
|
)
|
|
|
|
events = db.list_app_analytics_events(limit=20000, since_iso=since_dt.isoformat())
|
|
signup_rows = [
|
|
row
|
|
for row in events
|
|
if str(row.get("event_type") or "").strip().lower()
|
|
in {"signup_success", "signup_completed"}
|
|
]
|
|
|
|
def normalize_user_key(value: Any) -> str:
|
|
return str(value or "").strip().lower()
|
|
|
|
def analytics_payload(row: Dict[str, Any]) -> Dict[str, Any]:
|
|
payload = row.get("payload")
|
|
return payload if isinstance(payload, dict) else {}
|
|
|
|
def analytics_correlation_keys(row: Dict[str, Any]) -> set[str]:
|
|
payload = analytics_payload(row)
|
|
keys: set[str] = set()
|
|
user_id = normalize_user_key(row.get("user_id") or payload.get("user_id"))
|
|
client_id = str(row.get("client_id") or "").strip()
|
|
session_id = str(row.get("session_id") or "").strip()
|
|
if user_id:
|
|
keys.add(f"user:{user_id}")
|
|
if client_id:
|
|
keys.add(f"client:{client_id}")
|
|
if session_id:
|
|
keys.add(f"session:{session_id}")
|
|
return keys
|
|
|
|
signup_intent_keys: set[str] = set()
|
|
for row in events:
|
|
if str(row.get("event_type") or "").strip().lower() != "login_start":
|
|
continue
|
|
payload = analytics_payload(row)
|
|
mode = str(payload.get("mode") or payload.get("auth_mode") or "").strip().lower()
|
|
if mode == "signup":
|
|
signup_intent_keys.update(analytics_correlation_keys(row))
|
|
|
|
def has_signup_intent(row: Dict[str, Any]) -> bool:
|
|
payload = analytics_payload(row)
|
|
mode = str(payload.get("mode") or payload.get("auth_mode") or "").strip().lower()
|
|
if mode == "signup" or payload.get("signup_intent") is True:
|
|
return True
|
|
return bool(analytics_correlation_keys(row).intersection(signup_intent_keys))
|
|
|
|
trial_actor_keys = {
|
|
_app_analytics_actor_key(row)
|
|
for row in events
|
|
if str(row.get("event_type") or "").strip().lower() == "trial_created"
|
|
}
|
|
subscription_user_keys = {
|
|
normalize_user_key(row.get("user_id"))
|
|
for row in subscription_rows
|
|
if normalize_user_key(row.get("user_id"))
|
|
}
|
|
trial_subscription_user_keys = {
|
|
normalize_user_key(row.get("user_id"))
|
|
for row in subscription_rows
|
|
if normalize_user_key(row.get("user_id"))
|
|
and (
|
|
str(row.get("plan_code") or "").strip().lower() == "signup_trial_3d"
|
|
or str(row.get("source") or "").strip().lower() == "signup_trial"
|
|
)
|
|
}
|
|
trial_claim_user_keys = {
|
|
normalize_user_key(row.get("user_id"))
|
|
for row in trial_claims
|
|
if normalize_user_key(row.get("user_id"))
|
|
}
|
|
trial_event_user_keys: set[str] = set()
|
|
for row in entitlement_trial_events:
|
|
event_user_id = normalize_user_key(row.get("user_id"))
|
|
payload = row.get("payload") if isinstance(row.get("payload"), dict) else {}
|
|
payload_user_id = normalize_user_key(payload.get("user_id"))
|
|
if event_user_id:
|
|
trial_event_user_keys.add(event_user_id)
|
|
if payload_user_id:
|
|
trial_event_user_keys.add(payload_user_id)
|
|
|
|
backend_trial_user_keys = (
|
|
trial_subscription_user_keys | trial_claim_user_keys | trial_event_user_keys
|
|
)
|
|
trial_gaps: List[Dict[str, Any]] = []
|
|
|
|
for claim in trial_claims:
|
|
claim_user_id = normalize_user_key(claim.get("user_id"))
|
|
if not claim_user_id or claim_user_id in trial_subscription_user_keys:
|
|
continue
|
|
gap = {
|
|
"claim_id": claim.get("id"),
|
|
"user_id": claim.get("user_id"),
|
|
"email": claim.get("email"),
|
|
"created_at": claim.get("created_at") or claim.get("claimed_at"),
|
|
"reason": "trial_claim_without_subscription",
|
|
}
|
|
trial_gaps.append(gap)
|
|
if len(trial_gaps) <= 20:
|
|
issues.append(
|
|
_risk_issue(
|
|
category="signup_trial",
|
|
severity="high",
|
|
title="试用 claim 已写入但订阅缺失",
|
|
detail="trial_claims 已记录该用户领取试用,但 subscriptions 中没有 signup_trial_3d 记录。",
|
|
user_id=gap.get("user_id"),
|
|
created_at=gap.get("created_at"),
|
|
reference=str(gap.get("claim_id") or ""),
|
|
payload=gap,
|
|
)
|
|
)
|
|
|
|
for row in signup_rows[:300]:
|
|
if not has_signup_intent(row):
|
|
continue
|
|
actor_key = _app_analytics_actor_key(row)
|
|
if actor_key in trial_actor_keys:
|
|
continue
|
|
payload = analytics_payload(row)
|
|
signup_user_id = normalize_user_key(row.get("user_id") or payload.get("user_id"))
|
|
if not signup_user_id:
|
|
continue
|
|
if (
|
|
signup_user_id in backend_trial_user_keys
|
|
or signup_user_id in subscription_user_keys
|
|
):
|
|
continue
|
|
gap = {
|
|
"event_id": row.get("id"),
|
|
"actor_key": actor_key,
|
|
"user_id": signup_user_id,
|
|
"created_at": row.get("created_at"),
|
|
"reason": "signup_without_backend_trial_evidence",
|
|
}
|
|
trial_gaps.append(gap)
|
|
if len(trial_gaps) <= 20:
|
|
issues.append(
|
|
_risk_issue(
|
|
category="signup_trial",
|
|
severity="high",
|
|
title="注册成功后未发现后端试用记录",
|
|
detail=(
|
|
"该用户进入 signup_success,但没有 trial_created、trial_claims、"
|
|
"signup_trial subscription 或其他有效订阅证据。"
|
|
),
|
|
user_id=gap.get("user_id"),
|
|
created_at=gap.get("created_at"),
|
|
reference=str(gap.get("event_id") or ""),
|
|
payload=gap,
|
|
)
|
|
)
|
|
|
|
payment_incidents_raw = db.list_payment_audit_events(
|
|
limit=max(safe_limit, 500),
|
|
event_type="payment_intent_failed",
|
|
)
|
|
grouped_payment_incidents = _group_payment_incidents(payment_incidents_raw)
|
|
unresolved_incidents = grouped_payment_incidents["incidents"]
|
|
|
|
issues.sort(key=lambda item: str(item.get("created_at") or ""), reverse=True)
|
|
recent_rewards = [
|
|
{
|
|
"id": row.get("id"),
|
|
"referral_attribution_id": row.get("referral_attribution_id"),
|
|
"referrer_user_id": row.get("referrer_user_id"),
|
|
"referred_user_id": row.get("referred_user_id"),
|
|
"payment_intent_id": row.get("payment_intent_id"),
|
|
"reward_points": int(row.get("reward_points") or 0),
|
|
"reward_days": int(row.get("reward_days") or 0),
|
|
"tx_hash": row.get("tx_hash"),
|
|
"explorer_url": _payment_explorer_url("polygon", row.get("tx_hash")),
|
|
"created_at": row.get("created_at"),
|
|
}
|
|
for row in referral_rewards[:safe_limit]
|
|
]
|
|
|
|
return {
|
|
"checked_at": _to_utc_iso(now),
|
|
"window_days": safe_days,
|
|
"summary": {
|
|
"issues": len(issues),
|
|
"stuck_intents": len(stuck_intents),
|
|
"trial_gaps": len(trial_gaps),
|
|
"payment_incidents": grouped_payment_incidents["total"],
|
|
"payment_incident_events": grouped_payment_incidents["raw_total"],
|
|
"points_discount_issues": len(points_issues),
|
|
"referral_settlement_issues": len(referral_settlement_issues),
|
|
"monthly_cap_hits": len(monthly_cap_hits),
|
|
"recent_referral_rewards": len(recent_rewards),
|
|
"recent_trial_claims": len(trial_claims),
|
|
},
|
|
"issues": issues[:safe_limit],
|
|
"stuck_intents": stuck_intents[:safe_limit],
|
|
"trial_gaps": trial_gaps[:safe_limit],
|
|
"payment_incidents": unresolved_incidents[:safe_limit],
|
|
"points_discount_issues": points_issues[:safe_limit],
|
|
"referral_settlement_issues": referral_settlement_issues[:safe_limit],
|
|
"monthly_cap_hits": monthly_cap_hits[:safe_limit],
|
|
"recent_referral_rewards": recent_rewards,
|
|
"recent_trial_claims": trial_claims,
|
|
"query_errors": query_errors,
|
|
}
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════
|
|
# Membership helpers
|
|
# ═══════════════════════════════════════════════════════════════════════
|
|
|
|
def _list_active_subscriptions_with_windows(
|
|
limit: int,
|
|
) -> tuple[list[dict[str, Any]], dict[str, dict[str, Any]], bool]:
|
|
active_window_query = getattr(
|
|
legacy_routes.SUPABASE_ENTITLEMENT,
|
|
"list_active_subscription_windows",
|
|
None,
|
|
)
|
|
if not callable(active_window_query):
|
|
return [], {}, False
|
|
try:
|
|
active_window_payload = active_window_query(limit=limit)
|
|
except Exception:
|
|
return [], {}, False
|
|
if not isinstance(active_window_payload, dict):
|
|
return [], {}, False
|
|
|
|
active_window_subscriptions = active_window_payload.get("subscriptions")
|
|
active_window_windows = active_window_payload.get("windows")
|
|
if not isinstance(active_window_subscriptions, list):
|
|
return [], {}, False
|
|
if not active_window_subscriptions and not (
|
|
isinstance(active_window_windows, dict) and active_window_windows
|
|
):
|
|
return [], {}, False
|
|
|
|
subscriptions = [
|
|
item for item in active_window_subscriptions if isinstance(item, dict)
|
|
]
|
|
subscription_windows = (
|
|
active_window_windows if isinstance(active_window_windows, dict) else {}
|
|
)
|
|
return subscriptions, subscription_windows, True
|
|
|
|
|
|
def _build_membership_rows(
|
|
db: DBManager,
|
|
subscriptions: list[dict[str, Any]],
|
|
subscription_windows: dict[str, dict[str, Any]],
|
|
) -> list[dict[str, Any]]:
|
|
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
|
|
)
|
|
if not subscription_windows:
|
|
subscription_windows = legacy_routes.SUPABASE_ENTITLEMENT.list_subscription_windows(
|
|
subscription_user_ids,
|
|
bypass_cache=True,
|
|
)
|
|
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 = subscription_windows.get(user_id, {})
|
|
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
|
|
return sorted(
|
|
deduped.values(),
|
|
key=lambda item: str(item.get("expires_at") or ""),
|
|
)
|
|
|
|
|
|
def _build_membership_growth(
|
|
subscriptions: list[dict[str, Any]],
|
|
days: int,
|
|
) -> dict[str, Any]:
|
|
safe_days = max(7, min(365, int(days or 90)))
|
|
now = datetime.utcnow()
|
|
cutoff = now - timedelta(days=safe_days)
|
|
|
|
trial_by_day: dict[str, int] = defaultdict(int)
|
|
paid_by_day: dict[str, int] = defaultdict(int)
|
|
running = 0
|
|
|
|
for item in subscriptions:
|
|
starts_raw = str(item.get("starts_at") or "").strip()
|
|
if not starts_raw:
|
|
continue
|
|
try:
|
|
dt = datetime.fromisoformat(starts_raw.replace("Z", "+00:00"))
|
|
if dt.tzinfo is not None:
|
|
dt = dt.replace(tzinfo=None)
|
|
except Exception:
|
|
continue
|
|
if dt < cutoff:
|
|
continue
|
|
day_key = dt.strftime("%Y-%m-%d")
|
|
source = str(item.get("source") or "").strip().lower()
|
|
plan = str(item.get("plan_code") or "").strip().lower()
|
|
is_trial = source == "signup_trial" or plan.startswith("signup_trial")
|
|
if is_trial:
|
|
trial_by_day[day_key] += 1
|
|
else:
|
|
paid_by_day[day_key] += 1
|
|
|
|
daily = []
|
|
cursor = cutoff.date()
|
|
while cursor <= now.date():
|
|
key = cursor.isoformat()
|
|
tc = trial_by_day.get(key, 0)
|
|
pc = paid_by_day.get(key, 0)
|
|
total = tc + pc
|
|
running += total
|
|
daily.append(
|
|
{
|
|
"date": key,
|
|
"trial": tc,
|
|
"paid": pc,
|
|
"total": total,
|
|
"cumulative": running,
|
|
}
|
|
)
|
|
cursor += timedelta(days=1)
|
|
|
|
return {"days": safe_days, "daily": daily}
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════
|
|
# Membership API
|
|
# ═══════════════════════════════════════════════════════════════════════
|
|
|
|
def list_ops_memberships(request: Request, limit: int = 200) -> Dict[str, Any]:
|
|
_require_ops(request)
|
|
db = _get_db()
|
|
reconcile_enabled = (
|
|
str(os.getenv("POLYWEATHER_OPS_MEMBERSHIPS_RECONCILE_ENABLED") or "")
|
|
.strip()
|
|
.lower()
|
|
in {"1", "true", "yes", "on"}
|
|
)
|
|
if reconcile_enabled and 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, subscription_windows, used_active_window_query = (
|
|
_list_active_subscriptions_with_windows(limit)
|
|
)
|
|
if not used_active_window_query:
|
|
subscriptions = legacy_routes.SUPABASE_ENTITLEMENT.list_active_subscriptions(
|
|
limit=limit
|
|
)
|
|
return {
|
|
"memberships": _build_membership_rows(
|
|
db,
|
|
subscriptions,
|
|
subscription_windows,
|
|
)
|
|
}
|
|
|
|
|
|
def get_ops_memberships_growth(request: Request, days: int = 90) -> dict[str, Any]:
|
|
_require_ops(request)
|
|
|
|
subscriptions, _, used_active_window_query = _list_active_subscriptions_with_windows(
|
|
limit=5000
|
|
)
|
|
if not used_active_window_query:
|
|
subscriptions = legacy_routes.SUPABASE_ENTITLEMENT.list_active_subscriptions(
|
|
limit=5000
|
|
)
|
|
return _build_membership_growth(subscriptions, days)
|
|
|
|
|
|
def get_ops_memberships_overview(
|
|
request: Request,
|
|
limit: int = 200,
|
|
days: int = 90,
|
|
) -> dict[str, Any]:
|
|
_require_ops(request)
|
|
db = _get_db()
|
|
safe_limit = max(1, min(int(limit or 200), 1000))
|
|
query_limit = max(safe_limit, 5000)
|
|
subscriptions, subscription_windows, used_active_window_query = (
|
|
_list_active_subscriptions_with_windows(limit=query_limit)
|
|
)
|
|
if not used_active_window_query:
|
|
subscriptions = legacy_routes.SUPABASE_ENTITLEMENT.list_active_subscriptions(
|
|
limit=query_limit
|
|
)
|
|
membership_subscriptions = subscriptions[:safe_limit]
|
|
return {
|
|
"memberships": _build_membership_rows(
|
|
db,
|
|
membership_subscriptions,
|
|
subscription_windows,
|
|
),
|
|
**_build_membership_growth(subscriptions, days),
|
|
}
|