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";