Add ops tools for payment incident triage and resolution
This commit is contained in:
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,9 +53,32 @@ type PaymentRuntimePayload = {
|
|||||||
id: number;
|
id: number;
|
||||||
event_type: string;
|
event_type: string;
|
||||||
created_at: 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 = {
|
type AuthMePayload = {
|
||||||
authenticated?: boolean;
|
authenticated?: boolean;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
@@ -130,10 +153,14 @@ export function OpsDashboard() {
|
|||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [users, setUsers] = useState<OpsUser[]>([]);
|
const [users, setUsers] = useState<OpsUser[]>([]);
|
||||||
const [memberships, setMemberships] = useState<MembershipEntry[]>([]);
|
const [memberships, setMemberships] = useState<MembershipEntry[]>([]);
|
||||||
|
const [paymentIncidents, setPaymentIncidents] = useState<PaymentIncident[]>([]);
|
||||||
|
const [incidentReasonFilter, setIncidentReasonFilter] = useState("all");
|
||||||
const [usersLoading, setUsersLoading] = useState(false);
|
const [usersLoading, setUsersLoading] = useState(false);
|
||||||
const [usersError, setUsersError] = useState<string | null>(null);
|
const [usersError, setUsersError] = useState<string | null>(null);
|
||||||
const [leaderboard, setLeaderboard] = useState<WeeklyLeaderboardEntry[]>([]);
|
const [leaderboard, setLeaderboard] = useState<WeeklyLeaderboardEntry[]>([]);
|
||||||
const [membershipsLoading, setMembershipsLoading] = useState(false);
|
const [membershipsLoading, setMembershipsLoading] = useState(false);
|
||||||
|
const [incidentsLoading, setIncidentsLoading] = useState(false);
|
||||||
|
const [resolvingIncidentId, setResolvingIncidentId] = useState<number | null>(null);
|
||||||
const [grantEmail, setGrantEmail] = useState("");
|
const [grantEmail, setGrantEmail] = useState("");
|
||||||
const [grantPoints, setGrantPoints] = useState("300");
|
const [grantPoints, setGrantPoints] = useState("300");
|
||||||
const [grantStatus, setGrantStatus] = useState<string | null>(null);
|
const [grantStatus, setGrantStatus] = useState<string | null>(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(() => {
|
useEffect(() => {
|
||||||
void loadUsers("");
|
void loadUsers("");
|
||||||
void loadLeaderboard();
|
void loadLeaderboard();
|
||||||
void loadMemberships();
|
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;
|
const rolloutDecision = status?.probability?.rollout?.decision;
|
||||||
|
|
||||||
@@ -256,12 +319,13 @@ export function OpsDashboard() {
|
|||||||
await loadUsers(searchQuery);
|
await loadUsers(searchQuery);
|
||||||
await loadLeaderboard();
|
await loadLeaderboard();
|
||||||
await loadMemberships();
|
await loadMemberships();
|
||||||
|
await loadPaymentIncidents(incidentReasonFilter);
|
||||||
} catch (submitError) {
|
} catch (submitError) {
|
||||||
setGrantError(String(submitError));
|
setGrantError(String(submitError));
|
||||||
} finally {
|
} finally {
|
||||||
setGrantLoading(false);
|
setGrantLoading(false);
|
||||||
}
|
}
|
||||||
}, [grantEmail, grantPoints, loadLeaderboard, loadMemberships, loadUsers, searchQuery]);
|
}, [grantEmail, grantPoints, incidentReasonFilter, loadLeaderboard, loadMemberships, loadPaymentIncidents, loadUsers, searchQuery]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-slate-950 px-4 py-8 text-slate-100 sm:px-6 lg:px-8">
|
<main className="min-h-screen bg-slate-950 px-4 py-8 text-slate-100 sm:px-6 lg:px-8">
|
||||||
@@ -419,6 +483,18 @@ export function OpsDashboard() {
|
|||||||
<span className="text-xs text-slate-500">#{item.id}</span>
|
<span className="text-xs text-slate-500">#{item.id}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-xs text-slate-500">{formatDateTime(item.created_at)}</div>
|
<div className="mt-1 text-xs text-slate-500">{formatDateTime(item.created_at)}</div>
|
||||||
|
{item.payload?.reason ? (
|
||||||
|
<div className="mt-2 space-y-1 text-xs text-amber-300">
|
||||||
|
<div>原因: {item.payload.reason}</div>
|
||||||
|
{item.payload.tx_hash ? <div>Tx: {maskUrl(item.payload.tx_hash)}</div> : null}
|
||||||
|
{item.payload.receiver_actual ? (
|
||||||
|
<div>实际收款: {maskUrl(item.payload.receiver_actual)}</div>
|
||||||
|
) : null}
|
||||||
|
{item.payload.receiver_expected ? (
|
||||||
|
<div>期望收款: {maskUrl(item.payload.receiver_expected)}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{!payments?.recent_audit_events?.length ? (
|
{!payments?.recent_audit_events?.length ? (
|
||||||
@@ -429,6 +505,83 @@ export function OpsDashboard() {
|
|||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<CardTitle>支付异常单</CardTitle>
|
||||||
|
<CardDescription>只显示已明确标记失败的支付确认事故。</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
className="rounded-xl border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-slate-200"
|
||||||
|
value={incidentReasonFilter}
|
||||||
|
onChange={(event) => {
|
||||||
|
const next = event.target.value;
|
||||||
|
setIncidentReasonFilter(next);
|
||||||
|
void loadPaymentIncidents(next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="all">全部原因</option>
|
||||||
|
<option value="receiver_mismatch">receiver_mismatch</option>
|
||||||
|
<option value="sender_mismatch">sender_mismatch</option>
|
||||||
|
<option value="event_mismatch">event_mismatch</option>
|
||||||
|
<option value="tx_reverted">tx_reverted</option>
|
||||||
|
</select>
|
||||||
|
<Button variant="secondary" onClick={() => void loadPaymentIncidents(incidentReasonFilter)} disabled={incidentsLoading}>
|
||||||
|
{incidentsLoading ? "加载中" : "刷新异常"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="overflow-x-auto rounded-2xl border border-slate-800 bg-slate-950/70">
|
||||||
|
<table className="min-w-full divide-y divide-slate-800 text-left text-sm">
|
||||||
|
<thead className="bg-slate-900/80 text-xs uppercase tracking-[0.14em] text-slate-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3">时间</th>
|
||||||
|
<th className="px-4 py-3">原因</th>
|
||||||
|
<th className="px-4 py-3">套餐</th>
|
||||||
|
<th className="px-4 py-3">Tx</th>
|
||||||
|
<th className="px-4 py-3">实际收款</th>
|
||||||
|
<th className="px-4 py-3">期望收款</th>
|
||||||
|
<th className="px-4 py-3">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800">
|
||||||
|
{paymentIncidents.map((item) => (
|
||||||
|
<tr key={item.id}>
|
||||||
|
<td className="px-4 py-3">{formatDateTime(item.created_at)}</td>
|
||||||
|
<td className="px-4 py-3">{item.payload?.reason || "-"}</td>
|
||||||
|
<td className="px-4 py-3">{item.payload?.plan_code || "-"}</td>
|
||||||
|
<td className="px-4 py-3">{maskUrl(item.payload?.tx_hash)}</td>
|
||||||
|
<td className="px-4 py-3">{maskUrl(item.payload?.receiver_actual)}</td>
|
||||||
|
<td className="px-4 py-3">{maskUrl(item.payload?.receiver_expected)}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void resolveIncident(item.id)}
|
||||||
|
disabled={resolvingIncidentId === item.id}
|
||||||
|
>
|
||||||
|
{resolvingIncidentId === item.id ? "处理中" : "标记已处理"}
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!paymentIncidents.length ? (
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-4 text-slate-500" colSpan={7}>
|
||||||
|
暂无支付异常单
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>当前会员</CardTitle>
|
<CardTitle>当前会员</CardTitle>
|
||||||
|
|||||||
+73
-10
@@ -197,19 +197,36 @@ class DBManager:
|
|||||||
)
|
)
|
||||||
conn.commit()
|
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))
|
safe_limit = max(1, min(int(limit or 50), 500))
|
||||||
|
kind = str(event_type or "").strip().lower()
|
||||||
with self._get_connection() as conn:
|
with self._get_connection() as conn:
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
rows = conn.execute(
|
if kind:
|
||||||
"""
|
rows = conn.execute(
|
||||||
SELECT id, event_type, payload_json, created_at
|
"""
|
||||||
FROM payment_audit_events
|
SELECT id, event_type, payload_json, created_at
|
||||||
ORDER BY id DESC
|
FROM payment_audit_events
|
||||||
LIMIT ?
|
WHERE event_type = ?
|
||||||
""",
|
ORDER BY id DESC
|
||||||
(safe_limit,),
|
LIMIT ?
|
||||||
).fetchall()
|
""",
|
||||||
|
(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 = []
|
out = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
try:
|
try:
|
||||||
@@ -226,6 +243,52 @@ class DBManager:
|
|||||||
)
|
)
|
||||||
return out
|
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
|
@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()
|
||||||
|
|||||||
@@ -1815,6 +1815,68 @@ class PaymentContractCheckoutService:
|
|||||||
"subscription": subscription_row,
|
"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:
|
def _notify_telegram(self, user_id: str, plan_code: str, amount_usdc: str, tx_hash: str) -> None:
|
||||||
if not self.notify_telegram:
|
if not self.notify_telegram:
|
||||||
return
|
return
|
||||||
@@ -1881,6 +1943,13 @@ class PaymentContractCheckoutService:
|
|||||||
# Wait for receipt first to avoid transient RPC lag on eth_getTransaction.
|
# Wait for receipt first to avoid transient RPC lag on eth_getTransaction.
|
||||||
receipt = self._wait_receipt(tx_hash_text)
|
receipt = self._wait_receipt(tx_hash_text)
|
||||||
if int(receipt.get("status") or 0) != 1:
|
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")
|
raise PaymentCheckoutError(400, "tx reverted")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -1900,12 +1969,31 @@ class PaymentContractCheckoutService:
|
|||||||
if not tx_to or not tx_from:
|
if not tx_to or not tx_from:
|
||||||
raise PaymentCheckoutError(409, "tx indexed partially; retry confirm")
|
raise PaymentCheckoutError(409, "tx indexed partially; retry confirm")
|
||||||
if tx_to != intent.receiver_address:
|
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(
|
raise PaymentCheckoutError(
|
||||||
400,
|
400,
|
||||||
f"tx to mismatch: got={tx_to} expected={intent.receiver_address}",
|
f"tx to mismatch: got={tx_to} expected={intent.receiver_address}",
|
||||||
)
|
)
|
||||||
if intent.payment_mode == "strict" and intent.allowed_wallet:
|
if intent.payment_mode == "strict" and intent.allowed_wallet:
|
||||||
if tx_from != 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(
|
raise PaymentCheckoutError(
|
||||||
400,
|
400,
|
||||||
f"tx sender mismatch: got={tx_from} expected={intent.allowed_wallet}",
|
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)
|
event_match = self._extract_matching_event(receipt, intent)
|
||||||
if not event_match:
|
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(
|
raise PaymentCheckoutError(
|
||||||
400,
|
400,
|
||||||
"OrderPaid event mismatch; ensure contract emits OrderPaid(orderId,payer,planId,token,amount)",
|
"OrderPaid event mismatch; ensure contract emits OrderPaid(orderId,payer,planId,token,amount)",
|
||||||
|
|||||||
@@ -294,6 +294,50 @@ async def ops_memberships(request: Request, limit: int = 200):
|
|||||||
return {"memberships": rows}
|
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")
|
@router.post("/api/ops/users/grant-points")
|
||||||
async def ops_grant_points(request: Request, body: GrantPointsRequest):
|
async def ops_grant_points(request: Request, body: GrantPointsRequest):
|
||||||
_assert_entitlement(request)
|
_assert_entitlement(request)
|
||||||
|
|||||||
Reference in New Issue
Block a user