mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
feat: add subscription push transfers
This commit is contained in:
@@ -94,6 +94,21 @@ enum WalletTransactionType {
|
||||
CARD_REDEEM
|
||||
ADMIN_ADJUST
|
||||
REFUND
|
||||
SUBSCRIPTION_TRANSFER_FEE
|
||||
SUBSCRIPTION_TRANSFER_REFUND
|
||||
}
|
||||
|
||||
enum SubscriptionTransferStatus {
|
||||
PENDING
|
||||
ACCEPTED
|
||||
REJECTED
|
||||
CANCELLED
|
||||
EXPIRED
|
||||
}
|
||||
|
||||
enum SubscriptionTransferFeePayer {
|
||||
SENDER
|
||||
RECIPIENT
|
||||
}
|
||||
|
||||
enum Protocol {
|
||||
@@ -221,6 +236,8 @@ model User {
|
||||
walletRechargeOrders WalletRechargeOrder[]
|
||||
redeemedRechargeCards RechargeCard[] @relation("RechargeCardRedeemer")
|
||||
createdRechargeCards RechargeCard[] @relation("RechargeCardCreator")
|
||||
sentSubscriptionTransfers SubscriptionTransfer[] @relation("SubscriptionTransferSender")
|
||||
receivedSubscriptionTransfers SubscriptionTransfer[] @relation("SubscriptionTransferRecipient")
|
||||
}
|
||||
|
||||
model EmailToken {
|
||||
@@ -327,6 +344,7 @@ model SubscriptionPlan {
|
||||
cartItems ShoppingCartItem[]
|
||||
orderItems OrderItem[]
|
||||
rechargeCards RechargeCard[]
|
||||
subscriptionTransfers SubscriptionTransfer[]
|
||||
|
||||
@@index([type, isActive, isFeatured, sortOrder])
|
||||
@@index([inboundId])
|
||||
@@ -353,11 +371,48 @@ model UserSubscription {
|
||||
nodeClient NodeClient?
|
||||
createOrder Order? @relation("OrderCreatedSubscription")
|
||||
targetOrders Order[] @relation("OrderTargetSubscription")
|
||||
transfers SubscriptionTransfer[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
model SubscriptionTransfer {
|
||||
id String @id @default(cuid())
|
||||
subscriptionId String
|
||||
planId String
|
||||
senderId String
|
||||
recipientId String
|
||||
senderEmail String
|
||||
recipientEmail String
|
||||
status SubscriptionTransferStatus @default(PENDING)
|
||||
feeAmount Decimal @default(0)
|
||||
feePayer SubscriptionTransferFeePayer @default(SENDER)
|
||||
feeChargedToId String?
|
||||
feeChargedAt DateTime?
|
||||
feeRefundedAt DateTime?
|
||||
cycleStartedAt DateTime
|
||||
expiresAt DateTime
|
||||
acceptedAt DateTime?
|
||||
rejectedAt DateTime?
|
||||
cancelledAt DateTime?
|
||||
expiredAt DateTime?
|
||||
note String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
subscription UserSubscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
|
||||
plan SubscriptionPlan @relation(fields: [planId], references: [id], onDelete: Cascade)
|
||||
sender User @relation("SubscriptionTransferSender", fields: [senderId], references: [id], onDelete: Cascade)
|
||||
recipient User @relation("SubscriptionTransferRecipient", fields: [recipientId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([subscriptionId, status, createdAt])
|
||||
@@index([senderId, createdAt])
|
||||
@@index([recipientId, createdAt])
|
||||
@@index([status, expiresAt])
|
||||
@@index([planId, cycleStartedAt])
|
||||
}
|
||||
|
||||
model SubscriptionAccessLog {
|
||||
id String @id @default(cuid())
|
||||
userId String?
|
||||
@@ -930,6 +985,11 @@ model AppConfig {
|
||||
inviteRewardCouponId String?
|
||||
inviteRewardRate Decimal @default(0)
|
||||
inviteRewardEnabled Boolean @default(false)
|
||||
subscriptionTransferEnabled Boolean @default(true)
|
||||
subscriptionTransferFee Decimal @default(0)
|
||||
subscriptionTransferLimitPerCycle Int @default(1)
|
||||
subscriptionTransferMinRemainingDays Int @default(0)
|
||||
subscriptionTransferMinRemainingTrafficGb Int @default(0)
|
||||
turnstileSiteKey String?
|
||||
turnstileSecretKey String?
|
||||
smtpEnabled Boolean @default(false)
|
||||
|
||||
@@ -59,6 +59,11 @@ const settingsSchema = z.object({
|
||||
inviteRewardEnabled: z.string().optional(),
|
||||
inviteRewardRate: z.coerce.number().min(0).max(100).optional(),
|
||||
inviteRewardCouponId: z.string().trim().optional(),
|
||||
subscriptionTransferEnabled: z.string().optional(),
|
||||
subscriptionTransferFee: z.coerce.number().min(0).max(100000).optional(),
|
||||
subscriptionTransferLimitPerCycle: z.coerce.number().int().min(0).max(100).optional(),
|
||||
subscriptionTransferMinRemainingDays: z.coerce.number().int().min(0).max(3650).optional(),
|
||||
subscriptionTransferMinRemainingTrafficGb: z.coerce.number().int().min(0).max(1000000).optional(),
|
||||
turnstileSiteKey: z.string().trim().optional(),
|
||||
turnstileSecretKey: z.string().trim().optional(),
|
||||
smtpEnabled: z.string().optional(),
|
||||
@@ -128,6 +133,7 @@ function booleanSettingData(field: BooleanSettingField, value: boolean) {
|
||||
subscriptionRiskAutoSuspend: { subscriptionRiskAutoSuspend: value },
|
||||
nodeAccessRiskEnabled: { nodeAccessRiskEnabled: value },
|
||||
inviteRewardEnabled: { inviteRewardEnabled: value },
|
||||
subscriptionTransferEnabled: { subscriptionTransferEnabled: value },
|
||||
smtpEnabled: { smtpEnabled: value },
|
||||
smtpSecure: { smtpSecure: value },
|
||||
}[field];
|
||||
@@ -243,6 +249,18 @@ function buildSettingsUpdate(parsed: z.infer<typeof settingsSchema>, current: Aw
|
||||
inviteRewardEnabled: optionalBoolean(parsed.inviteRewardEnabled, current.inviteRewardEnabled),
|
||||
inviteRewardRate: parsed.inviteRewardRate ?? Number(current.inviteRewardRate),
|
||||
inviteRewardCouponId: parsed.inviteRewardCouponId || null,
|
||||
subscriptionTransferEnabled: optionalBoolean(
|
||||
parsed.subscriptionTransferEnabled,
|
||||
current.subscriptionTransferEnabled,
|
||||
),
|
||||
subscriptionTransferFee:
|
||||
parsed.subscriptionTransferFee ?? Number(current.subscriptionTransferFee),
|
||||
subscriptionTransferLimitPerCycle:
|
||||
parsed.subscriptionTransferLimitPerCycle ?? current.subscriptionTransferLimitPerCycle,
|
||||
subscriptionTransferMinRemainingDays:
|
||||
parsed.subscriptionTransferMinRemainingDays ?? current.subscriptionTransferMinRemainingDays,
|
||||
subscriptionTransferMinRemainingTrafficGb:
|
||||
parsed.subscriptionTransferMinRemainingTrafficGb ?? current.subscriptionTransferMinRemainingTrafficGb,
|
||||
turnstileSiteKey,
|
||||
turnstileSecretKey,
|
||||
smtpEnabled,
|
||||
@@ -290,6 +308,7 @@ function revalidateSettingsViews() {
|
||||
revalidatePath("/dashboard");
|
||||
revalidatePath("/store");
|
||||
revalidatePath("/subscriptions");
|
||||
revalidatePath("/subscriptions/push");
|
||||
revalidatePath("/admin/nodes");
|
||||
revalidatePath("/account");
|
||||
revalidatePath("/support");
|
||||
|
||||
25
src/actions/admin/subscription-transfers.ts
Normal file
25
src/actions/admin/subscription-transfers.ts
Normal 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;
|
||||
}
|
||||
97
src/actions/user/subscription-transfer.ts
Normal file
97
src/actions/user/subscription-transfer.ts
Normal 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 失败") };
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
113
src/app/(admin)/admin/subscription-transfers/page.tsx
Normal file
113
src/app/(admin)/admin/subscription-transfers/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
115
src/app/(user)/subscriptions/push/page.tsx
Normal file
115
src/app/(user)/subscriptions/push/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
src/app/(user)/subscriptions/push/push-data.ts
Normal file
85
src/app/(user)/subscriptions/push/push-data.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
import { ArrowRightLeft, ArrowUpRight } from "lucide-react";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RenewalButton } from "./_components/renewal-button";
|
||||
@@ -53,6 +53,13 @@ export function SubscriptionActions({
|
||||
详情
|
||||
<ArrowUpRight className="size-3.5" />
|
||||
</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} />}
|
||||
{allowRenewal && <RenewalButton subscriptionId={subscriptionId} config={renewalConfig} />}
|
||||
{allowTrafficTopup && (
|
||||
|
||||
@@ -6,6 +6,7 @@ import { PageHeader, PageShell, SectionHeader } from "@/components/shared/page-s
|
||||
import { DataTableShell } from "@/components/shared/data-table-shell";
|
||||
import { StatusBadge } from "@/components/shared/status-badge";
|
||||
import { getPaymentProviderName } from "@/services/payment/catalog";
|
||||
import { getWalletTransactionTypeLabel } from "@/lib/domain-labels";
|
||||
import { WalletActions } from "./_components/wallet-actions";
|
||||
import { WalletBalanceCard } from "./_components/wallet-balance-card";
|
||||
import { WalletRechargeActions } from "./_components/wallet-recharge-actions";
|
||||
@@ -16,14 +17,6 @@ export const metadata: Metadata = {
|
||||
description: "管理账户余额、余额充值和充值卡兑换。",
|
||||
};
|
||||
|
||||
const transactionLabels: Record<string, string> = {
|
||||
BALANCE_RECHARGE: "余额充值",
|
||||
BALANCE_PAYMENT: "余额支付",
|
||||
CARD_REDEEM: "充值卡兑换",
|
||||
ADMIN_ADJUST: "后台调整",
|
||||
REFUND: "退款",
|
||||
};
|
||||
|
||||
const orderStatusLabels: Record<string, string> = {
|
||||
PENDING: "待支付",
|
||||
PAID: "已入账",
|
||||
@@ -71,7 +64,7 @@ export default async function WalletPage() {
|
||||
<tbody className="divide-y divide-border/50">
|
||||
{transactions.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td className="px-4 py-3"><StatusBadge>{transactionLabels[item.type] ?? item.type}</StatusBadge></td>
|
||||
<td className="px-4 py-3"><StatusBadge>{getWalletTransactionTypeLabel(item.type)}</StatusBadge></td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-medium">{item.description || item.order?.plan.name || item.rechargeCard?.code || "余额变动"}</p>
|
||||
{item.rechargeCard && <p className="mt-1 font-mono text-xs text-muted-foreground">{item.rechargeCard.code}</p>}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
ListChecks,
|
||||
DatabaseBackup,
|
||||
MessagesSquare,
|
||||
ArrowRightLeft,
|
||||
} from "lucide-react";
|
||||
import { Sidebar, type SidebarGroup, type SidebarLink } from "@/components/shared/sidebar";
|
||||
import { PRODUCT_NAME } from "@/lib/product";
|
||||
@@ -31,6 +32,7 @@ export const adminLinks: SidebarLink[] = [
|
||||
{ href: "/admin/users", label: "用户管理", icon: <Users size={16} /> },
|
||||
{ href: "/admin/orders", label: "订单管理", icon: <ClipboardList 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/payments", label: "支付配置", icon: <CreditCard size={16} /> },
|
||||
{ href: "/admin/traffic", label: "流量监控", icon: <Activity size={16} /> },
|
||||
@@ -49,21 +51,21 @@ export const adminNavGroups: SidebarGroup[] = [
|
||||
},
|
||||
{
|
||||
label: "商品与订单",
|
||||
links: [adminLinks[1], adminLinks[2], adminLinks[3], adminLinks[6], adminLinks[7], adminLinks[8], adminLinks[9]],
|
||||
links: [adminLinks[1], adminLinks[2], adminLinks[3], adminLinks[6], adminLinks[7], adminLinks[8], adminLinks[9], adminLinks[10]],
|
||||
},
|
||||
{
|
||||
label: "基础设施",
|
||||
links: [adminLinks[4], adminLinks[10], adminLinks[11], adminLinks[12]],
|
||||
links: [adminLinks[4], adminLinks[11], adminLinks[12], adminLinks[13]],
|
||||
defaultCollapsed: true,
|
||||
},
|
||||
{
|
||||
label: "用户支持",
|
||||
links: [adminLinks[5], adminLinks[13], adminLinks[14]],
|
||||
links: [adminLinks[5], adminLinks[14], adminLinks[15]],
|
||||
defaultCollapsed: true,
|
||||
},
|
||||
{
|
||||
label: "系统",
|
||||
links: [adminLinks[15], adminLinks[16]],
|
||||
links: [adminLinks[16], adminLinks[17]],
|
||||
defaultCollapsed: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
UserCircle2,
|
||||
MessageSquareWarning,
|
||||
WalletCards,
|
||||
ArrowRightLeft,
|
||||
} from "lucide-react";
|
||||
import { Sidebar, type SidebarGroup, type SidebarLink } from "@/components/shared/sidebar";
|
||||
import { PRODUCT_NAME } from "@/lib/product";
|
||||
@@ -19,6 +20,7 @@ export const userLinks: SidebarLink[] = [
|
||||
{ href: "/store", label: "套餐商店", icon: <ShoppingBag size={16} /> },
|
||||
{ href: "/cart", label: "购物车", icon: <ShoppingCart 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: "/orders", label: "我的订单", icon: <ClipboardList size={16} /> },
|
||||
{ href: "/support", label: "工单售后", icon: <MessageSquareWarning size={16} /> },
|
||||
@@ -28,15 +30,15 @@ export const userLinks: SidebarLink[] = [
|
||||
export const userNavGroups: SidebarGroup[] = [
|
||||
{
|
||||
label: "开始",
|
||||
links: userLinks.slice(0, 4),
|
||||
links: userLinks.slice(0, 5),
|
||||
},
|
||||
{
|
||||
label: "记录",
|
||||
links: userLinks.slice(4, 6),
|
||||
links: userLinks.slice(5, 7),
|
||||
},
|
||||
{
|
||||
label: "支持",
|
||||
links: userLinks.slice(6),
|
||||
links: userLinks.slice(7),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
const [{ startTrafficSyncScheduler }, { startLogCleanupScheduler }] = await Promise.all([
|
||||
const [{ startTrafficSyncScheduler }, { startLogCleanupScheduler }, { startSubscriptionTransferScheduler }] = await Promise.all([
|
||||
import("./services/traffic-sync-scheduler"),
|
||||
import("./services/log-cleanup-scheduler"),
|
||||
import("./services/subscription-transfer-scheduler"),
|
||||
]);
|
||||
startTrafficSyncScheduler();
|
||||
startLogCleanupScheduler();
|
||||
startSubscriptionTransferScheduler();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,14 @@ import type {
|
||||
OrderReviewStatus,
|
||||
OrderStatus,
|
||||
Role,
|
||||
SubscriptionTransferFeePayer,
|
||||
SubscriptionTransferStatus,
|
||||
SubscriptionStatus,
|
||||
SubscriptionType,
|
||||
TaskKind,
|
||||
TaskStatus,
|
||||
UserStatus,
|
||||
WalletTransactionType,
|
||||
} from "@prisma/client";
|
||||
|
||||
export const orderStatusLabels: Record<OrderStatus, string> = {
|
||||
@@ -93,6 +96,7 @@ export const booleanAppSettingLabels = {
|
||||
subscriptionRiskAutoSuspend: "风控自动暂停",
|
||||
nodeAccessRiskEnabled: "节点日志风控",
|
||||
inviteRewardEnabled: "自动发放奖励",
|
||||
subscriptionTransferEnabled: "套餐 Push",
|
||||
smtpEnabled: "邮件服务",
|
||||
smtpSecure: "SMTP SSL 直连",
|
||||
} as const;
|
||||
@@ -123,6 +127,29 @@ export const paymentChannelLabels: Record<string, string> = {
|
||||
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) {
|
||||
if (!value) return fallback;
|
||||
return map[value] ?? fallback;
|
||||
@@ -167,3 +194,15 @@ export function getPaymentProviderLabel(provider: string | null | undefined) {
|
||||
export function getBooleanAppSettingLabel(field: string | null | undefined) {
|
||||
return labelFromMap(booleanAppSettingLabels, field, "系统开关");
|
||||
}
|
||||
|
||||
export function getWalletTransactionTypeLabel(type: string | null | undefined) {
|
||||
return labelFromMap(walletTransactionTypeLabels, type, "余额变动");
|
||||
}
|
||||
|
||||
export function getSubscriptionTransferStatusLabel(status: string | null | undefined) {
|
||||
return labelFromMap(subscriptionTransferStatusLabels, status, "未知转让状态");
|
||||
}
|
||||
|
||||
export function getSubscriptionTransferFeePayerLabel(feePayer: string | null | undefined) {
|
||||
return labelFromMap(subscriptionTransferFeePayerLabels, feePayer, "费用承担方");
|
||||
}
|
||||
|
||||
@@ -237,7 +237,7 @@ export class ThreeXUIAdapter implements NodePanelAdapter {
|
||||
client.enable = enable;
|
||||
await this.jsonRequest(`/panel/api/inbounds/updateClient/${encodeURIComponent(this.getClientPrimaryKey(inbound.protocol, client))}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id: inboundId, settings: JSON.stringify(settings) }),
|
||||
body: JSON.stringify({ id: inboundId, settings: JSON.stringify({ clients: [client] }) }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
68
src/services/subscription-transfer-scheduler.ts
Normal file
68
src/services/subscription-transfer-scheduler.ts
Normal 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);
|
||||
}
|
||||
698
src/services/subscription-transfer.ts
Normal file
698
src/services/subscription-transfer.ts
Normal 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;
|
||||
}
|
||||
@@ -52,7 +52,7 @@ export async function creditWallet(
|
||||
input: {
|
||||
userId: string;
|
||||
amount: number | string | Prisma.Decimal;
|
||||
type: "BALANCE_RECHARGE" | "CARD_REDEEM" | "ADMIN_ADJUST" | "REFUND";
|
||||
type: "BALANCE_RECHARGE" | "CARD_REDEEM" | "ADMIN_ADJUST" | "REFUND" | "SUBSCRIPTION_TRANSFER_REFUND";
|
||||
description?: string;
|
||||
orderId?: string | null;
|
||||
rechargeOrderId?: string | null;
|
||||
@@ -86,13 +86,15 @@ export async function creditWallet(
|
||||
return wallet;
|
||||
}
|
||||
|
||||
export async function debitWalletForOrder(
|
||||
export async function debitWallet(
|
||||
db: DbClient,
|
||||
input: {
|
||||
userId: string;
|
||||
orderId: string;
|
||||
amount: number | string | Prisma.Decimal;
|
||||
type: "BALANCE_PAYMENT" | "SUBSCRIPTION_TRANSFER_FEE";
|
||||
description?: string;
|
||||
orderId?: string | null;
|
||||
metadata?: Prisma.InputJsonValue;
|
||||
},
|
||||
) {
|
||||
const amount = toMoneyDecimal(input.amount);
|
||||
@@ -107,7 +109,7 @@ export async function debitWalletForOrder(
|
||||
});
|
||||
|
||||
if (claimed.count === 0) {
|
||||
throw new Error("余额不足,请先充值后再支付");
|
||||
throw new Error("余额不足,请先充值后再操作");
|
||||
}
|
||||
|
||||
const wallet = await db.walletAccount.findUniqueOrThrow({
|
||||
@@ -118,17 +120,37 @@ export async function debitWalletForOrder(
|
||||
data: {
|
||||
walletId: wallet.id,
|
||||
userId: input.userId,
|
||||
type: "BALANCE_PAYMENT",
|
||||
type: input.type,
|
||||
amount: amount.negated(),
|
||||
balanceAfter: wallet.balance,
|
||||
description: input.description ?? "余额支付订单",
|
||||
orderId: input.orderId,
|
||||
description: input.description ?? "余额扣费",
|
||||
orderId: input.orderId ?? null,
|
||||
metadata: input.metadata ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
return wallet;
|
||||
}
|
||||
|
||||
export async function debitWalletForOrder(
|
||||
db: DbClient,
|
||||
input: {
|
||||
userId: string;
|
||||
orderId: string;
|
||||
amount: number | string | Prisma.Decimal;
|
||||
description?: string;
|
||||
},
|
||||
) {
|
||||
const amount = toMoneyDecimal(input.amount);
|
||||
return debitWallet(db, {
|
||||
userId: input.userId,
|
||||
orderId: input.orderId,
|
||||
amount,
|
||||
type: "BALANCE_PAYMENT",
|
||||
description: input.description ?? "余额支付订单",
|
||||
});
|
||||
}
|
||||
|
||||
type PlanCardSnapshot = {
|
||||
type: "PROXY" | "STREAMING";
|
||||
nodeId: string | null;
|
||||
|
||||
Reference in New Issue
Block a user