feat: add wallet and recharge cards

This commit is contained in:
JetSprow
2026-05-01 02:31:29 +10:00
parent 6d6489817d
commit 018bed3f36
32 changed files with 2058 additions and 170 deletions

View File

@@ -88,6 +88,14 @@ enum OrderKind {
TRAFFIC_TOPUP
}
enum WalletTransactionType {
BALANCE_RECHARGE
BALANCE_PAYMENT
CARD_REDEEM
ADMIN_ADJUST
REFUND
}
enum Protocol {
VMESS
VLESS
@@ -165,6 +173,18 @@ enum SupportTicketPriority {
URGENT
}
enum RechargeCardType {
BALANCE
PLAN
}
enum RechargeCardStatus {
UNUSED
REDEEMED
DISABLED
EXPIRED
}
model User {
id String @id @default(cuid())
email String @unique
@@ -196,6 +216,11 @@ model User {
supportTickets SupportTicket[]
supportReplies SupportTicketReply[]
emailTokens EmailToken[]
walletAccount WalletAccount?
walletTransactions WalletTransaction[]
walletRechargeOrders WalletRechargeOrder[]
redeemedRechargeCards RechargeCard[] @relation("RechargeCardRedeemer")
createdRechargeCards RechargeCard[] @relation("RechargeCardCreator")
}
model EmailToken {
@@ -301,6 +326,7 @@ model SubscriptionPlan {
orders Order[]
cartItems ShoppingCartItem[]
orderItems OrderItem[]
rechargeCards RechargeCard[]
@@index([type, isActive, isFeatured, sortOrder])
@@index([inboundId])
@@ -482,6 +508,7 @@ model NodeInbound {
selectedByOrders Order[]
cartItems ShoppingCartItem[]
orderItems OrderItem[]
rechargeCards RechargeCard[]
@@unique([serverId, tag])
@@unique([serverId, panelInboundId])
@@ -598,6 +625,7 @@ model Order {
items OrderItem[]
couponGrants CouponGrant[] @relation("CouponGrantUsedOrder")
inviteRewards InviteRewardLedger[]
walletTransactions WalletTransaction[]
@@index([userId])
@@index([kind])
@@ -608,6 +636,98 @@ model Order {
@@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 {
id String @id @default(cuid())
nodeId String

View File

@@ -78,6 +78,30 @@ export async function setPaymentConfigEnabled(
): Promise<PaymentActionResult> {
try {
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({
where: { provider },
select: { config: true, enabled: true },

View 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");
}

View 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");
}

View File

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

View File

@@ -1,7 +1,22 @@
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() {
const [coupons, promotions] = await Promise.all([
const [coupons, promotions, rechargeCards, planRows] = await Promise.all([
prisma.coupon.findMany({
orderBy: { createdAt: "desc" },
include: { _count: { select: { orders: true, grants: true } } },
@@ -11,7 +26,39 @@ export async function getCommerceData() {
orderBy: [{ sortOrder: "asc" }, { thresholdAmount: "asc" }],
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 };
}

View File

@@ -1,16 +1,19 @@
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 { ActiveStatusBadge, StatusBadge } from "@/components/admin/status-badge";
import { CopyButton } from "@/components/shared/copy-button";
import { PageHeader, PageShell, SectionHeader } from "@/components/shared/page-shell";
import { PendingSubmitButton } from "@/components/shared/pending-submit-button";
import { BooleanToggle } from "@/components/ui/boolean-toggle";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { formatDate } from "@/lib/utils";
import { getCommerceData } from "./commerce-data";
import { CommerceToggleButton } from "./_components/commerce-actions";
import { DiscountTypeSelect } from "./_components/discount-type-select";
import { RechargeCardForm } from "./_components/recharge-card-form";
function formatCouponDiscount(type: string, value: unknown) {
const numericValue = Number(value);
@@ -26,7 +29,7 @@ export const metadata: Metadata = {
};
export default async function AdminCommercePage() {
const { coupons, promotions } = await getCommerceData();
const { coupons, promotions, rechargeCards, plans } = await getCommerceData();
return (
<PageShell>
@@ -39,6 +42,7 @@ export default async function AdminCommercePage() {
<TabsList variant="line" className="surface-card p-1">
<TabsTrigger value="create"></TabsTrigger>
<TabsTrigger value="manage"></TabsTrigger>
<TabsTrigger value="cards"></TabsTrigger>
</TabsList>
<TabsContent value="create">
@@ -175,6 +179,57 @@ export default async function AdminCommercePage() {
</div>
</section>
</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>
</PageShell>
);

View File

@@ -2,7 +2,7 @@
import { useState, type FormEvent } from "react";
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 { StatusBadge } from "@/components/shared/status-badge";
import { BooleanToggle } from "@/components/ui/boolean-toggle";
@@ -80,6 +80,7 @@ export function PaymentConfigItem({
const [enabled, setEnabled] = useState(initialEnabled);
const [saving, setSaving] = useState(false);
const [statusSaving, setStatusSaving] = useState(false);
const [balanceDisableOpen, setBalanceDisableOpen] = useState(false);
const [checkboxValues, setCheckboxValues] = useState<Record<string, Set<string>>>(() =>
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;
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>) {
event.preventDefault();
if (saving || statusSaving) return;
@@ -160,129 +170,168 @@ export function PaymentConfigItem({
}
return (
<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">
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
<CreditCard className="size-4" />
</span>
<div className="min-w-0">
<div className="flex min-h-6 flex-wrap items-center gap-2">
<h3 className="text-base font-semibold leading-6 tracking-tight">{providerName}</h3>
{displayName && <StatusBadge tone="neutral">{displayName}</StatusBadge>}
<>
<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">
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
<CreditCard className="size-4" />
</span>
<div className="min-w-0">
<div className="flex min-h-6 flex-wrap items-center gap-2">
<h3 className="text-base font-semibold leading-6 tracking-tight">{providerName}</h3>
{displayName && <StatusBadge tone="neutral">{displayName}</StatusBadge>}
</div>
<p className="mt-1 text-sm text-muted-foreground text-pretty">{providerDescription}</p>
{checkboxSummaries.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2">
{checkboxSummaries.slice(0, 2).map((label) => (
<StatusBadge key={label} tone="info">{label}</StatusBadge>
))}
</div>
)}
</div>
<p className="mt-1 text-sm text-muted-foreground text-pretty">{providerDescription}</p>
{checkboxSummaries.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2">
{checkboxSummaries.slice(0, 2).map((label) => (
<StatusBadge key={label} tone="info">{label}</StatusBadge>
))}
</div>
)}
</div>
</div>
<div className="flex items-center justify-start lg:justify-end">
<BooleanToggle
className="w-full lg:w-40"
value={enabled}
onChange={(value) => void handleStatusToggle(value)}
trueLabel="启用"
falseLabel="停用"
ariaLabel={`${providerName}状态`}
disabled={saving || statusSaving}
/>
</div>
<div className="flex items-center justify-start lg:justify-end">
<BooleanToggle
className="w-full lg:w-40"
value={enabled}
onChange={(value) => void handleStatusToggle(value)}
trueLabel="启用"
falseLabel="停用"
ariaLabel={`${providerName}状态`}
disabled={saving || statusSaving}
/>
</div>
<Dialog open={open} onOpenChange={(nextOpen) => !saving && setOpen(nextOpen)}>
<DialogTrigger render={<Button variant="outline" size="sm" className="w-full lg:w-auto" />}>
<Pencil className="size-3.5" />
</DialogTrigger>
<DialogContent className="flex max-h-[min(90dvh,38rem)] flex-col overflow-hidden bg-card p-0 sm:max-w-[34rem]">
<DialogHeader className="border-b border-border/60 px-4 py-3 pr-10">
<div className="flex size-9 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
<ShieldCheck className="size-4" />
</div>
<DialogTitle>{providerName}</DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<Dialog open={open} onOpenChange={(nextOpen) => !saving && setOpen(nextOpen)}>
<DialogTrigger render={<Button variant="outline" size="sm" className="w-full lg:w-auto" />}>
<Pencil className="size-3.5" />
</DialogTrigger>
<DialogContent className="flex max-h-[min(90dvh,38rem)] flex-col overflow-hidden bg-card p-0 sm:max-w-[34rem]">
<DialogHeader className="border-b border-border/60 px-4 py-3 pr-10">
<div className="flex size-9 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
<ShieldCheck className="size-4" />
</div>
<DialogTitle>{providerName}</DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<DialogBody className="flex-1 px-4 py-3">
<form onSubmit={handleSubmit} className="space-y-3 text-[12px] leading-5 [&_[data-slot=input]]:!h-8 [&_[data-slot=input]]:!min-h-8 [&_[data-slot=input]]:!px-2.5 [&_[data-slot=input]]:!text-xs [&_[data-slot=label]]:!text-xs">
<div className="grid gap-3 sm:grid-cols-2">
{fields.map((field) =>
field.type === "checkboxes" ? (
<div key={field.key} className="sm:col-span-2">
<Label>{field.label}</Label>
<div className="mt-2 grid gap-2 sm:grid-cols-2">
{field.options?.map((option) => {
const selected = checkboxValues[field.key]?.has(option.value) ?? false;
return (
<button
key={option.value}
type="button"
aria-pressed={selected}
onClick={() => toggleCheckbox(field.key, option.value)}
className={cn(
"flex min-h-8 items-center justify-between gap-2 rounded-lg border px-2.5 py-1.5 text-left text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/20",
selected
? "border-primary/35 bg-primary/10 text-primary"
: "border-border bg-muted/20 text-muted-foreground hover:bg-muted/45 hover:text-foreground",
)}
>
<span className="whitespace-nowrap">{option.label}</span>
{selected && <Check className="size-4 shrink-0" />}
</button>
);
})}
<DialogBody className="flex-1 px-4 py-3">
<form onSubmit={handleSubmit} className="space-y-3 text-[12px] leading-5 [&_[data-slot=input]]:!h-8 [&_[data-slot=input]]:!min-h-8 [&_[data-slot=input]]:!px-2.5 [&_[data-slot=input]]:!text-xs [&_[data-slot=label]]:!text-xs">
<div className="grid gap-3 sm:grid-cols-2">
{fields.map((field) =>
field.type === "checkboxes" ? (
<div key={field.key} className="sm:col-span-2">
<Label>{field.label}</Label>
<div className="mt-2 grid gap-2 sm:grid-cols-2">
{field.options?.map((option) => {
const selected = checkboxValues[field.key]?.has(option.value) ?? false;
return (
<button
key={option.value}
type="button"
aria-pressed={selected}
onClick={() => toggleCheckbox(field.key, option.value)}
className={cn(
"flex min-h-8 items-center justify-between gap-2 rounded-lg border px-2.5 py-1.5 text-left text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/20",
selected
? "border-primary/35 bg-primary/10 text-primary"
: "border-border bg-muted/20 text-muted-foreground hover:bg-muted/45 hover:text-foreground",
)}
>
<span className="whitespace-nowrap">{option.label}</span>
{selected && <Check className="size-4 shrink-0" />}
</button>
);
})}
</div>
</div>
) : (
<div key={field.key} className="space-y-1.5">
<Label htmlFor={`${provider}-${field.key}`}>{field.label}</Label>
<Input
id={`${provider}-${field.key}`}
name={field.key}
type={field.secret ? "password" : "text"}
placeholder={field.secret && secretConfigured[field.key] ? "留空不变" : field.placeholder}
defaultValue={field.secret ? "" : currentConfig?.[field.key] || ""}
/>
{field.secret && secretConfigured[field.key] && (
<p className="text-xs leading-5 text-muted-foreground"></p>
)}
</div>
),
)}
</div>
<div className="rounded-lg border border-border bg-muted/20 p-2.5">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="flex items-center gap-1.5">
<Label className="whitespace-nowrap text-xs font-semibold"></Label>
<InlineHelp align="start"></InlineHelp>
</div>
</div>
) : (
<div key={field.key} className="space-y-1.5">
<Label htmlFor={`${provider}-${field.key}`}>{field.label}</Label>
<Input
id={`${provider}-${field.key}`}
name={field.key}
type={field.secret ? "password" : "text"}
placeholder={field.secret && secretConfigured[field.key] ? "留空不变" : field.placeholder}
defaultValue={field.secret ? "" : currentConfig?.[field.key] || ""}
/>
{field.secret && secretConfigured[field.key] && (
<p className="text-xs leading-5 text-muted-foreground"></p>
)}
<div className="flex justify-start sm:justify-end">
<StatusBadge tone={enabled ? "success" : "neutral"}>
{enabled ? "已启用" : "已停用"}
</StatusBadge>
</div>
),
)}
</div>
<div className="rounded-lg border border-border bg-muted/20 p-2.5">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="flex items-center gap-1.5">
<Label className="whitespace-nowrap text-xs font-semibold"></Label>
<InlineHelp align="start"></InlineHelp>
</div>
</div>
<div className="flex justify-start sm:justify-end">
<StatusBadge tone={enabled ? "success" : "neutral"}>
{enabled ? "已启用" : "已停用"}
</StatusBadge>
</div>
</div>
</div>
<DialogFooter className="-mx-4 -mb-3">
<Button type="button" variant="outline" className="h-8" onClick={() => setOpen(false)} disabled={saving}>
<DialogFooter className="-mx-4 -mb-3">
<Button type="button" variant="outline" className="h-8" onClick={() => setOpen(false)} disabled={saving}>
</Button>
<Button type="submit" className="h-8" disabled={saving}>
{saving ? "保存中..." : "保存配置"}
</Button>
</DialogFooter>
</form>
</DialogBody>
</DialogContent>
</Dialog>
</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="submit" className="h-8" disabled={saving}>
{saving ? "保存中..." : "保存配置"}
<Button
type="button"
variant="destructive"
onClick={() => {
setBalanceDisableOpen(false);
void commitStatusToggle(false);
}}
disabled={statusSaving}
>
{statusSaving ? "处理中..." : "确认关闭"}
</Button>
</DialogFooter>
</form>
</DialogBody>
</DialogContent>
</Dialog>
</section>
</DialogContent>
</Dialog>
)}
</>
);
}

View File

@@ -20,7 +20,9 @@ export async function getPaymentProviderConfigs() {
enabled: config.enabled,
config: redactPaymentConfigForClient(provider.id, configValue ?? {}),
}
: null,
: provider.id === "balance"
? { enabled: true, config: {} }
: null,
secretConfigured: configValue
? getPaymentSecretConfiguredState(provider.id, configValue)
: {},

View File

@@ -10,10 +10,12 @@ export const PLAN_BATCH_FORM_ID = "plan-batch-form";
export function PlansList({
plans,
activeCountMap,
reservedCardCountMap,
services,
}: {
plans: AdminPlanRow[];
activeCountMap: Map<string, number>;
reservedCardCountMap: Map<string, number>;
services: StreamingServiceOption[];
}) {
return (
@@ -31,6 +33,7 @@ export function PlansList({
key={plan.id}
plan={plan}
activeCount={activeCountMap.get(plan.id) ?? 0}
reservedCardCount={reservedCardCountMap.get(plan.id) ?? 0}
services={services}
batchFormId={PLAN_BATCH_FORM_ID}
/>

View File

@@ -23,6 +23,7 @@ export default async function PlansPage({
pageSize,
filters,
activeCountMap,
reservedCardCountMap,
serviceOptions,
} = await getAdminPlans(await searchParams);
@@ -60,6 +61,7 @@ export default async function PlansPage({
<PlansList
plans={plans}
activeCountMap={activeCountMap}
reservedCardCountMap={reservedCardCountMap}
services={serviceOptions}
/>
<Pagination total={total} pageSize={pageSize} page={page} />

View File

@@ -55,6 +55,7 @@ interface PlanListItem {
interface PlanCardProps {
plan: PlanListItem;
activeCount: number;
reservedCardCount: number;
services: StreamingServiceOption[];
batchFormId: string;
}
@@ -63,13 +64,13 @@ function toNumber(value: NumericLike): number | null {
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 };
const remaining = Math.max(0, plan.totalLimit - activeCount);
const remaining = Math.max(0, plan.totalLimit - activeCount - reservedCardCount);
return {
value: remaining.toString(),
hint: remaining === 0 ? "已售罄" : "剩余库存",
hint: reservedCardCount > 0 ? `预占 ${reservedCardCount}` : 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 stock = remainingStockSummary(plan, activeCount);
const stock = remainingStockSummary(plan, activeCount, reservedCardCount);
const Icon = plan.type === "PROXY" ? Network : Tv;
return (

View File

@@ -1,6 +1,7 @@
import type { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import { parsePage } from "@/lib/utils";
import { activePlanCardReservationWhere } from "@/services/plan-availability";
import type { StreamingServiceOption } from "./plan-form";
const planInclude = {
@@ -43,7 +44,7 @@ export async function getAdminPlans(
: {}),
} satisfies Prisma.SubscriptionPlanWhereInput;
const [plans, total, services, activeGroups] = await Promise.all([
const [plans, total, services, activeGroups, reservedCardGroups] = await Promise.all([
prisma.subscriptionPlan.findMany({
where,
include: planInclude,
@@ -62,11 +63,22 @@ export async function getAdminPlans(
where: { status: "ACTIVE" },
_count: { _all: true },
}),
prisma.rechargeCard.groupBy({
by: ["planId"],
where: {
...activePlanCardReservationWhere(),
planId: { not: null },
},
_count: { _all: true },
}),
]);
const activeCountMap = new Map(
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) => ({
id: service.id,
name: service.name,
@@ -80,6 +92,7 @@ export async function getAdminPlans(
pageSize,
filters: { q, type, status },
activeCountMap,
reservedCardCountMap,
serviceOptions,
};
}

View File

@@ -42,17 +42,22 @@ export function PaymentMethodSelector({
>
<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")}>
{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>
<span className="font-semibold">{provider.name}</span>
<p className="mt-1 text-xs text-muted-foreground">
{provider.provider === "usdt_trc20" ? "稳定币付款" : "按提示完成"}
{provider.provider === "balance"
? "从账户余额扣款"
: provider.provider === "usdt_trc20"
? "稳定币付款"
: "按提示完成"}
</p>
</div>
</div>
<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" />}
</div>
</button>

View File

@@ -9,6 +9,7 @@ export interface PaymentInfo {
paymentUrl?: string;
qrCode?: string;
raw?: {
status?: string;
walletAddress?: string;
usdtAmount?: string;
cnyAmount?: string;

View File

@@ -63,6 +63,10 @@ export function usePaymentFlow(orderId: string) {
});
setPayment(data);
if (provider === "balance" || data.raw?.status === "paid") {
setStatus("paid");
return;
}
setStatus("waiting");
if (

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

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

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

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

View 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 };
}

View File

@@ -7,6 +7,7 @@ import { rateLimit } from "@/lib/rate-limit";
import { getSiteBaseUrl } from "@/services/site-url";
import { getActiveSubscriptionRiskRestriction } from "@/services/subscription-risk-review";
import { getOrderStatusLabel } from "@/lib/domain-labels";
import { payOrderWithWallet } from "@/services/wallet";
import { v4 as uuidv4 } from "uuid";
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 tradeNo =
order.tradeNo

View File

@@ -1,9 +1,12 @@
import { jsonError, jsonOk } from "@/lib/api-response";
import { getEnabledProviders } from "@/services/payment/factory";
export async function GET() {
export async function GET(req: Request) {
try {
const providers = await getEnabledProviders();
const url = new URL(req.url);
const providers = await getEnabledProviders({
includeBalance: url.searchParams.get("target") !== "wallet",
});
return jsonOk(providers);
} catch (error) {
return jsonError(error, { fallback: "获取支付方式失败" });

View 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,
});
}

View 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: "创建充值支付失败" });
}
}

View 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" });
}

View File

@@ -8,6 +8,7 @@ import {
ShoppingCart,
UserCircle2,
MessageSquareWarning,
WalletCards,
} from "lucide-react";
import { Sidebar, type SidebarGroup, type SidebarLink } from "@/components/shared/sidebar";
import { PRODUCT_NAME } from "@/lib/product";
@@ -18,6 +19,7 @@ export const userLinks: SidebarLink[] = [
{ href: "/store", label: "套餐商店", icon: <ShoppingBag size={16} /> },
{ href: "/cart", label: "购物车", icon: <ShoppingCart size={16} /> },
{ href: "/subscriptions", label: "我的订阅", icon: <Radio size={16} /> },
{ href: "/wallet", label: "我的钱包", icon: <WalletCards size={16} /> },
{ href: "/orders", label: "我的订单", icon: <ClipboardList size={16} /> },
{ href: "/support", label: "工单售后", icon: <MessageSquareWarning size={16} /> },
{ href: "/account", label: "账户中心", icon: <UserCircle2 size={16} /> },
@@ -30,11 +32,11 @@ export const userNavGroups: SidebarGroup[] = [
},
{
label: "记录",
links: userLinks.slice(4, 5),
links: userLinks.slice(4, 6),
},
{
label: "支持",
links: userLinks.slice(5),
links: userLinks.slice(6),
},
];

View File

@@ -46,13 +46,26 @@ const usdtTrc20Schema = z.object({
.transform((value) => value ?? ""),
});
const balanceSchema = z.object({
displayName: displayNameField,
});
const paymentConfigSchemas = {
balance: balanceSchema,
epay: epaySchema,
alipay_f2f: alipayF2fSchema,
usdt_trc20: usdtTrc20Schema,
} as const;
export const PAYMENT_PROVIDER_DEFINITIONS: PaymentProviderDefinition[] = [
{
id: "balance",
name: "余额支付",
description: "使用用户钱包余额支付订单,默认启用",
fields: [
{ key: "displayName", label: "用户端显示名称", placeholder: "留空则显示余额支付" },
],
},
{
id: "epay",
name: "易支付",

View File

@@ -13,6 +13,10 @@ export async function getPaymentAdapter(provider: string): Promise<PaymentAdapte
// epay_alipay / epay_wxpay both use the epay adapter
const realProvider = provider.startsWith("epay") ? "epay" : provider;
if (realProvider === "balance") {
throw new Error("余额支付不需要外部支付适配器");
}
const config = await prisma.paymentConfig.findUnique({
where: { provider: realProvider },
});
@@ -28,11 +32,11 @@ export async function getPaymentAdapter(provider: string): Promise<PaymentAdapte
switch (realProvider) {
case "epay":
return new EasyPayAdapter(cfg as EasyPayConfig);
return new EasyPayAdapter(cfg as unknown as EasyPayConfig);
case "alipay_f2f":
return new AlipayF2FAdapter(cfg as AlipayF2FConfig);
return new AlipayF2FAdapter(cfg as unknown as AlipayF2FConfig);
case "usdt_trc20":
return new UsdtTrc20Adapter(cfg as UsdtTrc20Config);
return new UsdtTrc20Adapter(cfg as unknown as UsdtTrc20Config);
default:
throw new Error("未知支付方式");
}
@@ -44,15 +48,37 @@ export interface EnabledProvider {
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({
where: { enabled: true },
select: { provider: true, config: true },
});
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) {
if (c.provider === "balance") continue;
const cfg = c.config as Record<string, string> | null;
if (c.provider === "epay") {

View File

@@ -5,6 +5,7 @@ import { createNotification } from "@/services/notifications";
import { provisionSubscriptionWithDb } from "@/services/provision";
import { recordTaskFailure } from "@/services/task-center";
import { issueInviteRewardForOrder } from "@/services/invite-rewards";
import { processWalletRechargeSuccess } from "@/services/wallet";
export interface PaymentProcessResult {
processed: boolean;
@@ -144,7 +145,11 @@ export async function handleVerifiedPaymentSuccess(
});
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);

View File

@@ -1,5 +1,5 @@
import { prisma } from "@/lib/prisma";
import type { SubscriptionPlan, SubscriptionType } from "@prisma/client";
import { prisma, type DbClient } from "@/lib/prisma";
import type { Prisma, SubscriptionPlan, SubscriptionType } from "@prisma/client";
import { format } from "date-fns";
type PlanAvailabilityInput = Pick<
@@ -13,6 +13,7 @@ export interface PlanAvailability {
available: boolean;
reason: AvailabilityReason | null;
activeCount: number;
reservedCardCount: number;
totalLimit: number | null;
remainingByPlanLimit: number | null;
remainingByServiceCapacity: number | null;
@@ -41,24 +42,66 @@ export function buildUnavailableMessage(availability: PlanAvailability): string
return `${prefix},预计最早可购买时间:${formatAvailabilityDateTime(availability.nextAvailableAt)}`;
}
async function getEarliestPlanExpiry(planId: string): Promise<Date | null> {
const earliest = await prisma.userSubscription.findFirst({
where: {
planId,
status: "ACTIVE",
},
select: { endDate: true },
orderBy: { endDate: "asc" },
});
export function activePlanCardReservationWhere(now = new Date()): Prisma.RechargeCardWhereInput {
return {
type: "PLAN",
status: "UNUSED",
OR: [
{ expiresAt: null },
{ expiresAt: { gt: now } },
],
};
}
return earliest?.endDate ?? null;
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: {
planId,
status: "ACTIVE",
},
select: { endDate: true },
orderBy: { endDate: "asc" },
}),
db.rechargeCard.findFirst({
where: {
...activePlanCardReservationWhere(),
planId,
expiresAt: { not: null },
},
select: { expiresAt: true },
orderBy: { expiresAt: "asc" },
}),
]);
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(
db: DbClient,
planId: string,
userId: string,
): Promise<Date | null> {
const earliest = await prisma.userSubscription.findFirst({
const earliest = await db.userSubscription.findFirst({
where: {
planId,
userId,
@@ -71,10 +114,10 @@ async function getEarliestUserPlanExpiry(
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;
const earliest = await prisma.userSubscription.findFirst({
const earliest = await db.userSubscription.findFirst({
where: {
status: "ACTIVE",
streamingSlot: {
@@ -90,7 +133,49 @@ async function getEarliestServiceExpiry(serviceIds: string[]): Promise<Date | nu
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(
db: DbClient,
type: SubscriptionType,
streamingServiceId: string | null,
): Promise<{
@@ -103,7 +188,7 @@ async function evaluateStreamingCapacity(
}
if (streamingServiceId) {
const service = await prisma.streamingService.findUnique({
const service = await db.streamingService.findUnique({
where: { id: streamingServiceId },
select: {
id: true,
@@ -117,29 +202,42 @@ async function evaluateStreamingCapacity(
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) {
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 };
}
const services = await prisma.streamingService.findMany({
where: { isActive: true },
select: { id: true, maxSlots: true, usedSlots: true },
});
const [services, reservedCards] = await Promise.all([
db.streamingService.findMany({
where: { isActive: 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),
0,
);
const totalRemaining = Math.max(0, totalRemainingBeforeReservations - reservedCards);
if (totalRemaining > 0) {
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 };
}
@@ -172,26 +270,30 @@ function resolveNextAvailability(
export async function getPlanAvailability(
plan: PlanAvailabilityInput,
options?: { userId?: string },
options?: { userId?: string; db?: DbClient },
): Promise<PlanAvailability> {
const activeCount = await prisma.userSubscription.count({
where: {
planId: plan.id,
status: "ACTIVE",
},
});
const db = options?.db ?? prisma;
const [activeCount, reservedCardCount] = await Promise.all([
db.userSubscription.count({
where: {
planId: plan.id,
status: "ACTIVE",
},
}),
getPlanCardReservationCount(plan.id, { db }),
]);
const totalLimit = plan.totalLimit ?? null;
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 planNextAt = planBlocked ? await getEarliestPlanExpiry(plan.id) : null;
const planNextAt = planBlocked ? await getEarliestPlanStockRelease(db, plan.id) : null;
let remainingByUserLimit: number | null = null;
let userBlocked = false;
let userNextAt: Date | null = null;
if (plan.perUserLimit != null && options?.userId) {
const userActiveCount = await prisma.userSubscription.count({
const userActiveCount = await db.userSubscription.count({
where: {
planId: plan.id,
userId: options.userId,
@@ -201,11 +303,12 @@ export async function getPlanAvailability(
remainingByUserLimit = Math.max(0, plan.perUserLimit - userActiveCount);
userBlocked = remainingByUserLimit <= 0;
if (userBlocked) {
userNextAt = await getEarliestUserPlanExpiry(plan.id, options.userId);
userNextAt = await getEarliestUserPlanExpiry(db, plan.id, options.userId);
}
}
const streaming = await evaluateStreamingCapacity(
db,
plan.type,
plan.streamingServiceId ?? null,
);
@@ -221,6 +324,7 @@ export async function getPlanAvailability(
available: !userBlocked && !planBlocked && !streaming.blocked,
reason: resolution.reason,
activeCount,
reservedCardCount,
totalLimit,
remainingByPlanLimit,
remainingByServiceCapacity: streaming.remaining,

486
src/services/wallet.ts Normal file
View 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" },
});
}