mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
feat: add wallet and recharge cards
This commit is contained in:
@@ -88,6 +88,14 @@ enum OrderKind {
|
|||||||
TRAFFIC_TOPUP
|
TRAFFIC_TOPUP
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum WalletTransactionType {
|
||||||
|
BALANCE_RECHARGE
|
||||||
|
BALANCE_PAYMENT
|
||||||
|
CARD_REDEEM
|
||||||
|
ADMIN_ADJUST
|
||||||
|
REFUND
|
||||||
|
}
|
||||||
|
|
||||||
enum Protocol {
|
enum Protocol {
|
||||||
VMESS
|
VMESS
|
||||||
VLESS
|
VLESS
|
||||||
@@ -165,6 +173,18 @@ enum SupportTicketPriority {
|
|||||||
URGENT
|
URGENT
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum RechargeCardType {
|
||||||
|
BALANCE
|
||||||
|
PLAN
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RechargeCardStatus {
|
||||||
|
UNUSED
|
||||||
|
REDEEMED
|
||||||
|
DISABLED
|
||||||
|
EXPIRED
|
||||||
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
email String @unique
|
email String @unique
|
||||||
@@ -196,6 +216,11 @@ model User {
|
|||||||
supportTickets SupportTicket[]
|
supportTickets SupportTicket[]
|
||||||
supportReplies SupportTicketReply[]
|
supportReplies SupportTicketReply[]
|
||||||
emailTokens EmailToken[]
|
emailTokens EmailToken[]
|
||||||
|
walletAccount WalletAccount?
|
||||||
|
walletTransactions WalletTransaction[]
|
||||||
|
walletRechargeOrders WalletRechargeOrder[]
|
||||||
|
redeemedRechargeCards RechargeCard[] @relation("RechargeCardRedeemer")
|
||||||
|
createdRechargeCards RechargeCard[] @relation("RechargeCardCreator")
|
||||||
}
|
}
|
||||||
|
|
||||||
model EmailToken {
|
model EmailToken {
|
||||||
@@ -301,6 +326,7 @@ model SubscriptionPlan {
|
|||||||
orders Order[]
|
orders Order[]
|
||||||
cartItems ShoppingCartItem[]
|
cartItems ShoppingCartItem[]
|
||||||
orderItems OrderItem[]
|
orderItems OrderItem[]
|
||||||
|
rechargeCards RechargeCard[]
|
||||||
|
|
||||||
@@index([type, isActive, isFeatured, sortOrder])
|
@@index([type, isActive, isFeatured, sortOrder])
|
||||||
@@index([inboundId])
|
@@index([inboundId])
|
||||||
@@ -482,6 +508,7 @@ model NodeInbound {
|
|||||||
selectedByOrders Order[]
|
selectedByOrders Order[]
|
||||||
cartItems ShoppingCartItem[]
|
cartItems ShoppingCartItem[]
|
||||||
orderItems OrderItem[]
|
orderItems OrderItem[]
|
||||||
|
rechargeCards RechargeCard[]
|
||||||
|
|
||||||
@@unique([serverId, tag])
|
@@unique([serverId, tag])
|
||||||
@@unique([serverId, panelInboundId])
|
@@unique([serverId, panelInboundId])
|
||||||
@@ -598,6 +625,7 @@ model Order {
|
|||||||
items OrderItem[]
|
items OrderItem[]
|
||||||
couponGrants CouponGrant[] @relation("CouponGrantUsedOrder")
|
couponGrants CouponGrant[] @relation("CouponGrantUsedOrder")
|
||||||
inviteRewards InviteRewardLedger[]
|
inviteRewards InviteRewardLedger[]
|
||||||
|
walletTransactions WalletTransaction[]
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([kind])
|
@@index([kind])
|
||||||
@@ -608,6 +636,98 @@ model Order {
|
|||||||
@@index([couponId])
|
@@index([couponId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model WalletAccount {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String @unique
|
||||||
|
balance Decimal @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
transactions WalletTransaction[]
|
||||||
|
|
||||||
|
@@index([updatedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model WalletTransaction {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
walletId String
|
||||||
|
userId String
|
||||||
|
type WalletTransactionType
|
||||||
|
amount Decimal
|
||||||
|
balanceAfter Decimal
|
||||||
|
description String?
|
||||||
|
orderId String?
|
||||||
|
rechargeOrderId String?
|
||||||
|
rechargeCardId String?
|
||||||
|
metadata Json?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
wallet WalletAccount @relation(fields: [walletId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
order Order? @relation(fields: [orderId], references: [id], onDelete: SetNull)
|
||||||
|
rechargeOrder WalletRechargeOrder? @relation(fields: [rechargeOrderId], references: [id], onDelete: SetNull)
|
||||||
|
rechargeCard RechargeCard? @relation(fields: [rechargeCardId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([userId, createdAt])
|
||||||
|
@@index([walletId, createdAt])
|
||||||
|
@@index([orderId])
|
||||||
|
@@index([rechargeOrderId])
|
||||||
|
@@index([rechargeCardId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model WalletRechargeOrder {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
amount Decimal
|
||||||
|
status OrderStatus @default(PENDING)
|
||||||
|
paymentMethod String?
|
||||||
|
paymentRef String?
|
||||||
|
paymentUrl String?
|
||||||
|
tradeNo String? @unique
|
||||||
|
expireAt DateTime?
|
||||||
|
note String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
transactions WalletTransaction[]
|
||||||
|
|
||||||
|
@@index([userId, createdAt])
|
||||||
|
@@index([status])
|
||||||
|
@@index([tradeNo])
|
||||||
|
}
|
||||||
|
|
||||||
|
model RechargeCard {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
code String @unique
|
||||||
|
type RechargeCardType
|
||||||
|
status RechargeCardStatus @default(UNUSED)
|
||||||
|
balanceAmount Decimal?
|
||||||
|
planId String?
|
||||||
|
selectedInboundId String?
|
||||||
|
trafficGb Int?
|
||||||
|
batchName String?
|
||||||
|
note String?
|
||||||
|
expiresAt DateTime?
|
||||||
|
redeemedById String?
|
||||||
|
redeemedAt DateTime?
|
||||||
|
createdById String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
plan SubscriptionPlan? @relation(fields: [planId], references: [id], onDelete: SetNull)
|
||||||
|
selectedInbound NodeInbound? @relation(fields: [selectedInboundId], references: [id], onDelete: SetNull)
|
||||||
|
redeemedBy User? @relation("RechargeCardRedeemer", fields: [redeemedById], references: [id], onDelete: SetNull)
|
||||||
|
createdBy User? @relation("RechargeCardCreator", fields: [createdById], references: [id], onDelete: SetNull)
|
||||||
|
transactions WalletTransaction[]
|
||||||
|
|
||||||
|
@@index([type, status, createdAt])
|
||||||
|
@@index([planId])
|
||||||
|
@@index([redeemedById, redeemedAt])
|
||||||
|
@@index([expiresAt])
|
||||||
|
}
|
||||||
|
|
||||||
model NodeLatency {
|
model NodeLatency {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
nodeId String
|
nodeId String
|
||||||
|
|||||||
@@ -78,6 +78,30 @@ export async function setPaymentConfigEnabled(
|
|||||||
): Promise<PaymentActionResult> {
|
): Promise<PaymentActionResult> {
|
||||||
try {
|
try {
|
||||||
const session = await requireAdmin();
|
const session = await requireAdmin();
|
||||||
|
if (provider === "balance") {
|
||||||
|
const current = await prisma.paymentConfig.findUnique({
|
||||||
|
where: { provider },
|
||||||
|
select: { enabled: true, config: true },
|
||||||
|
});
|
||||||
|
if (current?.enabled !== enabled) {
|
||||||
|
await prisma.paymentConfig.upsert({
|
||||||
|
where: { provider },
|
||||||
|
create: { provider, enabled, config: current?.config ?? {} },
|
||||||
|
update: { enabled },
|
||||||
|
});
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: "payment.toggle",
|
||||||
|
targetType: "PaymentConfig",
|
||||||
|
targetId: provider,
|
||||||
|
targetLabel: getPaymentProviderName(provider),
|
||||||
|
message: `${enabled ? "启用" : "停用"}支付方式 ${getPaymentProviderName(provider)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
revalidatePath("/admin/payments");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
const current = await prisma.paymentConfig.findUnique({
|
const current = await prisma.paymentConfig.findUnique({
|
||||||
where: { provider },
|
where: { provider },
|
||||||
select: { config: true, enabled: true },
|
select: { config: true, enabled: true },
|
||||||
|
|||||||
64
src/actions/admin/recharge-cards.ts
Normal file
64
src/actions/admin/recharge-cards.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { requireAdmin } from "@/lib/require-auth";
|
||||||
|
import { actorFromSession, recordAuditLog } from "@/services/audit";
|
||||||
|
import { createRechargeCards } from "@/services/wallet";
|
||||||
|
|
||||||
|
const optionalDate = z.preprocess(
|
||||||
|
(value) => (value === "" || value == null ? undefined : new Date(String(value))),
|
||||||
|
z.date().optional(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const createRechargeCardsSchema = z.object({
|
||||||
|
type: z.enum(["BALANCE", "PLAN"]),
|
||||||
|
quantity: z.coerce.number().int().min(1).max(200).default(1),
|
||||||
|
balanceAmount: z.preprocess(
|
||||||
|
(value) => (value === "" || value == null ? undefined : Number(value)),
|
||||||
|
z.number().positive().optional(),
|
||||||
|
),
|
||||||
|
planId: z.string().trim().optional(),
|
||||||
|
batchName: z.string().trim().optional(),
|
||||||
|
expiresAt: optionalDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function createAdminRechargeCards(formData: FormData) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const data = createRechargeCardsSchema.parse(Object.fromEntries(formData));
|
||||||
|
|
||||||
|
if (data.type === "BALANCE" && !data.balanceAmount) {
|
||||||
|
throw new Error("请输入余额卡金额");
|
||||||
|
}
|
||||||
|
if (data.type === "PLAN" && !data.planId) {
|
||||||
|
throw new Error("请选择套餐");
|
||||||
|
}
|
||||||
|
|
||||||
|
const cards = await createRechargeCards({
|
||||||
|
createdById: session.user.id,
|
||||||
|
type: data.type,
|
||||||
|
quantity: data.quantity,
|
||||||
|
balanceAmount: data.balanceAmount,
|
||||||
|
planId: data.planId,
|
||||||
|
batchName: data.batchName || null,
|
||||||
|
expiresAt: data.expiresAt ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAuditLog({
|
||||||
|
actor: actorFromSession(session),
|
||||||
|
action: "recharge_card.create",
|
||||||
|
targetType: "RechargeCard",
|
||||||
|
targetLabel: data.type === "BALANCE" ? "余额充值卡" : "套餐充值卡",
|
||||||
|
message: `生成 ${cards.length} 张${data.type === "BALANCE" ? "余额充值卡" : "套餐充值卡"}`,
|
||||||
|
metadata: {
|
||||||
|
type: data.type,
|
||||||
|
quantity: cards.length,
|
||||||
|
batchName: data.batchName || null,
|
||||||
|
planId: data.planId || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/commerce");
|
||||||
|
revalidatePath("/admin/plans");
|
||||||
|
revalidatePath("/store");
|
||||||
|
}
|
||||||
30
src/actions/user/wallet.ts
Normal file
30
src/actions/user/wallet.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { requireAuth } from "@/lib/require-auth";
|
||||||
|
import { createWalletRechargeOrder, redeemRechargeCard } from "@/services/wallet";
|
||||||
|
|
||||||
|
const rechargeSchema = z.object({
|
||||||
|
amount: z.coerce.number().min(1, "充值金额不能低于 1 元").max(100000, "单次充值金额过大"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const redeemSchema = z.object({
|
||||||
|
code: z.string().trim().min(4, "请输入充值卡卡密"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function createWalletRecharge(formData: FormData) {
|
||||||
|
const session = await requireAuth();
|
||||||
|
const data = rechargeSchema.parse(Object.fromEntries(formData));
|
||||||
|
const order = await createWalletRechargeOrder(session.user.id, data.amount);
|
||||||
|
return { id: order.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function redeemWalletCard(formData: FormData) {
|
||||||
|
const session = await requireAuth();
|
||||||
|
const data = redeemSchema.parse(Object.fromEntries(formData));
|
||||||
|
await redeemRechargeCard(session.user.id, data.code);
|
||||||
|
revalidatePath("/wallet");
|
||||||
|
revalidatePath("/subscriptions");
|
||||||
|
revalidatePath("/dashboard");
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { createAdminRechargeCards } from "@/actions/admin/recharge-cards";
|
||||||
|
import { BooleanToggle } from "@/components/ui/boolean-toggle";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { getErrorMessage } from "@/lib/errors";
|
||||||
|
|
||||||
|
interface PlanOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: "PROXY" | "STREAMING";
|
||||||
|
remainingCount: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RechargeCardForm({ plans }: { plans: PlanOption[] }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [type, setType] = useState<"BALANCE" | "PLAN">("BALANCE");
|
||||||
|
const [planId, setPlanId] = useState(plans[0]?.id ?? "");
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const selectedPlan = plans.find((plan) => plan.id === planId) ?? null;
|
||||||
|
const planSoldOut = type === "PLAN" && selectedPlan?.remainingCount === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="form-panel space-y-4"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = event.currentTarget;
|
||||||
|
const formData = new FormData(form);
|
||||||
|
formData.set("type", type);
|
||||||
|
if (type === "PLAN") formData.set("planId", planId);
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
await createAdminRechargeCards(formData);
|
||||||
|
toast.success("充值卡已生成");
|
||||||
|
form.reset();
|
||||||
|
router.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getErrorMessage(error, "生成充值卡失败"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold">生成充值卡</h3>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">余额卡充值钱包,套餐卡兑换后直接激活套餐。</p>
|
||||||
|
</div>
|
||||||
|
<BooleanToggle
|
||||||
|
value={type === "PLAN"}
|
||||||
|
onChange={(value) => setType(value ? "PLAN" : "BALANCE")}
|
||||||
|
trueLabel="套餐卡"
|
||||||
|
falseLabel="余额卡"
|
||||||
|
ariaLabel="充值卡类型"
|
||||||
|
className="w-36"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="hidden" name="type" value={type} />
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="recharge-card-quantity">生成数量</Label>
|
||||||
|
<Input
|
||||||
|
id="recharge-card-quantity"
|
||||||
|
name="quantity"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max={type === "PLAN" && selectedPlan?.remainingCount != null ? Math.min(200, selectedPlan.remainingCount) : 200}
|
||||||
|
defaultValue={1}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="recharge-card-batch">批次名称</Label>
|
||||||
|
<Input id="recharge-card-batch" name="batchName" placeholder="五月活动" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{type === "BALANCE" ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="recharge-card-amount">余额金额</Label>
|
||||||
|
<Input id="recharge-card-amount" name="balanceAmount" type="number" min="0.01" step="0.01" placeholder="100.00" required />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>绑定套餐</Label>
|
||||||
|
<Select value={planId} onValueChange={(value) => setPlanId(value ?? "")}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="选择套餐" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{plans.map((plan) => (
|
||||||
|
<SelectItem key={plan.id} value={plan.id}>
|
||||||
|
{plan.name} · {plan.type === "PROXY" ? "代理" : "流媒体"} · 余 {plan.remainingCount ?? "不限"}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{selectedPlan && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{selectedPlan.remainingCount == null ? "库存不限" : `当前可生成 ${selectedPlan.remainingCount} 张`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="recharge-card-expires">过期时间</Label>
|
||||||
|
<Input id="recharge-card-expires" name="expiresAt" type="datetime-local" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button className="w-full" disabled={pending || (type === "PLAN" && (!planId || planSoldOut))}>
|
||||||
|
{pending ? "生成中..." : planSoldOut ? "套餐已售罄" : "生成充值卡"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,22 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { getPlanAvailability } from "@/services/plan-availability";
|
||||||
|
|
||||||
|
function getRechargeCardPlanRemaining(
|
||||||
|
planType: "PROXY" | "STREAMING",
|
||||||
|
availability: Awaited<ReturnType<typeof getPlanAvailability>>,
|
||||||
|
) {
|
||||||
|
const limits: number[] = [];
|
||||||
|
if (availability.remainingByPlanLimit != null) {
|
||||||
|
limits.push(availability.remainingByPlanLimit);
|
||||||
|
}
|
||||||
|
if (planType === "STREAMING" && availability.remainingByServiceCapacity != null) {
|
||||||
|
limits.push(availability.remainingByServiceCapacity);
|
||||||
|
}
|
||||||
|
return limits.length > 0 ? Math.min(...limits) : null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getCommerceData() {
|
export async function getCommerceData() {
|
||||||
const [coupons, promotions] = await Promise.all([
|
const [coupons, promotions, rechargeCards, planRows] = await Promise.all([
|
||||||
prisma.coupon.findMany({
|
prisma.coupon.findMany({
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
include: { _count: { select: { orders: true, grants: true } } },
|
include: { _count: { select: { orders: true, grants: true } } },
|
||||||
@@ -11,7 +26,39 @@ export async function getCommerceData() {
|
|||||||
orderBy: [{ sortOrder: "asc" }, { thresholdAmount: "asc" }],
|
orderBy: [{ sortOrder: "asc" }, { thresholdAmount: "asc" }],
|
||||||
take: 30,
|
take: 30,
|
||||||
}),
|
}),
|
||||||
|
prisma.rechargeCard.findMany({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: {
|
||||||
|
plan: { select: { name: true, type: true } },
|
||||||
|
redeemedBy: { select: { email: true } },
|
||||||
|
},
|
||||||
|
take: 50,
|
||||||
|
}),
|
||||||
|
prisma.subscriptionPlan.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: [{ type: "asc" }, { sortOrder: "asc" }, { createdAt: "desc" }],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
type: true,
|
||||||
|
totalLimit: true,
|
||||||
|
perUserLimit: true,
|
||||||
|
streamingServiceId: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return { coupons, promotions };
|
const plans = await Promise.all(
|
||||||
|
planRows.map(async (plan) => {
|
||||||
|
const availability = await getPlanAvailability(plan);
|
||||||
|
return {
|
||||||
|
id: plan.id,
|
||||||
|
name: plan.name,
|
||||||
|
type: plan.type,
|
||||||
|
remainingCount: getRechargeCardPlanRemaining(plan.type, availability),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { coupons, promotions, rechargeCards, plans };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Gift, Sparkles } from "lucide-react";
|
import { Gift, Sparkles, WalletCards } from "lucide-react";
|
||||||
import { createCoupon, createPromotionRule } from "@/actions/admin/commerce";
|
import { createCoupon, createPromotionRule } from "@/actions/admin/commerce";
|
||||||
import { ActiveStatusBadge, StatusBadge } from "@/components/admin/status-badge";
|
import { ActiveStatusBadge, StatusBadge } from "@/components/admin/status-badge";
|
||||||
|
import { CopyButton } from "@/components/shared/copy-button";
|
||||||
import { PageHeader, PageShell, SectionHeader } from "@/components/shared/page-shell";
|
import { PageHeader, PageShell, SectionHeader } from "@/components/shared/page-shell";
|
||||||
import { PendingSubmitButton } from "@/components/shared/pending-submit-button";
|
import { PendingSubmitButton } from "@/components/shared/pending-submit-button";
|
||||||
import { BooleanToggle } from "@/components/ui/boolean-toggle";
|
import { BooleanToggle } from "@/components/ui/boolean-toggle";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { formatDate } from "@/lib/utils";
|
||||||
import { getCommerceData } from "./commerce-data";
|
import { getCommerceData } from "./commerce-data";
|
||||||
import { CommerceToggleButton } from "./_components/commerce-actions";
|
import { CommerceToggleButton } from "./_components/commerce-actions";
|
||||||
import { DiscountTypeSelect } from "./_components/discount-type-select";
|
import { DiscountTypeSelect } from "./_components/discount-type-select";
|
||||||
|
import { RechargeCardForm } from "./_components/recharge-card-form";
|
||||||
|
|
||||||
function formatCouponDiscount(type: string, value: unknown) {
|
function formatCouponDiscount(type: string, value: unknown) {
|
||||||
const numericValue = Number(value);
|
const numericValue = Number(value);
|
||||||
@@ -26,7 +29,7 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function AdminCommercePage() {
|
export default async function AdminCommercePage() {
|
||||||
const { coupons, promotions } = await getCommerceData();
|
const { coupons, promotions, rechargeCards, plans } = await getCommerceData();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
@@ -39,6 +42,7 @@ export default async function AdminCommercePage() {
|
|||||||
<TabsList variant="line" className="surface-card p-1">
|
<TabsList variant="line" className="surface-card p-1">
|
||||||
<TabsTrigger value="create">新建规则</TabsTrigger>
|
<TabsTrigger value="create">新建规则</TabsTrigger>
|
||||||
<TabsTrigger value="manage">规则列表</TabsTrigger>
|
<TabsTrigger value="manage">规则列表</TabsTrigger>
|
||||||
|
<TabsTrigger value="cards">充值卡</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="create">
|
<TabsContent value="create">
|
||||||
@@ -175,6 +179,57 @@ export default async function AdminCommercePage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="cards" className="space-y-6">
|
||||||
|
<section className="grid gap-5 xl:grid-cols-[minmax(22rem,0.75fr)_1fr]">
|
||||||
|
<RechargeCardForm plans={plans} />
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<SectionHeader title="最近充值卡" />
|
||||||
|
<div className="surface-card divide-y divide-border/60 overflow-hidden rounded-xl">
|
||||||
|
{rechargeCards.map((card) => (
|
||||||
|
<article key={card.id} className="grid gap-4 px-4 py-4 lg:grid-cols-[minmax(0,1fr)_minmax(14rem,0.6fr)_auto] lg:items-center">
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||||
|
<WalletCards className="size-4" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex min-h-6 flex-wrap items-center gap-2">
|
||||||
|
<h3 className="min-w-0 truncate font-mono font-semibold">{card.code}</h3>
|
||||||
|
<StatusBadge tone={card.status === "UNUSED" ? "success" : "neutral"}>
|
||||||
|
{card.status === "UNUSED"
|
||||||
|
? "未使用"
|
||||||
|
: card.status === "REDEEMED"
|
||||||
|
? "已兑换"
|
||||||
|
: card.status === "EXPIRED"
|
||||||
|
? "已过期"
|
||||||
|
: "已停用"}
|
||||||
|
</StatusBadge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{card.type === "BALANCE"
|
||||||
|
? `余额卡 ¥${Number(card.balanceAmount ?? 0).toFixed(2)}`
|
||||||
|
: `套餐卡 ${card.plan?.name ?? "套餐已删除"}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{card.batchName && <StatusBadge>{card.batchName}</StatusBadge>}
|
||||||
|
{card.redeemedBy && <StatusBadge tone="info">{card.redeemedBy.email}</StatusBadge>}
|
||||||
|
{card.expiresAt && <StatusBadge>到期 {formatDate(card.expiresAt)}</StatusBadge>}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-start lg:justify-end">
|
||||||
|
<CopyButton text={card.code} />
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
{rechargeCards.length === 0 && (
|
||||||
|
<p className="px-4 py-8 text-center text-sm text-muted-foreground">暂无充值卡</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, type FormEvent } from "react";
|
import { useState, type FormEvent } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Check, CreditCard, Pencil, ShieldCheck } from "lucide-react";
|
import { AlertTriangle, Check, CreditCard, Pencil, ShieldCheck } from "lucide-react";
|
||||||
import { savePaymentConfig, setPaymentConfigEnabled } from "@/actions/admin/payments";
|
import { savePaymentConfig, setPaymentConfigEnabled } from "@/actions/admin/payments";
|
||||||
import { StatusBadge } from "@/components/shared/status-badge";
|
import { StatusBadge } from "@/components/shared/status-badge";
|
||||||
import { BooleanToggle } from "@/components/ui/boolean-toggle";
|
import { BooleanToggle } from "@/components/ui/boolean-toggle";
|
||||||
@@ -80,6 +80,7 @@ export function PaymentConfigItem({
|
|||||||
const [enabled, setEnabled] = useState(initialEnabled);
|
const [enabled, setEnabled] = useState(initialEnabled);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [statusSaving, setStatusSaving] = useState(false);
|
const [statusSaving, setStatusSaving] = useState(false);
|
||||||
|
const [balanceDisableOpen, setBalanceDisableOpen] = useState(false);
|
||||||
const [checkboxValues, setCheckboxValues] = useState<Record<string, Set<string>>>(() =>
|
const [checkboxValues, setCheckboxValues] = useState<Record<string, Set<string>>>(() =>
|
||||||
buildInitialCheckboxValues(fields, currentConfig),
|
buildInitialCheckboxValues(fields, currentConfig),
|
||||||
);
|
);
|
||||||
@@ -100,7 +101,7 @@ export function PaymentConfigItem({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleStatusToggle(nextEnabled: boolean) {
|
async function commitStatusToggle(nextEnabled: boolean) {
|
||||||
if (statusSaving || enabled === nextEnabled) return;
|
if (statusSaving || enabled === nextEnabled) return;
|
||||||
|
|
||||||
const previousEnabled = enabled;
|
const previousEnabled = enabled;
|
||||||
@@ -123,6 +124,15 @@ export function PaymentConfigItem({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleStatusToggle(nextEnabled: boolean) {
|
||||||
|
if (statusSaving || enabled === nextEnabled) return;
|
||||||
|
if (provider === "balance" && !nextEnabled) {
|
||||||
|
setBalanceDisableOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await commitStatusToggle(nextEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (saving || statusSaving) return;
|
if (saving || statusSaving) return;
|
||||||
@@ -160,6 +170,7 @@ export function PaymentConfigItem({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<section className="grid gap-4 px-4 py-4 lg:grid-cols-[minmax(0,1fr)_10rem_auto] lg:items-center">
|
<section className="grid gap-4 px-4 py-4 lg:grid-cols-[minmax(0,1fr)_10rem_auto] lg:items-center">
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
|
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
|
||||||
@@ -284,5 +295,43 @@ export function PaymentConfigItem({
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{provider === "balance" && (
|
||||||
|
<Dialog open={balanceDisableOpen} onOpenChange={(nextOpen) => !statusSaving && setBalanceDisableOpen(nextOpen)}>
|
||||||
|
<DialogContent className="max-w-[22rem]">
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="mb-1 flex size-9 items-center justify-center rounded-lg border border-destructive/15 bg-destructive/10 text-destructive">
|
||||||
|
<AlertTriangle className="size-5" />
|
||||||
|
</div>
|
||||||
|
<DialogTitle>关闭余额支付?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
关闭后用户不能再用账户余额支付订单,余额充值和充值卡兑换仍会保留。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setBalanceDisableOpen(false)}
|
||||||
|
disabled={statusSaving}
|
||||||
|
>
|
||||||
|
保持开启
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
setBalanceDisableOpen(false);
|
||||||
|
void commitStatusToggle(false);
|
||||||
|
}}
|
||||||
|
disabled={statusSaving}
|
||||||
|
>
|
||||||
|
{statusSaving ? "处理中..." : "确认关闭"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export async function getPaymentProviderConfigs() {
|
|||||||
enabled: config.enabled,
|
enabled: config.enabled,
|
||||||
config: redactPaymentConfigForClient(provider.id, configValue ?? {}),
|
config: redactPaymentConfigForClient(provider.id, configValue ?? {}),
|
||||||
}
|
}
|
||||||
|
: provider.id === "balance"
|
||||||
|
? { enabled: true, config: {} }
|
||||||
: null,
|
: null,
|
||||||
secretConfigured: configValue
|
secretConfigured: configValue
|
||||||
? getPaymentSecretConfiguredState(provider.id, configValue)
|
? getPaymentSecretConfiguredState(provider.id, configValue)
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ export const PLAN_BATCH_FORM_ID = "plan-batch-form";
|
|||||||
export function PlansList({
|
export function PlansList({
|
||||||
plans,
|
plans,
|
||||||
activeCountMap,
|
activeCountMap,
|
||||||
|
reservedCardCountMap,
|
||||||
services,
|
services,
|
||||||
}: {
|
}: {
|
||||||
plans: AdminPlanRow[];
|
plans: AdminPlanRow[];
|
||||||
activeCountMap: Map<string, number>;
|
activeCountMap: Map<string, number>;
|
||||||
|
reservedCardCountMap: Map<string, number>;
|
||||||
services: StreamingServiceOption[];
|
services: StreamingServiceOption[];
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -31,6 +33,7 @@ export function PlansList({
|
|||||||
key={plan.id}
|
key={plan.id}
|
||||||
plan={plan}
|
plan={plan}
|
||||||
activeCount={activeCountMap.get(plan.id) ?? 0}
|
activeCount={activeCountMap.get(plan.id) ?? 0}
|
||||||
|
reservedCardCount={reservedCardCountMap.get(plan.id) ?? 0}
|
||||||
services={services}
|
services={services}
|
||||||
batchFormId={PLAN_BATCH_FORM_ID}
|
batchFormId={PLAN_BATCH_FORM_ID}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export default async function PlansPage({
|
|||||||
pageSize,
|
pageSize,
|
||||||
filters,
|
filters,
|
||||||
activeCountMap,
|
activeCountMap,
|
||||||
|
reservedCardCountMap,
|
||||||
serviceOptions,
|
serviceOptions,
|
||||||
} = await getAdminPlans(await searchParams);
|
} = await getAdminPlans(await searchParams);
|
||||||
|
|
||||||
@@ -60,6 +61,7 @@ export default async function PlansPage({
|
|||||||
<PlansList
|
<PlansList
|
||||||
plans={plans}
|
plans={plans}
|
||||||
activeCountMap={activeCountMap}
|
activeCountMap={activeCountMap}
|
||||||
|
reservedCardCountMap={reservedCardCountMap}
|
||||||
services={serviceOptions}
|
services={serviceOptions}
|
||||||
/>
|
/>
|
||||||
<Pagination total={total} pageSize={pageSize} page={page} />
|
<Pagination total={total} pageSize={pageSize} page={page} />
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ interface PlanListItem {
|
|||||||
interface PlanCardProps {
|
interface PlanCardProps {
|
||||||
plan: PlanListItem;
|
plan: PlanListItem;
|
||||||
activeCount: number;
|
activeCount: number;
|
||||||
|
reservedCardCount: number;
|
||||||
services: StreamingServiceOption[];
|
services: StreamingServiceOption[];
|
||||||
batchFormId: string;
|
batchFormId: string;
|
||||||
}
|
}
|
||||||
@@ -63,13 +64,13 @@ function toNumber(value: NumericLike): number | null {
|
|||||||
return value == null ? null : Number(value);
|
return value == null ? null : Number(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function remainingStockSummary(plan: PlanListItem, activeCount: number) {
|
function remainingStockSummary(plan: PlanListItem, activeCount: number, reservedCardCount: number) {
|
||||||
if (plan.totalLimit == null) return { value: "∞", hint: "剩余库存", empty: false };
|
if (plan.totalLimit == null) return { value: "∞", hint: "剩余库存", empty: false };
|
||||||
|
|
||||||
const remaining = Math.max(0, plan.totalLimit - activeCount);
|
const remaining = Math.max(0, plan.totalLimit - activeCount - reservedCardCount);
|
||||||
return {
|
return {
|
||||||
value: remaining.toString(),
|
value: remaining.toString(),
|
||||||
hint: remaining === 0 ? "已售罄" : "剩余库存",
|
hint: reservedCardCount > 0 ? `预占 ${reservedCardCount}` : remaining === 0 ? "已售罄" : "剩余库存",
|
||||||
empty: remaining === 0,
|
empty: remaining === 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -112,9 +113,9 @@ function buildPlanFormValue(plan: PlanListItem): PlanFormValue {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PlanCard({ plan, activeCount, services, batchFormId }: PlanCardProps) {
|
export function PlanCard({ plan, activeCount, reservedCardCount, services, batchFormId }: PlanCardProps) {
|
||||||
const planFormValue = buildPlanFormValue(plan);
|
const planFormValue = buildPlanFormValue(plan);
|
||||||
const stock = remainingStockSummary(plan, activeCount);
|
const stock = remainingStockSummary(plan, activeCount, reservedCardCount);
|
||||||
const Icon = plan.type === "PROXY" ? Network : Tv;
|
const Icon = plan.type === "PROXY" ? Network : Tv;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Prisma } from "@prisma/client";
|
import type { Prisma } from "@prisma/client";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { parsePage } from "@/lib/utils";
|
import { parsePage } from "@/lib/utils";
|
||||||
|
import { activePlanCardReservationWhere } from "@/services/plan-availability";
|
||||||
import type { StreamingServiceOption } from "./plan-form";
|
import type { StreamingServiceOption } from "./plan-form";
|
||||||
|
|
||||||
const planInclude = {
|
const planInclude = {
|
||||||
@@ -43,7 +44,7 @@ export async function getAdminPlans(
|
|||||||
: {}),
|
: {}),
|
||||||
} satisfies Prisma.SubscriptionPlanWhereInput;
|
} satisfies Prisma.SubscriptionPlanWhereInput;
|
||||||
|
|
||||||
const [plans, total, services, activeGroups] = await Promise.all([
|
const [plans, total, services, activeGroups, reservedCardGroups] = await Promise.all([
|
||||||
prisma.subscriptionPlan.findMany({
|
prisma.subscriptionPlan.findMany({
|
||||||
where,
|
where,
|
||||||
include: planInclude,
|
include: planInclude,
|
||||||
@@ -62,11 +63,22 @@ export async function getAdminPlans(
|
|||||||
where: { status: "ACTIVE" },
|
where: { status: "ACTIVE" },
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
}),
|
}),
|
||||||
|
prisma.rechargeCard.groupBy({
|
||||||
|
by: ["planId"],
|
||||||
|
where: {
|
||||||
|
...activePlanCardReservationWhere(),
|
||||||
|
planId: { not: null },
|
||||||
|
},
|
||||||
|
_count: { _all: true },
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const activeCountMap = new Map(
|
const activeCountMap = new Map(
|
||||||
activeGroups.map((item) => [item.planId, item._count._all]),
|
activeGroups.map((item) => [item.planId, item._count._all]),
|
||||||
);
|
);
|
||||||
|
const reservedCardCountMap = new Map(
|
||||||
|
reservedCardGroups.map((item) => [item.planId ?? "", item._count._all]),
|
||||||
|
);
|
||||||
const serviceOptions: StreamingServiceOption[] = services.map((service) => ({
|
const serviceOptions: StreamingServiceOption[] = services.map((service) => ({
|
||||||
id: service.id,
|
id: service.id,
|
||||||
name: service.name,
|
name: service.name,
|
||||||
@@ -80,6 +92,7 @@ export async function getAdminPlans(
|
|||||||
pageSize,
|
pageSize,
|
||||||
filters: { q, type, status },
|
filters: { q, type, status },
|
||||||
activeCountMap,
|
activeCountMap,
|
||||||
|
reservedCardCountMap,
|
||||||
serviceOptions,
|
serviceOptions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,17 +42,22 @@ export function PaymentMethodSelector({
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className={cn("flex size-9 items-center justify-center rounded-lg transition-colors duration-200", selected ? "bg-primary text-primary-foreground" : "bg-muted text-primary")}>
|
<div className={cn("flex size-9 items-center justify-center rounded-lg transition-colors duration-200", selected ? "bg-primary text-primary-foreground" : "bg-muted text-primary")}>
|
||||||
{provider.provider === "usdt_trc20" ? <Coins className="size-5" /> : <CreditCard className="size-5" />}
|
{provider.provider === "usdt_trc20" || provider.provider === "balance" ? <Coins className="size-5" /> : <CreditCard className="size-5" />}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="font-semibold">{provider.name}</span>
|
<span className="font-semibold">{provider.name}</span>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
{provider.provider === "usdt_trc20" ? "稳定币付款" : "按提示完成"}
|
{provider.provider === "balance"
|
||||||
|
? "从账户余额扣款"
|
||||||
|
: provider.provider === "usdt_trc20"
|
||||||
|
? "稳定币付款"
|
||||||
|
: "按提示完成"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{provider.provider === "usdt_trc20" && <StatusBadge tone="info">Crypto</StatusBadge>}
|
{provider.provider === "balance" && <StatusBadge tone="success">余额</StatusBadge>}
|
||||||
|
{provider.provider === "usdt_trc20" && <StatusBadge tone="info">链上</StatusBadge>}
|
||||||
{selected && <CheckCircle2 className="size-5 text-primary" />}
|
{selected && <CheckCircle2 className="size-5 text-primary" />}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface PaymentInfo {
|
|||||||
paymentUrl?: string;
|
paymentUrl?: string;
|
||||||
qrCode?: string;
|
qrCode?: string;
|
||||||
raw?: {
|
raw?: {
|
||||||
|
status?: string;
|
||||||
walletAddress?: string;
|
walletAddress?: string;
|
||||||
usdtAmount?: string;
|
usdtAmount?: string;
|
||||||
cnyAmount?: string;
|
cnyAmount?: string;
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ export function usePaymentFlow(orderId: string) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
setPayment(data);
|
setPayment(data);
|
||||||
|
if (provider === "balance" || data.raw?.status === "paid") {
|
||||||
|
setStatus("paid");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setStatus("waiting");
|
setStatus("waiting");
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
91
src/app/(user)/wallet/_components/wallet-actions.tsx
Normal file
91
src/app/(user)/wallet/_components/wallet-actions.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { CreditCard, Gift } from "lucide-react";
|
||||||
|
import { createWalletRecharge, redeemWalletCard } from "@/actions/user/wallet";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { getErrorMessage } from "@/lib/errors";
|
||||||
|
|
||||||
|
export function WalletActions() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [rechargePending, startRecharge] = useTransition();
|
||||||
|
const [redeemPending, startRedeem] = useTransition();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="grid gap-5 lg:grid-cols-2">
|
||||||
|
<form
|
||||||
|
className="form-panel space-y-4"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = event.currentTarget;
|
||||||
|
const formData = new FormData(form);
|
||||||
|
startRecharge(async () => {
|
||||||
|
try {
|
||||||
|
const order = await createWalletRecharge(formData);
|
||||||
|
router.push(`/wallet/recharge/${order.id}`);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getErrorMessage(error, "创建充值订单失败"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||||
|
<CreditCard className="size-4" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold">余额充值</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">输入金额后选择在线支付方式。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="wallet-recharge-amount">充值金额</Label>
|
||||||
|
<Input id="wallet-recharge-amount" name="amount" type="number" min="1" step="0.01" placeholder="100.00" required />
|
||||||
|
</div>
|
||||||
|
<Button className="w-full" disabled={rechargePending}>
|
||||||
|
{rechargePending ? "创建中..." : "去支付"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="form-panel space-y-4"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = event.currentTarget;
|
||||||
|
const formData = new FormData(form);
|
||||||
|
startRedeem(async () => {
|
||||||
|
try {
|
||||||
|
await redeemWalletCard(formData);
|
||||||
|
form.reset();
|
||||||
|
toast.success("充值卡兑换成功");
|
||||||
|
router.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getErrorMessage(error, "充值卡兑换失败"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="flex size-10 items-center justify-center rounded-lg bg-amber-500/10 text-amber-700 dark:text-amber-300">
|
||||||
|
<Gift className="size-4" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold">充值卡兑换</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">余额卡入账余额,套餐卡会直接激活。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="wallet-redeem-code">卡密</Label>
|
||||||
|
<Input id="wallet-redeem-code" name="code" placeholder="JB-XXXXXX-XXXXXX-XXXXXX" required />
|
||||||
|
</div>
|
||||||
|
<Button className="w-full" variant="outline" disabled={redeemPending}>
|
||||||
|
{redeemPending ? "兑换中..." : "立即兑换"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
144
src/app/(user)/wallet/page.tsx
Normal file
144
src/app/(user)/wallet/page.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Coins, ReceiptText } from "lucide-react";
|
||||||
|
import { getActiveSession } from "@/lib/require-auth";
|
||||||
|
import { formatDate } from "@/lib/utils";
|
||||||
|
import { PageHeader, PageShell, SectionHeader } from "@/components/shared/page-shell";
|
||||||
|
import { DataTableShell } from "@/components/shared/data-table-shell";
|
||||||
|
import { StatusBadge } from "@/components/shared/status-badge";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
import { getPaymentProviderName } from "@/services/payment/catalog";
|
||||||
|
import { WalletActions } from "./_components/wallet-actions";
|
||||||
|
import { getWalletPageData } from "./wallet-data";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "我的钱包",
|
||||||
|
description: "管理账户余额、余额充值和充值卡兑换。",
|
||||||
|
};
|
||||||
|
|
||||||
|
const transactionLabels: Record<string, string> = {
|
||||||
|
BALANCE_RECHARGE: "余额充值",
|
||||||
|
BALANCE_PAYMENT: "余额支付",
|
||||||
|
CARD_REDEEM: "充值卡兑换",
|
||||||
|
ADMIN_ADJUST: "后台调整",
|
||||||
|
REFUND: "退款",
|
||||||
|
};
|
||||||
|
|
||||||
|
const orderStatusLabels: Record<string, string> = {
|
||||||
|
PENDING: "待支付",
|
||||||
|
PAID: "已入账",
|
||||||
|
CANCELLED: "已取消",
|
||||||
|
REFUNDED: "已退款",
|
||||||
|
};
|
||||||
|
|
||||||
|
function money(value: unknown) {
|
||||||
|
return `¥${Number(value).toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function WalletPage() {
|
||||||
|
const session = await getActiveSession();
|
||||||
|
const { wallet, transactions, rechargeOrders } = await getWalletPageData(session!.user.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell>
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="钱包"
|
||||||
|
title="我的钱包"
|
||||||
|
description="余额可用于订单支付,充值卡可兑换余额或直接激活套餐。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="surface-card rounded-xl p-5">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="flex size-12 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||||
|
<Coins className="size-5" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">当前余额</p>
|
||||||
|
<p className="mt-1 text-3xl font-semibold tabular-nums">{money(wallet.balance)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link href="/orders" className={buttonVariants({ variant: "outline" })}>
|
||||||
|
查看订单
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<WalletActions />
|
||||||
|
|
||||||
|
<section className="space-y-4">
|
||||||
|
<SectionHeader title="余额流水" />
|
||||||
|
<DataTableShell
|
||||||
|
isEmpty={transactions.length === 0}
|
||||||
|
emptyTitle="暂无余额流水"
|
||||||
|
emptyIcon={<ReceiptText className="size-4" />}
|
||||||
|
>
|
||||||
|
<table className="w-full min-w-[760px] text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border/60 text-left text-xs text-muted-foreground">
|
||||||
|
<th className="px-4 py-3 font-medium">类型</th>
|
||||||
|
<th className="px-4 py-3 font-medium">说明</th>
|
||||||
|
<th className="px-4 py-3 text-right font-medium">变动</th>
|
||||||
|
<th className="px-4 py-3 text-right font-medium">余额</th>
|
||||||
|
<th className="px-4 py-3 font-medium">时间</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border/50">
|
||||||
|
{transactions.map((item) => (
|
||||||
|
<tr key={item.id}>
|
||||||
|
<td className="px-4 py-3"><StatusBadge>{transactionLabels[item.type] ?? item.type}</StatusBadge></td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<p className="font-medium">{item.description || item.order?.plan.name || item.rechargeCard?.code || "余额变动"}</p>
|
||||||
|
{item.rechargeCard && <p className="mt-1 font-mono text-xs text-muted-foreground">{item.rechargeCard.code}</p>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right font-mono font-semibold tabular-nums">{money(item.amount)}</td>
|
||||||
|
<td className="px-4 py-3 text-right font-mono tabular-nums">{money(item.balanceAfter)}</td>
|
||||||
|
<td className="px-4 py-3 whitespace-nowrap text-muted-foreground">{formatDate(item.createdAt)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</DataTableShell>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-4">
|
||||||
|
<SectionHeader title="充值订单" />
|
||||||
|
<DataTableShell
|
||||||
|
isEmpty={rechargeOrders.length === 0}
|
||||||
|
emptyTitle="暂无充值订单"
|
||||||
|
>
|
||||||
|
<table className="w-full min-w-[680px] text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border/60 text-left text-xs text-muted-foreground">
|
||||||
|
<th className="px-4 py-3 font-medium">金额</th>
|
||||||
|
<th className="px-4 py-3 font-medium">状态</th>
|
||||||
|
<th className="px-4 py-3 font-medium">支付方式</th>
|
||||||
|
<th className="px-4 py-3 font-medium">时间</th>
|
||||||
|
<th className="px-4 py-3 text-right font-medium">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border/50">
|
||||||
|
{rechargeOrders.map((order) => (
|
||||||
|
<tr key={order.id}>
|
||||||
|
<td className="px-4 py-3 font-mono font-semibold">{money(order.amount)}</td>
|
||||||
|
<td className="px-4 py-3"><StatusBadge>{orderStatusLabels[order.status] ?? order.status}</StatusBadge></td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground">
|
||||||
|
{order.paymentMethod ? getPaymentProviderName(order.paymentMethod) : "未选择"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 whitespace-nowrap text-muted-foreground">{formatDate(order.createdAt)}</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
{order.status === "PENDING" && (
|
||||||
|
<Link href={`/wallet/recharge/${order.id}`} className={buttonVariants({ size: "sm" })}>
|
||||||
|
继续支付
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</DataTableShell>
|
||||||
|
</section>
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
src/app/(user)/wallet/recharge/[id]/page.tsx
Normal file
27
src/app/(user)/wallet/recharge/[id]/page.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { SiteFooter } from "@/components/shared/site-footer";
|
||||||
|
import { RechargePayClient } from "./recharge-pay-client";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "余额充值",
|
||||||
|
description: "完成钱包余额充值。",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function WalletRechargePage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-[100dvh] flex-col px-4 py-10">
|
||||||
|
<div className="flex flex-1 items-center justify-center">
|
||||||
|
<div className="w-full max-w-2xl">
|
||||||
|
<RechargePayClient rechargeId={id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SiteFooter className="mt-6 max-w-2xl" />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
240
src/app/(user)/wallet/recharge/[id]/recharge-pay-client.tsx
Normal file
240
src/app/(user)/wallet/recharge/[id]/recharge-pay-client.tsx
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useEffectEvent, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { CheckCircle2, Coins, CreditCard } from "lucide-react";
|
||||||
|
import { AlipayQrView, UsdtView } from "@/app/(payment)/pay/[orderId]/_components/payment-detail-panels";
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
|
import { fetchJson } from "@/lib/fetch-json";
|
||||||
|
import { getErrorMessage } from "@/lib/errors";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface PaymentProvider {
|
||||||
|
provider: string;
|
||||||
|
name: string;
|
||||||
|
channel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RechargeSnapshot {
|
||||||
|
id: string;
|
||||||
|
amount: number;
|
||||||
|
status: "PENDING" | "PAID" | "CANCELLED" | "REFUNDED";
|
||||||
|
paymentMethod: string | null;
|
||||||
|
tradeNo: string | null;
|
||||||
|
paymentUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaymentInfo {
|
||||||
|
tradeNo: string;
|
||||||
|
paymentUrl?: string;
|
||||||
|
qrCode?: string;
|
||||||
|
raw?: {
|
||||||
|
walletAddress?: string;
|
||||||
|
usdtAmount?: string;
|
||||||
|
cnyAmount?: string;
|
||||||
|
exchangeRate?: number;
|
||||||
|
network?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSafePaymentUrl(value: string) {
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
return url.protocol === "https:" || url.protocol === "http:";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RechargePayClient({ rechargeId }: { rechargeId: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [providers, setProviders] = useState<PaymentProvider[]>([]);
|
||||||
|
const [selectedIdx, setSelectedIdx] = useState(-1);
|
||||||
|
const [recharge, setRecharge] = useState<RechargeSnapshot | null>(null);
|
||||||
|
const [payment, setPayment] = useState<PaymentInfo | null>(null);
|
||||||
|
const [status, setStatus] = useState<"booting" | "idle" | "creating" | "waiting" | "paid">("booting");
|
||||||
|
const [pageError, setPageError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const selectedProvider = useMemo(
|
||||||
|
() => (selectedIdx >= 0 ? providers[selectedIdx] : null),
|
||||||
|
[providers, selectedIdx],
|
||||||
|
);
|
||||||
|
|
||||||
|
async function createPayment(provider: PaymentProvider) {
|
||||||
|
setPageError(null);
|
||||||
|
setStatus("creating");
|
||||||
|
try {
|
||||||
|
const data = await fetchJson<PaymentInfo>("/api/wallet/recharge/payment/create", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ rechargeId, provider: provider.provider, channel: provider.channel }),
|
||||||
|
});
|
||||||
|
setPayment(data);
|
||||||
|
setStatus("waiting");
|
||||||
|
if (
|
||||||
|
data.paymentUrl
|
||||||
|
&& provider.provider === "epay"
|
||||||
|
&& isSafePaymentUrl(data.paymentUrl)
|
||||||
|
) {
|
||||||
|
window.location.href = data.paymentUrl;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setStatus("idle");
|
||||||
|
setPageError(getErrorMessage(error, "充值支付创建失败"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadPageState = useEffectEvent(async () => {
|
||||||
|
try {
|
||||||
|
const [providerList, order] = await Promise.all([
|
||||||
|
fetchJson<PaymentProvider[]>("/api/payment/providers?target=wallet"),
|
||||||
|
fetchJson<RechargeSnapshot>(`/api/wallet/recharge/${rechargeId}`),
|
||||||
|
]);
|
||||||
|
setProviders(providerList);
|
||||||
|
setRecharge(order);
|
||||||
|
|
||||||
|
if (order.status === "PAID") {
|
||||||
|
setStatus("paid");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultIdx = order.paymentMethod
|
||||||
|
? providerList.findIndex((provider) => provider.provider === order.paymentMethod)
|
||||||
|
: providerList.length === 1 ? 0 : -1;
|
||||||
|
setSelectedIdx(defaultIdx);
|
||||||
|
|
||||||
|
if (!order.paymentMethod || !order.tradeNo) {
|
||||||
|
setPayment(null);
|
||||||
|
setStatus("idle");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPayment({
|
||||||
|
tradeNo: order.tradeNo,
|
||||||
|
paymentUrl: order.paymentUrl ?? undefined,
|
||||||
|
});
|
||||||
|
setStatus("waiting");
|
||||||
|
} catch (error) {
|
||||||
|
setPageError(getErrorMessage(error, "加载充值订单失败"));
|
||||||
|
setStatus("idle");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const pollPaymentStatus = useEffectEvent(async () => {
|
||||||
|
if (!payment?.tradeNo) return;
|
||||||
|
try {
|
||||||
|
const result = await fetchJson<{ status: string }>(`/api/wallet/recharge/query/${payment.tradeNo}`);
|
||||||
|
if (result.status === "paid") {
|
||||||
|
setStatus("paid");
|
||||||
|
setPageError(null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setStatus("idle");
|
||||||
|
setPageError(getErrorMessage(error, "查询充值结果失败"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
void loadPageState();
|
||||||
|
}, 0);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [rechargeId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status !== "waiting" || !payment?.tradeNo) return;
|
||||||
|
const timer = window.setTimeout(() => void pollPaymentStatus(), 0);
|
||||||
|
const interval = window.setInterval(() => void pollPaymentStatus(), 5000);
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
window.clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, [payment?.tradeNo, status]);
|
||||||
|
|
||||||
|
if (status === "paid") {
|
||||||
|
return (
|
||||||
|
<div className="surface-card mx-auto max-w-xl rounded-xl p-6 text-center">
|
||||||
|
<div className="mx-auto flex size-12 items-center justify-center rounded-xl bg-primary text-primary-foreground">
|
||||||
|
<CheckCircle2 className="size-6" />
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-4 text-2xl font-semibold">充值成功</h1>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">余额已入账,可直接用于订单支付。</p>
|
||||||
|
<div className="mt-5 flex justify-center gap-2">
|
||||||
|
<Button onClick={() => router.push("/wallet")}>返回钱包</Button>
|
||||||
|
<Link href="/store" className={buttonVariants({ variant: "outline" })}>去商店</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="surface-card mx-auto max-w-xl space-y-5 rounded-xl p-5">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">余额充值</p>
|
||||||
|
<h1 className="mt-1 text-3xl font-semibold tabular-nums">¥{(recharge?.amount ?? 0).toFixed(2)}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pageError && (
|
||||||
|
<div className="rounded-lg border border-destructive/15 bg-destructive/10 px-4 py-3 text-sm font-medium text-destructive">
|
||||||
|
{pageError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === "booting" && <p className="py-4 text-center text-sm text-muted-foreground">正在读取充值订单…</p>}
|
||||||
|
|
||||||
|
{!payment && status !== "booting" && (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{providers.map((provider, index) => {
|
||||||
|
const selected = selectedIdx === index;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${provider.provider}-${provider.channel ?? ""}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedIdx(index)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-between gap-3 rounded-lg border p-3 text-left transition-colors",
|
||||||
|
selected ? "border-primary/25 bg-primary/8" : "border-border bg-muted/20 hover:bg-card",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-3">
|
||||||
|
<span className="flex size-9 items-center justify-center rounded-lg bg-muted text-primary">
|
||||||
|
{provider.provider === "usdt_trc20" ? <Coins className="size-5" /> : <CreditCard className="size-5" />}
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold">{provider.name}</span>
|
||||||
|
</span>
|
||||||
|
{selected && <CheckCircle2 className="size-5 text-primary" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
size="lg"
|
||||||
|
disabled={!selectedProvider || status === "creating"}
|
||||||
|
onClick={() => selectedProvider && void createPayment(selectedProvider)}
|
||||||
|
>
|
||||||
|
{status === "creating" ? "正在准备支付..." : "继续支付"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{payment && selectedProvider?.provider === "alipay_f2f" && payment.qrCode && (
|
||||||
|
<AlipayQrView qrCode={payment.qrCode} />
|
||||||
|
)}
|
||||||
|
{payment && selectedProvider?.provider === "usdt_trc20" && payment.raw && (
|
||||||
|
<UsdtView raw={payment.raw} />
|
||||||
|
)}
|
||||||
|
{payment && status === "waiting" && (
|
||||||
|
<div className="rounded-lg border border-primary/15 bg-primary/10 px-4 py-3 text-center text-sm text-primary">
|
||||||
|
<p className="animate-pulse font-semibold">正在等待支付结果返回</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Link href="/wallet" className={buttonVariants({ variant: "outline" })}>返回钱包</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/app/(user)/wallet/wallet-data.ts
Normal file
24
src/app/(user)/wallet/wallet-data.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { getOrCreateWallet } from "@/services/wallet";
|
||||||
|
|
||||||
|
export async function getWalletPageData(userId: string) {
|
||||||
|
const wallet = await getOrCreateWallet(prisma, userId);
|
||||||
|
const [transactions, rechargeOrders] = await Promise.all([
|
||||||
|
prisma.walletTransaction.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 30,
|
||||||
|
include: {
|
||||||
|
order: { select: { id: true, plan: { select: { name: true } } } },
|
||||||
|
rechargeCard: { select: { code: true, type: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.walletRechargeOrder.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 8,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { wallet, transactions, rechargeOrders };
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { rateLimit } from "@/lib/rate-limit";
|
|||||||
import { getSiteBaseUrl } from "@/services/site-url";
|
import { getSiteBaseUrl } from "@/services/site-url";
|
||||||
import { getActiveSubscriptionRiskRestriction } from "@/services/subscription-risk-review";
|
import { getActiveSubscriptionRiskRestriction } from "@/services/subscription-risk-review";
|
||||||
import { getOrderStatusLabel } from "@/lib/domain-labels";
|
import { getOrderStatusLabel } from "@/lib/domain-labels";
|
||||||
|
import { payOrderWithWallet } from "@/services/wallet";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
const createPaymentSchema = z.object({
|
const createPaymentSchema = z.object({
|
||||||
@@ -72,6 +73,14 @@ export async function POST(req: Request) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (payload.provider === "balance") {
|
||||||
|
const result = await payOrderWithWallet(order.id, session.user.id);
|
||||||
|
return jsonOk({
|
||||||
|
tradeNo: result.tradeNo,
|
||||||
|
raw: { status: "paid" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const adapter = await getPaymentAdapter(payload.provider);
|
const adapter = await getPaymentAdapter(payload.provider);
|
||||||
const tradeNo =
|
const tradeNo =
|
||||||
order.tradeNo
|
order.tradeNo
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { jsonError, jsonOk } from "@/lib/api-response";
|
import { jsonError, jsonOk } from "@/lib/api-response";
|
||||||
import { getEnabledProviders } from "@/services/payment/factory";
|
import { getEnabledProviders } from "@/services/payment/factory";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET(req: Request) {
|
||||||
try {
|
try {
|
||||||
const providers = await getEnabledProviders();
|
const url = new URL(req.url);
|
||||||
|
const providers = await getEnabledProviders({
|
||||||
|
includeBalance: url.searchParams.get("target") !== "wallet",
|
||||||
|
});
|
||||||
return jsonOk(providers);
|
return jsonOk(providers);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return jsonError(error, { fallback: "获取支付方式失败" });
|
return jsonError(error, { fallback: "获取支付方式失败" });
|
||||||
|
|||||||
30
src/app/api/wallet/recharge/[id]/route.ts
Normal file
30
src/app/api/wallet/recharge/[id]/route.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { getActiveSession } from "@/lib/require-auth";
|
||||||
|
import { jsonError, jsonOk } from "@/lib/api-response";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const session = await getActiveSession();
|
||||||
|
if (!session) return jsonError("未登录", { status: 401 });
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const recharge = await prisma.walletRechargeOrder.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
if (!recharge || recharge.userId !== session.user.id) {
|
||||||
|
return jsonError("充值订单不存在", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonOk({
|
||||||
|
id: recharge.id,
|
||||||
|
amount: Number(recharge.amount),
|
||||||
|
status: recharge.status,
|
||||||
|
paymentMethod: recharge.paymentMethod,
|
||||||
|
tradeNo: recharge.tradeNo,
|
||||||
|
paymentUrl: recharge.paymentUrl,
|
||||||
|
expireAt: recharge.expireAt?.toISOString() ?? null,
|
||||||
|
note: recharge.note,
|
||||||
|
});
|
||||||
|
}
|
||||||
90
src/app/api/wallet/recharge/payment/create/route.ts
Normal file
90
src/app/api/wallet/recharge/payment/create/route.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { getActiveSession } from "@/lib/require-auth";
|
||||||
|
import { jsonError, jsonOk } from "@/lib/api-response";
|
||||||
|
import { getPaymentAdapter } from "@/services/payment/factory";
|
||||||
|
import { getSiteBaseUrl } from "@/services/site-url";
|
||||||
|
|
||||||
|
const createRechargePaymentSchema = z.object({
|
||||||
|
rechargeId: z.string().trim().min(1),
|
||||||
|
provider: z.string().trim().min(1),
|
||||||
|
channel: z.string().trim().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function isSafePaymentUrl(value: string | undefined) {
|
||||||
|
if (!value) return true;
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
return url.protocol === "https:" || url.protocol === "http:";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const session = await getActiveSession();
|
||||||
|
if (!session) return jsonError("未登录", { status: 401 });
|
||||||
|
|
||||||
|
const payload = createRechargePaymentSchema.parse(await req.json());
|
||||||
|
if (payload.provider === "balance") {
|
||||||
|
return jsonError("余额充值不能使用余额支付", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const recharge = await prisma.walletRechargeOrder.findUnique({
|
||||||
|
where: { id: payload.rechargeId },
|
||||||
|
});
|
||||||
|
if (!recharge || recharge.userId !== session.user.id) {
|
||||||
|
return jsonError("充值订单不存在", { status: 404 });
|
||||||
|
}
|
||||||
|
if (recharge.status !== "PENDING") {
|
||||||
|
return jsonError("充值订单已处理,不能继续支付", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const adapter = await getPaymentAdapter(payload.provider);
|
||||||
|
const tradeNo =
|
||||||
|
recharge.tradeNo
|
||||||
|
|| `WR-${Date.now()}-${uuidv4().slice(0, 8)}-${Number(recharge.amount).toFixed(2)}`;
|
||||||
|
|
||||||
|
const baseUrl = await getSiteBaseUrl({ headers: req.headers, requestUrl: req.url });
|
||||||
|
if (!baseUrl) {
|
||||||
|
return jsonError("请先在后台系统设置里配置网站 URL", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await adapter.createPayment({
|
||||||
|
tradeNo,
|
||||||
|
amount: Number(recharge.amount),
|
||||||
|
subject: `余额充值-${tradeNo.slice(0, 8)}`,
|
||||||
|
notifyUrl: `${baseUrl}/api/payment/notify/${payload.provider}`,
|
||||||
|
returnUrl: `${baseUrl}/wallet/recharge/${payload.rechargeId}?status=return`,
|
||||||
|
channel: payload.channel,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return jsonError("支付单创建失败,请检查支付配置或稍后重试", { status: 500 });
|
||||||
|
}
|
||||||
|
if (!isSafePaymentUrl(result.paymentUrl)) {
|
||||||
|
return jsonError("支付网关返回了无效跳转地址", { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.walletRechargeOrder.update({
|
||||||
|
where: { id: payload.rechargeId },
|
||||||
|
data: {
|
||||||
|
tradeNo,
|
||||||
|
paymentMethod: payload.provider,
|
||||||
|
paymentUrl: result.paymentUrl || null,
|
||||||
|
expireAt: new Date(Date.now() + 30 * 60 * 1000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return jsonOk({
|
||||||
|
tradeNo,
|
||||||
|
paymentUrl: result.paymentUrl,
|
||||||
|
qrCode: result.qrCode,
|
||||||
|
raw: result.raw,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return jsonError(error, { fallback: "创建充值支付失败" });
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/app/api/wallet/recharge/query/[tradeNo]/route.ts
Normal file
50
src/app/api/wallet/recharge/query/[tradeNo]/route.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { getActiveSession } from "@/lib/require-auth";
|
||||||
|
import { jsonError, jsonOk } from "@/lib/api-response";
|
||||||
|
import { getPaymentAdapter } from "@/services/payment/factory";
|
||||||
|
import { processWalletRechargeSuccess } from "@/services/wallet";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: Request,
|
||||||
|
{ params }: { params: Promise<{ tradeNo: string }> },
|
||||||
|
) {
|
||||||
|
const session = await getActiveSession();
|
||||||
|
if (!session) return jsonError("未登录", { status: 401 });
|
||||||
|
|
||||||
|
const { tradeNo } = await params;
|
||||||
|
const recharge = await prisma.walletRechargeOrder.findUnique({
|
||||||
|
where: { tradeNo },
|
||||||
|
select: {
|
||||||
|
userId: true,
|
||||||
|
status: true,
|
||||||
|
paymentMethod: true,
|
||||||
|
createdAt: true,
|
||||||
|
note: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!recharge || recharge.userId !== session.user.id) {
|
||||||
|
return jsonError("充值订单不存在", { status: 404 });
|
||||||
|
}
|
||||||
|
if (recharge.status === "PAID") {
|
||||||
|
return jsonOk({ status: "paid" });
|
||||||
|
}
|
||||||
|
if (recharge.status !== "PENDING" || !recharge.paymentMethod) {
|
||||||
|
return jsonOk({ status: recharge.status.toLowerCase() });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const adapter = await getPaymentAdapter(recharge.paymentMethod);
|
||||||
|
const result = await adapter.queryOrder(tradeNo, recharge.createdAt.getTime());
|
||||||
|
if (result?.status === "success") {
|
||||||
|
const processed = await processWalletRechargeSuccess(tradeNo, result.amount, result.paymentRef);
|
||||||
|
if (processed.finalStatus === "PAID") {
|
||||||
|
return jsonOk({ status: "paid" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return jsonError(error, { fallback: "查询充值支付状态失败" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonOk({ status: "pending" });
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
UserCircle2,
|
UserCircle2,
|
||||||
MessageSquareWarning,
|
MessageSquareWarning,
|
||||||
|
WalletCards,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Sidebar, type SidebarGroup, type SidebarLink } from "@/components/shared/sidebar";
|
import { Sidebar, type SidebarGroup, type SidebarLink } from "@/components/shared/sidebar";
|
||||||
import { PRODUCT_NAME } from "@/lib/product";
|
import { PRODUCT_NAME } from "@/lib/product";
|
||||||
@@ -18,6 +19,7 @@ export const userLinks: SidebarLink[] = [
|
|||||||
{ href: "/store", label: "套餐商店", icon: <ShoppingBag size={16} /> },
|
{ href: "/store", label: "套餐商店", icon: <ShoppingBag size={16} /> },
|
||||||
{ href: "/cart", label: "购物车", icon: <ShoppingCart size={16} /> },
|
{ href: "/cart", label: "购物车", icon: <ShoppingCart size={16} /> },
|
||||||
{ href: "/subscriptions", label: "我的订阅", icon: <Radio size={16} /> },
|
{ href: "/subscriptions", label: "我的订阅", icon: <Radio size={16} /> },
|
||||||
|
{ href: "/wallet", label: "我的钱包", icon: <WalletCards size={16} /> },
|
||||||
{ href: "/orders", label: "我的订单", icon: <ClipboardList size={16} /> },
|
{ href: "/orders", label: "我的订单", icon: <ClipboardList size={16} /> },
|
||||||
{ href: "/support", label: "工单售后", icon: <MessageSquareWarning size={16} /> },
|
{ href: "/support", label: "工单售后", icon: <MessageSquareWarning size={16} /> },
|
||||||
{ href: "/account", label: "账户中心", icon: <UserCircle2 size={16} /> },
|
{ href: "/account", label: "账户中心", icon: <UserCircle2 size={16} /> },
|
||||||
@@ -30,11 +32,11 @@ export const userNavGroups: SidebarGroup[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "记录",
|
label: "记录",
|
||||||
links: userLinks.slice(4, 5),
|
links: userLinks.slice(4, 6),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "支持",
|
label: "支持",
|
||||||
links: userLinks.slice(5),
|
links: userLinks.slice(6),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -46,13 +46,26 @@ const usdtTrc20Schema = z.object({
|
|||||||
.transform((value) => value ?? ""),
|
.transform((value) => value ?? ""),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const balanceSchema = z.object({
|
||||||
|
displayName: displayNameField,
|
||||||
|
});
|
||||||
|
|
||||||
const paymentConfigSchemas = {
|
const paymentConfigSchemas = {
|
||||||
|
balance: balanceSchema,
|
||||||
epay: epaySchema,
|
epay: epaySchema,
|
||||||
alipay_f2f: alipayF2fSchema,
|
alipay_f2f: alipayF2fSchema,
|
||||||
usdt_trc20: usdtTrc20Schema,
|
usdt_trc20: usdtTrc20Schema,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const PAYMENT_PROVIDER_DEFINITIONS: PaymentProviderDefinition[] = [
|
export const PAYMENT_PROVIDER_DEFINITIONS: PaymentProviderDefinition[] = [
|
||||||
|
{
|
||||||
|
id: "balance",
|
||||||
|
name: "余额支付",
|
||||||
|
description: "使用用户钱包余额支付订单,默认启用",
|
||||||
|
fields: [
|
||||||
|
{ key: "displayName", label: "用户端显示名称", placeholder: "留空则显示余额支付" },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "epay",
|
id: "epay",
|
||||||
name: "易支付",
|
name: "易支付",
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ export async function getPaymentAdapter(provider: string): Promise<PaymentAdapte
|
|||||||
// epay_alipay / epay_wxpay both use the epay adapter
|
// epay_alipay / epay_wxpay both use the epay adapter
|
||||||
const realProvider = provider.startsWith("epay") ? "epay" : provider;
|
const realProvider = provider.startsWith("epay") ? "epay" : provider;
|
||||||
|
|
||||||
|
if (realProvider === "balance") {
|
||||||
|
throw new Error("余额支付不需要外部支付适配器");
|
||||||
|
}
|
||||||
|
|
||||||
const config = await prisma.paymentConfig.findUnique({
|
const config = await prisma.paymentConfig.findUnique({
|
||||||
where: { provider: realProvider },
|
where: { provider: realProvider },
|
||||||
});
|
});
|
||||||
@@ -28,11 +32,11 @@ export async function getPaymentAdapter(provider: string): Promise<PaymentAdapte
|
|||||||
|
|
||||||
switch (realProvider) {
|
switch (realProvider) {
|
||||||
case "epay":
|
case "epay":
|
||||||
return new EasyPayAdapter(cfg as EasyPayConfig);
|
return new EasyPayAdapter(cfg as unknown as EasyPayConfig);
|
||||||
case "alipay_f2f":
|
case "alipay_f2f":
|
||||||
return new AlipayF2FAdapter(cfg as AlipayF2FConfig);
|
return new AlipayF2FAdapter(cfg as unknown as AlipayF2FConfig);
|
||||||
case "usdt_trc20":
|
case "usdt_trc20":
|
||||||
return new UsdtTrc20Adapter(cfg as UsdtTrc20Config);
|
return new UsdtTrc20Adapter(cfg as unknown as UsdtTrc20Config);
|
||||||
default:
|
default:
|
||||||
throw new Error("未知支付方式");
|
throw new Error("未知支付方式");
|
||||||
}
|
}
|
||||||
@@ -44,15 +48,37 @@ export interface EnabledProvider {
|
|||||||
channel?: string;
|
channel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getEnabledProviders(): Promise<EnabledProvider[]> {
|
export async function isBalancePaymentEnabled() {
|
||||||
|
const config = await prisma.paymentConfig.findUnique({
|
||||||
|
where: { provider: "balance" },
|
||||||
|
select: { enabled: true },
|
||||||
|
});
|
||||||
|
return config?.enabled ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEnabledProviders(options: { includeBalance?: boolean } = {}): Promise<EnabledProvider[]> {
|
||||||
const configs = await prisma.paymentConfig.findMany({
|
const configs = await prisma.paymentConfig.findMany({
|
||||||
where: { enabled: true },
|
where: { enabled: true },
|
||||||
select: { provider: true, config: true },
|
select: { provider: true, config: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const result: EnabledProvider[] = [];
|
const result: EnabledProvider[] = [];
|
||||||
|
const includeBalance = options.includeBalance ?? true;
|
||||||
|
|
||||||
|
if (includeBalance && await isBalancePaymentEnabled()) {
|
||||||
|
const balanceConfig = await prisma.paymentConfig.findUnique({
|
||||||
|
where: { provider: "balance" },
|
||||||
|
select: { config: true },
|
||||||
|
});
|
||||||
|
const cfg = balanceConfig?.config as Record<string, string> | null;
|
||||||
|
result.push({
|
||||||
|
provider: "balance",
|
||||||
|
name: cfg?.displayName || getPaymentProviderName("balance"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
for (const c of configs) {
|
for (const c of configs) {
|
||||||
|
if (c.provider === "balance") continue;
|
||||||
const cfg = c.config as Record<string, string> | null;
|
const cfg = c.config as Record<string, string> | null;
|
||||||
|
|
||||||
if (c.provider === "epay") {
|
if (c.provider === "epay") {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { createNotification } from "@/services/notifications";
|
|||||||
import { provisionSubscriptionWithDb } from "@/services/provision";
|
import { provisionSubscriptionWithDb } from "@/services/provision";
|
||||||
import { recordTaskFailure } from "@/services/task-center";
|
import { recordTaskFailure } from "@/services/task-center";
|
||||||
import { issueInviteRewardForOrder } from "@/services/invite-rewards";
|
import { issueInviteRewardForOrder } from "@/services/invite-rewards";
|
||||||
|
import { processWalletRechargeSuccess } from "@/services/wallet";
|
||||||
|
|
||||||
export interface PaymentProcessResult {
|
export interface PaymentProcessResult {
|
||||||
processed: boolean;
|
processed: boolean;
|
||||||
@@ -144,7 +145,11 @@ export async function handleVerifiedPaymentSuccess(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!order) {
|
if (!order) {
|
||||||
return { processed: false, finalStatus: null } satisfies PaymentProcessResult;
|
const walletResult = await processWalletRechargeSuccess(tradeNo, paidAmount, paymentRef);
|
||||||
|
return {
|
||||||
|
processed: walletResult.processed,
|
||||||
|
finalStatus: walletResult.finalStatus,
|
||||||
|
} satisfies PaymentProcessResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
const expectedAmount = Number(order.amount);
|
const expectedAmount = Number(order.amount);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma, type DbClient } from "@/lib/prisma";
|
||||||
import type { SubscriptionPlan, SubscriptionType } from "@prisma/client";
|
import type { Prisma, SubscriptionPlan, SubscriptionType } from "@prisma/client";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
|
|
||||||
type PlanAvailabilityInput = Pick<
|
type PlanAvailabilityInput = Pick<
|
||||||
@@ -13,6 +13,7 @@ export interface PlanAvailability {
|
|||||||
available: boolean;
|
available: boolean;
|
||||||
reason: AvailabilityReason | null;
|
reason: AvailabilityReason | null;
|
||||||
activeCount: number;
|
activeCount: number;
|
||||||
|
reservedCardCount: number;
|
||||||
totalLimit: number | null;
|
totalLimit: number | null;
|
||||||
remainingByPlanLimit: number | null;
|
remainingByPlanLimit: number | null;
|
||||||
remainingByServiceCapacity: number | null;
|
remainingByServiceCapacity: number | null;
|
||||||
@@ -41,24 +42,66 @@ export function buildUnavailableMessage(availability: PlanAvailability): string
|
|||||||
return `${prefix},预计最早可购买时间:${formatAvailabilityDateTime(availability.nextAvailableAt)}`;
|
return `${prefix},预计最早可购买时间:${formatAvailabilityDateTime(availability.nextAvailableAt)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getEarliestPlanExpiry(planId: string): Promise<Date | null> {
|
export function activePlanCardReservationWhere(now = new Date()): Prisma.RechargeCardWhereInput {
|
||||||
const earliest = await prisma.userSubscription.findFirst({
|
return {
|
||||||
|
type: "PLAN",
|
||||||
|
status: "UNUSED",
|
||||||
|
OR: [
|
||||||
|
{ expiresAt: null },
|
||||||
|
{ expiresAt: { gt: now } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPlanCardReservationCount(
|
||||||
|
planId: string,
|
||||||
|
options: { db?: DbClient; now?: Date } = {},
|
||||||
|
): Promise<number> {
|
||||||
|
const db = options.db ?? prisma;
|
||||||
|
return db.rechargeCard.count({
|
||||||
|
where: {
|
||||||
|
...activePlanCardReservationWhere(options.now),
|
||||||
|
planId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getEarliestPlanStockRelease(db: DbClient, planId: string): Promise<Date | null> {
|
||||||
|
const [earliestSubscription, earliestCard] = await Promise.all([
|
||||||
|
db.userSubscription.findFirst({
|
||||||
where: {
|
where: {
|
||||||
planId,
|
planId,
|
||||||
status: "ACTIVE",
|
status: "ACTIVE",
|
||||||
},
|
},
|
||||||
select: { endDate: true },
|
select: { endDate: true },
|
||||||
orderBy: { endDate: "asc" },
|
orderBy: { endDate: "asc" },
|
||||||
});
|
}),
|
||||||
|
db.rechargeCard.findFirst({
|
||||||
|
where: {
|
||||||
|
...activePlanCardReservationWhere(),
|
||||||
|
planId,
|
||||||
|
expiresAt: { not: null },
|
||||||
|
},
|
||||||
|
select: { expiresAt: true },
|
||||||
|
orderBy: { expiresAt: "asc" },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
return earliest?.endDate ?? null;
|
const candidates = [
|
||||||
|
earliestSubscription?.endDate ?? null,
|
||||||
|
earliestCard?.expiresAt ?? null,
|
||||||
|
].filter((date): date is Date => Boolean(date));
|
||||||
|
|
||||||
|
if (candidates.length === 0) return null;
|
||||||
|
return candidates.sort((a, b) => a.getTime() - b.getTime())[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getEarliestUserPlanExpiry(
|
async function getEarliestUserPlanExpiry(
|
||||||
|
db: DbClient,
|
||||||
planId: string,
|
planId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<Date | null> {
|
): Promise<Date | null> {
|
||||||
const earliest = await prisma.userSubscription.findFirst({
|
const earliest = await db.userSubscription.findFirst({
|
||||||
where: {
|
where: {
|
||||||
planId,
|
planId,
|
||||||
userId,
|
userId,
|
||||||
@@ -71,10 +114,10 @@ async function getEarliestUserPlanExpiry(
|
|||||||
return earliest?.endDate ?? null;
|
return earliest?.endDate ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getEarliestServiceExpiry(serviceIds: string[]): Promise<Date | null> {
|
async function getEarliestServiceExpiry(db: DbClient, serviceIds: string[]): Promise<Date | null> {
|
||||||
if (serviceIds.length === 0) return null;
|
if (serviceIds.length === 0) return null;
|
||||||
|
|
||||||
const earliest = await prisma.userSubscription.findFirst({
|
const earliest = await db.userSubscription.findFirst({
|
||||||
where: {
|
where: {
|
||||||
status: "ACTIVE",
|
status: "ACTIVE",
|
||||||
streamingSlot: {
|
streamingSlot: {
|
||||||
@@ -90,7 +133,49 @@ async function getEarliestServiceExpiry(serviceIds: string[]): Promise<Date | nu
|
|||||||
return earliest?.endDate ?? null;
|
return earliest?.endDate ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function earliestDate(...dates: Array<Date | null | undefined>) {
|
||||||
|
const candidates = dates.filter((date): date is Date => Boolean(date));
|
||||||
|
if (candidates.length === 0) return null;
|
||||||
|
return candidates.sort((a, b) => a.getTime() - b.getTime())[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function streamingCardReservationPlanFilter(streamingServiceId?: string) {
|
||||||
|
return streamingServiceId
|
||||||
|
? { type: "STREAMING" as const, streamingServiceId }
|
||||||
|
: { type: "STREAMING" as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStreamingCardReservationCount(
|
||||||
|
db: DbClient,
|
||||||
|
streamingServiceId?: string,
|
||||||
|
): Promise<number> {
|
||||||
|
return db.rechargeCard.count({
|
||||||
|
where: {
|
||||||
|
...activePlanCardReservationWhere(),
|
||||||
|
plan: { is: streamingCardReservationPlanFilter(streamingServiceId) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getEarliestStreamingCardReservationExpiry(
|
||||||
|
db: DbClient,
|
||||||
|
streamingServiceId?: string,
|
||||||
|
): Promise<Date | null> {
|
||||||
|
const earliest = await db.rechargeCard.findFirst({
|
||||||
|
where: {
|
||||||
|
...activePlanCardReservationWhere(),
|
||||||
|
expiresAt: { not: null },
|
||||||
|
plan: { is: streamingCardReservationPlanFilter(streamingServiceId) },
|
||||||
|
},
|
||||||
|
select: { expiresAt: true },
|
||||||
|
orderBy: { expiresAt: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return earliest?.expiresAt ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
async function evaluateStreamingCapacity(
|
async function evaluateStreamingCapacity(
|
||||||
|
db: DbClient,
|
||||||
type: SubscriptionType,
|
type: SubscriptionType,
|
||||||
streamingServiceId: string | null,
|
streamingServiceId: string | null,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
@@ -103,7 +188,7 @@ async function evaluateStreamingCapacity(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (streamingServiceId) {
|
if (streamingServiceId) {
|
||||||
const service = await prisma.streamingService.findUnique({
|
const service = await db.streamingService.findUnique({
|
||||||
where: { id: streamingServiceId },
|
where: { id: streamingServiceId },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -117,29 +202,42 @@ async function evaluateStreamingCapacity(
|
|||||||
return { blocked: true, remaining: 0, nextAvailableAt: null };
|
return { blocked: true, remaining: 0, nextAvailableAt: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const remaining = Math.max(0, service.maxSlots - service.usedSlots);
|
const reservedCards = await getStreamingCardReservationCount(db, service.id);
|
||||||
|
const remaining = Math.max(0, service.maxSlots - service.usedSlots - reservedCards);
|
||||||
if (remaining > 0) {
|
if (remaining > 0) {
|
||||||
return { blocked: false, remaining, nextAvailableAt: null };
|
return { blocked: false, remaining, nextAvailableAt: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextAvailableAt = await getEarliestServiceExpiry([service.id]);
|
const [serviceNextAt, cardNextAt] = await Promise.all([
|
||||||
|
getEarliestServiceExpiry(db, [service.id]),
|
||||||
|
getEarliestStreamingCardReservationExpiry(db, service.id),
|
||||||
|
]);
|
||||||
|
const nextAvailableAt = earliestDate(serviceNextAt, cardNextAt);
|
||||||
return { blocked: true, remaining: 0, nextAvailableAt };
|
return { blocked: true, remaining: 0, nextAvailableAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
const services = await prisma.streamingService.findMany({
|
const [services, reservedCards] = await Promise.all([
|
||||||
|
db.streamingService.findMany({
|
||||||
where: { isActive: true },
|
where: { isActive: true },
|
||||||
select: { id: true, maxSlots: true, usedSlots: true },
|
select: { id: true, maxSlots: true, usedSlots: true },
|
||||||
});
|
}),
|
||||||
|
getStreamingCardReservationCount(db),
|
||||||
|
]);
|
||||||
|
|
||||||
const totalRemaining = services.reduce(
|
const totalRemainingBeforeReservations = services.reduce(
|
||||||
(sum, service) => sum + Math.max(0, service.maxSlots - service.usedSlots),
|
(sum, service) => sum + Math.max(0, service.maxSlots - service.usedSlots),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
const totalRemaining = Math.max(0, totalRemainingBeforeReservations - reservedCards);
|
||||||
if (totalRemaining > 0) {
|
if (totalRemaining > 0) {
|
||||||
return { blocked: false, remaining: totalRemaining, nextAvailableAt: null };
|
return { blocked: false, remaining: totalRemaining, nextAvailableAt: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextAvailableAt = await getEarliestServiceExpiry(services.map((service) => service.id));
|
const [serviceNextAt, cardNextAt] = await Promise.all([
|
||||||
|
getEarliestServiceExpiry(db, services.map((service) => service.id)),
|
||||||
|
getEarliestStreamingCardReservationExpiry(db),
|
||||||
|
]);
|
||||||
|
const nextAvailableAt = earliestDate(serviceNextAt, cardNextAt);
|
||||||
return { blocked: true, remaining: 0, nextAvailableAt };
|
return { blocked: true, remaining: 0, nextAvailableAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,26 +270,30 @@ function resolveNextAvailability(
|
|||||||
|
|
||||||
export async function getPlanAvailability(
|
export async function getPlanAvailability(
|
||||||
plan: PlanAvailabilityInput,
|
plan: PlanAvailabilityInput,
|
||||||
options?: { userId?: string },
|
options?: { userId?: string; db?: DbClient },
|
||||||
): Promise<PlanAvailability> {
|
): Promise<PlanAvailability> {
|
||||||
const activeCount = await prisma.userSubscription.count({
|
const db = options?.db ?? prisma;
|
||||||
|
const [activeCount, reservedCardCount] = await Promise.all([
|
||||||
|
db.userSubscription.count({
|
||||||
where: {
|
where: {
|
||||||
planId: plan.id,
|
planId: plan.id,
|
||||||
status: "ACTIVE",
|
status: "ACTIVE",
|
||||||
},
|
},
|
||||||
});
|
}),
|
||||||
|
getPlanCardReservationCount(plan.id, { db }),
|
||||||
|
]);
|
||||||
|
|
||||||
const totalLimit = plan.totalLimit ?? null;
|
const totalLimit = plan.totalLimit ?? null;
|
||||||
const remainingByPlanLimit =
|
const remainingByPlanLimit =
|
||||||
totalLimit == null ? null : Math.max(0, totalLimit - activeCount);
|
totalLimit == null ? null : Math.max(0, totalLimit - activeCount - reservedCardCount);
|
||||||
const planBlocked = remainingByPlanLimit !== null && remainingByPlanLimit <= 0;
|
const planBlocked = remainingByPlanLimit !== null && remainingByPlanLimit <= 0;
|
||||||
const planNextAt = planBlocked ? await getEarliestPlanExpiry(plan.id) : null;
|
const planNextAt = planBlocked ? await getEarliestPlanStockRelease(db, plan.id) : null;
|
||||||
|
|
||||||
let remainingByUserLimit: number | null = null;
|
let remainingByUserLimit: number | null = null;
|
||||||
let userBlocked = false;
|
let userBlocked = false;
|
||||||
let userNextAt: Date | null = null;
|
let userNextAt: Date | null = null;
|
||||||
if (plan.perUserLimit != null && options?.userId) {
|
if (plan.perUserLimit != null && options?.userId) {
|
||||||
const userActiveCount = await prisma.userSubscription.count({
|
const userActiveCount = await db.userSubscription.count({
|
||||||
where: {
|
where: {
|
||||||
planId: plan.id,
|
planId: plan.id,
|
||||||
userId: options.userId,
|
userId: options.userId,
|
||||||
@@ -201,11 +303,12 @@ export async function getPlanAvailability(
|
|||||||
remainingByUserLimit = Math.max(0, plan.perUserLimit - userActiveCount);
|
remainingByUserLimit = Math.max(0, plan.perUserLimit - userActiveCount);
|
||||||
userBlocked = remainingByUserLimit <= 0;
|
userBlocked = remainingByUserLimit <= 0;
|
||||||
if (userBlocked) {
|
if (userBlocked) {
|
||||||
userNextAt = await getEarliestUserPlanExpiry(plan.id, options.userId);
|
userNextAt = await getEarliestUserPlanExpiry(db, plan.id, options.userId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const streaming = await evaluateStreamingCapacity(
|
const streaming = await evaluateStreamingCapacity(
|
||||||
|
db,
|
||||||
plan.type,
|
plan.type,
|
||||||
plan.streamingServiceId ?? null,
|
plan.streamingServiceId ?? null,
|
||||||
);
|
);
|
||||||
@@ -221,6 +324,7 @@ export async function getPlanAvailability(
|
|||||||
available: !userBlocked && !planBlocked && !streaming.blocked,
|
available: !userBlocked && !planBlocked && !streaming.blocked,
|
||||||
reason: resolution.reason,
|
reason: resolution.reason,
|
||||||
activeCount,
|
activeCount,
|
||||||
|
reservedCardCount,
|
||||||
totalLimit,
|
totalLimit,
|
||||||
remainingByPlanLimit,
|
remainingByPlanLimit,
|
||||||
remainingByServiceCapacity: streaming.remaining,
|
remainingByServiceCapacity: streaming.remaining,
|
||||||
|
|||||||
486
src/services/wallet.ts
Normal file
486
src/services/wallet.ts
Normal file
@@ -0,0 +1,486 @@
|
|||||||
|
import crypto from "crypto";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { prisma, type DbClient } from "@/lib/prisma";
|
||||||
|
import { createNotification } from "@/services/notifications";
|
||||||
|
import { provisionSubscriptionWithDb } from "@/services/provision";
|
||||||
|
import { getPaymentProviderName } from "@/services/payment/catalog";
|
||||||
|
import { getPlanAvailability, type PlanAvailability } from "@/services/plan-availability";
|
||||||
|
|
||||||
|
const MONEY_SCALE = 100;
|
||||||
|
|
||||||
|
function toMoneyDecimal(value: number | string | Prisma.Decimal) {
|
||||||
|
const amount = value instanceof Prisma.Decimal ? value.toNumber() : Number(value);
|
||||||
|
if (!Number.isFinite(amount) || amount <= 0) {
|
||||||
|
throw new Error("金额必须大于 0");
|
||||||
|
}
|
||||||
|
return new Prisma.Decimal(Math.round(amount * MONEY_SCALE) / MONEY_SCALE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateRedeemCode(prefix = "JB") {
|
||||||
|
const random = crypto.randomUUID().replace(/-/g, "").slice(0, 18).toUpperCase();
|
||||||
|
return `${prefix}-${random.slice(0, 6)}-${random.slice(6, 12)}-${random.slice(12, 18)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUniqueRedeemCode(db: DbClient, prefix?: string) {
|
||||||
|
for (let index = 0; index < 8; index += 1) {
|
||||||
|
const code = generateRedeemCode(prefix);
|
||||||
|
const existing = await db.rechargeCard.findUnique({
|
||||||
|
where: { code },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!existing) return code;
|
||||||
|
}
|
||||||
|
throw new Error("充值卡卡密生成失败,请重试");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOrCreateWallet(db: DbClient, userId: string) {
|
||||||
|
return db.walletAccount.upsert({
|
||||||
|
where: { userId },
|
||||||
|
update: {},
|
||||||
|
create: { userId, balance: 0 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWalletBalance(userId: string) {
|
||||||
|
const wallet = await getOrCreateWallet(prisma, userId);
|
||||||
|
return Number(wallet.balance);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function creditWallet(
|
||||||
|
db: DbClient,
|
||||||
|
input: {
|
||||||
|
userId: string;
|
||||||
|
amount: number | string | Prisma.Decimal;
|
||||||
|
type: "BALANCE_RECHARGE" | "CARD_REDEEM" | "ADMIN_ADJUST" | "REFUND";
|
||||||
|
description?: string;
|
||||||
|
orderId?: string | null;
|
||||||
|
rechargeOrderId?: string | null;
|
||||||
|
rechargeCardId?: string | null;
|
||||||
|
metadata?: Prisma.InputJsonValue;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const amount = toMoneyDecimal(input.amount);
|
||||||
|
await getOrCreateWallet(db, input.userId);
|
||||||
|
|
||||||
|
const wallet = await db.walletAccount.update({
|
||||||
|
where: { userId: input.userId },
|
||||||
|
data: { balance: { increment: amount } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.walletTransaction.create({
|
||||||
|
data: {
|
||||||
|
walletId: wallet.id,
|
||||||
|
userId: input.userId,
|
||||||
|
type: input.type,
|
||||||
|
amount,
|
||||||
|
balanceAfter: wallet.balance,
|
||||||
|
description: input.description ?? null,
|
||||||
|
orderId: input.orderId ?? null,
|
||||||
|
rechargeOrderId: input.rechargeOrderId ?? null,
|
||||||
|
rechargeCardId: input.rechargeCardId ?? null,
|
||||||
|
metadata: input.metadata ?? undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return wallet;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function debitWalletForOrder(
|
||||||
|
db: DbClient,
|
||||||
|
input: {
|
||||||
|
userId: string;
|
||||||
|
orderId: string;
|
||||||
|
amount: number | string | Prisma.Decimal;
|
||||||
|
description?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const amount = toMoneyDecimal(input.amount);
|
||||||
|
await getOrCreateWallet(db, input.userId);
|
||||||
|
|
||||||
|
const claimed = await db.walletAccount.updateMany({
|
||||||
|
where: {
|
||||||
|
userId: input.userId,
|
||||||
|
balance: { gte: amount },
|
||||||
|
},
|
||||||
|
data: { balance: { decrement: amount } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (claimed.count === 0) {
|
||||||
|
throw new Error("余额不足,请先充值后再支付");
|
||||||
|
}
|
||||||
|
|
||||||
|
const wallet = await db.walletAccount.findUniqueOrThrow({
|
||||||
|
where: { userId: input.userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.walletTransaction.create({
|
||||||
|
data: {
|
||||||
|
walletId: wallet.id,
|
||||||
|
userId: input.userId,
|
||||||
|
type: "BALANCE_PAYMENT",
|
||||||
|
amount: amount.negated(),
|
||||||
|
balanceAfter: wallet.balance,
|
||||||
|
description: input.description ?? "余额支付订单",
|
||||||
|
orderId: input.orderId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return wallet;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlanCardSnapshot = {
|
||||||
|
type: "PROXY" | "STREAMING";
|
||||||
|
nodeId: string | null;
|
||||||
|
inboundId: string | null;
|
||||||
|
fixedTrafficGb: number | null;
|
||||||
|
minTrafficGb: number | null;
|
||||||
|
totalTrafficGb: number | null;
|
||||||
|
inboundOptions?: Array<{ inboundId: string; inbound: { isActive: boolean; serverId: string } }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolvePlanCardConfig(card: {
|
||||||
|
selectedInboundId: string | null;
|
||||||
|
trafficGb: number | null;
|
||||||
|
plan: PlanCardSnapshot;
|
||||||
|
}) {
|
||||||
|
const plan = card.plan;
|
||||||
|
if (plan.type === "STREAMING") {
|
||||||
|
return { selectedInboundId: null, trafficGb: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedInboundId =
|
||||||
|
card.selectedInboundId
|
||||||
|
?? plan.inboundId
|
||||||
|
?? card.plan.inboundOptions?.find(
|
||||||
|
(item) => item.inbound.isActive && (!plan.nodeId || item.inbound.serverId === plan.nodeId),
|
||||||
|
)?.inboundId
|
||||||
|
?? null;
|
||||||
|
|
||||||
|
const trafficGb =
|
||||||
|
card.trafficGb
|
||||||
|
?? plan.fixedTrafficGb
|
||||||
|
?? plan.minTrafficGb
|
||||||
|
?? plan.totalTrafficGb
|
||||||
|
?? null;
|
||||||
|
|
||||||
|
if (!selectedInboundId) {
|
||||||
|
throw new Error("套餐充值卡缺少可用线路,无法自动开通代理套餐");
|
||||||
|
}
|
||||||
|
if (!trafficGb || trafficGb <= 0) {
|
||||||
|
throw new Error("套餐充值卡缺少可用流量配置");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { selectedInboundId, trafficGb };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlanCardGenerationLimit(planType: "PROXY" | "STREAMING", availability: PlanAvailability) {
|
||||||
|
const limits: number[] = [];
|
||||||
|
if (availability.remainingByPlanLimit != null) {
|
||||||
|
limits.push(availability.remainingByPlanLimit);
|
||||||
|
}
|
||||||
|
if (planType === "STREAMING" && availability.remainingByServiceCapacity != null) {
|
||||||
|
limits.push(availability.remainingByServiceCapacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
return limits.length > 0 ? Math.min(...limits) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function payOrderWithWallet(orderId: string, userId: string) {
|
||||||
|
const tradeNo = `BAL-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
|
||||||
|
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const config = await tx.paymentConfig.findUnique({
|
||||||
|
where: { provider: "balance" },
|
||||||
|
select: { enabled: true },
|
||||||
|
});
|
||||||
|
if (config && !config.enabled) {
|
||||||
|
throw new Error("余额支付当前已关闭");
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = await tx.order.findUnique({
|
||||||
|
where: { id: orderId },
|
||||||
|
include: { plan: true, user: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!order || order.userId !== userId) {
|
||||||
|
throw new Error("订单不存在");
|
||||||
|
}
|
||||||
|
if (order.status !== "PENDING") {
|
||||||
|
throw new Error("这笔订单已经不在待支付状态");
|
||||||
|
}
|
||||||
|
|
||||||
|
const amount = toMoneyDecimal(order.amount);
|
||||||
|
await debitWalletForOrder(tx, {
|
||||||
|
userId,
|
||||||
|
orderId,
|
||||||
|
amount,
|
||||||
|
description: `余额支付 ${order.plan.name}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const paidOrder = await tx.order.update({
|
||||||
|
where: { id: orderId },
|
||||||
|
data: {
|
||||||
|
status: "PAID",
|
||||||
|
paymentMethod: "balance",
|
||||||
|
paymentRef: tradeNo,
|
||||||
|
paymentUrl: null,
|
||||||
|
tradeNo,
|
||||||
|
expireAt: null,
|
||||||
|
note: null,
|
||||||
|
},
|
||||||
|
include: { plan: true, user: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const affectedNodeIds = await provisionSubscriptionWithDb(paidOrder, tx);
|
||||||
|
return { tradeNo, affectedNodeIds };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createWalletRechargeOrder(userId: string, amountValue: number) {
|
||||||
|
const amount = toMoneyDecimal(amountValue);
|
||||||
|
return prisma.walletRechargeOrder.create({
|
||||||
|
data: { userId, amount },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rechargeOrder = await prisma.walletRechargeOrder.findUnique({
|
||||||
|
where: { tradeNo },
|
||||||
|
});
|
||||||
|
if (!rechargeOrder) {
|
||||||
|
return { processed: false, finalStatus: null as null | "PENDING" | "PAID" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedAmount = Number(rechargeOrder.amount);
|
||||||
|
if (Math.abs(expectedAmount - paidAmount) > 0.01) {
|
||||||
|
throw new Error("支付金额与充值金额不一致");
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const claimed = await tx.walletRechargeOrder.updateMany({
|
||||||
|
where: { id: rechargeOrder.id, status: "PENDING" },
|
||||||
|
data: {
|
||||||
|
status: "PAID",
|
||||||
|
paymentRef: paymentRef ?? rechargeOrder.paymentRef,
|
||||||
|
note: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (claimed.count === 0) {
|
||||||
|
const current = await tx.walletRechargeOrder.findUnique({
|
||||||
|
where: { id: rechargeOrder.id },
|
||||||
|
select: { status: true },
|
||||||
|
});
|
||||||
|
return { processed: false, finalStatus: current?.status ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
await creditWallet(tx, {
|
||||||
|
userId: rechargeOrder.userId,
|
||||||
|
amount: rechargeOrder.amount,
|
||||||
|
type: "BALANCE_RECHARGE",
|
||||||
|
description: `${getPaymentProviderName(rechargeOrder.paymentMethod ?? "")} 余额充值`,
|
||||||
|
rechargeOrderId: rechargeOrder.id,
|
||||||
|
metadata: { tradeNo },
|
||||||
|
});
|
||||||
|
|
||||||
|
await createNotification(
|
||||||
|
{
|
||||||
|
userId: rechargeOrder.userId,
|
||||||
|
type: "ORDER",
|
||||||
|
level: "SUCCESS",
|
||||||
|
title: "余额充值成功",
|
||||||
|
body: `已充值 ¥${expectedAmount.toFixed(2)} 到账户余额。`,
|
||||||
|
link: "/wallet",
|
||||||
|
dedupeKey: `wallet-recharge:${rechargeOrder.id}`,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { processed: true, finalStatus: "PAID" as const };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRechargeCards(input: {
|
||||||
|
createdById: string;
|
||||||
|
type: "BALANCE" | "PLAN";
|
||||||
|
quantity: number;
|
||||||
|
balanceAmount?: number;
|
||||||
|
planId?: string;
|
||||||
|
batchName?: string | null;
|
||||||
|
expiresAt?: Date | null;
|
||||||
|
}) {
|
||||||
|
const quantity = Math.min(Math.max(input.quantity, 1), 200);
|
||||||
|
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
let plan:
|
||||||
|
| (NonNullable<Awaited<ReturnType<typeof tx.subscriptionPlan.findUnique>>> & {
|
||||||
|
inboundOptions: Array<{ inboundId: string; inbound: { isActive: boolean; serverId: string } }>;
|
||||||
|
})
|
||||||
|
| null = null;
|
||||||
|
|
||||||
|
if (input.type === "PLAN") {
|
||||||
|
if (!input.planId) throw new Error("请选择套餐");
|
||||||
|
plan = await tx.subscriptionPlan.findUnique({
|
||||||
|
where: { id: input.planId },
|
||||||
|
include: {
|
||||||
|
inboundOptions: {
|
||||||
|
include: { inbound: { select: { isActive: true, serverId: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!plan) throw new Error("套餐不存在");
|
||||||
|
if (!plan.isActive) throw new Error("只能为上架中的套餐生成兑换卡");
|
||||||
|
|
||||||
|
const availability = await getPlanAvailability(plan, { db: tx });
|
||||||
|
const remaining = getPlanCardGenerationLimit(plan.type, availability);
|
||||||
|
if (remaining != null && quantity > remaining) {
|
||||||
|
throw new Error(`套餐剩余库存不足,当前最多可生成 ${remaining} 张`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cards = [];
|
||||||
|
for (let index = 0; index < quantity; index += 1) {
|
||||||
|
const code = await createUniqueRedeemCode(tx, input.type === "PLAN" ? "JP" : "JB");
|
||||||
|
cards.push(
|
||||||
|
await tx.rechargeCard.create({
|
||||||
|
data: {
|
||||||
|
code,
|
||||||
|
type: input.type,
|
||||||
|
balanceAmount:
|
||||||
|
input.type === "BALANCE" ? toMoneyDecimal(input.balanceAmount ?? 0) : null,
|
||||||
|
planId: input.type === "PLAN" ? input.planId! : null,
|
||||||
|
trafficGb: input.type === "PLAN" && plan?.type === "PROXY"
|
||||||
|
? plan.fixedTrafficGb ?? plan.minTrafficGb ?? plan.totalTrafficGb
|
||||||
|
: null,
|
||||||
|
selectedInboundId: input.type === "PLAN" && plan?.type === "PROXY"
|
||||||
|
? plan.inboundId
|
||||||
|
?? plan.inboundOptions.find(
|
||||||
|
(item) => item.inbound.isActive && (!plan?.nodeId || item.inbound.serverId === plan.nodeId),
|
||||||
|
)?.inboundId
|
||||||
|
?? null
|
||||||
|
: null,
|
||||||
|
batchName: input.batchName || null,
|
||||||
|
expiresAt: input.expiresAt ?? null,
|
||||||
|
createdById: input.createdById,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return cards;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function redeemRechargeCard(userId: string, rawCode: string) {
|
||||||
|
const code = rawCode.trim().toUpperCase();
|
||||||
|
if (!code) throw new Error("请输入充值卡卡密");
|
||||||
|
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const card = await tx.rechargeCard.findUnique({
|
||||||
|
where: { code },
|
||||||
|
include: {
|
||||||
|
plan: {
|
||||||
|
include: {
|
||||||
|
inboundOptions: {
|
||||||
|
include: { inbound: { select: { isActive: true, serverId: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!card) throw new Error("充值卡不存在");
|
||||||
|
if (card.status !== "UNUSED") throw new Error("这张充值卡已使用或已停用");
|
||||||
|
if (card.expiresAt && card.expiresAt < new Date()) {
|
||||||
|
await tx.rechargeCard.update({
|
||||||
|
where: { id: card.id },
|
||||||
|
data: { status: "EXPIRED" },
|
||||||
|
});
|
||||||
|
throw new Error("这张充值卡已过期");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (card.type === "BALANCE") {
|
||||||
|
if (!card.balanceAmount || Number(card.balanceAmount) <= 0) {
|
||||||
|
throw new Error("余额充值卡金额无效");
|
||||||
|
}
|
||||||
|
|
||||||
|
await creditWallet(tx, {
|
||||||
|
userId,
|
||||||
|
amount: card.balanceAmount,
|
||||||
|
type: "CARD_REDEEM",
|
||||||
|
description: "兑换余额充值卡",
|
||||||
|
rechargeCardId: card.id,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (!card.plan) throw new Error("套餐充值卡绑定的套餐不存在");
|
||||||
|
const { selectedInboundId, trafficGb } = resolvePlanCardConfig({
|
||||||
|
selectedInboundId: card.selectedInboundId,
|
||||||
|
trafficGb: card.trafficGb,
|
||||||
|
plan: card.plan,
|
||||||
|
});
|
||||||
|
const order = await tx.order.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
planId: card.plan.id,
|
||||||
|
kind: "NEW_PURCHASE",
|
||||||
|
selectedInboundId,
|
||||||
|
trafficGb,
|
||||||
|
amount: 0,
|
||||||
|
subtotalAmount: 0,
|
||||||
|
discountAmount: 0,
|
||||||
|
status: "PAID",
|
||||||
|
paymentMethod: "recharge_card",
|
||||||
|
paymentRef: card.code,
|
||||||
|
tradeNo: `CARD-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`,
|
||||||
|
},
|
||||||
|
include: { plan: true, user: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await provisionSubscriptionWithDb(order, tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.rechargeCard.update({
|
||||||
|
where: { id: card.id },
|
||||||
|
data: {
|
||||||
|
status: "REDEEMED",
|
||||||
|
redeemedById: userId,
|
||||||
|
redeemedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await createNotification(
|
||||||
|
{
|
||||||
|
userId,
|
||||||
|
type: "ORDER",
|
||||||
|
level: "SUCCESS",
|
||||||
|
title: card.type === "BALANCE" ? "余额充值卡兑换成功" : "套餐充值卡兑换成功",
|
||||||
|
body: card.type === "BALANCE"
|
||||||
|
? `已充值 ¥${Number(card.balanceAmount).toFixed(2)} 到账户余额。`
|
||||||
|
: `${card.plan?.name ?? "套餐"} 已激活。`,
|
||||||
|
link: card.type === "BALANCE" ? "/wallet" : "/subscriptions",
|
||||||
|
dedupeKey: `recharge-card-redeemed:${card.id}`,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
|
||||||
|
return card.type;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expireOldRechargeCards() {
|
||||||
|
await prisma.rechargeCard.updateMany({
|
||||||
|
where: {
|
||||||
|
status: "UNUSED",
|
||||||
|
expiresAt: { lt: new Date() },
|
||||||
|
},
|
||||||
|
data: { status: "EXPIRED" },
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user