From e662ef7d3b7dfafeddff48b9eddffc80d04b5670 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Sat, 21 Mar 2026 13:44:32 +0800 Subject: [PATCH] Add ops tools for payment incident triage and resolution --- .../incidents/[eventId]/resolve/route.ts | 44 +++++ .../app/api/ops/payments/incidents/route.ts | 42 +++++ frontend/components/ops/OpsDashboard.tsx | 157 +++++++++++++++++- src/database/db_manager.py | 83 +++++++-- src/payments/contract_checkout.py | 96 +++++++++++ web/routes.py | 44 +++++ 6 files changed, 454 insertions(+), 12 deletions(-) create mode 100644 frontend/app/api/ops/payments/incidents/[eventId]/resolve/route.ts create mode 100644 frontend/app/api/ops/payments/incidents/route.ts diff --git a/frontend/app/api/ops/payments/incidents/[eventId]/resolve/route.ts b/frontend/app/api/ops/payments/incidents/[eventId]/resolve/route.ts new file mode 100644 index 00000000..bcd34dac --- /dev/null +++ b/frontend/app/api/ops/payments/incidents/[eventId]/resolve/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + applyAuthResponseCookies, + buildBackendRequestHeaders, +} from "@/lib/backend-auth"; + +const API_BASE = process.env.POLYWEATHER_API_BASE_URL; + +type RouteContext = { + params: Promise<{ eventId: string }>; +}; + +export async function POST(req: NextRequest, context: RouteContext) { + if (!API_BASE) { + return NextResponse.json( + { error: "POLYWEATHER_API_BASE_URL is not configured" }, + { status: 500 }, + ); + } + + try { + const auth = await buildBackendRequestHeaders(req); + const { eventId } = await context.params; + const res = await fetch(`${API_BASE}/api/ops/payments/incidents/${eventId}/resolve`, { + method: "POST", + headers: auth.headers, + cache: "no-store", + }); + const raw = await res.text(); + const response = new NextResponse(raw, { + status: res.status, + headers: { + "Content-Type": res.headers.get("content-type") || "application/json", + "Cache-Control": "no-store", + }, + }); + return applyAuthResponseCookies(response, auth.response); + } catch (error) { + return NextResponse.json( + { error: "Failed to resolve payment incident", detail: String(error) }, + { status: 500 }, + ); + } +} diff --git a/frontend/app/api/ops/payments/incidents/route.ts b/frontend/app/api/ops/payments/incidents/route.ts new file mode 100644 index 00000000..25ac3c8c --- /dev/null +++ b/frontend/app/api/ops/payments/incidents/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + applyAuthResponseCookies, + buildBackendRequestHeaders, +} from "@/lib/backend-auth"; + +const API_BASE = process.env.POLYWEATHER_API_BASE_URL; + +export async function GET(req: NextRequest) { + if (!API_BASE) { + return NextResponse.json( + { error: "POLYWEATHER_API_BASE_URL is not configured" }, + { status: 500 }, + ); + } + + try { + const auth = await buildBackendRequestHeaders(req); + const url = new URL(`${API_BASE}/api/ops/payments/incidents`); + const limit = req.nextUrl.searchParams.get("limit"); + if (limit) url.searchParams.set("limit", limit); + + const res = await fetch(url.toString(), { + headers: auth.headers, + cache: "no-store", + }); + const raw = await res.text(); + const response = new NextResponse(raw, { + status: res.status, + headers: { + "Content-Type": res.headers.get("content-type") || "application/json", + "Cache-Control": "no-store", + }, + }); + return applyAuthResponseCookies(response, auth.response); + } catch (error) { + return NextResponse.json( + { error: "Failed to fetch payment incidents", detail: String(error) }, + { status: 500 }, + ); + } +} diff --git a/frontend/components/ops/OpsDashboard.tsx b/frontend/components/ops/OpsDashboard.tsx index 05473a40..d2d73c51 100644 --- a/frontend/components/ops/OpsDashboard.tsx +++ b/frontend/components/ops/OpsDashboard.tsx @@ -53,9 +53,32 @@ type PaymentRuntimePayload = { id: number; event_type: string; created_at: string; + payload?: { + reason?: string; + detail?: string; + tx_hash?: string; + receiver_actual?: string; + receiver_expected?: string; + }; }>; }; +type PaymentIncident = { + id: number; + event_type: string; + created_at: string; + payload?: { + reason?: string; + detail?: string; + tx_hash?: string; + receiver_actual?: string; + receiver_expected?: string; + plan_code?: string; + resolved_at?: string; + resolved_by?: string; + }; +}; + type AuthMePayload = { authenticated?: boolean; email?: string | null; @@ -130,10 +153,14 @@ export function OpsDashboard() { const [searchQuery, setSearchQuery] = useState(""); const [users, setUsers] = useState([]); const [memberships, setMemberships] = useState([]); + const [paymentIncidents, setPaymentIncidents] = useState([]); + const [incidentReasonFilter, setIncidentReasonFilter] = useState("all"); const [usersLoading, setUsersLoading] = useState(false); const [usersError, setUsersError] = useState(null); const [leaderboard, setLeaderboard] = useState([]); const [membershipsLoading, setMembershipsLoading] = useState(false); + const [incidentsLoading, setIncidentsLoading] = useState(false); + const [resolvingIncidentId, setResolvingIncidentId] = useState(null); const [grantEmail, setGrantEmail] = useState(""); const [grantPoints, setGrantPoints] = useState("300"); const [grantStatus, setGrantStatus] = useState(null); @@ -210,11 +237,47 @@ export function OpsDashboard() { } }, []); + const loadPaymentIncidents = useCallback(async (reasonFilter?: string) => { + setIncidentsLoading(true); + try { + const url = new URL("/api/ops/payments/incidents", window.location.origin); + url.searchParams.set("limit", "20"); + const selectedReason = String(reasonFilter || incidentReasonFilter || "all"); + if (selectedReason && selectedReason !== "all") { + url.searchParams.set("reason", selectedReason); + } + const data = await readJson<{ incidents?: PaymentIncident[] }>(url.toString()); + setPaymentIncidents(data.incidents || []); + } catch { + setPaymentIncidents([]); + } finally { + setIncidentsLoading(false); + } + }, [incidentReasonFilter]); + useEffect(() => { void loadUsers(""); void loadLeaderboard(); void loadMemberships(); - }, [loadLeaderboard, loadMemberships, loadUsers]); + void loadPaymentIncidents(incidentReasonFilter); + }, [loadLeaderboard, loadMemberships, loadPaymentIncidents, loadUsers]); + + const resolveIncident = useCallback(async (eventId: number) => { + setResolvingIncidentId(eventId); + try { + const response = await fetch(`/api/ops/payments/incidents/${eventId}/resolve`, { + method: "POST", + }); + if (!response.ok) { + const raw = await response.text(); + throw new Error(raw || `HTTP ${response.status}`); + } + await loadPaymentIncidents(incidentReasonFilter); + await load(); + } finally { + setResolvingIncidentId(null); + } + }, [incidentReasonFilter, load, loadPaymentIncidents]); const rolloutDecision = status?.probability?.rollout?.decision; @@ -256,12 +319,13 @@ export function OpsDashboard() { await loadUsers(searchQuery); await loadLeaderboard(); await loadMemberships(); + await loadPaymentIncidents(incidentReasonFilter); } catch (submitError) { setGrantError(String(submitError)); } finally { setGrantLoading(false); } - }, [grantEmail, grantPoints, loadLeaderboard, loadMemberships, loadUsers, searchQuery]); + }, [grantEmail, grantPoints, incidentReasonFilter, loadLeaderboard, loadMemberships, loadPaymentIncidents, loadUsers, searchQuery]); return (
@@ -419,6 +483,18 @@ export function OpsDashboard() { #{item.id}
{formatDateTime(item.created_at)}
+ {item.payload?.reason ? ( +
+
原因: {item.payload.reason}
+ {item.payload.tx_hash ?
Tx: {maskUrl(item.payload.tx_hash)}
: null} + {item.payload.receiver_actual ? ( +
实际收款: {maskUrl(item.payload.receiver_actual)}
+ ) : null} + {item.payload.receiver_expected ? ( +
期望收款: {maskUrl(item.payload.receiver_expected)}
+ ) : null} +
+ ) : null} ))} {!payments?.recent_audit_events?.length ? ( @@ -429,6 +505,83 @@ export function OpsDashboard() { + + +
+
+ 支付异常单 + 只显示已明确标记失败的支付确认事故。 +
+
+ + +
+
+
+ +
+ + + + + + + + + + + + + + {paymentIncidents.map((item) => ( + + + + + + + + + + ))} + {!paymentIncidents.length ? ( + + + + ) : null} + +
时间原因套餐Tx实际收款期望收款操作
{formatDateTime(item.created_at)}{item.payload?.reason || "-"}{item.payload?.plan_code || "-"}{maskUrl(item.payload?.tx_hash)}{maskUrl(item.payload?.receiver_actual)}{maskUrl(item.payload?.receiver_expected)} + +
+ 暂无支付异常单 +
+
+
+
+ 当前会员 diff --git a/src/database/db_manager.py b/src/database/db_manager.py index 60a9f0c2..f69511aa 100644 --- a/src/database/db_manager.py +++ b/src/database/db_manager.py @@ -197,19 +197,36 @@ class DBManager: ) conn.commit() - def list_payment_audit_events(self, limit: int = 50) -> List[Dict[str, Any]]: + def list_payment_audit_events( + self, + limit: int = 50, + event_type: Optional[str] = None, + ) -> List[Dict[str, Any]]: safe_limit = max(1, min(int(limit or 50), 500)) + kind = str(event_type or "").strip().lower() with self._get_connection() as conn: conn.row_factory = sqlite3.Row - rows = conn.execute( - """ - SELECT id, event_type, payload_json, created_at - FROM payment_audit_events - ORDER BY id DESC - LIMIT ? - """, - (safe_limit,), - ).fetchall() + if kind: + rows = conn.execute( + """ + SELECT id, event_type, payload_json, created_at + FROM payment_audit_events + WHERE event_type = ? + ORDER BY id DESC + LIMIT ? + """, + (kind, safe_limit), + ).fetchall() + else: + rows = conn.execute( + """ + SELECT id, event_type, payload_json, created_at + FROM payment_audit_events + ORDER BY id DESC + LIMIT ? + """, + (safe_limit,), + ).fetchall() out = [] for row in rows: try: @@ -226,6 +243,52 @@ class DBManager: ) return out + def mark_payment_audit_event_resolved( + self, + event_id: int, + resolved_by: str, + ) -> Optional[Dict[str, Any]]: + safe_id = int(event_id or 0) + actor = str(resolved_by or "").strip().lower() + if safe_id <= 0 or not actor: + return None + with self._get_connection() as conn: + conn.row_factory = sqlite3.Row + row = conn.execute( + """ + SELECT id, event_type, payload_json, created_at + FROM payment_audit_events + WHERE id = ? + LIMIT 1 + """, + (safe_id,), + ).fetchone() + if not row: + return None + try: + payload = json.loads(str(row["payload_json"] or "{}")) + except Exception: + payload = {} + if not isinstance(payload, dict): + payload = {} + payload["resolved_at"] = datetime.now().isoformat() + payload["resolved_by"] = actor + conn.execute( + """ + UPDATE payment_audit_events + SET payload_json = ? + WHERE id = ? + """, + (json.dumps(payload, ensure_ascii=False), safe_id), + ) + conn.commit() + return { + "id": int(row["id"]), + "event_type": str(row["event_type"] or ""), + "payload": payload, + "created_at": row["created_at"], + } + @staticmethod def _safe_week_key(value: str) -> str: text = str(value or "").strip() diff --git a/src/payments/contract_checkout.py b/src/payments/contract_checkout.py index e372ef36..884d017b 100644 --- a/src/payments/contract_checkout.py +++ b/src/payments/contract_checkout.py @@ -1815,6 +1815,68 @@ class PaymentContractCheckoutService: "subscription": subscription_row, } + def _mark_intent_failed( + self, + *, + user_id: str, + intent: PaymentIntentRecord, + tx_hash: str, + reason: str, + detail: str, + extra: Optional[Dict[str, Any]] = None, + ) -> None: + now_iso = _to_iso(_now_utc()) + metadata = dict(intent.metadata or {}) + metadata["confirm_failure"] = { + "reason": str(reason or "").strip().lower(), + "detail": str(detail or "").strip(), + "tx_hash": str(tx_hash or "").strip().lower(), + "at": now_iso, + **(extra or {}), + } + self._rest( + "PATCH", + "payment_intents", + params={"id": f"eq.{intent.intent_id}", "user_id": f"eq.{user_id}"}, + payload={ + "status": "failed", + "metadata": metadata, + "updated_at": now_iso, + }, + prefer="return=representation", + allowed_status=[200], + ) + if tx_hash: + self._rest( + "POST", + "payment_transactions", + params={"on_conflict": "tx_hash"}, + payload={ + "intent_id": intent.intent_id, + "chain_id": self.chain_id, + "tx_hash": str(tx_hash).strip().lower(), + "from_address": None, + "to_address": intent.receiver_address, + "status": "failed", + "updated_at": now_iso, + }, + prefer="resolution=merge-duplicates,return=representation", + allowed_status=[200, 201], + ) + self._db.append_payment_audit_event( + "payment_intent_failed", + { + "intent_id": intent.intent_id, + "user_id": user_id, + "plan_code": intent.plan_code, + "reason": str(reason or "").strip().lower(), + "detail": str(detail or "").strip(), + "tx_hash": str(tx_hash or "").strip().lower(), + "receiver_expected": intent.receiver_address, + **(extra or {}), + }, + ) + def _notify_telegram(self, user_id: str, plan_code: str, amount_usdc: str, tx_hash: str) -> None: if not self.notify_telegram: return @@ -1881,6 +1943,13 @@ class PaymentContractCheckoutService: # Wait for receipt first to avoid transient RPC lag on eth_getTransaction. receipt = self._wait_receipt(tx_hash_text) if int(receipt.get("status") or 0) != 1: + self._mark_intent_failed( + user_id=user_id, + intent=intent, + tx_hash=tx_hash_text, + reason="tx_reverted", + detail="tx reverted", + ) raise PaymentCheckoutError(400, "tx reverted") try: @@ -1900,12 +1969,31 @@ class PaymentContractCheckoutService: if not tx_to or not tx_from: raise PaymentCheckoutError(409, "tx indexed partially; retry confirm") if tx_to != intent.receiver_address: + self._mark_intent_failed( + user_id=user_id, + intent=intent, + tx_hash=tx_hash_text, + reason="receiver_mismatch", + detail=f"tx to mismatch: got={tx_to} expected={intent.receiver_address}", + extra={ + "receiver_actual": tx_to, + "from_address": tx_from, + }, + ) raise PaymentCheckoutError( 400, f"tx to mismatch: got={tx_to} expected={intent.receiver_address}", ) if intent.payment_mode == "strict" and intent.allowed_wallet: if tx_from != intent.allowed_wallet: + self._mark_intent_failed( + user_id=user_id, + intent=intent, + tx_hash=tx_hash_text, + reason="sender_mismatch", + detail=f"tx sender mismatch: got={tx_from} expected={intent.allowed_wallet}", + extra={"from_address": tx_from}, + ) raise PaymentCheckoutError( 400, f"tx sender mismatch: got={tx_from} expected={intent.allowed_wallet}", @@ -1921,6 +2009,14 @@ class PaymentContractCheckoutService: ) event_match = self._extract_matching_event(receipt, intent) if not event_match: + self._mark_intent_failed( + user_id=user_id, + intent=intent, + tx_hash=tx_hash_text, + reason="event_mismatch", + detail="OrderPaid event mismatch; ensure contract emits OrderPaid(orderId,payer,planId,token,amount)", + extra={"from_address": tx_from, "receiver_actual": tx_to}, + ) raise PaymentCheckoutError( 400, "OrderPaid event mismatch; ensure contract emits OrderPaid(orderId,payer,planId,token,amount)", diff --git a/web/routes.py b/web/routes.py index 620d2a23..fcd83370 100644 --- a/web/routes.py +++ b/web/routes.py @@ -294,6 +294,50 @@ async def ops_memberships(request: Request, limit: int = 200): return {"memberships": rows} +@router.get("/api/ops/payments/incidents") +async def ops_payment_incidents( + request: Request, + limit: int = 50, + reason: str = "", + include_resolved: bool = False, +): + _assert_entitlement(request) + _require_ops_admin(request) + from src.database.db_manager import DBManager + + 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} + + +@router.post("/api/ops/payments/incidents/{event_id}/resolve") +async def ops_resolve_payment_incident(request: Request, event_id: int): + _assert_entitlement(request) + admin = _require_ops_admin(request) + from src.database.db_manager import DBManager + + 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} + + @router.post("/api/ops/users/grant-points") async def ops_grant_points(request: Request, body: GrantPointsRequest): _assert_entitlement(request)