From 018bed3f368e02805f71390d6e480363c9da354d Mon Sep 17 00:00:00 2001 From: JetSprow Date: Fri, 1 May 2026 02:31:29 +1000 Subject: [PATCH] feat: add wallet and recharge cards --- prisma/schema.prisma | 120 +++++ src/actions/admin/payments.ts | 24 + src/actions/admin/recharge-cards.ts | 64 +++ src/actions/user/wallet.ts | 30 ++ .../_components/recharge-card-form.tsx | 124 +++++ .../(admin)/admin/commerce/commerce-data.ts | 51 +- src/app/(admin)/admin/commerce/page.tsx | 59 ++- .../(admin)/admin/payments/config-form.tsx | 273 ++++++---- .../(admin)/admin/payments/payments-data.ts | 4 +- .../admin/plans/_components/plans-list.tsx | 3 + src/app/(admin)/admin/plans/page.tsx | 2 + src/app/(admin)/admin/plans/plan-card.tsx | 11 +- src/app/(admin)/admin/plans/plans-data.ts | 15 +- .../_components/payment-method-selector.tsx | 11 +- .../(payment)/pay/[orderId]/payment-types.ts | 1 + .../pay/[orderId]/use-payment-flow.ts | 4 + .../wallet/_components/wallet-actions.tsx | 91 ++++ src/app/(user)/wallet/page.tsx | 144 ++++++ src/app/(user)/wallet/recharge/[id]/page.tsx | 27 + .../recharge/[id]/recharge-pay-client.tsx | 240 +++++++++ src/app/(user)/wallet/wallet-data.ts | 24 + src/app/api/payment/create/route.ts | 9 + src/app/api/payment/providers/route.ts | 7 +- src/app/api/wallet/recharge/[id]/route.ts | 30 ++ .../wallet/recharge/payment/create/route.ts | 90 ++++ .../wallet/recharge/query/[tradeNo]/route.ts | 50 ++ src/components/user/sidebar.tsx | 6 +- src/services/payment/catalog.ts | 13 + src/services/payment/factory.ts | 34 +- src/services/payment/process.ts | 7 +- src/services/plan-availability.ts | 174 +++++-- src/services/wallet.ts | 486 ++++++++++++++++++ 32 files changed, 2058 insertions(+), 170 deletions(-) create mode 100644 src/actions/admin/recharge-cards.ts create mode 100644 src/actions/user/wallet.ts create mode 100644 src/app/(admin)/admin/commerce/_components/recharge-card-form.tsx create mode 100644 src/app/(user)/wallet/_components/wallet-actions.tsx create mode 100644 src/app/(user)/wallet/page.tsx create mode 100644 src/app/(user)/wallet/recharge/[id]/page.tsx create mode 100644 src/app/(user)/wallet/recharge/[id]/recharge-pay-client.tsx create mode 100644 src/app/(user)/wallet/wallet-data.ts create mode 100644 src/app/api/wallet/recharge/[id]/route.ts create mode 100644 src/app/api/wallet/recharge/payment/create/route.ts create mode 100644 src/app/api/wallet/recharge/query/[tradeNo]/route.ts create mode 100644 src/services/wallet.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 15fd4a1..210690a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/src/actions/admin/payments.ts b/src/actions/admin/payments.ts index f8e7b95..9bf8f81 100644 --- a/src/actions/admin/payments.ts +++ b/src/actions/admin/payments.ts @@ -78,6 +78,30 @@ export async function setPaymentConfigEnabled( ): Promise { 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 }, diff --git a/src/actions/admin/recharge-cards.ts b/src/actions/admin/recharge-cards.ts new file mode 100644 index 0000000..5fdbe99 --- /dev/null +++ b/src/actions/admin/recharge-cards.ts @@ -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"); +} diff --git a/src/actions/user/wallet.ts b/src/actions/user/wallet.ts new file mode 100644 index 0000000..f9bf20f --- /dev/null +++ b/src/actions/user/wallet.ts @@ -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"); +} diff --git a/src/app/(admin)/admin/commerce/_components/recharge-card-form.tsx b/src/app/(admin)/admin/commerce/_components/recharge-card-form.tsx new file mode 100644 index 0000000..5490235 --- /dev/null +++ b/src/app/(admin)/admin/commerce/_components/recharge-card-form.tsx @@ -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 ( +
{ + 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, "生成充值卡失败")); + } + }); + }} + > +
+
+

生成充值卡

+

余额卡充值钱包,套餐卡兑换后直接激活套餐。

