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

@@ -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");

View File

@@ -0,0 +1,25 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { requireAdmin } from "@/lib/require-auth";
import { actorFromSession } from "@/services/audit";
import { deleteSubscriptionTransferAsAdmin } from "@/services/subscription-transfer";
const transferIdSchema = z.string().trim().min(1, "套餐 Push 记录不存在");
export async function deleteAdminSubscriptionTransfer(transferId: string) {
const session = await requireAdmin();
const id = transferIdSchema.parse(transferId);
const result = await deleteSubscriptionTransferAsAdmin({
transferId: id,
actor: actorFromSession(session),
});
revalidatePath("/admin/subscription-transfers");
revalidatePath("/subscriptions");
revalidatePath("/subscriptions/push");
return result;
}

View File

@@ -0,0 +1,97 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { requireAuth } from "@/lib/require-auth";
import { getErrorMessage } from "@/lib/errors";
import { actorFromSession } from "@/services/audit";
import {
acceptSubscriptionTransfer,
cancelSubscriptionTransfer,
createSubscriptionTransfer,
rejectSubscriptionTransfer,
} from "@/services/subscription-transfer";
const createTransferSchema = z.object({
subscriptionId: z.string().trim().min(1, "请选择要 Push 的套餐"),
recipientEmail: z.string().trim().email("请输入正确的接收方邮箱"),
password: z.string().min(1, "请输入当前账户密码"),
feePayer: z.enum(["SENDER", "RECIPIENT"]),
});
const transferIdSchema = z.string().trim().min(1, "套餐 Push 不存在");
type UserTransferActionResult = { ok: true; id?: string } | { ok: false; error: string };
function revalidateTransferViews(subscriptionId?: string) {
revalidatePath("/subscriptions");
revalidatePath("/subscriptions/push");
revalidatePath("/wallet");
if (subscriptionId) revalidatePath(`/subscriptions/${subscriptionId}`);
}
export async function createUserSubscriptionTransfer(formData: FormData): Promise<UserTransferActionResult> {
try {
const session = await requireAuth();
const data = createTransferSchema.parse(Object.fromEntries(formData));
const transfer = await createSubscriptionTransfer({
senderId: session.user.id,
recipientEmail: data.recipientEmail,
subscriptionId: data.subscriptionId,
password: data.password,
feePayer: data.feePayer,
actor: actorFromSession(session),
});
revalidateTransferViews(data.subscriptionId);
return { ok: true, id: transfer.id };
} catch (error) {
return { ok: false, error: getErrorMessage(error, "发起套餐 Push 失败") };
}
}
export async function acceptUserSubscriptionTransfer(transferId: string): Promise<UserTransferActionResult> {
try {
const session = await requireAuth();
const id = transferIdSchema.parse(transferId);
const transfer = await acceptSubscriptionTransfer({
transferId: id,
recipientId: session.user.id,
actor: actorFromSession(session),
});
revalidateTransferViews(transfer.subscriptionId);
return { ok: true };
} catch (error) {
return { ok: false, error: getErrorMessage(error, "接收套餐 Push 失败") };
}
}
export async function rejectUserSubscriptionTransfer(transferId: string): Promise<UserTransferActionResult> {
try {
const session = await requireAuth();
const id = transferIdSchema.parse(transferId);
const transfer = await rejectSubscriptionTransfer({
transferId: id,
recipientId: session.user.id,
actor: actorFromSession(session),
});
revalidateTransferViews(transfer.subscriptionId);
return { ok: true };
} catch (error) {
return { ok: false, error: getErrorMessage(error, "拒收套餐 Push 失败") };
}
}
export async function cancelUserSubscriptionTransfer(transferId: string): Promise<UserTransferActionResult> {
try {
const session = await requireAuth();
const id = transferIdSchema.parse(transferId);
const transfer = await cancelSubscriptionTransfer({
transferId: id,
senderId: session.user.id,
actor: actorFromSession(session),
});
revalidateTransferViews(transfer.subscriptionId);
return { ok: true };
} catch (error) {
return { ok: false, error: getErrorMessage(error, "取消套餐 Push 失败") };
}
}