diff --git a/README.md b/README.md index 493e706..88b05b2 100644 --- a/README.md +++ b/README.md @@ -384,7 +384,7 @@ SMTP 配置在后台“系统设置”中完成,密码会加密保存在数据 ## 钱包、充值卡与套餐 Push -钱包在用户端 `/wallet` 使用。用户可以创建余额充值订单,选择除余额支付外的外部支付方式完成充值;充值订单可取消,管理员可在订单页的“充值订单”标签查看。普通商品订单支持余额支付,余额不足时会给出可读错误。 +钱包在用户端 `/wallet` 使用。用户可以创建余额充值订单,选择除余额支付外的外部支付方式完成充值;充值订单可取消,管理员可在订单页的“充值订单”标签查看、手动确认入账、取消或删除记录。普通商品订单支持余额支付,余额不足时会给出可读错误。 充值卡在后台“商业配置”中生成: diff --git a/docs/API.md b/docs/API.md index 0669ee2..b071d10 100644 --- a/docs/API.md +++ b/docs/API.md @@ -367,6 +367,12 @@ Server Actions 是后台和用户端写操作的主要入口。它们不是公 - `updateOrderReview(...)`:更新风控/复核状态。 - `batchOrderOperation(formData)`:批量操作订单。 +#### 充值订单:`src/actions/admin/recharge-orders.ts` + +- `confirmAdminWalletRecharge(id)`:手动确认待支付充值订单,立即给用户钱包入账。 +- `cancelAdminWalletRecharge(id)`:取消待支付充值订单,不改变钱包余额。 +- `deleteAdminWalletRecharge(id)`:删除充值订单记录;已入账订单只删除记录,不回滚余额,钱包流水保留。 + #### 其他管理动作 - 用户:`src/actions/admin/users.ts` diff --git a/src/actions/admin/recharge-orders.ts b/src/actions/admin/recharge-orders.ts new file mode 100644 index 0000000..fb34683 --- /dev/null +++ b/src/actions/admin/recharge-orders.ts @@ -0,0 +1,153 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { prisma } from "@/lib/prisma"; +import { requireAdmin } from "@/lib/require-auth"; +import { actorFromSession, recordAuditLog } from "@/services/audit"; +import { processWalletRechargeOrderSuccess } from "@/services/wallet"; + +const rechargeOrderIdSchema = z.string().trim().min(1, "充值订单不存在"); + +function manualTradeNo(orderId: string) { + return `WR-MANUAL-${Date.now()}-${orderId.slice(0, 8)}`; +} + +function revalidateRechargeOrderViews(orderId: string) { + revalidatePath("/admin/orders"); + revalidatePath("/wallet"); + revalidatePath(`/wallet/recharge/${orderId}`); +} + +export async function confirmAdminWalletRecharge(rechargeOrderId: string) { + const session = await requireAdmin(); + const id = rechargeOrderIdSchema.parse(rechargeOrderId); + const recharge = await prisma.walletRechargeOrder.findUnique({ + where: { id }, + include: { user: { select: { email: true, name: true } } }, + }); + + if (!recharge) { + throw new Error("充值订单不存在或已被删除"); + } + if (recharge.status !== "PENDING") { + throw new Error("只能确认待支付充值订单"); + } + + const tradeNo = recharge.tradeNo ?? manualTradeNo(recharge.id); + const paymentRef = recharge.paymentRef ?? `manual:${session.user.id}:${Date.now()}`; + const result = await processWalletRechargeOrderSuccess({ + rechargeOrderId: recharge.id, + paidAmount: Number(recharge.amount), + paymentMethod: recharge.paymentMethod ?? "manual", + paymentRef, + tradeNo, + description: "管理员手动确认余额充值", + metadata: { + manual: true, + adminUserId: session.user.id, + adminEmail: session.user.email ?? null, + }, + }); + + if (result.finalStatus !== "PAID") { + throw new Error("充值订单状态已变化,请刷新后重试"); + } + + await recordAuditLog({ + actor: actorFromSession(session), + action: "wallet_recharge.confirm", + targetType: "WalletRechargeOrder", + targetId: recharge.id, + targetLabel: recharge.user.email, + message: `手动确认充值订单 ${recharge.id},入账 ¥${Number(recharge.amount).toFixed(2)}`, + metadata: { + userEmail: recharge.user.email, + amount: Number(recharge.amount), + tradeNo, + paymentRef, + }, + }); + + revalidateRechargeOrderViews(recharge.id); +} + +export async function cancelAdminWalletRecharge(rechargeOrderId: string) { + const session = await requireAdmin(); + const id = rechargeOrderIdSchema.parse(rechargeOrderId); + const recharge = await prisma.walletRechargeOrder.findUnique({ + where: { id }, + include: { user: { select: { email: true, name: true } } }, + }); + + if (!recharge) { + throw new Error("充值订单不存在或已被删除"); + } + if (recharge.status !== "PENDING") { + throw new Error("只能取消待支付充值订单"); + } + + await prisma.walletRechargeOrder.update({ + where: { id: recharge.id }, + data: { + status: "CANCELLED", + paymentUrl: null, + expireAt: null, + note: "管理员手动取消", + }, + }); + + await recordAuditLog({ + actor: actorFromSession(session), + action: "wallet_recharge.cancel", + targetType: "WalletRechargeOrder", + targetId: recharge.id, + targetLabel: recharge.user.email, + message: `取消充值订单 ${recharge.id}`, + metadata: { + userEmail: recharge.user.email, + amount: Number(recharge.amount), + tradeNo: recharge.tradeNo, + paymentMethod: recharge.paymentMethod, + }, + }); + + revalidateRechargeOrderViews(recharge.id); +} + +export async function deleteAdminWalletRecharge(rechargeOrderId: string) { + const session = await requireAdmin(); + const id = rechargeOrderIdSchema.parse(rechargeOrderId); + const recharge = await prisma.walletRechargeOrder.findUnique({ + where: { id }, + include: { + user: { select: { email: true, name: true } }, + transactions: { select: { id: true }, take: 5 }, + }, + }); + + if (!recharge) { + throw new Error("充值订单不存在或已被删除"); + } + + await prisma.walletRechargeOrder.delete({ where: { id: recharge.id } }); + + await recordAuditLog({ + actor: actorFromSession(session), + action: "wallet_recharge.delete", + targetType: "WalletRechargeOrder", + targetId: recharge.id, + targetLabel: recharge.user.email, + message: `删除充值订单 ${recharge.id}`, + metadata: { + userEmail: recharge.user.email, + amount: Number(recharge.amount), + status: recharge.status, + tradeNo: recharge.tradeNo, + paymentMethod: recharge.paymentMethod, + keptWalletTransactions: recharge.transactions.length, + }, + }); + + revalidateRechargeOrderViews(recharge.id); +} diff --git a/src/app/(admin)/admin/orders/_components/recharge-orders-table.tsx b/src/app/(admin)/admin/orders/_components/recharge-orders-table.tsx index aebbc13..5a9d37f 100644 --- a/src/app/(admin)/admin/orders/_components/recharge-orders-table.tsx +++ b/src/app/(admin)/admin/orders/_components/recharge-orders-table.tsx @@ -8,9 +8,10 @@ import { DataTableHeaderRow, DataTableRow, } from "@/components/shared/data-table"; -import { OrderStatusBadge } from "@/components/shared/domain-badges"; +import { StatusBadge, type StatusTone } from "@/components/shared/status-badge"; import { getPaymentProviderName } from "@/services/payment/catalog"; import { formatDateShort } from "@/lib/utils"; +import { RechargeOrderActions } from "../recharge-order-actions"; import type { AdminRechargeOrderRow } from "../orders-data"; interface RechargeOrdersTableProps { @@ -22,9 +23,28 @@ function formatAmount(amount: { toString(): string }) { } function getPaymentLabel(provider: string | null) { + if (provider === "manual") return "手动确认"; return provider ? getPaymentProviderName(provider) : "未选择支付"; } +const rechargeStatusLabels: Record = { + PENDING: "待支付", + PAID: "已入账", + CANCELLED: "已取消", + REFUNDED: "已退款", +}; + +function getRechargeStatusTone(status: AdminRechargeOrderRow["status"]): StatusTone { + if (status === "PAID") return "success"; + if (status === "PENDING") return "warning"; + if (status === "CANCELLED") return "neutral"; + return "danger"; +} + +function RechargeStatusBadge({ status }: { status: AdminRechargeOrderRow["status"] }) { + return {rechargeStatusLabels[status]}; +} + export function RechargeOrdersTable({ rechargeOrders }: RechargeOrdersTableProps) { return ( {order.user.email}

