Fix ops payment incident grouping
This commit is contained in:
@@ -33,7 +33,9 @@ export function runTests() {
|
|||||||
paymentsPage.includes("Intent 卡住") &&
|
paymentsPage.includes("Intent 卡住") &&
|
||||||
paymentsPage.includes("积分异常") &&
|
paymentsPage.includes("积分异常") &&
|
||||||
paymentsPage.includes("推荐奖励结算") &&
|
paymentsPage.includes("推荐奖励结算") &&
|
||||||
paymentsPage.includes("月度邀请封顶"),
|
paymentsPage.includes("月度邀请封顶") &&
|
||||||
|
paymentsPage.includes("occurrence_count") &&
|
||||||
|
paymentsPage.includes("支付事件不匹配"),
|
||||||
"ops payment page must surface trial, stuck intent, referral, points, and monthly-cap risk signals",
|
"ops payment page must surface trial, stuck intent, referral, points, and monthly-cap risk signals",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ function paymentReasonLabel(reason?: string) {
|
|||||||
if (key === "tx_not_found") return "链上交易未找到";
|
if (key === "tx_not_found") return "链上交易未找到";
|
||||||
if (key === "tx_reverted") return "链上交易失败";
|
if (key === "tx_reverted") return "链上交易失败";
|
||||||
if (key === "expired") return "订单已过期";
|
if (key === "expired") return "订单已过期";
|
||||||
|
if (key === "event_mismatch") return "支付事件不匹配";
|
||||||
|
if (key === "direct_transfer_mismatch") return "直接转账不匹配";
|
||||||
if (key === "unknown") return "未知原因";
|
if (key === "unknown") return "未知原因";
|
||||||
return key || "未知原因";
|
return key || "未知原因";
|
||||||
}
|
}
|
||||||
@@ -134,7 +136,7 @@ export function PaymentsPageClient() {
|
|||||||
const reasonCounts: Record<string, number> = {};
|
const reasonCounts: Record<string, number> = {};
|
||||||
incidents.forEach((inc) => {
|
incidents.forEach((inc) => {
|
||||||
const r = inc.reason || "unknown";
|
const r = inc.reason || "unknown";
|
||||||
reasonCounts[r] = (reasonCounts[r] || 0) + 1;
|
reasonCounts[r] = (reasonCounts[r] || 0) + Math.max(1, Number(inc.occurrence_count ?? 1));
|
||||||
});
|
});
|
||||||
|
|
||||||
const incidentPieData = Object.entries(reasonCounts).map(([name, value]) => ({
|
const incidentPieData = Object.entries(reasonCounts).map(([name, value]) => ({
|
||||||
@@ -318,6 +320,11 @@ export function PaymentsPageClient() {
|
|||||||
<td className="py-2 pr-4 text-slate-500 font-mono">{inc.id}</td>
|
<td className="py-2 pr-4 text-slate-500 font-mono">{inc.id}</td>
|
||||||
<td className="py-2 pr-4">
|
<td className="py-2 pr-4">
|
||||||
<div className="font-bold text-amber-600">{paymentReasonLabel(inc.reason)}</div>
|
<div className="font-bold text-amber-600">{paymentReasonLabel(inc.reason)}</div>
|
||||||
|
{Number(inc.occurrence_count ?? 1) > 1 ? (
|
||||||
|
<div className="mt-0.5 text-[11px] font-semibold text-slate-400">
|
||||||
|
同类重复 {Number(inc.occurrence_count).toLocaleString()} 次 · 最早 {compactDate(inc.first_seen_at)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="mt-0.5 max-w-xl truncate text-xs text-slate-500" title={inc.detail || inc.reason || ""}>
|
<div className="mt-0.5 max-w-xl truncate text-xs text-slate-500" title={inc.detail || inc.reason || ""}>
|
||||||
{inc.detail || inc.reason || "—"}
|
{inc.detail || inc.reason || "—"}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -85,11 +85,14 @@ export type SourceHealthPayload = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type PaymentRuntimePayload = {
|
export type PaymentRuntimePayload = {
|
||||||
rpc?: string;
|
rpc?: Record<string, unknown> | string;
|
||||||
chain_id?: number;
|
chain_id?: number;
|
||||||
receiver_contract?: string;
|
receiver_contract?: string;
|
||||||
last_scanned_block?: number;
|
last_scanned_block?: number;
|
||||||
audit_events_count?: number;
|
audit_events_count?: number;
|
||||||
|
checkout?: Record<string, unknown>;
|
||||||
|
event_loop_state?: Record<string, unknown>;
|
||||||
|
recent_audit_events?: Array<Record<string, unknown>>;
|
||||||
recent_events?: Array<Record<string, unknown>>;
|
recent_events?: Array<Record<string, unknown>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -106,6 +109,10 @@ export type PaymentIncident = {
|
|||||||
resolved?: boolean;
|
resolved?: boolean;
|
||||||
resolved_at?: string;
|
resolved_at?: string;
|
||||||
resolved_by?: string;
|
resolved_by?: string;
|
||||||
|
occurrence_count?: number;
|
||||||
|
event_ids?: number[];
|
||||||
|
first_seen_at?: string;
|
||||||
|
last_seen_at?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type IncidentsPayload = {
|
export type IncidentsPayload = {
|
||||||
|
|||||||
+119
-1
@@ -7,7 +7,7 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Optional, Dict, Any, List, Set
|
from typing import Optional, Dict, Any, List, Set, Tuple
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
@@ -1291,6 +1291,124 @@ class DBManager:
|
|||||||
"created_at": row["created_at"],
|
"created_at": row["created_at"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _payment_audit_resolution_key(
|
||||||
|
event_type: str,
|
||||||
|
payload: Dict[str, Any],
|
||||||
|
) -> Tuple[str, str, str, str, str]:
|
||||||
|
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()
|
||||||
|
intent_id = str(
|
||||||
|
payload.get("intent_id")
|
||||||
|
or payload.get("payment_intent_id")
|
||||||
|
or confirm_failure.get("intent_id")
|
||||||
|
or ""
|
||||||
|
).strip().lower()
|
||||||
|
user_id = str(payload.get("user_id") or "").strip().lower()
|
||||||
|
tx_hash = str(
|
||||||
|
payload.get("tx_hash")
|
||||||
|
or confirm_failure.get("tx_hash")
|
||||||
|
or ""
|
||||||
|
).strip().lower()
|
||||||
|
return str(event_type or "").strip().lower(), reason, user_id, intent_id, tx_hash
|
||||||
|
|
||||||
|
def mark_related_payment_audit_events_resolved(
|
||||||
|
self,
|
||||||
|
event_id: int,
|
||||||
|
resolved_by: str,
|
||||||
|
) -> List[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 []
|
||||||
|
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
target = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, event_type, payload_json, created_at
|
||||||
|
FROM payment_audit_events
|
||||||
|
WHERE id = ?
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(safe_id,),
|
||||||
|
).fetchone()
|
||||||
|
if not target:
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
target_payload = json.loads(str(target["payload_json"] or "{}"))
|
||||||
|
except Exception:
|
||||||
|
target_payload = {}
|
||||||
|
if not isinstance(target_payload, dict):
|
||||||
|
target_payload = {}
|
||||||
|
|
||||||
|
target_key = self._payment_audit_resolution_key(
|
||||||
|
str(target["event_type"] or ""),
|
||||||
|
target_payload,
|
||||||
|
)
|
||||||
|
if not (target_key[3] or target_key[4]):
|
||||||
|
single = self.mark_payment_audit_event_resolved(safe_id, actor)
|
||||||
|
return [single] if single else []
|
||||||
|
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, event_type, payload_json, created_at
|
||||||
|
FROM payment_audit_events
|
||||||
|
WHERE event_type = ?
|
||||||
|
ORDER BY id DESC
|
||||||
|
""",
|
||||||
|
(str(target["event_type"] or ""),),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
resolved_at = datetime.now().isoformat()
|
||||||
|
resolved_rows: List[Dict[str, Any]] = []
|
||||||
|
for row in rows:
|
||||||
|
try:
|
||||||
|
payload = json.loads(str(row["payload_json"] or "{}"))
|
||||||
|
except Exception:
|
||||||
|
payload = {}
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
payload = {}
|
||||||
|
if str(payload.get("resolved_at") or "").strip():
|
||||||
|
continue
|
||||||
|
if self._payment_audit_resolution_key(
|
||||||
|
str(row["event_type"] or ""),
|
||||||
|
payload,
|
||||||
|
) != target_key:
|
||||||
|
continue
|
||||||
|
|
||||||
|
payload["resolved_at"] = resolved_at
|
||||||
|
payload["resolved_by"] = actor
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE payment_audit_events
|
||||||
|
SET payload_json = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(json.dumps(payload, ensure_ascii=False), int(row["id"])),
|
||||||
|
)
|
||||||
|
resolved_rows.append(
|
||||||
|
{
|
||||||
|
"id": int(row["id"]),
|
||||||
|
"event_type": str(row["event_type"] or ""),
|
||||||
|
"payload": payload,
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
return resolved_rows
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _safe_week_key(value: str) -> str:
|
def _safe_week_key(value: str) -> str:
|
||||||
text = str(value or "").strip()
|
text = str(value or "").strip()
|
||||||
|
|||||||
@@ -548,6 +548,52 @@ def test_payment_runtime_state_and_audit_event_roundtrip(tmp_path):
|
|||||||
assert events[0]["payload"]["events"] == 2
|
assert events[0]["payload"]["events"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_mark_related_payment_audit_events_resolved(tmp_path):
|
||||||
|
db_path = tmp_path / "payments.db"
|
||||||
|
db = DBManager(str(db_path))
|
||||||
|
|
||||||
|
common_payload = {
|
||||||
|
"reason": "event_mismatch",
|
||||||
|
"intent_id": "intent-1",
|
||||||
|
"user_id": "user-1",
|
||||||
|
"tx_hash": "0x" + "1" * 64,
|
||||||
|
}
|
||||||
|
db.append_payment_audit_event("payment_intent_failed", dict(common_payload))
|
||||||
|
db.append_payment_audit_event("payment_intent_failed", dict(common_payload))
|
||||||
|
db.append_payment_audit_event(
|
||||||
|
"payment_intent_failed",
|
||||||
|
{
|
||||||
|
"reason": "event_mismatch",
|
||||||
|
"intent_id": "intent-2",
|
||||||
|
"user_id": "user-1",
|
||||||
|
"tx_hash": "0x" + "2" * 64,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
target_id = db.list_payment_audit_events(
|
||||||
|
limit=10,
|
||||||
|
event_type="payment_intent_failed",
|
||||||
|
)[-1]["id"]
|
||||||
|
|
||||||
|
resolved = db.mark_related_payment_audit_events_resolved(
|
||||||
|
target_id,
|
||||||
|
"ops@example.com",
|
||||||
|
)
|
||||||
|
events = db.list_payment_audit_events(limit=10, event_type="payment_intent_failed")
|
||||||
|
grouped = [
|
||||||
|
event for event in events
|
||||||
|
if event["payload"].get("intent_id") == "intent-1"
|
||||||
|
]
|
||||||
|
unrelated = [
|
||||||
|
event for event in events
|
||||||
|
if event["payload"].get("intent_id") == "intent-2"
|
||||||
|
][0]
|
||||||
|
|
||||||
|
assert len(resolved) == 2
|
||||||
|
assert all(event["payload"].get("resolved_by") == "ops@example.com" for event in grouped)
|
||||||
|
assert not unrelated["payload"].get("resolved_at")
|
||||||
|
|
||||||
|
|
||||||
def test_paid_subscription_replaces_active_trial_immediately(monkeypatch, tmp_path):
|
def test_paid_subscription_replaces_active_trial_immediately(monkeypatch, tmp_path):
|
||||||
_payment_env(monkeypatch, tmp_path)
|
_payment_env(monkeypatch, tmp_path)
|
||||||
service = PaymentContractCheckoutService()
|
service = PaymentContractCheckoutService()
|
||||||
|
|||||||
@@ -599,6 +599,84 @@ def test_ops_payment_incidents_expose_top_level_reason_and_filters_resolved(monk
|
|||||||
assert incident["resolved"] is False
|
assert incident["resolved"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_ops_payment_incidents_group_duplicate_failures(monkeypatch):
|
||||||
|
from src.database.db_manager import DBManager
|
||||||
|
|
||||||
|
older = "2026-05-25T12:26:44"
|
||||||
|
newer = "2026-05-25T12:29:51"
|
||||||
|
|
||||||
|
monkeypatch.setattr(ops_api.legacy_routes, "_require_ops_admin", lambda request: {"email": "ops@example.com"})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
DBManager,
|
||||||
|
"list_payment_audit_events",
|
||||||
|
lambda self, limit=50, event_type=None: [
|
||||||
|
{
|
||||||
|
"id": 275751,
|
||||||
|
"event_type": "payment_intent_failed",
|
||||||
|
"created_at": newer,
|
||||||
|
"payload": {
|
||||||
|
"reason": "event_mismatch",
|
||||||
|
"detail": "OrderPaid event mismatch",
|
||||||
|
"intent_id": "intent-1",
|
||||||
|
"user_id": "user-1",
|
||||||
|
"tx_hash": "0x" + "1" * 64,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 275730,
|
||||||
|
"event_type": "payment_intent_failed",
|
||||||
|
"created_at": older,
|
||||||
|
"payload": {
|
||||||
|
"reason": "event_mismatch",
|
||||||
|
"detail": "OrderPaid event mismatch",
|
||||||
|
"intent_id": "intent-1",
|
||||||
|
"user_id": "user-1",
|
||||||
|
"tx_hash": "0x" + "1" * 64,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = ops_api.list_ops_payment_incidents(None, limit=20)
|
||||||
|
|
||||||
|
assert payload["total"] == 1
|
||||||
|
assert payload["raw_total"] == 2
|
||||||
|
incident = payload["incidents"][0]
|
||||||
|
assert incident["id"] == 275751
|
||||||
|
assert incident["occurrence_count"] == 2
|
||||||
|
assert incident["event_ids"] == [275751, 275730]
|
||||||
|
assert incident["first_seen_at"] == older
|
||||||
|
assert incident["last_seen_at"] == newer
|
||||||
|
|
||||||
|
|
||||||
|
def test_ops_resolve_payment_incident_marks_duplicate_group(monkeypatch):
|
||||||
|
from src.database.db_manager import DBManager
|
||||||
|
|
||||||
|
called = {}
|
||||||
|
|
||||||
|
monkeypatch.setattr(ops_api.legacy_routes, "_require_ops_admin", lambda request: {"email": "ops@example.com"})
|
||||||
|
|
||||||
|
def mark_related(self, event_id, resolved_by):
|
||||||
|
called["event_id"] = event_id
|
||||||
|
called["resolved_by"] = resolved_by
|
||||||
|
return [
|
||||||
|
{"id": 275751, "payload": {"resolved_at": "now"}},
|
||||||
|
{"id": 275730, "payload": {"resolved_at": "now"}},
|
||||||
|
]
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
DBManager,
|
||||||
|
"mark_related_payment_audit_events_resolved",
|
||||||
|
mark_related,
|
||||||
|
raising=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = ops_api.resolve_ops_payment_incident(None, 275751)
|
||||||
|
|
||||||
|
assert called == {"event_id": 275751, "resolved_by": "ops@example.com"}
|
||||||
|
assert payload["resolved_count"] == 2
|
||||||
|
|
||||||
|
|
||||||
def test_cities_endpoint_uses_denver_display_name_for_aurora_market():
|
def test_cities_endpoint_uses_denver_display_name_for_aurora_market():
|
||||||
response = client.get("/api/cities")
|
response = client.get("/api/cities")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -1203,6 +1281,47 @@ def test_payment_runtime_endpoint_returns_shape():
|
|||||||
assert 'recent_audit_events' in payload
|
assert 'recent_audit_events' in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_payment_runtime_endpoint_returns_ops_summary_fields(monkeypatch):
|
||||||
|
from src.database.db_manager import DBManager
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
routes.PAYMENT_CHECKOUT,
|
||||||
|
"get_config_payload",
|
||||||
|
lambda: {
|
||||||
|
"enabled": True,
|
||||||
|
"chain_id": 137,
|
||||||
|
"receiver_contract": "0x351a1bca5f49dd0046a7cf0bafa7e12fa6441c3a",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
routes.PAYMENT_CHECKOUT,
|
||||||
|
"get_rpc_runtime_status",
|
||||||
|
lambda: {"connected": True, "chain_id": 137},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
DBManager,
|
||||||
|
"get_payment_runtime_state",
|
||||||
|
lambda self, key: {"last_scanned_block": 123456},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
DBManager,
|
||||||
|
"list_payment_audit_events",
|
||||||
|
lambda self, limit=20, event_type=None: [
|
||||||
|
{"id": 1, "event_type": "event_loop_cycle", "payload": {}, "created_at": "now"},
|
||||||
|
{"id": 2, "event_type": "payment_intent_failed", "payload": {}, "created_at": "now"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get("/api/payments/runtime")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["chain_id"] == 137
|
||||||
|
assert payload["receiver_contract"] == "0x351a1bca5f49dd0046a7cf0bafa7e12fa6441c3a"
|
||||||
|
assert payload["last_scanned_block"] == 123456
|
||||||
|
assert payload["audit_events_count"] == 2
|
||||||
|
|
||||||
|
|
||||||
def test_payment_config_does_not_require_entitlement(monkeypatch):
|
def test_payment_config_does_not_require_entitlement(monkeypatch):
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
routes,
|
routes,
|
||||||
|
|||||||
+95
-24
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
from fastapi import HTTPException, Request
|
from fastapi import HTTPException, Request
|
||||||
import requests as _requests
|
import requests as _requests
|
||||||
@@ -425,20 +425,26 @@ def _normalize_payment_incident(item: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def list_ops_payment_incidents(
|
def _payment_incident_group_key(item: Dict[str, Any]) -> Tuple[str, str, str, str]:
|
||||||
request: Request,
|
reason = str(item.get("reason") or "unknown").strip().lower()
|
||||||
limit: int = 50,
|
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 = "",
|
reason: str = "",
|
||||||
include_resolved: bool = False,
|
include_resolved: bool = False,
|
||||||
) -> Dict[str, Any]:
|
) -> 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()
|
normalized_reason = str(reason or "").strip().lower()
|
||||||
filtered = []
|
groups: Dict[Tuple[str, str, str, str], Dict[str, Any]] = {}
|
||||||
|
raw_total = 0
|
||||||
|
|
||||||
for item in incidents:
|
for item in incidents:
|
||||||
normalized_item = _normalize_payment_incident(item)
|
normalized_item = _normalize_payment_incident(item)
|
||||||
item_reason = str(normalized_item.get("reason") or "").strip().lower()
|
item_reason = str(normalized_item.get("reason") or "").strip().lower()
|
||||||
@@ -447,19 +453,86 @@ def list_ops_payment_incidents(
|
|||||||
continue
|
continue
|
||||||
if not include_resolved and resolved:
|
if not include_resolved and resolved:
|
||||||
continue
|
continue
|
||||||
filtered.append(normalized_item)
|
raw_total += 1
|
||||||
return {"incidents": filtered, "total": len(filtered)}
|
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_ops_payment_incidents(
|
||||||
|
request: Request,
|
||||||
|
limit: int = 50,
|
||||||
|
reason: str = "",
|
||||||
|
include_resolved: bool = False,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
_require_ops(request)
|
||||||
|
db = DBManager()
|
||||||
|
safe_limit = max(1, min(int(limit or 50), 200))
|
||||||
|
incidents = db.list_payment_audit_events(
|
||||||
|
limit=max(safe_limit, 500),
|
||||||
|
event_type="payment_intent_failed",
|
||||||
|
)
|
||||||
|
grouped = _group_payment_incidents(
|
||||||
|
incidents,
|
||||||
|
reason=reason,
|
||||||
|
include_resolved=include_resolved,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
**grouped,
|
||||||
|
"incidents": grouped["incidents"][:safe_limit],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def resolve_ops_payment_incident(request: Request, event_id: int) -> Dict[str, Any]:
|
def resolve_ops_payment_incident(request: Request, event_id: int) -> Dict[str, Any]:
|
||||||
admin = _require_ops(request) or {}
|
admin = _require_ops(request) or {}
|
||||||
db = DBManager()
|
db = DBManager()
|
||||||
resolved = db.mark_payment_audit_event_resolved(
|
resolved_group = db.mark_related_payment_audit_events_resolved(
|
||||||
event_id, str(admin.get("email") or "")
|
event_id, str(admin.get("email") or "")
|
||||||
)
|
)
|
||||||
if not resolved:
|
if not resolved_group:
|
||||||
raise HTTPException(status_code=404, detail="payment_incident_not_found")
|
raise HTTPException(status_code=404, detail="payment_incident_not_found")
|
||||||
return {"ok": True, "incident": resolved}
|
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(
|
def list_ops_payments(
|
||||||
@@ -913,15 +986,12 @@ def get_ops_billing_risk(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
payment_incidents = db.list_payment_audit_events(
|
payment_incidents_raw = db.list_payment_audit_events(
|
||||||
limit=safe_limit,
|
limit=max(safe_limit, 500),
|
||||||
event_type="payment_intent_failed",
|
event_type="payment_intent_failed",
|
||||||
)
|
)
|
||||||
unresolved_incidents = [
|
grouped_payment_incidents = _group_payment_incidents(payment_incidents_raw)
|
||||||
item
|
unresolved_incidents = grouped_payment_incidents["incidents"]
|
||||||
for item in payment_incidents
|
|
||||||
if not str((item.get("payload") or {}).get("resolved_at") or "").strip()
|
|
||||||
]
|
|
||||||
|
|
||||||
issues.sort(key=lambda item: str(item.get("created_at") or ""), reverse=True)
|
issues.sort(key=lambda item: str(item.get("created_at") or ""), reverse=True)
|
||||||
recent_rewards = [
|
recent_rewards = [
|
||||||
@@ -947,7 +1017,8 @@ def get_ops_billing_risk(
|
|||||||
"issues": len(issues),
|
"issues": len(issues),
|
||||||
"stuck_intents": len(stuck_intents),
|
"stuck_intents": len(stuck_intents),
|
||||||
"trial_gaps": len(trial_gaps),
|
"trial_gaps": len(trial_gaps),
|
||||||
"payment_incidents": len(unresolved_incidents),
|
"payment_incidents": grouped_payment_incidents["total"],
|
||||||
|
"payment_incident_events": grouped_payment_incidents["raw_total"],
|
||||||
"points_discount_issues": len(points_issues),
|
"points_discount_issues": len(points_issues),
|
||||||
"referral_settlement_issues": len(referral_settlement_issues),
|
"referral_settlement_issues": len(referral_settlement_issues),
|
||||||
"monthly_cap_hits": len(monthly_cap_hits),
|
"monthly_cap_hits": len(monthly_cap_hits),
|
||||||
|
|||||||
@@ -47,11 +47,19 @@ def get_payment_runtime(request: Request) -> Dict[str, Any]:
|
|||||||
legacy_routes._assert_entitlement(request)
|
legacy_routes._assert_entitlement(request)
|
||||||
try:
|
try:
|
||||||
db = DBManager()
|
db = DBManager()
|
||||||
|
checkout = legacy_routes.PAYMENT_CHECKOUT.get_config_payload()
|
||||||
|
rpc = legacy_routes.PAYMENT_CHECKOUT.get_rpc_runtime_status()
|
||||||
|
event_loop_state = db.get_payment_runtime_state("payment_event_loop") or {}
|
||||||
|
recent_audit_events = db.list_payment_audit_events(limit=20)
|
||||||
return {
|
return {
|
||||||
"checkout": legacy_routes.PAYMENT_CHECKOUT.get_config_payload(),
|
"checkout": checkout,
|
||||||
"rpc": legacy_routes.PAYMENT_CHECKOUT.get_rpc_runtime_status(),
|
"rpc": rpc,
|
||||||
"event_loop_state": db.get_payment_runtime_state("payment_event_loop") or {},
|
"event_loop_state": event_loop_state,
|
||||||
"recent_audit_events": db.list_payment_audit_events(limit=20),
|
"recent_audit_events": recent_audit_events,
|
||||||
|
"chain_id": checkout.get("chain_id") or rpc.get("chain_id"),
|
||||||
|
"receiver_contract": checkout.get("receiver_contract"),
|
||||||
|
"last_scanned_block": event_loop_state.get("last_scanned_block"),
|
||||||
|
"audit_events_count": len(recent_audit_events),
|
||||||
}
|
}
|
||||||
except legacy_routes.PaymentCheckoutError as exc:
|
except legacy_routes.PaymentCheckoutError as exc:
|
||||||
_raise_payment_error(exc)
|
_raise_payment_error(exc)
|
||||||
|
|||||||
Reference in New Issue
Block a user