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

@@ -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,

View File

@@ -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:
</div>
</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")}>
<div className={sectionHeadingClassName}>
<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 },
};
}