{order.user.name || "未设置昵称"}

- +
@@ -50,10 +70,11 @@ export function RechargeOrdersTable({ rechargeOrders }: RechargeOrdersTableProps

{formatDateShort(order.createdAt)}

+ ))} > - + 用户 @@ -62,6 +83,7 @@ export function RechargeOrdersTable({ rechargeOrders }: RechargeOrdersTableProps 状态 备注 时间 + 操作 @@ -81,7 +103,7 @@ export function RechargeOrdersTable({ rechargeOrders }: RechargeOrdersTableProps
- + {order.note || order.paymentRef || "—"} @@ -89,6 +111,9 @@ export function RechargeOrdersTable({ rechargeOrders }: RechargeOrdersTableProps {formatDateShort(order.createdAt)} + + + ))} diff --git a/src/app/(admin)/admin/orders/recharge-order-actions.tsx b/src/app/(admin)/admin/orders/recharge-order-actions.tsx new file mode 100644 index 0000000..f2b5899 --- /dev/null +++ b/src/app/(admin)/admin/orders/recharge-order-actions.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { CheckCircle2, Trash2, XCircle } from "lucide-react"; +import { + cancelAdminWalletRecharge, + confirmAdminWalletRecharge, + deleteAdminWalletRecharge, +} from "@/actions/admin/recharge-orders"; +import { ConfirmActionButton } from "@/components/shared/confirm-action-button"; + +type RechargeOrderStatus = "PENDING" | "PAID" | "CANCELLED" | "REFUNDED"; + +export function RechargeOrderActions({ + orderId, + status, +}: { + orderId: string; + status: RechargeOrderStatus; +}) { + const router = useRouter(); + const isPending = status === "PENDING"; + const isPaid = status === "PAID"; + + return ( +
+ {isPending && ( + <> + { + await confirmAdminWalletRecharge(orderId); + }} + onSuccess={() => router.refresh()} + > + + 确认 + + { + await cancelAdminWalletRecharge(orderId); + }} + onSuccess={() => router.refresh()} + > + + 取消 + + + )} + { + await deleteAdminWalletRecharge(orderId); + }} + onSuccess={() => router.refresh()} + > + + 删除 + +
+ ); +} diff --git a/src/lib/audit-display.ts b/src/lib/audit-display.ts index ce56219..926e9f1 100644 --- a/src/lib/audit-display.ts +++ b/src/lib/audit-display.ts @@ -31,6 +31,7 @@ export const auditActionFilterOptions = [ { label: "风控操作", value: "risk." }, { label: "系统设置", value: "settings." }, { label: "支付配置", value: "payment." }, + { label: "钱包充值", value: "wallet_recharge." }, { label: "公告操作", value: "announcement." }, { label: "工单操作", value: "support." }, { label: "优惠规则", value: "coupon." }, @@ -112,6 +113,9 @@ const auditActionLabels: Record = { "user.force_delete": "强制删除用户", "user.status": "更新用户状态", "user.update": "更新用户", + "wallet_recharge.cancel": "取消充值订单", + "wallet_recharge.confirm": "确认充值入账", + "wallet_recharge.delete": "删除充值记录", }; const auditTargetTypeLabels: Record = { @@ -134,6 +138,7 @@ const auditTargetTypeLabels: Record = { TrafficSync: "流量同步", User: "用户", UserSubscription: "订阅", + WalletRechargeOrder: "充值订单", }; const tokenLabels: Record = { diff --git a/src/lib/domain-labels.ts b/src/lib/domain-labels.ts index 355effc..8fa7aeb 100644 --- a/src/lib/domain-labels.ts +++ b/src/lib/domain-labels.ts @@ -117,9 +117,11 @@ export const nodeStatusLabels: Record = { }; export const paymentProviderLabels: Record = { + balance: "余额支付", epay: "易支付", alipay_f2f: "支付宝当面付", usdt_trc20: "USDT (TRC20)", + manual: "手动确认", }; export const paymentChannelLabels: Record = { diff --git a/src/services/payment/catalog.ts b/src/services/payment/catalog.ts index c9d704c..664f13f 100644 --- a/src/services/payment/catalog.ts +++ b/src/services/payment/catalog.ts @@ -198,6 +198,7 @@ export function getPaymentProviderDefinition(provider: string) { } export function getPaymentProviderName(provider: string): string { + if (provider === "manual") return "手动确认"; return getPaymentProviderDefinition(provider)?.name ?? provider; } diff --git a/src/services/wallet.ts b/src/services/wallet.ts index dca3972..18e5981 100644 --- a/src/services/wallet.ts +++ b/src/services/wallet.ts @@ -1,5 +1,5 @@ import crypto from "crypto"; -import { Prisma } from "@prisma/client"; +import { Prisma, type OrderStatus } from "@prisma/client"; import { prisma, type DbClient } from "@/lib/prisma"; import { formatDate } from "@/lib/utils"; import { createNotification } from "@/services/notifications"; @@ -298,33 +298,43 @@ export async function createWalletRechargeOrder(userId: string, amountValue: num }); } -export async function processWalletRechargeSuccess( - tradeNo: string, - paidAmount: number, - paymentRef?: string, -) { - if (!Number.isFinite(paidAmount) || paidAmount <= 0) { - return { processed: false, finalStatus: null as null | "PENDING" | "PAID" }; - } - +export async function processWalletRechargeOrderSuccess(input: { + rechargeOrderId: string; + paidAmount?: number; + paymentRef?: string | null; + paymentMethod?: string | null; + tradeNo?: string | null; + description?: string; + metadata?: Record; +}) { const rechargeOrder = await prisma.walletRechargeOrder.findUnique({ - where: { tradeNo }, + where: { id: input.rechargeOrderId }, }); if (!rechargeOrder) { - return { processed: false, finalStatus: null as null | "PENDING" | "PAID" }; + return { processed: false, finalStatus: null as OrderStatus | null }; } const expectedAmount = Number(rechargeOrder.amount); - if (Math.abs(expectedAmount - paidAmount) > 0.01) { + if ( + input.paidAmount !== undefined + && (!Number.isFinite(input.paidAmount) || Math.abs(expectedAmount - input.paidAmount) > 0.01) + ) { throw new Error("支付金额与充值金额不一致"); } + const tradeNo = input.tradeNo ?? rechargeOrder.tradeNo; + const paymentMethod = input.paymentMethod ?? rechargeOrder.paymentMethod; + return prisma.$transaction(async (tx) => { const claimed = await tx.walletRechargeOrder.updateMany({ where: { id: rechargeOrder.id, status: "PENDING" }, data: { status: "PAID", - paymentRef: paymentRef ?? rechargeOrder.paymentRef, + paymentRef: input.paymentRef ?? rechargeOrder.paymentRef, + paymentMethod, + tradeNo, + paymentUrl: null, + expireAt: null, note: null, }, }); @@ -341,9 +351,11 @@ export async function processWalletRechargeSuccess( userId: rechargeOrder.userId, amount: rechargeOrder.amount, type: "BALANCE_RECHARGE", - description: `${getPaymentProviderName(rechargeOrder.paymentMethod ?? "")} 余额充值`, + description: + input.description + ?? `${paymentMethod ? getPaymentProviderName(paymentMethod) : "余额"} 余额充值`, rechargeOrderId: rechargeOrder.id, - metadata: { tradeNo }, + metadata: { tradeNo, ...(input.metadata ?? {}) }, }); await createNotification( @@ -363,6 +375,30 @@ export async function processWalletRechargeSuccess( }); } +export async function processWalletRechargeSuccess( + tradeNo: string, + paidAmount: number, + paymentRef?: string, +) { + if (!Number.isFinite(paidAmount) || paidAmount <= 0) { + return { processed: false, finalStatus: null as OrderStatus | null }; + } + + const rechargeOrder = await prisma.walletRechargeOrder.findUnique({ + where: { tradeNo }, + }); + if (!rechargeOrder) { + return { processed: false, finalStatus: null as OrderStatus | null }; + } + + return processWalletRechargeOrderSuccess({ + rechargeOrderId: rechargeOrder.id, + paidAmount, + paymentRef, + tradeNo, + }); +} + export async function createRechargeCards(input: { createdById: string; type: "BALANCE" | "PLAN";