+
+ setType(value ? "PLAN" : "BALANCE")} + trueLabel="套餐卡" + falseLabel="余额卡" + ariaLabel="充值卡类型" + className="w-36" + /> +
+ + + +
+
+ + +
+
+ + +
+
+ + {type === "BALANCE" ? ( +
+ + +
+ ) : ( +
+ + + {selectedPlan && ( +

+ {selectedPlan.remainingCount == null ? "库存不限" : `当前可生成 ${selectedPlan.remainingCount} 张`} +

+ )} +
+ )} + +
+ + +
+ + +
+ ); +} diff --git a/src/app/(admin)/admin/commerce/commerce-data.ts b/src/app/(admin)/admin/commerce/commerce-data.ts index 484eb56..3ac2aa5 100644 --- a/src/app/(admin)/admin/commerce/commerce-data.ts +++ b/src/app/(admin)/admin/commerce/commerce-data.ts @@ -1,7 +1,22 @@ import { prisma } from "@/lib/prisma"; +import { getPlanAvailability } from "@/services/plan-availability"; + +function getRechargeCardPlanRemaining( + planType: "PROXY" | "STREAMING", + availability: Awaited>, +) { + 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 }; } diff --git a/src/app/(admin)/admin/commerce/page.tsx b/src/app/(admin)/admin/commerce/page.tsx index 6958107..d1fd519 100644 --- a/src/app/(admin)/admin/commerce/page.tsx +++ b/src/app/(admin)/admin/commerce/page.tsx @@ -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 ( @@ -39,6 +42,7 @@ export default async function AdminCommercePage() { 新建规则 规则列表 + 充值卡 @@ -175,6 +179,57 @@ export default async function AdminCommercePage() { + + +
+ + +
+ +
+ {rechargeCards.map((card) => ( +
+
+ + + +
+
+

{card.code}

+ + {card.status === "UNUSED" + ? "未使用" + : card.status === "REDEEMED" + ? "已兑换" + : card.status === "EXPIRED" + ? "已过期" + : "已停用"} + +
+

+ {card.type === "BALANCE" + ? `余额卡 ¥${Number(card.balanceAmount ?? 0).toFixed(2)}` + : `套餐卡 ${card.plan?.name ?? "套餐已删除"}`} +

+
+
+
+ {card.batchName && {card.batchName}} + {card.redeemedBy && {card.redeemedBy.email}} + {card.expiresAt && 到期 {formatDate(card.expiresAt)}} +
+
+ +
+
+ ))} + {rechargeCards.length === 0 && ( +

暂无充值卡

+ )} +
+
+
+
); diff --git a/src/app/(admin)/admin/payments/config-form.tsx b/src/app/(admin)/admin/payments/config-form.tsx index 90cd4ea..680c004 100644 --- a/src/app/(admin)/admin/payments/config-form.tsx +++ b/src/app/(admin)/admin/payments/config-form.tsx @@ -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>>(() => 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) { event.preventDefault(); if (saving || statusSaving) return; @@ -160,129 +170,168 @@ export function PaymentConfigItem({ } return ( -
-
- - - -
-
-

{providerName}

- {displayName && {displayName}} + <> +
+
+ + + +
+
+

{providerName}

+ {displayName && {displayName}} +
+

{providerDescription}

+ {checkboxSummaries.length > 0 && ( +
+ {checkboxSummaries.slice(0, 2).map((label) => ( + {label} + ))} +
+ )}
-

{providerDescription}

- {checkboxSummaries.length > 0 && ( -
- {checkboxSummaries.slice(0, 2).map((label) => ( - {label} - ))} -
- )}
-
-
- void handleStatusToggle(value)} - trueLabel="启用" - falseLabel="停用" - ariaLabel={`${providerName}状态`} - disabled={saving || statusSaving} - /> -
+
+ void handleStatusToggle(value)} + trueLabel="启用" + falseLabel="停用" + ariaLabel={`${providerName}状态`} + disabled={saving || statusSaving} + /> +
- !saving && setOpen(nextOpen)}> - }> - - 编辑配置 - - - -
- -
- 编辑{providerName} - 留空的密钥字段会保持原值。 -
+ !saving && setOpen(nextOpen)}> + }> + + 编辑配置 + + + +
+ +
+ 编辑{providerName} + 留空的密钥字段会保持原值。 +
- -
-
- {fields.map((field) => - field.type === "checkboxes" ? ( -
- -
- {field.options?.map((option) => { - const selected = checkboxValues[field.key]?.has(option.value) ?? false; - return ( - - ); - })} + + +
+ {fields.map((field) => + field.type === "checkboxes" ? ( +
+ +
+ {field.options?.map((option) => { + const selected = checkboxValues[field.key]?.has(option.value) ?? false; + return ( + + ); + })} +
+
+ ) : ( +
+ + + {field.secret && secretConfigured[field.key] && ( +

已保存,留空不变。

+ )} +
+ ), + )} +
+ +
+
+
+
+ + 开关即时生效。
- ) : ( -
- - - {field.secret && secretConfigured[field.key] && ( -

已保存,留空不变。

- )} +
+ + {enabled ? "已启用" : "已停用"} +
- ), - )} -
- -
-
-
-
- - 开关即时生效。 -
-
-
- - {enabled ? "已启用" : "已停用"} -
-
- - + + + + + +
+
+ + {provider === "balance" && ( + !statusSaving && setBalanceDisableOpen(nextOpen)}> + + +
+ +
+ 关闭余额支付? + + 关闭后用户不能再用账户余额支付订单,余额充值和充值卡兑换仍会保留。 + +
+ + - - - -
-
- + + + )} + ); } diff --git a/src/app/(admin)/admin/payments/payments-data.ts b/src/app/(admin)/admin/payments/payments-data.ts index 536b364..c5da158 100644 --- a/src/app/(admin)/admin/payments/payments-data.ts +++ b/src/app/(admin)/admin/payments/payments-data.ts @@ -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) : {}, diff --git a/src/app/(admin)/admin/plans/_components/plans-list.tsx b/src/app/(admin)/admin/plans/_components/plans-list.tsx index 0b2ccb3..77b4b40 100644 --- a/src/app/(admin)/admin/plans/_components/plans-list.tsx +++ b/src/app/(admin)/admin/plans/_components/plans-list.tsx @@ -10,10 +10,12 @@ export const PLAN_BATCH_FORM_ID = "plan-batch-form"; export function PlansList({ plans, activeCountMap, + reservedCardCountMap, services, }: { plans: AdminPlanRow[]; activeCountMap: Map; + reservedCardCountMap: Map; 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} /> diff --git a/src/app/(admin)/admin/plans/page.tsx b/src/app/(admin)/admin/plans/page.tsx index c2749bf..e1392be 100644 --- a/src/app/(admin)/admin/plans/page.tsx +++ b/src/app/(admin)/admin/plans/page.tsx @@ -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({ diff --git a/src/app/(admin)/admin/plans/plan-card.tsx b/src/app/(admin)/admin/plans/plan-card.tsx index 7c887c4..d81795b 100644 --- a/src/app/(admin)/admin/plans/plan-card.tsx +++ b/src/app/(admin)/admin/plans/plan-card.tsx @@ -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 ( diff --git a/src/app/(admin)/admin/plans/plans-data.ts b/src/app/(admin)/admin/plans/plans-data.ts index f33cda1..86a2d68 100644 --- a/src/app/(admin)/admin/plans/plans-data.ts +++ b/src/app/(admin)/admin/plans/plans-data.ts @@ -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, }; } diff --git a/src/app/(payment)/pay/[orderId]/_components/payment-method-selector.tsx b/src/app/(payment)/pay/[orderId]/_components/payment-method-selector.tsx index bf72953..d1cc69c 100644 --- a/src/app/(payment)/pay/[orderId]/_components/payment-method-selector.tsx +++ b/src/app/(payment)/pay/[orderId]/_components/payment-method-selector.tsx @@ -42,17 +42,22 @@ export function PaymentMethodSelector({ >
- {provider.provider === "usdt_trc20" ? : } + {provider.provider === "usdt_trc20" || provider.provider === "balance" ? : }
{provider.name}

- {provider.provider === "usdt_trc20" ? "稳定币付款" : "按提示完成"} + {provider.provider === "balance" + ? "从账户余额扣款" + : provider.provider === "usdt_trc20" + ? "稳定币付款" + : "按提示完成"}

- {provider.provider === "usdt_trc20" && Crypto} + {provider.provider === "balance" && 余额} + {provider.provider === "usdt_trc20" && 链上} {selected && }
diff --git a/src/app/(payment)/pay/[orderId]/payment-types.ts b/src/app/(payment)/pay/[orderId]/payment-types.ts index 8cfd262..adb219a 100644 --- a/src/app/(payment)/pay/[orderId]/payment-types.ts +++ b/src/app/(payment)/pay/[orderId]/payment-types.ts @@ -9,6 +9,7 @@ export interface PaymentInfo { paymentUrl?: string; qrCode?: string; raw?: { + status?: string; walletAddress?: string; usdtAmount?: string; cnyAmount?: string; diff --git a/src/app/(payment)/pay/[orderId]/use-payment-flow.ts b/src/app/(payment)/pay/[orderId]/use-payment-flow.ts index 95e7657..d2c8d4d 100644 --- a/src/app/(payment)/pay/[orderId]/use-payment-flow.ts +++ b/src/app/(payment)/pay/[orderId]/use-payment-flow.ts @@ -63,6 +63,10 @@ export function usePaymentFlow(orderId: string) { }); setPayment(data); + if (provider === "balance" || data.raw?.status === "paid") { + setStatus("paid"); + return; + } setStatus("waiting"); if ( diff --git a/src/app/(user)/wallet/_components/wallet-actions.tsx b/src/app/(user)/wallet/_components/wallet-actions.tsx new file mode 100644 index 0000000..d64cee4 --- /dev/null +++ b/src/app/(user)/wallet/_components/wallet-actions.tsx @@ -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 ( +
+
{ + 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, "创建充值订单失败")); + } + }); + }} + > +
+ + + +
+

余额充值

+

输入金额后选择在线支付方式。

+
+
+
+ + +
+ +
+ +
{ + 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, "充值卡兑换失败")); + } + }); + }} + > +
+ + + +
+

