添加后台支付成功记录列表
This commit is contained in:
@@ -1,11 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { opsApi } from "@/lib/ops-api";
|
import { opsApi } from "@/lib/ops-api";
|
||||||
import type { PaymentRuntimePayload, PaymentIncident } from "@/types/ops";
|
import type { PaymentRuntimePayload, PaymentIncident, PaymentRecord } from "@/types/ops";
|
||||||
import {
|
import {
|
||||||
PieChart, Pie, Cell, ResponsiveContainer, Tooltip,
|
PieChart, Pie, Cell, ResponsiveContainer, Tooltip,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
@@ -14,17 +14,20 @@ export function PaymentsPageClient() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [runtime, setRuntime] = useState<PaymentRuntimePayload | null>(null);
|
const [runtime, setRuntime] = useState<PaymentRuntimePayload | null>(null);
|
||||||
const [incidents, setIncidents] = useState<PaymentIncident[]>([]);
|
const [incidents, setIncidents] = useState<PaymentIncident[]>([]);
|
||||||
|
const [payments, setPayments] = useState<PaymentRecord[]>([]);
|
||||||
const [resolving, setResolving] = useState<Set<number>>(new Set());
|
const [resolving, setResolving] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [rt, inc] = await Promise.all([
|
const [rt, inc, pay] = await Promise.all([
|
||||||
opsApi.paymentRuntime() as Promise<PaymentRuntimePayload>,
|
opsApi.paymentRuntime() as Promise<PaymentRuntimePayload>,
|
||||||
opsApi.incidents(50),
|
opsApi.incidents(50),
|
||||||
|
opsApi.listPayments(50),
|
||||||
]);
|
]);
|
||||||
setRuntime(rt);
|
setRuntime(rt);
|
||||||
setIncidents((inc as unknown as { incidents?: PaymentIncident[] }).incidents ?? []);
|
setIncidents((inc as unknown as { incidents?: PaymentIncident[] }).incidents ?? []);
|
||||||
|
setPayments((pay as unknown as { payments?: PaymentRecord[] }).payments ?? []);
|
||||||
} catch { /* */ }
|
} catch { /* */ }
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
@@ -177,6 +180,54 @@ export function PaymentsPageClient() {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ export const opsApi = {
|
|||||||
paymentRuntime() {
|
paymentRuntime() {
|
||||||
return opsFetch<Record<string, unknown>>("/api/payments/runtime");
|
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) {
|
async funnel(days = 30) {
|
||||||
const raw = await opsFetch<{
|
const raw = await opsFetch<{
|
||||||
events?: Record<string, { total?: number; unique_users?: number }>;
|
events?: Record<string, { total?: number; unique_users?: number }>;
|
||||||
@@ -84,4 +87,22 @@ export const opsApi = {
|
|||||||
const qs = new URLSearchParams(params).toString();
|
const qs = new URLSearchParams(params).toString();
|
||||||
return opsFetch<Record<string, unknown>>(`/api/ops/truth-history?${qs}`);
|
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;
|
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 = {
|
export type OpsUser = {
|
||||||
telegram_id?: number;
|
telegram_id?: number;
|
||||||
username?: string;
|
username?: string;
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ from web.services.ops_api import (
|
|||||||
get_ops_logs,
|
get_ops_logs,
|
||||||
get_ops_truth_history,
|
get_ops_truth_history,
|
||||||
get_ops_weekly_leaderboard,
|
get_ops_weekly_leaderboard,
|
||||||
|
get_ops_user_subscriptions,
|
||||||
grant_ops_points,
|
grant_ops_points,
|
||||||
grant_ops_subscription,
|
grant_ops_subscription,
|
||||||
list_ops_memberships,
|
list_ops_memberships,
|
||||||
list_ops_payment_incidents,
|
list_ops_payment_incidents,
|
||||||
resolve_ops_payment_incident,
|
resolve_ops_payment_incident,
|
||||||
|
list_ops_payments,
|
||||||
search_ops_users,
|
search_ops_users,
|
||||||
update_ops_config,
|
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)
|
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")
|
@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):
|
||||||
return grant_ops_points(request, body)
|
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)
|
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 ────────────────────────────────────────────────────────────
|
# ── Logs ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/api/ops/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}
|
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]:
|
def grant_ops_points(request: Request, body: GrantPointsRequest) -> Dict[str, Any]:
|
||||||
admin = _require_ops(request) or {}
|
admin = _require_ops(request) or {}
|
||||||
db = DBManager()
|
db = DBManager()
|
||||||
|
|||||||
Reference in New Issue
Block a user