feat: add subscription push transfers

This commit is contained in:
JetSprow
2026-05-01 04:39:15 +10:00
parent b2a50514a4
commit e718d5edab
23 changed files with 2049 additions and 27 deletions

View File

@@ -94,6 +94,21 @@ enum WalletTransactionType {
CARD_REDEEM CARD_REDEEM
ADMIN_ADJUST ADMIN_ADJUST
REFUND REFUND
SUBSCRIPTION_TRANSFER_FEE
SUBSCRIPTION_TRANSFER_REFUND
}
enum SubscriptionTransferStatus {
PENDING
ACCEPTED
REJECTED
CANCELLED
EXPIRED
}
enum SubscriptionTransferFeePayer {
SENDER
RECIPIENT
} }
enum Protocol { enum Protocol {
@@ -221,6 +236,8 @@ model User {
walletRechargeOrders WalletRechargeOrder[] walletRechargeOrders WalletRechargeOrder[]
redeemedRechargeCards RechargeCard[] @relation("RechargeCardRedeemer") redeemedRechargeCards RechargeCard[] @relation("RechargeCardRedeemer")
createdRechargeCards RechargeCard[] @relation("RechargeCardCreator") createdRechargeCards RechargeCard[] @relation("RechargeCardCreator")
sentSubscriptionTransfers SubscriptionTransfer[] @relation("SubscriptionTransferSender")
receivedSubscriptionTransfers SubscriptionTransfer[] @relation("SubscriptionTransferRecipient")
} }
model EmailToken { model EmailToken {
@@ -327,6 +344,7 @@ model SubscriptionPlan {
cartItems ShoppingCartItem[] cartItems ShoppingCartItem[]
orderItems OrderItem[] orderItems OrderItem[]
rechargeCards RechargeCard[] rechargeCards RechargeCard[]
subscriptionTransfers SubscriptionTransfer[]
@@index([type, isActive, isFeatured, sortOrder]) @@index([type, isActive, isFeatured, sortOrder])
@@index([inboundId]) @@index([inboundId])
@@ -353,11 +371,48 @@ model UserSubscription {
nodeClient NodeClient? nodeClient NodeClient?
createOrder Order? @relation("OrderCreatedSubscription") createOrder Order? @relation("OrderCreatedSubscription")
targetOrders Order[] @relation("OrderTargetSubscription") targetOrders Order[] @relation("OrderTargetSubscription")
transfers SubscriptionTransfer[]
@@index([userId]) @@index([userId])
@@index([status]) @@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 { model SubscriptionAccessLog {
id String @id @default(cuid()) id String @id @default(cuid())
userId String? userId String?
@@ -930,6 +985,11 @@ model AppConfig {
inviteRewardCouponId String? inviteRewardCouponId String?
inviteRewardRate Decimal @default(0) inviteRewardRate Decimal @default(0)
inviteRewardEnabled Boolean @default(false) 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? turnstileSiteKey String?
turnstileSecretKey String? turnstileSecretKey String?
smtpEnabled Boolean @default(false) smtpEnabled Boolean @default(false)

View File

@@ -59,6 +59,11 @@ const settingsSchema = z.object({
inviteRewardEnabled: z.string().optional(), inviteRewardEnabled: z.string().optional(),
inviteRewardRate: z.coerce.number().min(0).max(100).optional(), inviteRewardRate: z.coerce.number().min(0).max(100).optional(),
inviteRewardCouponId: z.string().trim().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(), turnstileSiteKey: z.string().trim().optional(),
turnstileSecretKey: z.string().trim().optional(), turnstileSecretKey: z.string().trim().optional(),
smtpEnabled: z.string().optional(), smtpEnabled: z.string().optional(),
@@ -128,6 +133,7 @@ function booleanSettingData(field: BooleanSettingField, value: boolean) {
subscriptionRiskAutoSuspend: { subscriptionRiskAutoSuspend: value }, subscriptionRiskAutoSuspend: { subscriptionRiskAutoSuspend: value },
nodeAccessRiskEnabled: { nodeAccessRiskEnabled: value }, nodeAccessRiskEnabled: { nodeAccessRiskEnabled: value },
inviteRewardEnabled: { inviteRewardEnabled: value }, inviteRewardEnabled: { inviteRewardEnabled: value },
subscriptionTransferEnabled: { subscriptionTransferEnabled: value },
smtpEnabled: { smtpEnabled: value }, smtpEnabled: { smtpEnabled: value },
smtpSecure: { smtpSecure: value }, smtpSecure: { smtpSecure: value },
}[field]; }[field];
@@ -243,6 +249,18 @@ function buildSettingsUpdate(parsed: z.infer<typeof settingsSchema>, current: Aw
inviteRewardEnabled: optionalBoolean(parsed.inviteRewardEnabled, current.inviteRewardEnabled), inviteRewardEnabled: optionalBoolean(parsed.inviteRewardEnabled, current.inviteRewardEnabled),
inviteRewardRate: parsed.inviteRewardRate ?? Number(current.inviteRewardRate), inviteRewardRate: parsed.inviteRewardRate ?? Number(current.inviteRewardRate),
inviteRewardCouponId: parsed.inviteRewardCouponId || null, 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, turnstileSiteKey,
turnstileSecretKey, turnstileSecretKey,
smtpEnabled, smtpEnabled,
@@ -290,6 +308,7 @@ function revalidateSettingsViews() {
revalidatePath("/dashboard"); revalidatePath("/dashboard");
revalidatePath("/store"); revalidatePath("/store");
revalidatePath("/subscriptions"); revalidatePath("/subscriptions");
revalidatePath("/subscriptions/push");
revalidatePath("/admin/nodes"); revalidatePath("/admin/nodes");
revalidatePath("/account"); revalidatePath("/account");
revalidatePath("/support"); revalidatePath("/support");

View File

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

View File

@@ -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<UserTransferActionResult> {
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<UserTransferActionResult> {
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<UserTransferActionResult> {
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<UserTransferActionResult> {
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 失败") };
}
}

View File

@@ -62,6 +62,11 @@ export default async function AdminSettingsPage() {
inviteRewardEnabled: config.inviteRewardEnabled, inviteRewardEnabled: config.inviteRewardEnabled,
inviteRewardRate: Number(config.inviteRewardRate), inviteRewardRate: Number(config.inviteRewardRate),
inviteRewardCouponId: config.inviteRewardCouponId, inviteRewardCouponId: config.inviteRewardCouponId,
subscriptionTransferEnabled: config.subscriptionTransferEnabled,
subscriptionTransferFee: Number(config.subscriptionTransferFee),
subscriptionTransferLimitPerCycle: config.subscriptionTransferLimitPerCycle,
subscriptionTransferMinRemainingDays: config.subscriptionTransferMinRemainingDays,
subscriptionTransferMinRemainingTrafficGb: config.subscriptionTransferMinRemainingTrafficGb,
turnstileSiteKey: config.turnstileSiteKey, turnstileSiteKey: config.turnstileSiteKey,
turnstileSecretConfigured: Boolean(config.turnstileSecretKey), turnstileSecretConfigured: Boolean(config.turnstileSecretKey),
smtpEnabled: config.smtpEnabled, smtpEnabled: config.smtpEnabled,

View File

@@ -2,7 +2,7 @@
import { useState, type FormEvent, type ReactNode } from "react"; import { useState, type FormEvent, type ReactNode } from "react";
import { useRouter } from "next/navigation"; 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 { cleanupExpiredAdminLogs } from "@/actions/admin/logs";
import { ConfirmActionButton } from "@/components/shared/confirm-action-button"; import { ConfirmActionButton } from "@/components/shared/confirm-action-button";
import { BooleanToggle } from "@/components/ui/boolean-toggle"; import { BooleanToggle } from "@/components/ui/boolean-toggle";
@@ -72,6 +72,11 @@ interface AppConfig {
inviteRewardEnabled: boolean; inviteRewardEnabled: boolean;
inviteRewardRate: number; inviteRewardRate: number;
inviteRewardCouponId: string | null; inviteRewardCouponId: string | null;
subscriptionTransferEnabled: boolean;
subscriptionTransferFee: number;
subscriptionTransferLimitPerCycle: number;
subscriptionTransferMinRemainingDays: number;
subscriptionTransferMinRemainingTrafficGb: number;
turnstileSiteKey: string | null; turnstileSiteKey: string | null;
turnstileSecretConfigured: boolean; turnstileSecretConfigured: boolean;
smtpEnabled: boolean; smtpEnabled: boolean;
@@ -102,6 +107,7 @@ type SettingsSectionValue =
| "auth" | "auth"
| "email" | "email"
| "invite" | "invite"
| "transfer"
| "turnstile" | "turnstile"
| "notices"; | "notices";
@@ -115,6 +121,7 @@ const settingsNavItems = [
{ value: "auth", label: "注册" }, { value: "auth", label: "注册" },
{ value: "email", label: "邮件" }, { value: "email", label: "邮件" },
{ value: "invite", label: "邀请" }, { value: "invite", label: "邀请" },
{ value: "transfer", label: "转让" },
{ value: "turnstile", label: "验证" }, { value: "turnstile", label: "验证" },
{ value: "notices", label: "公告" }, { value: "notices", label: "公告" },
] satisfies Array<{ value: SettingsSectionValue; label: string }>; ] satisfies Array<{ value: SettingsSectionValue; label: string }>;
@@ -168,6 +175,7 @@ function initialToggleValues(config: AppConfig): ToggleValues {
subscriptionRiskAutoSuspend: config.subscriptionRiskAutoSuspend, subscriptionRiskAutoSuspend: config.subscriptionRiskAutoSuspend,
nodeAccessRiskEnabled: config.nodeAccessRiskEnabled, nodeAccessRiskEnabled: config.nodeAccessRiskEnabled,
inviteRewardEnabled: config.inviteRewardEnabled, inviteRewardEnabled: config.inviteRewardEnabled,
subscriptionTransferEnabled: config.subscriptionTransferEnabled,
smtpEnabled: config.smtpEnabled, smtpEnabled: config.smtpEnabled,
smtpSecure: config.smtpSecure, smtpSecure: config.smtpSecure,
}; };
@@ -835,6 +843,80 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
</div> </div>
</section> </section>
<section id="settings-transfer" className={sectionClass("transfer")}>
<div className={sectionHeadingClassName}>
<ArrowRightLeft className="size-4 text-primary" /> Push
<InlineHelp align="start"></InlineHelp>
</div>
<div className="grid gap-5 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="subscriptionTransferEnabled"> Push</Label>
{renderImmediateToggle("subscriptionTransferEnabled", {
id: "subscriptionTransferEnabled",
trueLabel: "允许",
falseLabel: "关闭",
ariaLabel: "允许套餐 Push",
})}
</div>
<div className="space-y-2">
<LabelWithHelp htmlFor="subscriptionTransferFee" help="固定手续费,可填 0。">
</LabelWithHelp>
<Input
id="subscriptionTransferFee"
name="subscriptionTransferFee"
type="number"
min={0}
max={100000}
step="0.01"
defaultValue={config.subscriptionTransferFee}
/>
</div>
<div className="space-y-2">
<LabelWithHelp htmlFor="subscriptionTransferLimitPerCycle" help="同一订阅周期内最多成功转让次数0 表示禁止。">
</LabelWithHelp>
<Input
id="subscriptionTransferLimitPerCycle"
name="subscriptionTransferLimitPerCycle"
type="number"
min={0}
max={100}
step={1}
defaultValue={config.subscriptionTransferLimitPerCycle}
/>
</div>
<div className="space-y-2">
<LabelWithHelp htmlFor="subscriptionTransferMinRemainingDays" help="低于这个剩余天数时不能转让0 表示不限制。">
</LabelWithHelp>
<Input
id="subscriptionTransferMinRemainingDays"
name="subscriptionTransferMinRemainingDays"
type="number"
min={0}
max={3650}
step={1}
defaultValue={config.subscriptionTransferMinRemainingDays}
/>
</div>
<div className="space-y-2">
<LabelWithHelp htmlFor="subscriptionTransferMinRemainingTrafficGb" help="代理套餐剩余流量低于此值时不能转让0 表示不限制。">
GB
</LabelWithHelp>
<Input
id="subscriptionTransferMinRemainingTrafficGb"
name="subscriptionTransferMinRemainingTrafficGb"
type="number"
min={0}
max={1000000}
step={1}
defaultValue={config.subscriptionTransferMinRemainingTrafficGb}
/>
</div>
</div>
</section>
<section id="settings-turnstile" className={sectionClass("turnstile")}> <section id="settings-turnstile" className={sectionClass("turnstile")}>
<div className={sectionHeadingClassName}> <div className={sectionHeadingClassName}>
<ShieldAlert className="size-4 text-primary" /> Cloudflare Turnstile <ShieldAlert className="size-4 text-primary" /> Cloudflare Turnstile

View File

@@ -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 (
<div className="rounded-lg border border-border/60 bg-muted/25 px-3 py-2">
<p className="text-[11px] font-medium text-muted-foreground">{label}</p>
<p className="mt-1 break-words text-sm font-medium leading-5">{value}</p>
</div>
);
}
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 (
<Dialog>
<DialogTrigger render={<Button type="button" variant="outline" size="sm" />}>
<Eye className="size-3.5" />
</DialogTrigger>
<DialogContent className="flex max-h-[min(90dvh,34rem)] flex-col overflow-hidden p-0 sm:max-w-[40rem]">
<DialogHeader className="border-b border-border/60 px-4 py-3 pr-10">
<div className="flex flex-wrap items-center gap-2">
<DialogTitle>Push </DialogTitle>
<StatusBadge tone={item.statusTone}>{item.statusLabel}</StatusBadge>
</div>
<DialogDescription>{item.senderLabel} {"->"} {item.recipientLabel}</DialogDescription>
</DialogHeader>
<DialogBody className="flex-1 px-4 py-3">
<div className="grid gap-2 sm:grid-cols-2">
{details.map((detail) => (
<DetailItem key={detail.label} {...detail} />
))}
</div>
</DialogBody>
</DialogContent>
</Dialog>
);
}
export function SubscriptionTransfersTable({ transfers }: { transfers: AdminSubscriptionTransferItem[] }) {
const router = useRouter();
if (transfers.length === 0) {
return (
<div className="surface-card rounded-xl px-4 py-8 text-center text-sm text-muted-foreground">
Push
</div>
);
}
return (
<div className="surface-card divide-y divide-border/60 overflow-hidden rounded-xl">
{transfers.map((item) => {
const deletingPending = item.status === "PENDING";
return (
<article
key={item.id}
className="grid gap-4 px-4 py-4 lg:grid-cols-[minmax(0,1fr)_minmax(16rem,0.8fr)_auto] lg:items-center"
>
<div className="min-w-0">
<div className="flex min-h-6 flex-wrap items-center gap-2">
<h3 className="min-w-0 truncate font-semibold">{item.planName}</h3>
<StatusBadge tone={item.statusTone}>{item.statusLabel}</StatusBadge>
</div>
<p className="mt-1 text-sm text-muted-foreground">{item.senderLabel} {"->"} {item.recipientLabel}</p>
</div>
<div className="flex flex-wrap gap-2">
<StatusBadge>{item.feeLabel}</StatusBadge>
<StatusBadge>{item.feePayerLabel}</StatusBadge>
<StatusBadge>{item.createdAtLabel}</StatusBadge>
</div>
<div className="flex flex-wrap justify-start gap-2 lg:justify-end">
<TransferDetailDialog item={item} />
<ConfirmActionButton
title="删除这条 Push 记录?"
description={
deletingPending
? "这条 Push 仍在待接收状态。删除会取消本次 Push、恢复转出方套餐并退回已扣手续费。"
: "只删除管理端历史记录,不会回滚已完成的套餐转移。"
}
confirmLabel="删除记录"
successMessage={deletingPending ? "Push 已取消并删除" : "Push 记录已删除"}
errorMessage="删除 Push 记录失败"
variant="destructive"
size="sm"
onConfirm={async () => {
await deleteAdminSubscriptionTransfer(item.id);
}}
onSuccess={() => router.refresh()}
>
<Trash2 className="size-3.5" />
</ConfirmActionButton>
</div>
</article>
);
})}
</div>
);
}

View File

@@ -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<string, StatusTone> = {
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<Record<string, string | string[] | undefined>>;
}) {
const { transfers, total, page, pageSize, filters } = await getAdminSubscriptionTransfers(await searchParams);
return (
<PageShell>
<PageHeader
eyebrow="商品与订单"
title="套餐 Push"
/>
<AdminFilterBar
q={filters.q}
searchPlaceholder="搜索邮箱、昵称、套餐名"
selects={[
{
name: "status",
value: filters.status,
options: [
{ label: "全部状态", value: "" },
{ label: "待接收", value: "PENDING" },
{ label: "已接收", value: "ACCEPTED" },
{ label: "已拒收", value: "REJECTED" },
{ label: "已取消", value: "CANCELLED" },
{ label: "已过期", value: "EXPIRED" },
],
},
]}
/>
<SubscriptionTransfersTable transfers={transfers.map(toItem)} />
<Pagination total={total} pageSize={pageSize} page={page} />
</PageShell>
);
}

View File

@@ -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<string, string | string[] | undefined>,
) {
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 },
};
}

View File

@@ -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 (
<form
className="form-panel space-y-4"
onSubmit={(event) => {
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 失败"));
}
});
}}
>
<div className="flex items-center gap-3">
<span className="flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
<ArrowRightLeft className="size-4" />
</span>
<div>
<h3 className="font-semibold"> Push</h3>
<p className="text-sm text-muted-foreground">访</p>
</div>
</div>
{!config.enabled && (
<div className="rounded-lg border border-destructive/15 bg-destructive/10 px-3 py-2 text-sm font-medium text-destructive">
Push
</div>
)}
<div className="space-y-2">
<Label htmlFor="push-subscription"></Label>
<Select value={subscriptionId} onValueChange={(value) => setSubscriptionId(value ?? "")}>
<SelectTrigger id="push-subscription" className="w-full">
<SelectValue placeholder="选择要 Push 的套餐">
{(value) => {
const subscription = subscriptions.find((item) => item.id === value);
return subscription ? subscriptionLabel(subscription) : "选择要 Push 的套餐";
}}
</SelectValue>
</SelectTrigger>
<SelectContent align="start">
{subscriptions.map((subscription) => (
<SelectItem key={subscription.id} value={subscription.id}>
{subscriptionLabel(subscription)}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedSubscription && (
<p className="text-xs text-muted-foreground">{remainingTrafficLabel(selectedSubscription)}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="recipientEmail"></Label>
<Input id="recipientEmail" name="recipientEmail" type="email" placeholder="user@example.com" required />
</div>
<div className="space-y-2">
<Label htmlFor="transferPassword"></Label>
<Input id="transferPassword" name="password" type="password" autoComplete="current-password" required />
</div>
<div className="space-y-2">
<Label htmlFor="transfer-fee-payer"></Label>
<BooleanToggle
id="transfer-fee-payer"
value={recipientPays}
onChange={setRecipientPays}
trueLabel="接收方支付"
falseLabel="我来支付"
ariaLabel="转让费承担方"
/>
<p className="text-xs text-muted-foreground">
{config.feeAmount > 0 ? `固定 ${money(config.feeAmount)}` : "当前免费"}
</p>
</div>
<div className="rounded-lg border border-border/60 bg-muted/25 px-3 py-2 text-xs leading-5 text-muted-foreground">
{config.limitPerCycle} {config.minRemainingDays} {config.minRemainingTrafficGb} GB Push
</div>
<Button type="submit" className="w-full" disabled={pending || !config.enabled || subscriptions.length === 0}>
{pending ? "发起中..." : subscriptions.length === 0 ? "暂无可 Push 套餐" : "发起 Push"}
</Button>
</form>
);
}

View File

@@ -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 (
<div className="rounded-lg border border-border/60 bg-muted/25 px-3 py-2">
<p className="text-[11px] font-medium text-muted-foreground">{label}</p>
<p className="mt-1 break-words text-sm font-medium leading-5">{value}</p>
</div>
);
}
function AcceptTransferDialog({ transfer }: { transfer: SubscriptionTransferItem }) {
const router = useRouter();
const [pending, startTransition] = useTransition();
return (
<Dialog>
<DialogTrigger render={<Button type="button" size="sm" />}>
<Eye className="size-3.5" />
</DialogTrigger>
<DialogContent className="flex max-h-[min(90dvh,34rem)] flex-col overflow-hidden p-0 sm:max-w-[36rem]">
<DialogHeader className="border-b border-border/60 px-4 py-3 pr-10">
<div className="flex flex-wrap items-center gap-2">
<DialogTitle></DialogTitle>
<StatusBadge tone={transfer.statusTone}>{transfer.statusLabel}</StatusBadge>
</div>
<DialogDescription>访</DialogDescription>
</DialogHeader>
<DialogBody className="flex-1 px-4 py-3">
<div className="grid gap-2 sm:grid-cols-2">
<DetailRow label="套餐" value={`${transfer.planName} · ${transfer.planTypeLabel}`} />
<DetailRow label="到期时间" value={transfer.endDateLabel} />
<DetailRow label="转出方" value={transfer.senderLabel} />
<DetailRow label="接收方" value={transfer.recipientLabel} />
<DetailRow label="费用" value={transfer.feeLabel} />
<DetailRow label="承担方" value={transfer.feePayerLabel} />
<DetailRow label="剩余流量" value={transfer.trafficLabel} />
<DetailRow label="确认截止" value={transfer.expiresAtLabel} />
</div>
</DialogBody>
<DialogFooter>
<Button
type="button"
disabled={pending}
onClick={() => {
startTransition(async () => {
try {
const result = await acceptUserSubscriptionTransfer(transfer.id);
if (!result.ok) {
throw new Error(result.error);
}
router.refresh();
toast.success("套餐已接收");
} catch (error) {
toast.error(getErrorMessage(error, "接收失败"));
}
});
}}
>
<CheckCircle2 className="size-4" />
{pending ? "接收中..." : "确认接收"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function SubscriptionPushList({ transfers }: { transfers: SubscriptionTransferItem[] }) {
const router = useRouter();
if (transfers.length === 0) {
return (
<div className="surface-card rounded-xl px-4 py-8 text-center text-sm text-muted-foreground">
Push
</div>
);
}
return (
<div className="surface-card divide-y divide-border/60 overflow-hidden rounded-xl">
{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 (
<article key={transfer.id} className="grid gap-4 px-4 py-4 lg:grid-cols-[minmax(0,1fr)_minmax(14rem,0.7fr)_auto] lg:items-center">
<div className="min-w-0">
<div className="flex min-h-6 flex-wrap items-center gap-2">
<h3 className="min-w-0 truncate font-semibold">{transfer.planName}</h3>
<StatusBadge tone={transfer.statusTone}>{transfer.statusLabel}</StatusBadge>
</div>
<p className="mt-1 text-sm text-muted-foreground">
{transfer.senderLabel} {"->"} {transfer.recipientLabel}
</p>
</div>
<div className="flex flex-wrap gap-2">
<StatusBadge>{transfer.feeLabel}</StatusBadge>
<StatusBadge>{transfer.feePayerLabel}</StatusBadge>
<StatusBadge>{transfer.expiresAtLabel}</StatusBadge>
</div>
<div className="flex flex-wrap justify-start gap-2 lg:justify-end">
{canAccept && <AcceptTransferDialog transfer={transfer} />}
{canReject && (
<ConfirmActionButton
size="sm"
variant="outline"
title="拒收这次 Push"
description="拒收后套餐会恢复给转出方。"
confirmLabel="拒收"
successMessage="已拒收"
errorMessage="拒收失败"
onConfirm={async () => {
const result = await rejectUserSubscriptionTransfer(transfer.id);
if (!result.ok) throw new Error(result.error);
}}
onSuccess={() => router.refresh()}
>
<XCircle className="size-3.5" />
</ConfirmActionButton>
)}
{canCancel && (
<ConfirmActionButton
size="sm"
variant="outline"
title="取消这次 Push"
description="取消后套餐会恢复可用,已支付的转让费会退回余额。"
confirmLabel="取消 Push"
successMessage="Push 已取消"
errorMessage="取消失败"
onConfirm={async () => {
const result = await cancelUserSubscriptionTransfer(transfer.id);
if (!result.ok) throw new Error(result.error);
}}
onSuccess={() => router.refresh()}
>
<RotateCcw className="size-3.5" />
</ConfirmActionButton>
)}
</div>
</article>
);
})}
</div>
);
}

View File

@@ -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<string, StatusTone> = {
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<Record<string, string | string[] | undefined>>;
}) {
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 (
<PageShell>
<PageHeader
eyebrow="订阅管理"
title="套餐 Push"
description="将未到期套餐转给其他用户,对方需在 24 小时内确认。"
/>
<section className="grid gap-4 sm:grid-cols-3">
<div className="surface-card rounded-xl p-4">
<p className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<WalletCards className="size-4 text-primary" />
</p>
<p className="mt-2 text-2xl font-semibold tabular-nums">{money(data.walletBalance)}</p>
</div>
<div className="surface-card rounded-xl p-4">
<p className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<ArrowRightLeft className="size-4 text-primary" />
</p>
<p className="mt-2 text-2xl font-semibold tabular-nums">{money(data.config.feeAmount)}</p>
</div>
<div className="surface-card rounded-xl p-4">
<p className="text-sm text-muted-foreground"></p>
<p className="mt-2 text-2xl font-semibold tabular-nums">{data.config.limitPerCycle}</p>
</div>
</section>
<section className="grid gap-5 xl:grid-cols-[minmax(22rem,0.75fr)_1fr]">
<SubscriptionPushForm
subscriptions={data.subscriptions}
config={data.config}
initialSubscriptionId={initialSubscriptionId}
/>
<div className="space-y-4">
<SectionHeader title="Push 记录" />
<SubscriptionPushList transfers={transferItems} />
</div>
</section>
</PageShell>
);
}

View File

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

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { ArrowUpRight } from "lucide-react"; import { ArrowRightLeft, ArrowUpRight } from "lucide-react";
import { buttonVariants } from "@/components/ui/button"; import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { RenewalButton } from "./_components/renewal-button"; import { RenewalButton } from "./_components/renewal-button";
@@ -53,6 +53,13 @@ export function SubscriptionActions({
<ArrowUpRight className="size-3.5" /> <ArrowUpRight className="size-3.5" />
</Link> </Link>
<Link
href={`/subscriptions/push?subscriptionId=${encodeURIComponent(subscriptionId)}`}
className={cn(buttonVariants({ size: "sm", variant: "outline" }), "flex-1 sm:flex-none")}
>
Push
<ArrowRightLeft className="size-3.5" />
</Link>
{type === "PROXY" && <ResetAccessButton subscriptionId={subscriptionId} />} {type === "PROXY" && <ResetAccessButton subscriptionId={subscriptionId} />}
{allowRenewal && <RenewalButton subscriptionId={subscriptionId} config={renewalConfig} />} {allowRenewal && <RenewalButton subscriptionId={subscriptionId} config={renewalConfig} />}
{allowTrafficTopup && ( {allowTrafficTopup && (

View File

@@ -6,6 +6,7 @@ import { PageHeader, PageShell, SectionHeader } from "@/components/shared/page-s
import { DataTableShell } from "@/components/shared/data-table-shell"; import { DataTableShell } from "@/components/shared/data-table-shell";
import { StatusBadge } from "@/components/shared/status-badge"; import { StatusBadge } from "@/components/shared/status-badge";
import { getPaymentProviderName } from "@/services/payment/catalog"; import { getPaymentProviderName } from "@/services/payment/catalog";
import { getWalletTransactionTypeLabel } from "@/lib/domain-labels";
import { WalletActions } from "./_components/wallet-actions"; import { WalletActions } from "./_components/wallet-actions";
import { WalletBalanceCard } from "./_components/wallet-balance-card"; import { WalletBalanceCard } from "./_components/wallet-balance-card";
import { WalletRechargeActions } from "./_components/wallet-recharge-actions"; import { WalletRechargeActions } from "./_components/wallet-recharge-actions";
@@ -16,14 +17,6 @@ export const metadata: Metadata = {
description: "管理账户余额、余额充值和充值卡兑换。", description: "管理账户余额、余额充值和充值卡兑换。",
}; };
const transactionLabels: Record<string, string> = {
BALANCE_RECHARGE: "余额充值",
BALANCE_PAYMENT: "余额支付",
CARD_REDEEM: "充值卡兑换",
ADMIN_ADJUST: "后台调整",
REFUND: "退款",
};
const orderStatusLabels: Record<string, string> = { const orderStatusLabels: Record<string, string> = {
PENDING: "待支付", PENDING: "待支付",
PAID: "已入账", PAID: "已入账",
@@ -71,7 +64,7 @@ export default async function WalletPage() {
<tbody className="divide-y divide-border/50"> <tbody className="divide-y divide-border/50">
{transactions.map((item) => ( {transactions.map((item) => (
<tr key={item.id}> <tr key={item.id}>
<td className="px-4 py-3"><StatusBadge>{transactionLabels[item.type] ?? item.type}</StatusBadge></td> <td className="px-4 py-3"><StatusBadge>{getWalletTransactionTypeLabel(item.type)}</StatusBadge></td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<p className="font-medium">{item.description || item.order?.plan.name || item.rechargeCard?.code || "余额变动"}</p> <p className="font-medium">{item.description || item.order?.plan.name || item.rechargeCard?.code || "余额变动"}</p>
{item.rechargeCard && <p className="mt-1 font-mono text-xs text-muted-foreground">{item.rechargeCard.code}</p>} {item.rechargeCard && <p className="mt-1 font-mono text-xs text-muted-foreground">{item.rechargeCard.code}</p>}

View File

@@ -18,6 +18,7 @@ import {
ListChecks, ListChecks,
DatabaseBackup, DatabaseBackup,
MessagesSquare, MessagesSquare,
ArrowRightLeft,
} from "lucide-react"; } from "lucide-react";
import { Sidebar, type SidebarGroup, type SidebarLink } from "@/components/shared/sidebar"; import { Sidebar, type SidebarGroup, type SidebarLink } from "@/components/shared/sidebar";
import { PRODUCT_NAME } from "@/lib/product"; import { PRODUCT_NAME } from "@/lib/product";
@@ -31,6 +32,7 @@ export const adminLinks: SidebarLink[] = [
{ href: "/admin/users", label: "用户管理", icon: <Users size={16} /> }, { href: "/admin/users", label: "用户管理", icon: <Users size={16} /> },
{ href: "/admin/orders", label: "订单管理", icon: <ClipboardList size={16} /> }, { href: "/admin/orders", label: "订单管理", icon: <ClipboardList size={16} /> },
{ href: "/admin/subscriptions", label: "订阅管理", icon: <Waypoints size={16} /> }, { href: "/admin/subscriptions", label: "订阅管理", icon: <Waypoints size={16} /> },
{ href: "/admin/subscription-transfers", label: "套餐 Push", icon: <ArrowRightLeft size={16} /> },
{ href: "/admin/subscription-risk", label: "订阅风控", icon: <ShieldAlert size={16} /> }, { href: "/admin/subscription-risk", label: "订阅风控", icon: <ShieldAlert size={16} /> },
{ href: "/admin/payments", label: "支付配置", icon: <CreditCard size={16} /> }, { href: "/admin/payments", label: "支付配置", icon: <CreditCard size={16} /> },
{ href: "/admin/traffic", label: "流量监控", icon: <Activity size={16} /> }, { href: "/admin/traffic", label: "流量监控", icon: <Activity size={16} /> },
@@ -49,21 +51,21 @@ export const adminNavGroups: SidebarGroup[] = [
}, },
{ {
label: "商品与订单", 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: "基础设施", label: "基础设施",
links: [adminLinks[4], adminLinks[10], adminLinks[11], adminLinks[12]], links: [adminLinks[4], adminLinks[11], adminLinks[12], adminLinks[13]],
defaultCollapsed: true, defaultCollapsed: true,
}, },
{ {
label: "用户支持", label: "用户支持",
links: [adminLinks[5], adminLinks[13], adminLinks[14]], links: [adminLinks[5], adminLinks[14], adminLinks[15]],
defaultCollapsed: true, defaultCollapsed: true,
}, },
{ {
label: "系统", label: "系统",
links: [adminLinks[15], adminLinks[16]], links: [adminLinks[16], adminLinks[17]],
defaultCollapsed: true, defaultCollapsed: true,
}, },
]; ];

View File

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

View File

@@ -1,10 +1,12 @@
export async function register() { export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") { 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/traffic-sync-scheduler"),
import("./services/log-cleanup-scheduler"), import("./services/log-cleanup-scheduler"),
import("./services/subscription-transfer-scheduler"),
]); ]);
startTrafficSyncScheduler(); startTrafficSyncScheduler();
startLogCleanupScheduler(); startLogCleanupScheduler();
startSubscriptionTransferScheduler();
} }
} }

View File

@@ -5,11 +5,14 @@ import type {
OrderReviewStatus, OrderReviewStatus,
OrderStatus, OrderStatus,
Role, Role,
SubscriptionTransferFeePayer,
SubscriptionTransferStatus,
SubscriptionStatus, SubscriptionStatus,
SubscriptionType, SubscriptionType,
TaskKind, TaskKind,
TaskStatus, TaskStatus,
UserStatus, UserStatus,
WalletTransactionType,
} from "@prisma/client"; } from "@prisma/client";
export const orderStatusLabels: Record<OrderStatus, string> = { export const orderStatusLabels: Record<OrderStatus, string> = {
@@ -93,6 +96,7 @@ export const booleanAppSettingLabels = {
subscriptionRiskAutoSuspend: "风控自动暂停", subscriptionRiskAutoSuspend: "风控自动暂停",
nodeAccessRiskEnabled: "节点日志风控", nodeAccessRiskEnabled: "节点日志风控",
inviteRewardEnabled: "自动发放奖励", inviteRewardEnabled: "自动发放奖励",
subscriptionTransferEnabled: "套餐 Push",
smtpEnabled: "邮件服务", smtpEnabled: "邮件服务",
smtpSecure: "SMTP SSL 直连", smtpSecure: "SMTP SSL 直连",
} as const; } as const;
@@ -123,6 +127,29 @@ export const paymentChannelLabels: Record<string, string> = {
wxpay: "微信支付", wxpay: "微信支付",
}; };
export const walletTransactionTypeLabels: Record<WalletTransactionType, string> = {
BALANCE_RECHARGE: "余额充值",
BALANCE_PAYMENT: "余额支付",
CARD_REDEEM: "充值卡兑换",
ADMIN_ADJUST: "后台调整",
REFUND: "退款",
SUBSCRIPTION_TRANSFER_FEE: "套餐 Push 手续费",
SUBSCRIPTION_TRANSFER_REFUND: "套餐 Push 退款",
};
export const subscriptionTransferStatusLabels: Record<SubscriptionTransferStatus, string> = {
PENDING: "待接收",
ACCEPTED: "已接收",
REJECTED: "已拒收",
CANCELLED: "已取消",
EXPIRED: "已过期",
};
export const subscriptionTransferFeePayerLabels: Record<SubscriptionTransferFeePayer, string> = {
SENDER: "转出方支付",
RECIPIENT: "接收方支付",
};
function labelFromMap(map: Partial<Record<string, string>>, value: string | null | undefined, fallback: string) { function labelFromMap(map: Partial<Record<string, string>>, value: string | null | undefined, fallback: string) {
if (!value) return fallback; if (!value) return fallback;
return map[value] ?? fallback; return map[value] ?? fallback;
@@ -167,3 +194,15 @@ export function getPaymentProviderLabel(provider: string | null | undefined) {
export function getBooleanAppSettingLabel(field: string | null | undefined) { export function getBooleanAppSettingLabel(field: string | null | undefined) {
return labelFromMap(booleanAppSettingLabels, field, "系统开关"); 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, "费用承担方");
}

View File

@@ -237,7 +237,7 @@ export class ThreeXUIAdapter implements NodePanelAdapter {
client.enable = enable; client.enable = enable;
await this.jsonRequest(`/panel/api/inbounds/updateClient/${encodeURIComponent(this.getClientPrimaryKey(inbound.protocol, client))}`, { await this.jsonRequest(`/panel/api/inbounds/updateClient/${encodeURIComponent(this.getClientPrimaryKey(inbound.protocol, client))}`, {
method: "POST", method: "POST",
body: JSON.stringify({ id: inboundId, settings: JSON.stringify(settings) }), body: JSON.stringify({ id: inboundId, settings: JSON.stringify({ clients: [client] }) }),
}); });
} }

View File

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

View File

@@ -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<TransferStatus, string> = {
PENDING: "待接收",
ACCEPTED: "已接收",
REJECTED: "已拒收",
CANCELLED: "已取消",
EXPIRED: "已过期",
};
export const subscriptionTransferFeePayerLabels: Record<TransferFeePayer, string> = {
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<ReturnType<typeof getTransferConfig>>,
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<typeof createPanelAdapter>[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<SubscriptionTransferWithDetail, "id" | "feeAmount" | "feeChargedToId" | "feeRefundedAt">,
) {
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<TransferStatus, "PENDING" | "ACCEPTED">,
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;
}

View File

@@ -52,7 +52,7 @@ export async function creditWallet(
input: { input: {
userId: string; userId: string;
amount: number | string | Prisma.Decimal; 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; description?: string;
orderId?: string | null; orderId?: string | null;
rechargeOrderId?: string | null; rechargeOrderId?: string | null;
@@ -86,13 +86,15 @@ export async function creditWallet(
return wallet; return wallet;
} }
export async function debitWalletForOrder( export async function debitWallet(
db: DbClient, db: DbClient,
input: { input: {
userId: string; userId: string;
orderId: string;
amount: number | string | Prisma.Decimal; amount: number | string | Prisma.Decimal;
type: "BALANCE_PAYMENT" | "SUBSCRIPTION_TRANSFER_FEE";
description?: string; description?: string;
orderId?: string | null;
metadata?: Prisma.InputJsonValue;
}, },
) { ) {
const amount = toMoneyDecimal(input.amount); const amount = toMoneyDecimal(input.amount);
@@ -107,7 +109,7 @@ export async function debitWalletForOrder(
}); });
if (claimed.count === 0) { if (claimed.count === 0) {
throw new Error("余额不足,请先充值后再支付"); throw new Error("余额不足,请先充值后再操作");
} }
const wallet = await db.walletAccount.findUniqueOrThrow({ const wallet = await db.walletAccount.findUniqueOrThrow({
@@ -118,17 +120,37 @@ export async function debitWalletForOrder(
data: { data: {
walletId: wallet.id, walletId: wallet.id,
userId: input.userId, userId: input.userId,
type: "BALANCE_PAYMENT", type: input.type,
amount: amount.negated(), amount: amount.negated(),
balanceAfter: wallet.balance, balanceAfter: wallet.balance,
description: input.description ?? "余额支付订单", description: input.description ?? "余额扣费",
orderId: input.orderId, orderId: input.orderId ?? null,
metadata: input.metadata ?? undefined,
}, },
}); });
return wallet; 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 PlanCardSnapshot = {
type: "PROXY" | "STREAMING"; type: "PROXY" | "STREAMING";
nodeId: string | null; nodeId: string | null;