feat: add admin wallet recharge order actions

This commit is contained in:
JetSprow
2026-05-01 05:28:20 +10:00
parent 8034392408
commit 1dd3157177
9 changed files with 333 additions and 21 deletions

View File

@@ -384,7 +384,7 @@ SMTP 配置在后台“系统设置”中完成,密码会加密保存在数据
## 钱包、充值卡与套餐 Push ## 钱包、充值卡与套餐 Push
钱包在用户端 `/wallet` 使用。用户可以创建余额充值订单,选择除余额支付外的外部支付方式完成充值;充值订单可取消,管理员可在订单页的“充值订单”标签查看。普通商品订单支持余额支付,余额不足时会给出可读错误。 钱包在用户端 `/wallet` 使用。用户可以创建余额充值订单,选择除余额支付外的外部支付方式完成充值;充值订单可取消,管理员可在订单页的“充值订单”标签查看、手动确认入账、取消或删除记录。普通商品订单支持余额支付,余额不足时会给出可读错误。
充值卡在后台“商业配置”中生成: 充值卡在后台“商业配置”中生成:

View File

@@ -367,6 +367,12 @@ Server Actions 是后台和用户端写操作的主要入口。它们不是公
- `updateOrderReview(...)`:更新风控/复核状态。 - `updateOrderReview(...)`:更新风控/复核状态。
- `batchOrderOperation(formData)`:批量操作订单。 - `batchOrderOperation(formData)`:批量操作订单。
#### 充值订单:`src/actions/admin/recharge-orders.ts`
- `confirmAdminWalletRecharge(id)`:手动确认待支付充值订单,立即给用户钱包入账。
- `cancelAdminWalletRecharge(id)`:取消待支付充值订单,不改变钱包余额。
- `deleteAdminWalletRecharge(id)`:删除充值订单记录;已入账订单只删除记录,不回滚余额,钱包流水保留。
#### 其他管理动作 #### 其他管理动作
- 用户:`src/actions/admin/users.ts` - 用户:`src/actions/admin/users.ts`

View File

@@ -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);
}

View File

@@ -8,9 +8,10 @@ import {
DataTableHeaderRow, DataTableHeaderRow,
DataTableRow, DataTableRow,
} from "@/components/shared/data-table"; } 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 { getPaymentProviderName } from "@/services/payment/catalog";
import { formatDateShort } from "@/lib/utils"; import { formatDateShort } from "@/lib/utils";
import { RechargeOrderActions } from "../recharge-order-actions";
import type { AdminRechargeOrderRow } from "../orders-data"; import type { AdminRechargeOrderRow } from "../orders-data";
interface RechargeOrdersTableProps { interface RechargeOrdersTableProps {
@@ -22,9 +23,28 @@ function formatAmount(amount: { toString(): string }) {
} }
function getPaymentLabel(provider: string | null) { function getPaymentLabel(provider: string | null) {
if (provider === "manual") return "手动确认";
return provider ? getPaymentProviderName(provider) : "未选择支付"; return provider ? getPaymentProviderName(provider) : "未选择支付";
} }
const rechargeStatusLabels: Record<AdminRechargeOrderRow["status"], string> = {
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 <StatusBadge tone={getRechargeStatusTone(status)}>{rechargeStatusLabels[status]}</StatusBadge>;
}
export function RechargeOrdersTable({ rechargeOrders }: RechargeOrdersTableProps) { export function RechargeOrdersTable({ rechargeOrders }: RechargeOrdersTableProps) {
return ( return (
<DataTableShell <DataTableShell
@@ -38,7 +58,7 @@ export function RechargeOrdersTable({ rechargeOrders }: RechargeOrdersTableProps
<p className="break-all text-sm font-semibold">{order.user.email}</p> <p className="break-all text-sm font-semibold">{order.user.email}</p>
<p className="mt-1 text-xs text-muted-foreground">{order.user.name || "未设置昵称"}</p> <p className="mt-1 text-xs text-muted-foreground">{order.user.name || "未设置昵称"}</p>
</div> </div>
<OrderStatusBadge status={order.status} /> <RechargeStatusBadge status={order.status} />
</div> </div>
<div className="rounded-lg bg-muted/25 p-3"> <div className="rounded-lg bg-muted/25 p-3">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
@@ -50,10 +70,11 @@ export function RechargeOrdersTable({ rechargeOrders }: RechargeOrdersTableProps
</p> </p>
<p className="mt-1 text-xs text-muted-foreground">{formatDateShort(order.createdAt)}</p> <p className="mt-1 text-xs text-muted-foreground">{formatDateShort(order.createdAt)}</p>
</div> </div>
<RechargeOrderActions orderId={order.id} status={order.status} />
</article> </article>
))} ))}
> >
<DataTable aria-label="充值订单列表" className="min-w-[980px]"> <DataTable aria-label="充值订单列表" className="min-w-[1120px]">
<DataTableHead> <DataTableHead>
<DataTableHeaderRow> <DataTableHeaderRow>
<DataTableHeadCell></DataTableHeadCell> <DataTableHeadCell></DataTableHeadCell>
@@ -62,6 +83,7 @@ export function RechargeOrdersTable({ rechargeOrders }: RechargeOrdersTableProps
<DataTableHeadCell></DataTableHeadCell> <DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell> <DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell> <DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell className="text-right"></DataTableHeadCell>
</DataTableHeaderRow> </DataTableHeaderRow>
</DataTableHead> </DataTableHead>
<DataTableBody> <DataTableBody>
@@ -81,7 +103,7 @@ export function RechargeOrdersTable({ rechargeOrders }: RechargeOrdersTableProps
</div> </div>
</DataTableCell> </DataTableCell>
<DataTableCell> <DataTableCell>
<OrderStatusBadge status={order.status} /> <RechargeStatusBadge status={order.status} />
</DataTableCell> </DataTableCell>
<DataTableCell className="max-w-64 whitespace-normal break-words text-xs text-muted-foreground"> <DataTableCell className="max-w-64 whitespace-normal break-words text-xs text-muted-foreground">
{order.note || order.paymentRef || "—"} {order.note || order.paymentRef || "—"}
@@ -89,6 +111,9 @@ export function RechargeOrdersTable({ rechargeOrders }: RechargeOrdersTableProps
<DataTableCell className="whitespace-nowrap text-muted-foreground"> <DataTableCell className="whitespace-nowrap text-muted-foreground">
{formatDateShort(order.createdAt)} {formatDateShort(order.createdAt)}
</DataTableCell> </DataTableCell>
<DataTableCell>
<RechargeOrderActions orderId={order.id} status={order.status} />
</DataTableCell>
</DataTableRow> </DataTableRow>
))} ))}
</DataTableBody> </DataTableBody>

