添加后台支付成功记录列表
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { RefreshCcw, CheckCircle2 } from "lucide-react";
|
||||
import { RefreshCcw, CheckCircle2, ExternalLink } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { opsApi } from "@/lib/ops-api";
|
||||
import type { PaymentRuntimePayload, PaymentIncident } from "@/types/ops";
|
||||
import type { PaymentRuntimePayload, PaymentIncident, PaymentRecord } from "@/types/ops";
|
||||
import {
|
||||
PieChart, Pie, Cell, ResponsiveContainer, Tooltip,
|
||||
} from "recharts";
|
||||
@@ -14,17 +14,20 @@ export function PaymentsPageClient() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [runtime, setRuntime] = useState<PaymentRuntimePayload | null>(null);
|
||||
const [incidents, setIncidents] = useState<PaymentIncident[]>([]);
|
||||
const [payments, setPayments] = useState<PaymentRecord[]>([]);
|
||||
const [resolving, setResolving] = useState<Set<number>>(new Set());
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [rt, inc] = await Promise.all([
|
||||
const [rt, inc, pay] = await Promise.all([
|
||||
opsApi.paymentRuntime() as Promise<PaymentRuntimePayload>,
|
||||
opsApi.incidents(50),
|
||||
opsApi.listPayments(50),
|
||||
]);
|
||||
setRuntime(rt);
|
||||
setIncidents((inc as unknown as { incidents?: PaymentIncident[] }).incidents ?? []);
|
||||
setPayments((pay as unknown as { payments?: PaymentRecord[] }).payments ?? []);
|
||||
} catch { /* */ }
|
||||
setLoading(false);
|
||||
};
|
||||
@@ -177,6 +180,54 @@ export function PaymentsPageClient() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>成功支付记录 ({payments.length})</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
{payments.length === 0 ? (
|
||||
<span className="text-sm text-slate-500">暂无记录</span>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/10 text-left text-slate-400">
|
||||
<th className="py-2 pr-4 font-medium">ID</th>
|
||||
<th className="py-2 pr-4 font-medium">用户</th>
|
||||
<th className="py-2 pr-4 font-medium">金额</th>
|
||||
<th className="py-2 pr-4 font-medium">链</th>
|
||||
<th className="py-2 pr-4 font-medium">Tx Hash</th>
|
||||
<th className="py-2 pr-4 font-medium">时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{payments.map((p) => (
|
||||
<tr key={p.id} className="border-b border-white/5">
|
||||
<td className="py-2 pr-4 text-slate-500 font-mono text-xs">{p.id}</td>
|
||||
<td className="py-2 pr-4 text-slate-400 font-mono text-xs" title={p.user_id}>{p.user_id?.slice(0, 10) ?? "—"}...</td>
|
||||
<td className="py-2 pr-4 text-emerald-300 font-mono">{p.amount} {p.currency}</td>
|
||||
<td className="py-2 pr-4 text-slate-400 text-xs">{p.chain ?? "—"}</td>
|
||||
<td className="py-2 pr-4 text-xs">
|
||||
{p.tx_hash ? (
|
||||
<a
|
||||
href={`https://polygonscan.com/tx/${p.tx_hash}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-400 hover:text-blue-300 font-mono inline-flex items-center gap-1"
|
||||
>
|
||||
{p.tx_hash.slice(0, 8)}...{p.tx_hash.slice(-6)}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
) : "—"}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-slate-400 text-xs whitespace-nowrap">{p.created_at?.slice(0, 19) ?? "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ export const opsApi = {
|
||||
paymentRuntime() {
|
||||
return opsFetch<Record<string, unknown>>("/api/payments/runtime");
|
||||
},
|
||||
listPayments(limit = 50) {
|
||||
return opsFetch<{ payments?: Array<Record<string, unknown>>; total?: number }>(`/api/ops/payments?limit=${limit}`);
|
||||
},
|
||||
async funnel(days = 30) {
|
||||
const raw = await opsFetch<{
|
||||
events?: Record<string, { total?: number; unique_users?: number }>;
|
||||
@@ -84,4 +87,22 @@ export const opsApi = {
|
||||
const qs = new URLSearchParams(params).toString();
|
||||
return opsFetch<Record<string, unknown>>(`/api/ops/truth-history?${qs}`);
|
||||
},
|
||||
userSubscriptions(email: string) {
|
||||
return opsFetch<{
|
||||
email: string;
|
||||
user_id: string;
|
||||
subscriptions: Array<{
|
||||
id?: string;
|
||||
user_id?: string;
|
||||
status?: string;
|
||||
plan_code?: string;
|
||||
source?: string;
|
||||
starts_at?: string;
|
||||
expires_at?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}>;
|
||||
count: number;
|
||||
}>(`/api/ops/subscriptions/user?email=${encodeURIComponent(email)}`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -75,6 +75,22 @@ export type IncidentsPayload = {
|
||||
total?: number;
|
||||
};
|
||||
|
||||
export type PaymentRecord = {
|
||||
id: number;
|
||||
user_id?: string;
|
||||
amount?: number;
|
||||
currency?: string;
|
||||
chain?: string;
|
||||
tx_hash?: string;
|
||||
status?: string;
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
export type PaymentsPayload = {
|
||||
payments?: PaymentRecord[];
|
||||
total?: number;
|
||||
};
|
||||
|
||||
export type OpsUser = {
|
||||
telegram_id?: number;
|
||||
username?: string;
|
||||
|
||||
@@ -12,11 +12,13 @@ from web.services.ops_api import (
|
||||
get_ops_logs,
|
||||
get_ops_truth_history,
|
||||
get_ops_weekly_leaderboard,
|
||||
get_ops_user_subscriptions,
|
||||
grant_ops_points,
|
||||
grant_ops_subscription,
|
||||
list_ops_memberships,
|
||||
list_ops_payment_incidents,
|
||||
resolve_ops_payment_incident,
|
||||
list_ops_payments,
|
||||
search_ops_users,
|
||||
update_ops_config,
|
||||
)
|
||||
@@ -64,6 +66,11 @@ async def ops_resolve_payment_incident(request: Request, event_id: int):
|
||||
return resolve_ops_payment_incident(request, event_id)
|
||||
|
||||
|
||||
@router.get("/api/ops/payments")
|
||||
async def ops_payments(request: Request, limit: int = 50):
|
||||
return list_ops_payments(request, limit=limit)
|
||||
|
||||
|
||||
@router.post("/api/ops/users/grant-points")
|
||||
async def ops_grant_points(request: Request, body: GrantPointsRequest):
|
||||
return grant_ops_points(request, body)
|
||||
@@ -134,6 +141,11 @@ async def ops_subscription_extend(request: Request):
|
||||
return extend_ops_subscription(request, email=email, additional_days=days)
|
||||
|
||||
|
||||
@router.get("/api/ops/subscriptions/user")
|
||||
async def ops_user_subscriptions(request: Request, email: str = ""):
|
||||
return get_ops_user_subscriptions(request, email=email)
|
||||
|
||||
|
||||
# ── Logs ────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/ops/logs")
|
||||
|
||||
@@ -191,6 +191,41 @@ def resolve_ops_payment_incident(request: Request, event_id: int) -> Dict[str, A
|
||||
return {"ok": True, "incident": resolved}
|
||||
|
||||
|
||||
def list_ops_payments(
|
||||
request: Request,
|
||||
limit: int = 50,
|
||||
) -> Dict[str, Any]:
|
||||
"""List successful payment records from Supabase."""
|
||||
_require_ops(request)
|
||||
import os
|
||||
import requests as _requests
|
||||
|
||||
supabase_url = str(os.getenv("SUPABASE_URL") or "").strip().rstrip("/")
|
||||
service_role_key = str(os.getenv("SUPABASE_SERVICE_ROLE_KEY") or "").strip()
|
||||
if not supabase_url or not service_role_key:
|
||||
raise HTTPException(status_code=503, detail="Supabase not configured")
|
||||
|
||||
headers = {
|
||||
"apikey": service_role_key,
|
||||
"Authorization": f"Bearer {service_role_key}",
|
||||
}
|
||||
safe_limit = max(1, min(int(limit or 50), 200))
|
||||
resp = _requests.get(
|
||||
f"{supabase_url}/rest/v1/payments",
|
||||
headers=headers,
|
||||
params={
|
||||
"select": "id,user_id,amount,currency,chain,tx_hash,status,created_at",
|
||||
"order": "created_at.desc",
|
||||
"limit": str(safe_limit),
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
rows = resp.json() if resp.ok and resp.content else []
|
||||
if not isinstance(rows, list):
|
||||
rows = []
|
||||
return {"payments": rows, "total": len(rows)}
|
||||
|
||||
|
||||
def grant_ops_points(request: Request, body: GrantPointsRequest) -> Dict[str, Any]:
|
||||
admin = _require_ops(request) or {}
|
||||
db = DBManager()
|
||||
|
||||
Reference in New Issue
Block a user