Add ops tools for payment incident triage and resolution

This commit is contained in:
2569718930@qq.com
2026-03-21 13:44:32 +08:00
parent 0425c237b1
commit e662ef7d3b
6 changed files with 454 additions and 12 deletions
+73 -10
View File
@@ -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()
+96
View File
@@ -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)",