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;
|
||||
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<OpsUser[]>([]);
|
||||
const [memberships, setMemberships] = useState<MembershipEntry[]>([]);
|
||||
const [paymentIncidents, setPaymentIncidents] = useState<PaymentIncident[]>([]);
|
||||
const [incidentReasonFilter, setIncidentReasonFilter] = useState("all");
|
||||
const [usersLoading, setUsersLoading] = useState(false);
|
||||
const [usersError, setUsersError] = useState<string | null>(null);
|
||||
const [leaderboard, setLeaderboard] = useState<WeeklyLeaderboardEntry[]>([]);
|
||||
const [membershipsLoading, setMembershipsLoading] = useState(false);
|
||||
const [incidentsLoading, setIncidentsLoading] = useState(false);
|
||||
const [resolvingIncidentId, setResolvingIncidentId] = useState<number | null>(null);
|
||||
const [grantEmail, setGrantEmail] = useState("");
|
||||
const [grantPoints, setGrantPoints] = useState("300");
|
||||
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(() => {
|
||||
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 (
|
||||
<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>
|
||||
</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>
|
||||
))}
|
||||
{!payments?.recent_audit_events?.length ? (
|
||||
@@ -429,6 +505,83 @@ export function OpsDashboard() {
|
||||
</Card>
|
||||
</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>
|
||||
<CardHeader>
|
||||
<CardTitle>当前会员</CardTitle>
|
||||
|
||||
+73
-10
@@ -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()
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user