充值卡兑换

+

余额卡入账余额,套餐卡会直接激活。

+
+
+
+ + +
+ +
+
+ ); +} diff --git a/src/app/(user)/wallet/page.tsx b/src/app/(user)/wallet/page.tsx new file mode 100644 index 0000000..618d820 --- /dev/null +++ b/src/app/(user)/wallet/page.tsx @@ -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 = { + BALANCE_RECHARGE: "余额充值", + BALANCE_PAYMENT: "余额支付", + CARD_REDEEM: "充值卡兑换", + ADMIN_ADJUST: "后台调整", + REFUND: "退款", +}; + +const orderStatusLabels: Record = { + 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 ( + + + +
+
+
+ + + +
+

当前余额

+

{money(wallet.balance)}

+
+
+ + 查看订单 + +
+
+ + + +
+ + } + > + + + + + + + + + + + + {transactions.map((item) => ( + + + + + + + + ))} + +
类型说明变动余额时间
{transactionLabels[item.type] ?? item.type} +

{item.description || item.order?.plan.name || item.rechargeCard?.code || "余额变动"}

+ {item.rechargeCard &&

{item.rechargeCard.code}

} +
{money(item.amount)}{money(item.balanceAfter)}{formatDate(item.createdAt)}
+
+
+ +
+ + + + + + + + + + + + + + {rechargeOrders.map((order) => ( + + + + + + + + ))} + +
金额状态支付方式时间操作
{money(order.amount)}{orderStatusLabels[order.status] ?? order.status} + {order.paymentMethod ? getPaymentProviderName(order.paymentMethod) : "未选择"} + {formatDate(order.createdAt)} + {order.status === "PENDING" && ( + + 继续支付 + + )} +
+
+
+
+ ); +} diff --git a/src/app/(user)/wallet/recharge/[id]/page.tsx b/src/app/(user)/wallet/recharge/[id]/page.tsx new file mode 100644 index 0000000..4528ec6 --- /dev/null +++ b/src/app/(user)/wallet/recharge/[id]/page.tsx @@ -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 ( +
+
+
+ +
+
+ +
+ ); +} diff --git a/src/app/(user)/wallet/recharge/[id]/recharge-pay-client.tsx b/src/app/(user)/wallet/recharge/[id]/recharge-pay-client.tsx new file mode 100644 index 0000000..83fd7eb --- /dev/null +++ b/src/app/(user)/wallet/recharge/[id]/recharge-pay-client.tsx @@ -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([]); + const [selectedIdx, setSelectedIdx] = useState(-1); + const [recharge, setRecharge] = useState(null); + const [payment, setPayment] = useState(null); + const [status, setStatus] = useState<"booting" | "idle" | "creating" | "waiting" | "paid">("booting"); + const [pageError, setPageError] = useState(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("/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("/api/payment/providers?target=wallet"), + fetchJson(`/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 ( +
+
+ +
+

充值成功

+

余额已入账,可直接用于订单支付。

+
+ + 去商店 +
+
+ ); + } + + return ( +
+
+

余额充值

+

¥{(recharge?.amount ?? 0).toFixed(2)}

+
+ + {pageError && ( +
+ {pageError} +
+ )} + + {status === "booting" &&

正在读取充值订单…

} + + {!payment && status !== "booting" && ( + <> +
+ {providers.map((provider, index) => { + const selected = selectedIdx === index; + return ( + + ); + })} +
+ + + )} + + {payment && selectedProvider?.provider === "alipay_f2f" && payment.qrCode && ( + + )} + {payment && selectedProvider?.provider === "usdt_trc20" && payment.raw && ( + + )} + {payment && status === "waiting" && ( +
+

正在等待支付结果返回

+
+ )} + +
+ 返回钱包 +
+
+ ); +} diff --git a/src/app/(user)/wallet/wallet-data.ts b/src/app/(user)/wallet/wallet-data.ts new file mode 100644 index 0000000..527e03b --- /dev/null +++ b/src/app/(user)/wallet/wallet-data.ts @@ -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 }; +} diff --git a/src/app/api/payment/create/route.ts b/src/app/api/payment/create/route.ts index 5eb6ffb..cd911a3 100644 --- a/src/app/api/payment/create/route.ts +++ b/src/app/api/payment/create/route.ts @@ -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 diff --git a/src/app/api/payment/providers/route.ts b/src/app/api/payment/providers/route.ts index c77c1c2..6f2bddf 100644 --- a/src/app/api/payment/providers/route.ts +++ b/src/app/api/payment/providers/route.ts @@ -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: "获取支付方式失败" }); diff --git a/src/app/api/wallet/recharge/[id]/route.ts b/src/app/api/wallet/recharge/[id]/route.ts new file mode 100644 index 0000000..c772476 --- /dev/null +++ b/src/app/api/wallet/recharge/[id]/route.ts @@ -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, + }); +} diff --git a/src/app/api/wallet/recharge/payment/create/route.ts b/src/app/api/wallet/recharge/payment/create/route.ts new file mode 100644 index 0000000..05f4561 --- /dev/null +++ b/src/app/api/wallet/recharge/payment/create/route.ts @@ -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: "创建充值支付失败" }); + } +} diff --git a/src/app/api/wallet/recharge/query/[tradeNo]/route.ts b/src/app/api/wallet/recharge/query/[tradeNo]/route.ts new file mode 100644 index 0000000..4cebff5 --- /dev/null +++ b/src/app/api/wallet/recharge/query/[tradeNo]/route.ts @@ -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" }); +} diff --git a/src/components/user/sidebar.tsx b/src/components/user/sidebar.tsx index ad2a02e..20fb602 100644 --- a/src/components/user/sidebar.tsx +++ b/src/components/user/sidebar.tsx @@ -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: }, { href: "/cart", label: "购物车", icon: }, { href: "/subscriptions", label: "我的订阅", icon: }, + { href: "/wallet", label: "我的钱包", icon: }, { href: "/orders", label: "我的订单", icon: }, { href: "/support", label: "工单售后", icon: }, { href: "/account", label: "账户中心", icon: }, @@ -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), }, ]; diff --git a/src/services/payment/catalog.ts b/src/services/payment/catalog.ts index 9835865..c9d704c 100644 --- a/src/services/payment/catalog.ts +++ b/src/services/payment/catalog.ts @@ -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: "易支付", diff --git a/src/services/payment/factory.ts b/src/services/payment/factory.ts index e8c013c..f9bdb9c 100644 --- a/src/services/payment/factory.ts +++ b/src/services/payment/factory.ts @@ -13,6 +13,10 @@ export async function getPaymentAdapter(provider: string): Promise { +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 { 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 | 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 | null; if (c.provider === "epay") { diff --git a/src/services/payment/process.ts b/src/services/payment/process.ts index fa92618..e93b3b6 100644 --- a/src/services/payment/process.ts +++ b/src/services/payment/process.ts @@ -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); diff --git a/src/services/plan-availability.ts b/src/services/plan-availability.ts index 28f4446..2f0f2c2 100644 --- a/src/services/plan-availability.ts +++ b/src/services/plan-availability.ts @@ -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 { - 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 { + const db = options.db ?? prisma; + return db.rechargeCard.count({ + where: { + ...activePlanCardReservationWhere(options.now), + planId, + }, + }); +} + +async function getEarliestPlanStockRelease(db: DbClient, planId: string): Promise { + 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 { - 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 { +async function getEarliestServiceExpiry(db: DbClient, serviceIds: string[]): Promise { 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) { + 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 { + return db.rechargeCard.count({ + where: { + ...activePlanCardReservationWhere(), + plan: { is: streamingCardReservationPlanFilter(streamingServiceId) }, + }, + }); +} + +async function getEarliestStreamingCardReservationExpiry( + db: DbClient, + streamingServiceId?: string, +): Promise { + 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 { - 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, diff --git a/src/services/wallet.ts b/src/services/wallet.ts new file mode 100644 index 0000000..e32264e --- /dev/null +++ b/src/services/wallet.ts @@ -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>> & { + 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" }, + }); +}