From e718d5edabc038fdc9940590639cbcd4e8e2df90 Mon Sep 17 00:00:00 2001 From: JetSprow Date: Fri, 1 May 2026 04:39:15 +1000 Subject: [PATCH] feat: add subscription push transfers --- prisma/schema.prisma | 60 ++ src/actions/admin/settings.ts | 19 + src/actions/admin/subscription-transfers.ts | 25 + src/actions/user/subscription-transfer.ts | 97 +++ src/app/(admin)/admin/settings/page.tsx | 5 + .../(admin)/admin/settings/settings-form.tsx | 84 ++- .../subscription-transfers-table.tsx | 155 ++++ .../admin/subscription-transfers/page.tsx | 113 +++ .../subscription-transfers/transfers-data.ts | 77 ++ .../_components/subscription-push-form.tsx | 163 ++++ .../_components/subscription-push-list.tsx | 193 +++++ src/app/(user)/subscriptions/push/page.tsx | 115 +++ .../(user)/subscriptions/push/push-data.ts | 85 +++ .../subscriptions/subscription-actions.tsx | 9 +- src/app/(user)/wallet/page.tsx | 11 +- src/components/admin/sidebar.tsx | 10 +- src/components/user/sidebar.tsx | 8 +- src/instrumentation.ts | 4 +- src/lib/domain-labels.ts | 39 + src/services/node-panel/three-x-ui.ts | 2 +- .../subscription-transfer-scheduler.ts | 68 ++ src/services/subscription-transfer.ts | 698 ++++++++++++++++++ src/services/wallet.ts | 36 +- 23 files changed, 2049 insertions(+), 27 deletions(-) create mode 100644 src/actions/admin/subscription-transfers.ts create mode 100644 src/actions/user/subscription-transfer.ts create mode 100644 src/app/(admin)/admin/subscription-transfers/_components/subscription-transfers-table.tsx create mode 100644 src/app/(admin)/admin/subscription-transfers/page.tsx create mode 100644 src/app/(admin)/admin/subscription-transfers/transfers-data.ts create mode 100644 src/app/(user)/subscriptions/push/_components/subscription-push-form.tsx create mode 100644 src/app/(user)/subscriptions/push/_components/subscription-push-list.tsx create mode 100644 src/app/(user)/subscriptions/push/page.tsx create mode 100644 src/app/(user)/subscriptions/push/push-data.ts create mode 100644 src/services/subscription-transfer-scheduler.ts create mode 100644 src/services/subscription-transfer.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 210690a..5dbb90b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -94,6 +94,21 @@ enum WalletTransactionType { CARD_REDEEM ADMIN_ADJUST REFUND + SUBSCRIPTION_TRANSFER_FEE + SUBSCRIPTION_TRANSFER_REFUND +} + +enum SubscriptionTransferStatus { + PENDING + ACCEPTED + REJECTED + CANCELLED + EXPIRED +} + +enum SubscriptionTransferFeePayer { + SENDER + RECIPIENT } enum Protocol { @@ -221,6 +236,8 @@ model User { walletRechargeOrders WalletRechargeOrder[] redeemedRechargeCards RechargeCard[] @relation("RechargeCardRedeemer") createdRechargeCards RechargeCard[] @relation("RechargeCardCreator") + sentSubscriptionTransfers SubscriptionTransfer[] @relation("SubscriptionTransferSender") + receivedSubscriptionTransfers SubscriptionTransfer[] @relation("SubscriptionTransferRecipient") } model EmailToken { @@ -327,6 +344,7 @@ model SubscriptionPlan { cartItems ShoppingCartItem[] orderItems OrderItem[] rechargeCards RechargeCard[] + subscriptionTransfers SubscriptionTransfer[] @@index([type, isActive, isFeatured, sortOrder]) @@index([inboundId]) @@ -353,11 +371,48 @@ model UserSubscription { nodeClient NodeClient? createOrder Order? @relation("OrderCreatedSubscription") targetOrders Order[] @relation("OrderTargetSubscription") + transfers SubscriptionTransfer[] @@index([userId]) @@index([status]) } +model SubscriptionTransfer { + id String @id @default(cuid()) + subscriptionId String + planId String + senderId String + recipientId String + senderEmail String + recipientEmail String + status SubscriptionTransferStatus @default(PENDING) + feeAmount Decimal @default(0) + feePayer SubscriptionTransferFeePayer @default(SENDER) + feeChargedToId String? + feeChargedAt DateTime? + feeRefundedAt DateTime? + cycleStartedAt DateTime + expiresAt DateTime + acceptedAt DateTime? + rejectedAt DateTime? + cancelledAt DateTime? + expiredAt DateTime? + note String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + subscription UserSubscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade) + plan SubscriptionPlan @relation(fields: [planId], references: [id], onDelete: Cascade) + sender User @relation("SubscriptionTransferSender", fields: [senderId], references: [id], onDelete: Cascade) + recipient User @relation("SubscriptionTransferRecipient", fields: [recipientId], references: [id], onDelete: Cascade) + + @@index([subscriptionId, status, createdAt]) + @@index([senderId, createdAt]) + @@index([recipientId, createdAt]) + @@index([status, expiresAt]) + @@index([planId, cycleStartedAt]) +} + model SubscriptionAccessLog { id String @id @default(cuid()) userId String? @@ -930,6 +985,11 @@ model AppConfig { inviteRewardCouponId String? inviteRewardRate Decimal @default(0) inviteRewardEnabled Boolean @default(false) + subscriptionTransferEnabled Boolean @default(true) + subscriptionTransferFee Decimal @default(0) + subscriptionTransferLimitPerCycle Int @default(1) + subscriptionTransferMinRemainingDays Int @default(0) + subscriptionTransferMinRemainingTrafficGb Int @default(0) turnstileSiteKey String? turnstileSecretKey String? smtpEnabled Boolean @default(false) diff --git a/src/actions/admin/settings.ts b/src/actions/admin/settings.ts index 054683f..87e88f1 100644 --- a/src/actions/admin/settings.ts +++ b/src/actions/admin/settings.ts @@ -59,6 +59,11 @@ const settingsSchema = z.object({ inviteRewardEnabled: z.string().optional(), inviteRewardRate: z.coerce.number().min(0).max(100).optional(), inviteRewardCouponId: z.string().trim().optional(), + subscriptionTransferEnabled: z.string().optional(), + subscriptionTransferFee: z.coerce.number().min(0).max(100000).optional(), + subscriptionTransferLimitPerCycle: z.coerce.number().int().min(0).max(100).optional(), + subscriptionTransferMinRemainingDays: z.coerce.number().int().min(0).max(3650).optional(), + subscriptionTransferMinRemainingTrafficGb: z.coerce.number().int().min(0).max(1000000).optional(), turnstileSiteKey: z.string().trim().optional(), turnstileSecretKey: z.string().trim().optional(), smtpEnabled: z.string().optional(), @@ -128,6 +133,7 @@ function booleanSettingData(field: BooleanSettingField, value: boolean) { subscriptionRiskAutoSuspend: { subscriptionRiskAutoSuspend: value }, nodeAccessRiskEnabled: { nodeAccessRiskEnabled: value }, inviteRewardEnabled: { inviteRewardEnabled: value }, + subscriptionTransferEnabled: { subscriptionTransferEnabled: value }, smtpEnabled: { smtpEnabled: value }, smtpSecure: { smtpSecure: value }, }[field]; @@ -243,6 +249,18 @@ function buildSettingsUpdate(parsed: z.infer, current: Aw inviteRewardEnabled: optionalBoolean(parsed.inviteRewardEnabled, current.inviteRewardEnabled), inviteRewardRate: parsed.inviteRewardRate ?? Number(current.inviteRewardRate), inviteRewardCouponId: parsed.inviteRewardCouponId || null, + subscriptionTransferEnabled: optionalBoolean( + parsed.subscriptionTransferEnabled, + current.subscriptionTransferEnabled, + ), + subscriptionTransferFee: + parsed.subscriptionTransferFee ?? Number(current.subscriptionTransferFee), + subscriptionTransferLimitPerCycle: + parsed.subscriptionTransferLimitPerCycle ?? current.subscriptionTransferLimitPerCycle, + subscriptionTransferMinRemainingDays: + parsed.subscriptionTransferMinRemainingDays ?? current.subscriptionTransferMinRemainingDays, + subscriptionTransferMinRemainingTrafficGb: + parsed.subscriptionTransferMinRemainingTrafficGb ?? current.subscriptionTransferMinRemainingTrafficGb, turnstileSiteKey, turnstileSecretKey, smtpEnabled, @@ -290,6 +308,7 @@ function revalidateSettingsViews() { revalidatePath("/dashboard"); revalidatePath("/store"); revalidatePath("/subscriptions"); + revalidatePath("/subscriptions/push"); revalidatePath("/admin/nodes"); revalidatePath("/account"); revalidatePath("/support"); diff --git a/src/actions/admin/subscription-transfers.ts b/src/actions/admin/subscription-transfers.ts new file mode 100644 index 0000000..d9e859f --- /dev/null +++ b/src/actions/admin/subscription-transfers.ts @@ -0,0 +1,25 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { requireAdmin } from "@/lib/require-auth"; +import { actorFromSession } from "@/services/audit"; +import { deleteSubscriptionTransferAsAdmin } from "@/services/subscription-transfer"; + +const transferIdSchema = z.string().trim().min(1, "套餐 Push 记录不存在"); + +export async function deleteAdminSubscriptionTransfer(transferId: string) { + const session = await requireAdmin(); + const id = transferIdSchema.parse(transferId); + + const result = await deleteSubscriptionTransferAsAdmin({ + transferId: id, + actor: actorFromSession(session), + }); + + revalidatePath("/admin/subscription-transfers"); + revalidatePath("/subscriptions"); + revalidatePath("/subscriptions/push"); + + return result; +} diff --git a/src/actions/user/subscription-transfer.ts b/src/actions/user/subscription-transfer.ts new file mode 100644 index 0000000..460e5ec --- /dev/null +++ b/src/actions/user/subscription-transfer.ts @@ -0,0 +1,97 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { requireAuth } from "@/lib/require-auth"; +import { getErrorMessage } from "@/lib/errors"; +import { actorFromSession } from "@/services/audit"; +import { + acceptSubscriptionTransfer, + cancelSubscriptionTransfer, + createSubscriptionTransfer, + rejectSubscriptionTransfer, +} from "@/services/subscription-transfer"; + +const createTransferSchema = z.object({ + subscriptionId: z.string().trim().min(1, "请选择要 Push 的套餐"), + recipientEmail: z.string().trim().email("请输入正确的接收方邮箱"), + password: z.string().min(1, "请输入当前账户密码"), + feePayer: z.enum(["SENDER", "RECIPIENT"]), +}); + +const transferIdSchema = z.string().trim().min(1, "套餐 Push 不存在"); +type UserTransferActionResult = { ok: true; id?: string } | { ok: false; error: string }; + +function revalidateTransferViews(subscriptionId?: string) { + revalidatePath("/subscriptions"); + revalidatePath("/subscriptions/push"); + revalidatePath("/wallet"); + if (subscriptionId) revalidatePath(`/subscriptions/${subscriptionId}`); +} + +export async function createUserSubscriptionTransfer(formData: FormData): Promise { + try { + const session = await requireAuth(); + const data = createTransferSchema.parse(Object.fromEntries(formData)); + const transfer = await createSubscriptionTransfer({ + senderId: session.user.id, + recipientEmail: data.recipientEmail, + subscriptionId: data.subscriptionId, + password: data.password, + feePayer: data.feePayer, + actor: actorFromSession(session), + }); + revalidateTransferViews(data.subscriptionId); + return { ok: true, id: transfer.id }; + } catch (error) { + return { ok: false, error: getErrorMessage(error, "发起套餐 Push 失败") }; + } +} + +export async function acceptUserSubscriptionTransfer(transferId: string): Promise { + try { + const session = await requireAuth(); + const id = transferIdSchema.parse(transferId); + const transfer = await acceptSubscriptionTransfer({ + transferId: id, + recipientId: session.user.id, + actor: actorFromSession(session), + }); + revalidateTransferViews(transfer.subscriptionId); + return { ok: true }; + } catch (error) { + return { ok: false, error: getErrorMessage(error, "接收套餐 Push 失败") }; + } +} + +export async function rejectUserSubscriptionTransfer(transferId: string): Promise { + try { + const session = await requireAuth(); + const id = transferIdSchema.parse(transferId); + const transfer = await rejectSubscriptionTransfer({ + transferId: id, + recipientId: session.user.id, + actor: actorFromSession(session), + }); + revalidateTransferViews(transfer.subscriptionId); + return { ok: true }; + } catch (error) { + return { ok: false, error: getErrorMessage(error, "拒收套餐 Push 失败") }; + } +} + +export async function cancelUserSubscriptionTransfer(transferId: string): Promise { + try { + const session = await requireAuth(); + const id = transferIdSchema.parse(transferId); + const transfer = await cancelSubscriptionTransfer({ + transferId: id, + senderId: session.user.id, + actor: actorFromSession(session), + }); + revalidateTransferViews(transfer.subscriptionId); + return { ok: true }; + } catch (error) { + return { ok: false, error: getErrorMessage(error, "取消套餐 Push 失败") }; + } +} diff --git a/src/app/(admin)/admin/settings/page.tsx b/src/app/(admin)/admin/settings/page.tsx index 9f56407..25b385a 100644 --- a/src/app/(admin)/admin/settings/page.tsx +++ b/src/app/(admin)/admin/settings/page.tsx @@ -62,6 +62,11 @@ export default async function AdminSettingsPage() { inviteRewardEnabled: config.inviteRewardEnabled, inviteRewardRate: Number(config.inviteRewardRate), inviteRewardCouponId: config.inviteRewardCouponId, + subscriptionTransferEnabled: config.subscriptionTransferEnabled, + subscriptionTransferFee: Number(config.subscriptionTransferFee), + subscriptionTransferLimitPerCycle: config.subscriptionTransferLimitPerCycle, + subscriptionTransferMinRemainingDays: config.subscriptionTransferMinRemainingDays, + subscriptionTransferMinRemainingTrafficGb: config.subscriptionTransferMinRemainingTrafficGb, turnstileSiteKey: config.turnstileSiteKey, turnstileSecretConfigured: Boolean(config.turnstileSecretKey), smtpEnabled: config.smtpEnabled, diff --git a/src/app/(admin)/admin/settings/settings-form.tsx b/src/app/(admin)/admin/settings/settings-form.tsx index eabb982..8467eb2 100644 --- a/src/app/(admin)/admin/settings/settings-form.tsx +++ b/src/app/(admin)/admin/settings/settings-form.tsx @@ -2,7 +2,7 @@ import { useState, type FormEvent, type ReactNode } from "react"; import { useRouter } from "next/navigation"; -import { Bell, Clock3, Gift, LifeBuoy, Mail, RadioTower, Send, Settings2, ShieldAlert, ShieldCheck, Trash2 } from "lucide-react"; +import { ArrowRightLeft, Bell, Clock3, Gift, LifeBuoy, Mail, RadioTower, Send, Settings2, ShieldAlert, ShieldCheck, Trash2 } from "lucide-react"; import { cleanupExpiredAdminLogs } from "@/actions/admin/logs"; import { ConfirmActionButton } from "@/components/shared/confirm-action-button"; import { BooleanToggle } from "@/components/ui/boolean-toggle"; @@ -72,6 +72,11 @@ interface AppConfig { inviteRewardEnabled: boolean; inviteRewardRate: number; inviteRewardCouponId: string | null; + subscriptionTransferEnabled: boolean; + subscriptionTransferFee: number; + subscriptionTransferLimitPerCycle: number; + subscriptionTransferMinRemainingDays: number; + subscriptionTransferMinRemainingTrafficGb: number; turnstileSiteKey: string | null; turnstileSecretConfigured: boolean; smtpEnabled: boolean; @@ -102,6 +107,7 @@ type SettingsSectionValue = | "auth" | "email" | "invite" + | "transfer" | "turnstile" | "notices"; @@ -115,6 +121,7 @@ const settingsNavItems = [ { value: "auth", label: "注册" }, { value: "email", label: "邮件" }, { value: "invite", label: "邀请" }, + { value: "transfer", label: "转让" }, { value: "turnstile", label: "验证" }, { value: "notices", label: "公告" }, ] satisfies Array<{ value: SettingsSectionValue; label: string }>; @@ -168,6 +175,7 @@ function initialToggleValues(config: AppConfig): ToggleValues { subscriptionRiskAutoSuspend: config.subscriptionRiskAutoSuspend, nodeAccessRiskEnabled: config.nodeAccessRiskEnabled, inviteRewardEnabled: config.inviteRewardEnabled, + subscriptionTransferEnabled: config.subscriptionTransferEnabled, smtpEnabled: config.smtpEnabled, smtpSecure: config.smtpSecure, }; @@ -835,6 +843,80 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons: +
+
+ 套餐 Push + 控制用户间套餐转让。 +
+
+
+ + {renderImmediateToggle("subscriptionTransferEnabled", { + id: "subscriptionTransferEnabled", + trueLabel: "允许", + falseLabel: "关闭", + ariaLabel: "允许套餐 Push", + })} +
+
+ + 转让费(元) + + +
+
+ + 单周期次数 + + +
+
+ + 剩余天数下限 + + +
+
+ + 剩余流量下限(GB) + + +
+
+
+
Cloudflare Turnstile diff --git a/src/app/(admin)/admin/subscription-transfers/_components/subscription-transfers-table.tsx b/src/app/(admin)/admin/subscription-transfers/_components/subscription-transfers-table.tsx new file mode 100644 index 0000000..09f5179 --- /dev/null +++ b/src/app/(admin)/admin/subscription-transfers/_components/subscription-transfers-table.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { Eye, Trash2 } from "lucide-react"; +import { deleteAdminSubscriptionTransfer } from "@/actions/admin/subscription-transfers"; +import { ConfirmActionButton } from "@/components/shared/confirm-action-button"; +import { StatusBadge, type StatusTone } from "@/components/shared/status-badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; + +export interface AdminSubscriptionTransferItem { + id: string; + status: string; + statusLabel: string; + statusTone: StatusTone; + planName: string; + planTypeLabel: string; + senderLabel: string; + recipientLabel: string; + feeLabel: string; + feePayerLabel: string; + feeChargedLabel: string; + feeRefundedLabel: string; + cycleStartedAtLabel: string; + createdAtLabel: string; + expiresAtLabel: string; + acceptedAtLabel: string; + endDateLabel: string; + trafficLabel: string; + nodeLabel: string; +} + +function DetailItem({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +function TransferDetailDialog({ item }: { item: AdminSubscriptionTransferItem }) { + const details = [ + { label: "套餐", value: `${item.planName} · ${item.planTypeLabel}` }, + { label: "状态", value: item.statusLabel }, + { label: "转出方", value: item.senderLabel }, + { label: "接收方", value: item.recipientLabel }, + { label: "费用", value: item.feeLabel }, + { label: "承担方", value: item.feePayerLabel }, + { label: "扣费", value: item.feeChargedLabel }, + { label: "退款", value: item.feeRefundedLabel }, + { label: "周期起点", value: item.cycleStartedAtLabel }, + { label: "发起时间", value: item.createdAtLabel }, + { label: "确认截止", value: item.expiresAtLabel }, + { label: "接收时间", value: item.acceptedAtLabel }, + { label: "套餐到期", value: item.endDateLabel }, + { label: "剩余流量", value: item.trafficLabel }, + { label: "节点", value: item.nodeLabel }, + ]; + + return ( + + }> + + 详情 + + + +
+ Push 详情 + {item.statusLabel} +
+ {item.senderLabel} {"->"} {item.recipientLabel} +
+ +
+ {details.map((detail) => ( + + ))} +
+
+
+
+ ); +} + +export function SubscriptionTransfersTable({ transfers }: { transfers: AdminSubscriptionTransferItem[] }) { + const router = useRouter(); + + if (transfers.length === 0) { + return ( +
+ 暂无套餐 Push 记录 +
+ ); + } + + return ( +
+ {transfers.map((item) => { + const deletingPending = item.status === "PENDING"; + return ( +
+
+
+

{item.planName}

+ {item.statusLabel} +
+

{item.senderLabel} {"->"} {item.recipientLabel}

+
+
+ {item.feeLabel} + {item.feePayerLabel} + {item.createdAtLabel} +
+
+ + { + await deleteAdminSubscriptionTransfer(item.id); + }} + onSuccess={() => router.refresh()} + > + + 删除 + +
+
+ ); + })} +
+ ); +} diff --git a/src/app/(admin)/admin/subscription-transfers/page.tsx b/src/app/(admin)/admin/subscription-transfers/page.tsx new file mode 100644 index 0000000..57c5d76 --- /dev/null +++ b/src/app/(admin)/admin/subscription-transfers/page.tsx @@ -0,0 +1,113 @@ +import type { Metadata } from "next"; +import { AdminFilterBar } from "@/components/admin/filter-bar"; +import { PageHeader, PageShell } from "@/components/shared/page-shell"; +import { Pagination } from "@/components/shared/pagination"; +import type { StatusTone } from "@/components/shared/status-badge"; +import { formatBytes, formatDate } from "@/lib/utils"; +import { + getSubscriptionTransferFeePayerLabel, + getSubscriptionTransferStatusLabel, +} from "@/lib/domain-labels"; +import { getAdminSubscriptionTransfers, type AdminSubscriptionTransferRow } from "./transfers-data"; +import { + SubscriptionTransfersTable, + type AdminSubscriptionTransferItem, +} from "./_components/subscription-transfers-table"; + +export const metadata: Metadata = { + title: "套餐 Push", + description: "查看用户间套餐转让记录。", +}; + +const statusTones: Record = { + PENDING: "warning", + ACCEPTED: "success", + REJECTED: "neutral", + CANCELLED: "neutral", + EXPIRED: "danger", +}; + +function money(value: unknown) { + return `¥${Number(value).toFixed(2)}`; +} + +function userLabel(user: { name: string | null; email: string }) { + return user.name ? `${user.name} · ${user.email}` : user.email; +} + +function trafficLabel(row: AdminSubscriptionTransferRow) { + const sub = row.subscription; + if (sub.plan.type !== "PROXY") return "不涉及流量限制"; + if (!sub.trafficLimit) return "不限流量"; + const remaining = sub.trafficLimit > sub.trafficUsed ? sub.trafficLimit - sub.trafficUsed : BigInt(0); + return `${formatBytes(remaining)} / ${formatBytes(sub.trafficLimit)}`; +} + +function nodeLabel(row: AdminSubscriptionTransferRow) { + const client = row.subscription.nodeClient; + if (!client) return row.subscription.streamingSlot?.service.name ?? "无节点"; + return `${client.inbound.server.name} · ${client.inbound.tag}`; +} + +function toItem(row: AdminSubscriptionTransferRow): AdminSubscriptionTransferItem { + return { + id: row.id, + status: row.status, + statusLabel: getSubscriptionTransferStatusLabel(row.status), + statusTone: statusTones[row.status] ?? "neutral", + planName: row.plan.name, + planTypeLabel: row.plan.type === "PROXY" ? "代理" : "流媒体", + senderLabel: userLabel(row.sender), + recipientLabel: userLabel(row.recipient), + feeLabel: money(row.feeAmount), + feePayerLabel: getSubscriptionTransferFeePayerLabel(row.feePayer), + feeChargedLabel: row.feeChargedAt ? formatDate(row.feeChargedAt) : "未扣费", + feeRefundedLabel: row.feeRefundedAt ? formatDate(row.feeRefundedAt) : "未退款", + cycleStartedAtLabel: formatDate(row.cycleStartedAt), + createdAtLabel: formatDate(row.createdAt), + expiresAtLabel: formatDate(row.expiresAt), + acceptedAtLabel: row.acceptedAt ? formatDate(row.acceptedAt) : "未接收", + endDateLabel: formatDate(row.subscription.endDate), + trafficLabel: trafficLabel(row), + nodeLabel: nodeLabel(row), + }; +} + +export default async function AdminSubscriptionTransfersPage({ + searchParams, +}: { + searchParams: Promise>; +}) { + const { transfers, total, page, pageSize, filters } = await getAdminSubscriptionTransfers(await searchParams); + + return ( + + + + + + + + + ); +} diff --git a/src/app/(admin)/admin/subscription-transfers/transfers-data.ts b/src/app/(admin)/admin/subscription-transfers/transfers-data.ts new file mode 100644 index 0000000..42fff36 --- /dev/null +++ b/src/app/(admin)/admin/subscription-transfers/transfers-data.ts @@ -0,0 +1,77 @@ +import type { Prisma } from "@prisma/client"; +import { prisma } from "@/lib/prisma"; +import { parsePage } from "@/lib/utils"; +import { processExpiredSubscriptionTransfers } from "@/services/subscription-transfer"; + +const adminTransferInclude = { + plan: true, + subscription: { + include: { + plan: true, + nodeClient: { + include: { + inbound: { + include: { + server: true, + }, + }, + }, + }, + streamingSlot: { + include: { + service: true, + }, + }, + }, + }, + sender: { select: { id: true, email: true, name: true } }, + recipient: { select: { id: true, email: true, name: true } }, +} satisfies Prisma.SubscriptionTransferInclude; + +export type AdminSubscriptionTransferRow = Prisma.SubscriptionTransferGetPayload<{ + include: typeof adminTransferInclude; +}>; + +export async function getAdminSubscriptionTransfers( + searchParams: Record, +) { + await processExpiredSubscriptionTransfers(); + + const { page, skip, pageSize } = parsePage(searchParams); + const q = typeof searchParams.q === "string" ? searchParams.q.trim() : ""; + const status = typeof searchParams.status === "string" ? searchParams.status : ""; + + const where = { + ...(status ? { status: status as "PENDING" | "ACCEPTED" | "REJECTED" | "CANCELLED" | "EXPIRED" } : {}), + ...(q + ? { + OR: [ + { senderEmail: { contains: q } }, + { recipientEmail: { contains: q } }, + { plan: { name: { contains: q } } }, + { sender: { name: { contains: q } } }, + { recipient: { name: { contains: q } } }, + ], + } + : {}), + } satisfies Prisma.SubscriptionTransferWhereInput; + + const [transfers, total] = await Promise.all([ + prisma.subscriptionTransfer.findMany({ + where, + include: adminTransferInclude, + orderBy: { createdAt: "desc" }, + skip, + take: pageSize, + }), + prisma.subscriptionTransfer.count({ where }), + ]); + + return { + transfers, + total, + page, + pageSize, + filters: { q, status }, + }; +} diff --git a/src/app/(user)/subscriptions/push/_components/subscription-push-form.tsx b/src/app/(user)/subscriptions/push/_components/subscription-push-form.tsx new file mode 100644 index 0000000..13e83f5 --- /dev/null +++ b/src/app/(user)/subscriptions/push/_components/subscription-push-form.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { useMemo, useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { ArrowRightLeft } from "lucide-react"; +import { toast } from "sonner"; +import { createUserSubscriptionTransfer } from "@/actions/user/subscription-transfer"; +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 { BooleanToggle } from "@/components/ui/boolean-toggle"; +import { getErrorMessage } from "@/lib/errors"; +import { formatDate, formatBytes } from "@/lib/utils"; +import type { PushSubscriptionOption } from "../push-data"; + +interface TransferConfig { + enabled: boolean; + feeAmount: number; + limitPerCycle: number; + minRemainingDays: number; + minRemainingTrafficGb: number; +} + +function money(value: number) { + return `¥${value.toFixed(2)}`; +} + +function subscriptionLabel(subscription: PushSubscriptionOption) { + const type = subscription.plan.type === "PROXY" ? "代理" : "流媒体"; + return `${subscription.plan.name} · ${type} · ${formatDate(subscription.endDate)}`; +} + +function remainingTrafficLabel(subscription: PushSubscriptionOption) { + if (subscription.plan.type !== "PROXY") return "不涉及流量限制"; + if (!subscription.trafficLimit) return "不限流量"; + const used = subscription.trafficUsed; + const remaining = subscription.trafficLimit > used ? subscription.trafficLimit - used : BigInt(0); + return `剩余 ${formatBytes(remaining)}`; +} + +export function SubscriptionPushForm({ + subscriptions, + config, + initialSubscriptionId, +}: { + subscriptions: PushSubscriptionOption[]; + config: TransferConfig; + initialSubscriptionId?: string; +}) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [subscriptionId, setSubscriptionId] = useState( + subscriptions.some((item) => item.id === initialSubscriptionId) + ? initialSubscriptionId! + : subscriptions[0]?.id ?? "", + ); + const [recipientPays, setRecipientPays] = useState(false); + const selectedSubscription = useMemo( + () => subscriptions.find((item) => item.id === subscriptionId) ?? null, + [subscriptionId, subscriptions], + ); + + return ( +
{ + event.preventDefault(); + const form = event.currentTarget; + const formData = new FormData(form); + formData.set("subscriptionId", subscriptionId); + formData.set("feePayer", recipientPays ? "RECIPIENT" : "SENDER"); + startTransition(async () => { + try { + const result = await createUserSubscriptionTransfer(formData); + if (!result.ok) { + throw new Error(result.error); + } + form.reset(); + setRecipientPays(false); + router.refresh(); + toast.success("套餐 Push 已发起"); + } catch (error) { + toast.error(getErrorMessage(error, "发起套餐 Push 失败")); + } + }); + }} + > +
+ + + +
+

发起 Push

+

对方确认前,套餐会暂停访问。

+
+
+ + {!config.enabled && ( +
+ 管理员已关闭套餐 Push。 +
+ )} + +
+ + + {selectedSubscription && ( +

{remainingTrafficLabel(selectedSubscription)}

+ )} +
+ +
+ + +
+ +
+ + +
+ +
+ + +

+ {config.feeAmount > 0 ? `固定 ${money(config.feeAmount)}` : "当前免费"} +

+
+ +
+ 单周期最多 {config.limitPerCycle} 次;低于 {config.minRemainingDays} 天或代理剩余流量低于 {config.minRemainingTrafficGb} GB 时不可 Push。 +
+ + +
+ ); +} diff --git a/src/app/(user)/subscriptions/push/_components/subscription-push-list.tsx b/src/app/(user)/subscriptions/push/_components/subscription-push-list.tsx new file mode 100644 index 0000000..fccee42 --- /dev/null +++ b/src/app/(user)/subscriptions/push/_components/subscription-push-list.tsx @@ -0,0 +1,193 @@ +"use client"; + +import { useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { CheckCircle2, Eye, RotateCcw, XCircle } from "lucide-react"; +import { + acceptUserSubscriptionTransfer, + cancelUserSubscriptionTransfer, + rejectUserSubscriptionTransfer, +} from "@/actions/user/subscription-transfer"; +import { ConfirmActionButton } from "@/components/shared/confirm-action-button"; +import { StatusBadge, type StatusTone } from "@/components/shared/status-badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { getErrorMessage } from "@/lib/errors"; + +export interface SubscriptionTransferItem { + id: string; + role: "incoming" | "outgoing"; + status: "PENDING" | "ACCEPTED" | "REJECTED" | "CANCELLED" | "EXPIRED"; + statusLabel: string; + statusTone: StatusTone; + planName: string; + planTypeLabel: string; + senderLabel: string; + recipientLabel: string; + feeLabel: string; + feePayerLabel: string; + createdAtLabel: string; + expiresAtLabel: string; + acceptedAtLabel: string | null; + endDateLabel: string; + trafficLabel: string; +} + +function DetailRow({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +function AcceptTransferDialog({ transfer }: { transfer: SubscriptionTransferItem }) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + + return ( + + }> + + 查看并接收 + + + +
+ 确认接收套餐 + {transfer.statusLabel} +
+ 接收后套餐会进入你的账户,原访问凭据会失效。 +
+ +
+ + + + + + + + +
+
+ + + +
+
+ ); +} + +export function SubscriptionPushList({ transfers }: { transfers: SubscriptionTransferItem[] }) { + const router = useRouter(); + + if (transfers.length === 0) { + return ( +
+ 暂无套餐 Push 记录 +
+ ); + } + + return ( +
+ {transfers.map((transfer) => { + const canAccept = transfer.role === "incoming" && transfer.status === "PENDING"; + const canReject = transfer.role === "incoming" && transfer.status === "PENDING"; + const canCancel = transfer.role === "outgoing" && transfer.status === "PENDING"; + + return ( +
+
+
+

{transfer.planName}

+ {transfer.statusLabel} +
+

+ {transfer.senderLabel} {"->"} {transfer.recipientLabel} +

+
+
+ {transfer.feeLabel} + {transfer.feePayerLabel} + {transfer.expiresAtLabel} +
+
+ {canAccept && } + {canReject && ( + { + const result = await rejectUserSubscriptionTransfer(transfer.id); + if (!result.ok) throw new Error(result.error); + }} + onSuccess={() => router.refresh()} + > + + 拒收 + + )} + {canCancel && ( + { + const result = await cancelUserSubscriptionTransfer(transfer.id); + if (!result.ok) throw new Error(result.error); + }} + onSuccess={() => router.refresh()} + > + + 取消 + + )} +
+
+ ); + })} +
+ ); +} diff --git a/src/app/(user)/subscriptions/push/page.tsx b/src/app/(user)/subscriptions/push/page.tsx new file mode 100644 index 0000000..d76cacb --- /dev/null +++ b/src/app/(user)/subscriptions/push/page.tsx @@ -0,0 +1,115 @@ +import type { Metadata } from "next"; +import { ArrowRightLeft, WalletCards } from "lucide-react"; +import { getActiveSession } from "@/lib/require-auth"; +import { formatBytes, formatDate } from "@/lib/utils"; +import { getSubscriptionTransferFeePayerLabel, getSubscriptionTransferStatusLabel } from "@/lib/domain-labels"; +import { PageHeader, PageShell, SectionHeader } from "@/components/shared/page-shell"; +import type { StatusTone } from "@/components/shared/status-badge"; +import { getSubscriptionPushPageData, type UserSubscriptionTransferRow } from "./push-data"; +import { SubscriptionPushForm } from "./_components/subscription-push-form"; +import { SubscriptionPushList, type SubscriptionTransferItem } from "./_components/subscription-push-list"; + +export const metadata: Metadata = { + title: "套餐 Push", + description: "发起和接收用户间套餐转让。", +}; + +const statusTones: Record = { + PENDING: "warning", + ACCEPTED: "success", + REJECTED: "neutral", + CANCELLED: "neutral", + EXPIRED: "danger", +}; + +function money(value: unknown) { + return `¥${Number(value).toFixed(2)}`; +} + +function userLabel(user: { name: string | null; email: string }) { + return user.name ? `${user.name} · ${user.email}` : user.email; +} + +function trafficLabel(transfer: UserSubscriptionTransferRow) { + const sub = transfer.subscription; + if (sub.plan.type !== "PROXY") return "不涉及流量限制"; + if (!sub.trafficLimit) return "不限流量"; + const remaining = sub.trafficLimit > sub.trafficUsed ? sub.trafficLimit - sub.trafficUsed : BigInt(0); + return `${formatBytes(remaining)} / ${formatBytes(sub.trafficLimit)}`; +} + +function toTransferItem(transfer: UserSubscriptionTransferRow, userId: string): SubscriptionTransferItem { + return { + id: transfer.id, + role: transfer.recipientId === userId ? "incoming" : "outgoing", + status: transfer.status, + statusLabel: getSubscriptionTransferStatusLabel(transfer.status), + statusTone: statusTones[transfer.status] ?? "neutral", + planName: transfer.plan.name, + planTypeLabel: transfer.plan.type === "PROXY" ? "代理" : "流媒体", + senderLabel: userLabel(transfer.sender), + recipientLabel: userLabel(transfer.recipient), + feeLabel: money(transfer.feeAmount), + feePayerLabel: getSubscriptionTransferFeePayerLabel(transfer.feePayer), + createdAtLabel: formatDate(transfer.createdAt), + expiresAtLabel: formatDate(transfer.expiresAt), + acceptedAtLabel: transfer.acceptedAt ? formatDate(transfer.acceptedAt) : null, + endDateLabel: formatDate(transfer.subscription.endDate), + trafficLabel: trafficLabel(transfer), + }; +} + +export default async function SubscriptionPushPage({ + searchParams, +}: { + searchParams: Promise>; +}) { + const session = await getActiveSession(); + const [params, data] = await Promise.all([ + searchParams, + getSubscriptionPushPageData(session!.user.id), + ]); + const initialSubscriptionId = typeof params.subscriptionId === "string" ? params.subscriptionId : undefined; + const transferItems = data.transfers.map((transfer) => toTransferItem(transfer, session!.user.id)); + + return ( + + + +
+
+

+ 当前余额 +

+

{money(data.walletBalance)}

+
+
+

+ 转让费 +

+

{money(data.config.feeAmount)}

+
+
+

单周期次数

+

{data.config.limitPerCycle}

+
+
+ +
+ +
+ + +
+
+
+ ); +} diff --git a/src/app/(user)/subscriptions/push/push-data.ts b/src/app/(user)/subscriptions/push/push-data.ts new file mode 100644 index 0000000..5c1b443 --- /dev/null +++ b/src/app/(user)/subscriptions/push/push-data.ts @@ -0,0 +1,85 @@ +import type { Prisma } from "@prisma/client"; +import { prisma } from "@/lib/prisma"; +import { getAppConfig } from "@/services/app-config"; +import { processExpiredSubscriptionTransfers } from "@/services/subscription-transfer"; + +const pushSubscriptionInclude = { + plan: true, + nodeClient: { + include: { + inbound: { + include: { + server: true, + }, + }, + }, + }, + streamingSlot: { + include: { + service: true, + }, + }, +} satisfies Prisma.UserSubscriptionInclude; + +const transferInclude = { + plan: true, + subscription: { + include: pushSubscriptionInclude, + }, + sender: { select: { id: true, email: true, name: true } }, + recipient: { select: { id: true, email: true, name: true } }, +} satisfies Prisma.SubscriptionTransferInclude; + +export type PushSubscriptionOption = Prisma.UserSubscriptionGetPayload<{ + include: typeof pushSubscriptionInclude; +}>; + +export type UserSubscriptionTransferRow = Prisma.SubscriptionTransferGetPayload<{ + include: typeof transferInclude; +}>; + +export async function getSubscriptionPushPageData(userId: string) { + await processExpiredSubscriptionTransfers(); + + const now = new Date(); + const [config, subscriptions, transfers, wallet] = await Promise.all([ + getAppConfig(), + prisma.userSubscription.findMany({ + where: { + userId, + status: "ACTIVE", + endDate: { gt: now }, + }, + include: pushSubscriptionInclude, + orderBy: { endDate: "asc" }, + }), + prisma.subscriptionTransfer.findMany({ + where: { + OR: [ + { senderId: userId }, + { recipientId: userId }, + ], + }, + include: transferInclude, + orderBy: { createdAt: "desc" }, + take: 80, + }), + prisma.walletAccount.findUnique({ + where: { userId }, + select: { balance: true }, + }), + ]); + + return { + config: { + enabled: config.subscriptionTransferEnabled, + feeAmount: Number(config.subscriptionTransferFee), + limitPerCycle: config.subscriptionTransferLimitPerCycle, + minRemainingDays: config.subscriptionTransferMinRemainingDays, + minRemainingTrafficGb: config.subscriptionTransferMinRemainingTrafficGb, + }, + subscriptions, + transfers, + walletBalance: Number(wallet?.balance ?? 0), + }; +} diff --git a/src/app/(user)/subscriptions/subscription-actions.tsx b/src/app/(user)/subscriptions/subscription-actions.tsx index 15d8223..042dad5 100644 --- a/src/app/(user)/subscriptions/subscription-actions.tsx +++ b/src/app/(user)/subscriptions/subscription-actions.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { ArrowUpRight } from "lucide-react"; +import { ArrowRightLeft, ArrowUpRight } from "lucide-react"; import { buttonVariants } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { RenewalButton } from "./_components/renewal-button"; @@ -53,6 +53,13 @@ export function SubscriptionActions({ 详情 + + Push + + {type === "PROXY" && } {allowRenewal && } {allowTrafficTopup && ( diff --git a/src/app/(user)/wallet/page.tsx b/src/app/(user)/wallet/page.tsx index bd1ab5d..09df4bf 100644 --- a/src/app/(user)/wallet/page.tsx +++ b/src/app/(user)/wallet/page.tsx @@ -6,6 +6,7 @@ import { PageHeader, PageShell, SectionHeader } from "@/components/shared/page-s import { DataTableShell } from "@/components/shared/data-table-shell"; import { StatusBadge } from "@/components/shared/status-badge"; import { getPaymentProviderName } from "@/services/payment/catalog"; +import { getWalletTransactionTypeLabel } from "@/lib/domain-labels"; import { WalletActions } from "./_components/wallet-actions"; import { WalletBalanceCard } from "./_components/wallet-balance-card"; import { WalletRechargeActions } from "./_components/wallet-recharge-actions"; @@ -16,14 +17,6 @@ export const metadata: Metadata = { description: "管理账户余额、余额充值和充值卡兑换。", }; -const transactionLabels: Record = { - BALANCE_RECHARGE: "余额充值", - BALANCE_PAYMENT: "余额支付", - CARD_REDEEM: "充值卡兑换", - ADMIN_ADJUST: "后台调整", - REFUND: "退款", -}; - const orderStatusLabels: Record = { PENDING: "待支付", PAID: "已入账", @@ -71,7 +64,7 @@ export default async function WalletPage() { {transactions.map((item) => ( - {transactionLabels[item.type] ?? item.type} + {getWalletTransactionTypeLabel(item.type)}

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

{item.rechargeCard &&

{item.rechargeCard.code}

} diff --git a/src/components/admin/sidebar.tsx b/src/components/admin/sidebar.tsx index c373211..6879861 100644 --- a/src/components/admin/sidebar.tsx +++ b/src/components/admin/sidebar.tsx @@ -18,6 +18,7 @@ import { ListChecks, DatabaseBackup, MessagesSquare, + ArrowRightLeft, } from "lucide-react"; import { Sidebar, type SidebarGroup, type SidebarLink } from "@/components/shared/sidebar"; import { PRODUCT_NAME } from "@/lib/product"; @@ -31,6 +32,7 @@ export const adminLinks: SidebarLink[] = [ { href: "/admin/users", label: "用户管理", icon: }, { href: "/admin/orders", label: "订单管理", icon: }, { href: "/admin/subscriptions", label: "订阅管理", icon: }, + { href: "/admin/subscription-transfers", label: "套餐 Push", icon: }, { href: "/admin/subscription-risk", label: "订阅风控", icon: }, { href: "/admin/payments", label: "支付配置", icon: }, { href: "/admin/traffic", label: "流量监控", icon: }, @@ -49,21 +51,21 @@ export const adminNavGroups: SidebarGroup[] = [ }, { label: "商品与订单", - links: [adminLinks[1], adminLinks[2], adminLinks[3], adminLinks[6], adminLinks[7], adminLinks[8], adminLinks[9]], + links: [adminLinks[1], adminLinks[2], adminLinks[3], adminLinks[6], adminLinks[7], adminLinks[8], adminLinks[9], adminLinks[10]], }, { label: "基础设施", - links: [adminLinks[4], adminLinks[10], adminLinks[11], adminLinks[12]], + links: [adminLinks[4], adminLinks[11], adminLinks[12], adminLinks[13]], defaultCollapsed: true, }, { label: "用户支持", - links: [adminLinks[5], adminLinks[13], adminLinks[14]], + links: [adminLinks[5], adminLinks[14], adminLinks[15]], defaultCollapsed: true, }, { label: "系统", - links: [adminLinks[15], adminLinks[16]], + links: [adminLinks[16], adminLinks[17]], defaultCollapsed: true, }, ]; diff --git a/src/components/user/sidebar.tsx b/src/components/user/sidebar.tsx index 20fb602..4b7888f 100644 --- a/src/components/user/sidebar.tsx +++ b/src/components/user/sidebar.tsx @@ -9,6 +9,7 @@ import { UserCircle2, MessageSquareWarning, WalletCards, + ArrowRightLeft, } from "lucide-react"; import { Sidebar, type SidebarGroup, type SidebarLink } from "@/components/shared/sidebar"; import { PRODUCT_NAME } from "@/lib/product"; @@ -19,6 +20,7 @@ export const userLinks: SidebarLink[] = [ { href: "/store", label: "套餐商店", icon: }, { href: "/cart", label: "购物车", icon: }, { href: "/subscriptions", label: "我的订阅", icon: }, + { href: "/subscriptions/push", label: "套餐 Push", icon: }, { href: "/wallet", label: "我的钱包", icon: }, { href: "/orders", label: "我的订单", icon: }, { href: "/support", label: "工单售后", icon: }, @@ -28,15 +30,15 @@ export const userLinks: SidebarLink[] = [ export const userNavGroups: SidebarGroup[] = [ { label: "开始", - links: userLinks.slice(0, 4), + links: userLinks.slice(0, 5), }, { label: "记录", - links: userLinks.slice(4, 6), + links: userLinks.slice(5, 7), }, { label: "支持", - links: userLinks.slice(6), + links: userLinks.slice(7), }, ]; diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 463b764..b8916ee 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -1,10 +1,12 @@ export async function register() { if (process.env.NEXT_RUNTIME === "nodejs") { - const [{ startTrafficSyncScheduler }, { startLogCleanupScheduler }] = await Promise.all([ + const [{ startTrafficSyncScheduler }, { startLogCleanupScheduler }, { startSubscriptionTransferScheduler }] = await Promise.all([ import("./services/traffic-sync-scheduler"), import("./services/log-cleanup-scheduler"), + import("./services/subscription-transfer-scheduler"), ]); startTrafficSyncScheduler(); startLogCleanupScheduler(); + startSubscriptionTransferScheduler(); } } diff --git a/src/lib/domain-labels.ts b/src/lib/domain-labels.ts index 62fe53d..355effc 100644 --- a/src/lib/domain-labels.ts +++ b/src/lib/domain-labels.ts @@ -5,11 +5,14 @@ import type { OrderReviewStatus, OrderStatus, Role, + SubscriptionTransferFeePayer, + SubscriptionTransferStatus, SubscriptionStatus, SubscriptionType, TaskKind, TaskStatus, UserStatus, + WalletTransactionType, } from "@prisma/client"; export const orderStatusLabels: Record = { @@ -93,6 +96,7 @@ export const booleanAppSettingLabels = { subscriptionRiskAutoSuspend: "风控自动暂停", nodeAccessRiskEnabled: "节点日志风控", inviteRewardEnabled: "自动发放奖励", + subscriptionTransferEnabled: "套餐 Push", smtpEnabled: "邮件服务", smtpSecure: "SMTP SSL 直连", } as const; @@ -123,6 +127,29 @@ export const paymentChannelLabels: Record = { wxpay: "微信支付", }; +export const walletTransactionTypeLabels: Record = { + BALANCE_RECHARGE: "余额充值", + BALANCE_PAYMENT: "余额支付", + CARD_REDEEM: "充值卡兑换", + ADMIN_ADJUST: "后台调整", + REFUND: "退款", + SUBSCRIPTION_TRANSFER_FEE: "套餐 Push 手续费", + SUBSCRIPTION_TRANSFER_REFUND: "套餐 Push 退款", +}; + +export const subscriptionTransferStatusLabels: Record = { + PENDING: "待接收", + ACCEPTED: "已接收", + REJECTED: "已拒收", + CANCELLED: "已取消", + EXPIRED: "已过期", +}; + +export const subscriptionTransferFeePayerLabels: Record = { + SENDER: "转出方支付", + RECIPIENT: "接收方支付", +}; + function labelFromMap(map: Partial>, value: string | null | undefined, fallback: string) { if (!value) return fallback; return map[value] ?? fallback; @@ -167,3 +194,15 @@ export function getPaymentProviderLabel(provider: string | null | undefined) { export function getBooleanAppSettingLabel(field: string | null | undefined) { return labelFromMap(booleanAppSettingLabels, field, "系统开关"); } + +export function getWalletTransactionTypeLabel(type: string | null | undefined) { + return labelFromMap(walletTransactionTypeLabels, type, "余额变动"); +} + +export function getSubscriptionTransferStatusLabel(status: string | null | undefined) { + return labelFromMap(subscriptionTransferStatusLabels, status, "未知转让状态"); +} + +export function getSubscriptionTransferFeePayerLabel(feePayer: string | null | undefined) { + return labelFromMap(subscriptionTransferFeePayerLabels, feePayer, "费用承担方"); +} diff --git a/src/services/node-panel/three-x-ui.ts b/src/services/node-panel/three-x-ui.ts index 9d457ee..8339c6d 100644 --- a/src/services/node-panel/three-x-ui.ts +++ b/src/services/node-panel/three-x-ui.ts @@ -237,7 +237,7 @@ export class ThreeXUIAdapter implements NodePanelAdapter { client.enable = enable; await this.jsonRequest(`/panel/api/inbounds/updateClient/${encodeURIComponent(this.getClientPrimaryKey(inbound.protocol, client))}`, { method: "POST", - body: JSON.stringify({ id: inboundId, settings: JSON.stringify(settings) }), + body: JSON.stringify({ id: inboundId, settings: JSON.stringify({ clients: [client] }) }), }); } diff --git a/src/services/subscription-transfer-scheduler.ts b/src/services/subscription-transfer-scheduler.ts new file mode 100644 index 0000000..07d4902 --- /dev/null +++ b/src/services/subscription-transfer-scheduler.ts @@ -0,0 +1,68 @@ +import { processExpiredSubscriptionTransfers } from "@/services/subscription-transfer"; + +const DEFAULT_INTERVAL_SECONDS = 5 * 60; +const globalForTransferScheduler = globalThis as typeof globalThis & { + __jboardSubscriptionTransferScheduler?: SubscriptionTransferSchedulerState; +}; + +type Timer = ReturnType; + +interface SubscriptionTransferSchedulerState { + started: boolean; + running: boolean; + timer: Timer | null; +} + +function getState() { + if (!globalForTransferScheduler.__jboardSubscriptionTransferScheduler) { + globalForTransferScheduler.__jboardSubscriptionTransferScheduler = { + started: false, + running: false, + timer: null, + }; + } + return globalForTransferScheduler.__jboardSubscriptionTransferScheduler; +} + +function unrefTimer(timer: Timer) { + if (typeof timer === "object" && timer && "unref" in timer && typeof timer.unref === "function") { + timer.unref(); + } +} + +function scheduleNext(state: SubscriptionTransferSchedulerState) { + state.timer = setTimeout(() => { + void runTransferExpirationCycle(state); + }, DEFAULT_INTERVAL_SECONDS * 1000); + unrefTimer(state.timer); +} + +async function runTransferExpirationCycle(state: SubscriptionTransferSchedulerState) { + try { + if (!state.running) { + state.running = true; + try { + await processExpiredSubscriptionTransfers(); + } finally { + state.running = false; + } + } + } catch (error) { + console.error("J-Board subscription transfer scheduler failed", error); + } finally { + scheduleNext(state); + } +} + +export function startSubscriptionTransferScheduler() { + if (process.env.JBOARD_SUBSCRIPTION_TRANSFER_SCHEDULER === "false") return; + + const state = getState(); + if (state.started) return; + + state.started = true; + state.timer = setTimeout(() => { + void runTransferExpirationCycle(state); + }, 15 * 1000); + unrefTimer(state.timer); +} diff --git a/src/services/subscription-transfer.ts b/src/services/subscription-transfer.ts new file mode 100644 index 0000000..420ef12 --- /dev/null +++ b/src/services/subscription-transfer.ts @@ -0,0 +1,698 @@ +import { randomUUID } from "crypto"; +import bcrypt from "bcryptjs"; +import { addHours } from "date-fns"; +import { Prisma } from "@prisma/client"; +import { prisma, type DbClient } from "@/lib/prisma"; +import { bytesToGb, gbToBytes } from "@/lib/utils"; +import { createNotification } from "@/services/notifications"; +import { createPanelAdapter } from "@/services/node-panel/factory"; +import { generateNodeClientCredential } from "@/services/node-client-credential"; +import { getAppConfig } from "@/services/app-config"; +import { recordAuditLog, type AuditActor } from "@/services/audit"; +import { creditWallet, debitWallet } from "@/services/wallet"; + +export const SUBSCRIPTION_TRANSFER_EXPIRE_HOURS = 24; + +type TransferStatus = "PENDING" | "ACCEPTED" | "REJECTED" | "CANCELLED" | "EXPIRED"; +type TransferFeePayer = "SENDER" | "RECIPIENT"; + +const transferSubscriptionInclude = { + plan: true, + user: true, + nodeClient: { + include: { + inbound: { + include: { + server: true, + }, + }, + }, + }, + streamingSlot: true, +} satisfies Prisma.UserSubscriptionInclude; + +const transferInclude = { + subscription: { + include: transferSubscriptionInclude, + }, + plan: true, + sender: { select: { id: true, email: true, name: true, role: true } }, + recipient: { select: { id: true, email: true, name: true, role: true } }, +} satisfies Prisma.SubscriptionTransferInclude; + +export type SubscriptionTransferWithDetail = Prisma.SubscriptionTransferGetPayload<{ + include: typeof transferInclude; +}>; + +export const subscriptionTransferStatusLabels: Record = { + PENDING: "待接收", + ACCEPTED: "已接收", + REJECTED: "已拒收", + CANCELLED: "已取消", + EXPIRED: "已过期", +}; + +export const subscriptionTransferFeePayerLabels: Record = { + SENDER: "转出方支付", + RECIPIENT: "接收方支付", +}; + +function normalizeEmail(value: string) { + return value.trim().toLowerCase(); +} + +function money(value: number | string | Prisma.Decimal) { + return new Prisma.Decimal(value).toFixed(2); +} + +function newDownloadToken() { + return randomUUID().replace(/-/g, ""); +} + +async function getCurrentCycleStartedAt(db: DbClient, subscriptionId: string, fallback: Date) { + const latestRenewal = await db.order.findFirst({ + where: { + targetSubscriptionId: subscriptionId, + kind: "RENEWAL", + status: "PAID", + }, + select: { createdAt: true }, + orderBy: { createdAt: "desc" }, + }); + + return latestRenewal?.createdAt ?? fallback; +} + +async function getTransferConfig(db: DbClient) { + const config = await getAppConfig(db); + return { + enabled: config.subscriptionTransferEnabled, + feeAmount: new Prisma.Decimal(config.subscriptionTransferFee), + limitPerCycle: Math.max(0, config.subscriptionTransferLimitPerCycle), + minRemainingDays: Math.max(0, config.subscriptionTransferMinRemainingDays), + minRemainingTrafficGb: Math.max(0, config.subscriptionTransferMinRemainingTrafficGb), + }; +} + +function remainingDays(endDate: Date, now = new Date()) { + return Math.max(0, Math.ceil((endDate.getTime() - now.getTime()) / (24 * 60 * 60 * 1000))); +} + +function assertTransferEligibility( + subscription: Prisma.UserSubscriptionGetPayload<{ include: typeof transferSubscriptionInclude }>, + config: Awaited>, + now = new Date(), +) { + const daysLeft = remainingDays(subscription.endDate, now); + if (config.minRemainingDays > 0 && daysLeft < config.minRemainingDays) { + throw new Error(`套餐剩余有效期不足 ${config.minRemainingDays} 天,不能 Push`); + } + + if ( + subscription.plan.type === "PROXY" + && config.minRemainingTrafficGb > 0 + && subscription.trafficLimit != null + ) { + const remainingBytes = subscription.trafficLimit > subscription.trafficUsed + ? subscription.trafficLimit - subscription.trafficUsed + : BigInt(0); + if (remainingBytes < gbToBytes(config.minRemainingTrafficGb)) { + throw new Error(`套餐剩余流量不足 ${config.minRemainingTrafficGb} GB,不能 Push`); + } + } +} + +async function setProxyClientEnabled(subscription: { + nodeClient: { + uuid: string; + email: string; + inbound: { + panelInboundId: number | null; + protocol: string; + server: Parameters[0]; + }; + } | null; +}, enable: boolean) { + if (!subscription.nodeClient) return; + const panelInboundId = subscription.nodeClient.inbound.panelInboundId; + if (panelInboundId == null) { + throw new Error("3x-ui 入站 ID 缺失,请先同步节点入站"); + } + + const adapter = createPanelAdapter(subscription.nodeClient.inbound.server); + await adapter.login(); + await adapter.updateClientEnable(panelInboundId, subscription.nodeClient.uuid, enable); +} + +async function rotateProxyClientForRecipient(subscription: Prisma.UserSubscriptionGetPayload<{ + include: typeof transferSubscriptionInclude; +}>, recipient: { id: string; email: string }) { + if (!subscription.nodeClient) return null; + + const panelInboundId = subscription.nodeClient.inbound.panelInboundId; + if (panelInboundId == null) { + throw new Error("3x-ui 入站 ID 缺失,请先同步节点入站"); + } + + const nextCredential = generateNodeClientCredential( + subscription.nodeClient.inbound.protocol, + subscription.nodeClient.inbound.settings, + ); + const nextEmail = `${recipient.email}-${subscription.id.slice(0, 8)}`; + const adapter = createPanelAdapter(subscription.nodeClient.inbound.server); + + await adapter.login(); + await adapter.deleteClient(panelInboundId, subscription.nodeClient.uuid); + await adapter.addClient({ + inboundId: panelInboundId, + email: nextEmail, + uuid: nextCredential, + subId: subscription.id, + totalGB: subscription.trafficLimit ? bytesToGb(subscription.trafficLimit) : 0, + expiryTime: subscription.endDate.getTime(), + protocol: subscription.nodeClient.inbound.protocol, + }); + + return { email: nextEmail, uuid: nextCredential }; +} + +async function refundTransferFeeIfNeeded( + db: DbClient, + transfer: Pick, +) { + if (!transfer.feeChargedToId || transfer.feeRefundedAt || Number(transfer.feeAmount) <= 0) return false; + + await creditWallet(db, { + userId: transfer.feeChargedToId, + amount: transfer.feeAmount, + type: "SUBSCRIPTION_TRANSFER_REFUND", + description: "套餐 Push 退回手续费", + metadata: { transferId: transfer.id }, + }); + return true; +} + +async function restorePendingTransfer( + db: DbClient, + transferId: string, + status: Exclude, + now = new Date(), +) { + const transfer = await db.subscriptionTransfer.findUnique({ + where: { id: transferId }, + include: transferInclude, + }); + if (!transfer || transfer.status !== "PENDING") return null; + + const refunded = await refundTransferFeeIfNeeded(db, transfer); + const statusTimeField = + status === "REJECTED" + ? { rejectedAt: now } + : status === "CANCELLED" + ? { cancelledAt: now } + : { expiredAt: now }; + + const restored = await db.subscriptionTransfer.update({ + where: { id: transfer.id }, + data: { + status, + feeRefundedAt: refunded ? now : transfer.feeRefundedAt, + ...statusTimeField, + }, + include: transferInclude, + }); + + await db.userSubscription.update({ + where: { id: transfer.subscriptionId }, + data: { status: "ACTIVE" }, + }); + if (transfer.subscription.nodeClient) { + await db.nodeClient.update({ + where: { id: transfer.subscription.nodeClient.id }, + data: { isEnabled: true }, + }); + } + + return restored; +} + +export async function createSubscriptionTransfer(input: { + senderId: string; + recipientEmail: string; + subscriptionId: string; + password: string; + feePayer: TransferFeePayer; + actor?: AuditActor; +}) { + await processExpiredSubscriptionTransfers(); + + const recipientEmail = normalizeEmail(input.recipientEmail); + const now = new Date(); + + const [sender, recipient, subscription, config] = await Promise.all([ + prisma.user.findUnique({ where: { id: input.senderId } }), + prisma.user.findUnique({ where: { email: recipientEmail } }), + prisma.userSubscription.findFirst({ + where: { id: input.subscriptionId, userId: input.senderId }, + include: transferSubscriptionInclude, + }), + getTransferConfig(prisma), + ]); + + if (!config.enabled) throw new Error("套餐 Push 当前未开放"); + if (config.limitPerCycle <= 0) throw new Error("套餐 Push 次数上限为 0,当前不能转让"); + if (!sender || sender.status !== "ACTIVE") throw new Error("当前账户不可操作"); + if (!await bcrypt.compare(input.password, sender.password)) { + throw new Error("账户密码不正确"); + } + if (!recipient || recipient.status !== "ACTIVE") { + throw new Error("接收用户不存在或不可用"); + } + if (recipient.id === sender.id) { + throw new Error("不能 Push 给自己"); + } + if (!subscription || subscription.status !== "ACTIVE" || subscription.endDate <= now) { + throw new Error("只能 Push 当前活跃且未到期的套餐"); + } + assertTransferEligibility(subscription, config, now); + + const cycleStartedAt = await getCurrentCycleStartedAt(prisma, subscription.id, subscription.startDate); + const [acceptedCount, activePending] = await Promise.all([ + prisma.subscriptionTransfer.count({ + where: { + subscriptionId: subscription.id, + status: "ACCEPTED", + cycleStartedAt, + }, + }), + prisma.subscriptionTransfer.findFirst({ + where: { + subscriptionId: subscription.id, + status: "PENDING", + }, + select: { id: true }, + }), + ]); + + if (activePending) throw new Error("这个套餐已经有待接收的 Push"); + if (acceptedCount >= config.limitPerCycle) { + throw new Error(`本周期最多可 Push ${config.limitPerCycle} 次,请续费后再转让,或提交工单申请人工处理`); + } + + await setProxyClientEnabled(subscription, false); + + try { + const transfer = await prisma.$transaction(async (tx) => { + const claimed = await tx.userSubscription.updateMany({ + where: { + id: subscription.id, + userId: sender.id, + status: "ACTIVE", + }, + data: { status: "SUSPENDED" }, + }); + if (claimed.count === 0) throw new Error("套餐状态已变化,请刷新后重试"); + + if (subscription.nodeClient) { + await tx.nodeClient.update({ + where: { id: subscription.nodeClient.id }, + data: { isEnabled: false }, + }); + } + + let feeChargedToId: string | null = null; + let feeChargedAt: Date | null = null; + if (config.feeAmount.gt(0) && input.feePayer === "SENDER") { + await debitWallet(tx, { + userId: sender.id, + amount: config.feeAmount, + type: "SUBSCRIPTION_TRANSFER_FEE", + description: "套餐 Push 手续费", + metadata: { subscriptionId: subscription.id, recipientEmail }, + }); + feeChargedToId = sender.id; + feeChargedAt = now; + } + + const created = await tx.subscriptionTransfer.create({ + data: { + subscriptionId: subscription.id, + planId: subscription.planId, + senderId: sender.id, + recipientId: recipient.id, + senderEmail: sender.email, + recipientEmail: recipient.email, + feeAmount: config.feeAmount, + feePayer: input.feePayer, + feeChargedToId, + feeChargedAt, + cycleStartedAt, + expiresAt: addHours(now, SUBSCRIPTION_TRANSFER_EXPIRE_HOURS), + }, + include: transferInclude, + }); + + await createNotification({ + userId: recipient.id, + type: "SUBSCRIPTION", + level: "INFO", + title: "有套餐等待接收", + body: `${sender.email} 向你 Push 了 ${subscription.plan.name},请在 24 小时内确认。`, + link: "/subscriptions/push", + }, tx); + await createNotification({ + userId: sender.id, + type: "SUBSCRIPTION", + level: "SUCCESS", + title: "套餐 Push 已发起", + body: `${subscription.plan.name} 已暂停,等待 ${recipient.email} 接收。`, + link: "/subscriptions/push", + }, tx); + + return created; + }); + + await recordAuditLog({ + actor: input.actor, + action: "subscription_transfer.create", + targetType: "SubscriptionTransfer", + targetId: transfer.id, + targetLabel: subscription.plan.name, + message: `发起套餐 Push:${sender.email} -> ${recipient.email}`, + metadata: { subscriptionId: subscription.id, feePayer: input.feePayer, feeAmount: money(config.feeAmount) }, + }); + + return transfer; + } catch (error) { + await setProxyClientEnabled(subscription, true).catch(() => undefined); + throw error; + } +} + +export async function acceptSubscriptionTransfer(input: { + transferId: string; + recipientId: string; + actor?: AuditActor; +}) { + await processExpiredSubscriptionTransfers(); + + const transfer = await prisma.subscriptionTransfer.findFirst({ + where: { id: input.transferId, recipientId: input.recipientId }, + include: transferInclude, + }); + const now = new Date(); + + if (!transfer) throw new Error("套餐 Push 不存在"); + if (transfer.status !== "PENDING") throw new Error("这条套餐 Push 已处理"); + if (transfer.expiresAt <= now) { + await expireSubscriptionTransfer(transfer.id, now); + throw new Error("这条套餐 Push 已过期"); + } + if (Number(transfer.feeAmount) > 0 && transfer.feePayer === "RECIPIENT") { + const wallet = await prisma.walletAccount.findUnique({ + where: { userId: transfer.recipientId }, + select: { balance: true }, + }); + if (!wallet || new Prisma.Decimal(wallet.balance).lt(transfer.feeAmount)) { + throw new Error(`余额不足,接收此套餐需要支付 ¥${money(transfer.feeAmount)} 手续费`); + } + } + + let nextProxyClient: { email: string; uuid: string } | null = null; + if (transfer.subscription.plan.type === "PROXY" && transfer.subscription.nodeClient) { + nextProxyClient = await rotateProxyClientForRecipient(transfer.subscription, transfer.recipient); + } + + const nextToken = newDownloadToken(); + const accepted = await prisma.$transaction(async (tx) => { + let feeChargedToId = transfer.feeChargedToId; + let feeChargedAt = transfer.feeChargedAt; + + if (Number(transfer.feeAmount) > 0 && transfer.feePayer === "RECIPIENT") { + await debitWallet(tx, { + userId: transfer.recipientId, + amount: transfer.feeAmount, + type: "SUBSCRIPTION_TRANSFER_FEE", + description: "套餐 Push 接收手续费", + metadata: { transferId: transfer.id, subscriptionId: transfer.subscriptionId }, + }); + feeChargedToId = transfer.recipientId; + feeChargedAt = now; + } + + await tx.userSubscription.update({ + where: { id: transfer.subscriptionId }, + data: { + userId: transfer.recipientId, + status: "ACTIVE", + downloadToken: nextToken, + }, + }); + + if (transfer.subscription.nodeClient) { + await tx.nodeClient.update({ + where: { id: transfer.subscription.nodeClient.id }, + data: { + userId: transfer.recipientId, + isEnabled: true, + ...(nextProxyClient ?? {}), + }, + }); + } + if (transfer.subscription.streamingSlot) { + await tx.streamingSlot.update({ + where: { id: transfer.subscription.streamingSlot.id }, + data: { userId: transfer.recipientId }, + }); + } + + const updated = await tx.subscriptionTransfer.update({ + where: { id: transfer.id }, + data: { + status: "ACCEPTED", + acceptedAt: now, + feeChargedToId, + feeChargedAt, + }, + include: transferInclude, + }); + + await createNotification({ + userId: transfer.senderId, + type: "SUBSCRIPTION", + level: "SUCCESS", + title: "套餐 Push 已接收", + body: `${transfer.recipient.email} 已接收 ${transfer.plan.name}。`, + link: "/subscriptions/push", + }, tx); + await createNotification({ + userId: transfer.recipientId, + type: "SUBSCRIPTION", + level: "SUCCESS", + title: "套餐已接收", + body: `${transfer.plan.name} 已转入你的账户。`, + link: "/subscriptions", + }, tx); + + return updated; + }); + + await recordAuditLog({ + actor: input.actor, + action: "subscription_transfer.accept", + targetType: "SubscriptionTransfer", + targetId: transfer.id, + targetLabel: transfer.plan.name, + message: `接收套餐 Push:${transfer.sender.email} -> ${transfer.recipient.email}`, + metadata: { subscriptionId: transfer.subscriptionId }, + }); + + return accepted; +} + +export async function rejectSubscriptionTransfer(input: { + transferId: string; + recipientId: string; + actor?: AuditActor; +}) { + await processExpiredSubscriptionTransfers(); + const pending = await prisma.subscriptionTransfer.findFirst({ + where: { id: input.transferId, recipientId: input.recipientId, status: "PENDING" }, + select: { id: true }, + }); + if (!pending) { + throw new Error("套餐 Push 不存在或已处理"); + } + const restored = await prisma.$transaction((tx) => restorePendingTransfer(tx, input.transferId, "REJECTED")); + if (!restored) { + throw new Error("套餐 Push 不存在或已处理"); + } + + await setProxyClientEnabled(restored.subscription, true).catch(() => undefined); + await createNotification({ + userId: restored.senderId, + type: "SUBSCRIPTION", + level: "WARNING", + title: "套餐 Push 已被拒收", + body: `${restored.recipient.email} 拒收了 ${restored.plan.name},套餐已恢复。`, + link: "/subscriptions/push", + }); + await recordAuditLog({ + actor: input.actor, + action: "subscription_transfer.reject", + targetType: "SubscriptionTransfer", + targetId: restored.id, + targetLabel: restored.plan.name, + message: `拒收套餐 Push:${restored.sender.email} -> ${restored.recipient.email}`, + }); + + return restored; +} + +export async function cancelSubscriptionTransfer(input: { + transferId: string; + senderId: string; + actor?: AuditActor; +}) { + await processExpiredSubscriptionTransfers(); + const pending = await prisma.subscriptionTransfer.findFirst({ + where: { id: input.transferId, senderId: input.senderId, status: "PENDING" }, + select: { id: true }, + }); + if (!pending) { + throw new Error("套餐 Push 不存在或已处理"); + } + const restored = await prisma.$transaction((tx) => restorePendingTransfer(tx, input.transferId, "CANCELLED")); + if (!restored) { + throw new Error("套餐 Push 不存在或已处理"); + } + + await setProxyClientEnabled(restored.subscription, true).catch(() => undefined); + await createNotification({ + userId: restored.recipientId, + type: "SUBSCRIPTION", + level: "INFO", + title: "套餐 Push 已取消", + body: `${restored.sender.email} 取消了 ${restored.plan.name} 的 Push。`, + link: "/subscriptions/push", + }); + await recordAuditLog({ + actor: input.actor, + action: "subscription_transfer.cancel", + targetType: "SubscriptionTransfer", + targetId: restored.id, + targetLabel: restored.plan.name, + message: `取消套餐 Push:${restored.sender.email} -> ${restored.recipient.email}`, + }); + + return restored; +} + +export async function deleteSubscriptionTransferAsAdmin(input: { + transferId: string; + actor?: AuditActor; +}) { + await processExpiredSubscriptionTransfers(); + + const transfer = await prisma.subscriptionTransfer.findUnique({ + where: { id: input.transferId }, + include: transferInclude, + }); + if (!transfer) { + throw new Error("套餐 Push 记录不存在或已被删除"); + } + + let restored: SubscriptionTransferWithDetail | null = null; + if (transfer.status === "PENDING") { + restored = await prisma.$transaction(async (tx) => { + const cancelled = await restorePendingTransfer(tx, transfer.id, "CANCELLED"); + if (!cancelled) throw new Error("套餐 Push 状态已变化,请刷新后重试"); + await tx.subscriptionTransfer.delete({ where: { id: transfer.id } }); + return cancelled; + }); + + await setProxyClientEnabled(restored.subscription, true).catch(() => undefined); + await createNotification({ + userId: restored.senderId, + type: "SUBSCRIPTION", + level: "INFO", + title: "套餐 Push 已由管理员删除", + body: `${restored.plan.name} 的 Push 记录已删除,套餐已恢复。`, + link: "/subscriptions", + }); + await createNotification({ + userId: restored.recipientId, + type: "SUBSCRIPTION", + level: "INFO", + title: "套餐 Push 已取消", + body: `${restored.plan.name} 的 Push 已由管理员取消。`, + link: "/subscriptions/push", + }); + } else { + await prisma.subscriptionTransfer.delete({ where: { id: transfer.id } }); + } + + await recordAuditLog({ + actor: input.actor, + action: "subscription_transfer.delete", + targetType: "SubscriptionTransfer", + targetId: transfer.id, + targetLabel: transfer.plan.name, + message: `删除套餐 Push 记录:${transfer.sender.email} -> ${transfer.recipient.email}`, + metadata: { + subscriptionId: transfer.subscriptionId, + status: transfer.status, + feeAmount: money(transfer.feeAmount), + feePayer: transfer.feePayer, + restoredSubscription: Boolean(restored), + }, + }); + + return { restoredSubscription: Boolean(restored) }; +} + +export async function expireSubscriptionTransfer(transferId: string, now = new Date()) { + const restored = await prisma.$transaction((tx) => restorePendingTransfer(tx, transferId, "EXPIRED", now)); + if (!restored) return null; + + await setProxyClientEnabled(restored.subscription, true).catch(() => undefined); + await createNotification({ + userId: restored.senderId, + type: "SUBSCRIPTION", + level: "WARNING", + title: "套餐 Push 已过期", + body: `${restored.plan.name} 超过 24 小时未接收,套餐已恢复。`, + link: "/subscriptions/push", + }); + await createNotification({ + userId: restored.recipientId, + type: "SUBSCRIPTION", + level: "INFO", + title: "套餐 Push 已过期", + body: `${restored.plan.name} 的接收时间已过。`, + link: "/subscriptions/push", + }); + + return restored; +} + +export async function processExpiredSubscriptionTransfers(limit = 30) { + const now = new Date(); + const expired = await prisma.subscriptionTransfer.findMany({ + where: { + status: "PENDING", + expiresAt: { lte: now }, + }, + select: { id: true }, + orderBy: { expiresAt: "asc" }, + take: limit, + }); + + for (const transfer of expired) { + await expireSubscriptionTransfer(transfer.id, now).catch((error) => { + console.error("J-Board subscription transfer expiration failed", error); + }); + } + + return expired.length; +} diff --git a/src/services/wallet.ts b/src/services/wallet.ts index f2fbfeb..dca3972 100644 --- a/src/services/wallet.ts +++ b/src/services/wallet.ts @@ -52,7 +52,7 @@ export async function creditWallet( input: { userId: string; amount: number | string | Prisma.Decimal; - type: "BALANCE_RECHARGE" | "CARD_REDEEM" | "ADMIN_ADJUST" | "REFUND"; + type: "BALANCE_RECHARGE" | "CARD_REDEEM" | "ADMIN_ADJUST" | "REFUND" | "SUBSCRIPTION_TRANSFER_REFUND"; description?: string; orderId?: string | null; rechargeOrderId?: string | null; @@ -86,13 +86,15 @@ export async function creditWallet( return wallet; } -export async function debitWalletForOrder( +export async function debitWallet( db: DbClient, input: { userId: string; - orderId: string; amount: number | string | Prisma.Decimal; + type: "BALANCE_PAYMENT" | "SUBSCRIPTION_TRANSFER_FEE"; description?: string; + orderId?: string | null; + metadata?: Prisma.InputJsonValue; }, ) { const amount = toMoneyDecimal(input.amount); @@ -107,7 +109,7 @@ export async function debitWalletForOrder( }); if (claimed.count === 0) { - throw new Error("余额不足,请先充值后再支付"); + throw new Error("余额不足,请先充值后再操作"); } const wallet = await db.walletAccount.findUniqueOrThrow({ @@ -118,17 +120,37 @@ export async function debitWalletForOrder( data: { walletId: wallet.id, userId: input.userId, - type: "BALANCE_PAYMENT", + type: input.type, amount: amount.negated(), balanceAfter: wallet.balance, - description: input.description ?? "余额支付订单", - orderId: input.orderId, + description: input.description ?? "余额扣费", + orderId: input.orderId ?? 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); + return debitWallet(db, { + userId: input.userId, + orderId: input.orderId, + amount, + type: "BALANCE_PAYMENT", + description: input.description ?? "余额支付订单", + }); +} + type PlanCardSnapshot = { type: "PROXY" | "STREAMING"; nodeId: string | null;