View File

@@ -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 (
<div className="flex flex-wrap justify-start gap-2 lg:justify-end">
{isPending && (
<>
<ConfirmActionButton
title="确认这笔充值?"
description="会立即把充值金额入账到用户钱包,并将订单标记为已入账。"
confirmLabel="确认入账"
successMessage="充值订单已确认入账"
errorMessage="确认充值订单失败"
size="sm"
onConfirm={async () => {
await confirmAdminWalletRecharge(orderId);
}}
onSuccess={() => router.refresh()}
>
<CheckCircle2 className="size-3.5" />
</ConfirmActionButton>
<ConfirmActionButton
title="取消这笔充值?"
description="取消后用户不能继续支付这笔充值订单,钱包余额不会变化。"
confirmLabel="取消充值"
successMessage="充值订单已取消"
errorMessage="取消充值订单失败"
variant="destructive"
size="sm"
onConfirm={async () => {
await cancelAdminWalletRecharge(orderId);
}}
onSuccess={() => router.refresh()}
>
<XCircle className="size-3.5" />
</ConfirmActionButton>
</>
)}
<ConfirmActionButton
title="删除这条充值记录?"
description={
isPaid
? "只删除充值订单记录,不回滚已入账余额,钱包流水会保留。"
: "删除后这条充值记录不可恢复,不会改变用户钱包余额。"
}
confirmLabel="删除记录"
successMessage="充值记录已删除"
errorMessage="删除充值记录失败"
variant="destructive"
size="sm"
onConfirm={async () => {
await deleteAdminWalletRecharge(orderId);
}}
onSuccess={() => router.refresh()}
>
<Trash2 className="size-3.5" />
</ConfirmActionButton>
</div>
);
}

View File

@@ -31,6 +31,7 @@ export const auditActionFilterOptions = [
{ label: "风控操作", value: "risk." }, { label: "风控操作", value: "risk." },
{ label: "系统设置", value: "settings." }, { label: "系统设置", value: "settings." },
{ label: "支付配置", value: "payment." }, { label: "支付配置", value: "payment." },
{ label: "钱包充值", value: "wallet_recharge." },
{ label: "公告操作", value: "announcement." }, { label: "公告操作", value: "announcement." },
{ label: "工单操作", value: "support." }, { label: "工单操作", value: "support." },
{ label: "优惠规则", value: "coupon." }, { label: "优惠规则", value: "coupon." },
@@ -112,6 +113,9 @@ const auditActionLabels: Record<string, string> = {
"user.force_delete": "强制删除用户", "user.force_delete": "强制删除用户",
"user.status": "更新用户状态", "user.status": "更新用户状态",
"user.update": "更新用户", "user.update": "更新用户",
"wallet_recharge.cancel": "取消充值订单",
"wallet_recharge.confirm": "确认充值入账",
"wallet_recharge.delete": "删除充值记录",
}; };
const auditTargetTypeLabels: Record<string, string> = { const auditTargetTypeLabels: Record<string, string> = {
@@ -134,6 +138,7 @@ const auditTargetTypeLabels: Record<string, string> = {
TrafficSync: "流量同步", TrafficSync: "流量同步",
User: "用户", User: "用户",
UserSubscription: "订阅", UserSubscription: "订阅",
WalletRechargeOrder: "充值订单",
}; };
const tokenLabels: Record<string, string> = { const tokenLabels: Record<string, string> = {

View File

@@ -117,9 +117,11 @@ export const nodeStatusLabels: Record<string, string> = {
}; };
export const paymentProviderLabels: Record<string, string> = { export const paymentProviderLabels: Record<string, string> = {
balance: "余额支付",
epay: "易支付", epay: "易支付",
alipay_f2f: "支付宝当面付", alipay_f2f: "支付宝当面付",
usdt_trc20: "USDT (TRC20)", usdt_trc20: "USDT (TRC20)",
manual: "手动确认",
}; };
export const paymentChannelLabels: Record<string, string> = { export const paymentChannelLabels: Record<string, string> = {

View File

@@ -198,6 +198,7 @@ export function getPaymentProviderDefinition(provider: string) {
} }
export function getPaymentProviderName(provider: string): string { export function getPaymentProviderName(provider: string): string {
if (provider === "manual") return "手动确认";
return getPaymentProviderDefinition(provider)?.name ?? provider; return getPaymentProviderDefinition(provider)?.name ?? provider;
} }

View File

@@ -1,5 +1,5 @@
import crypto from "crypto"; import crypto from "crypto";
import { Prisma } from "@prisma/client"; import { Prisma, type OrderStatus } from "@prisma/client";
import { prisma, type DbClient } from "@/lib/prisma"; import { prisma, type DbClient } from "@/lib/prisma";
import { formatDate } from "@/lib/utils"; import { formatDate } from "@/lib/utils";
import { createNotification } from "@/services/notifications"; import { createNotification } from "@/services/notifications";
@@ -298,33 +298,43 @@ export async function createWalletRechargeOrder(userId: string, amountValue: num
}); });
} }
export async function processWalletRechargeSuccess( export async function processWalletRechargeOrderSuccess(input: {
tradeNo: string, rechargeOrderId: string;
paidAmount: number, paidAmount?: number;
paymentRef?: string, paymentRef?: string | null;
) { paymentMethod?: string | null;
if (!Number.isFinite(paidAmount) || paidAmount <= 0) { tradeNo?: string | null;
return { processed: false, finalStatus: null as null | "PENDING" | "PAID" }; description?: string;
} metadata?: Record<string, unknown>;
}) {
const rechargeOrder = await prisma.walletRechargeOrder.findUnique({ const rechargeOrder = await prisma.walletRechargeOrder.findUnique({
where: { tradeNo }, where: { id: input.rechargeOrderId },
}); });
if (!rechargeOrder) { if (!rechargeOrder) {
return { processed: false, finalStatus: null as null | "PENDING" | "PAID" }; return { processed: false, finalStatus: null as OrderStatus | null };
} }
const expectedAmount = Number(rechargeOrder.amount); 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("支付金额与充值金额不一致"); throw new Error("支付金额与充值金额不一致");
} }
const tradeNo = input.tradeNo ?? rechargeOrder.tradeNo;
const paymentMethod = input.paymentMethod ?? rechargeOrder.paymentMethod;
return prisma.$transaction(async (tx) => { return prisma.$transaction(async (tx) => {
const claimed = await tx.walletRechargeOrder.updateMany({ const claimed = await tx.walletRechargeOrder.updateMany({
where: { id: rechargeOrder.id, status: "PENDING" }, where: { id: rechargeOrder.id, status: "PENDING" },
data: { data: {
status: "PAID", status: "PAID",
paymentRef: paymentRef ?? rechargeOrder.paymentRef, paymentRef: input.paymentRef ?? rechargeOrder.paymentRef,
paymentMethod,
tradeNo,
paymentUrl: null,
expireAt: null,
note: null, note: null,
}, },
}); });
@@ -341,9 +351,11 @@ export async function processWalletRechargeSuccess(
userId: rechargeOrder.userId, userId: rechargeOrder.userId,
amount: rechargeOrder.amount, amount: rechargeOrder.amount,
type: "BALANCE_RECHARGE", type: "BALANCE_RECHARGE",
description: `${getPaymentProviderName(rechargeOrder.paymentMethod ?? "")} 余额充值`, description:
input.description
?? `${paymentMethod ? getPaymentProviderName(paymentMethod) : "余额"} 余额充值`,
rechargeOrderId: rechargeOrder.id, rechargeOrderId: rechargeOrder.id,
metadata: { tradeNo }, metadata: { tradeNo, ...(input.metadata ?? {}) },
}); });
await createNotification( 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: { export async function createRechargeCards(input: {
createdById: string; createdById: string;
type: "BALANCE" | "PLAN"; type: "BALANCE" | "PLAN";