Initial commit

This commit is contained in:
JetSprow
2026-04-29 05:12:39 +10:00
commit 27dbca9cbf
379 changed files with 43486 additions and 0 deletions

View File

@@ -0,0 +1,125 @@
import { prisma, type DbClient } from "@/lib/prisma";
import { createNotification } from "@/services/notifications";
function announcementNotificationPrefix(announcementId: string) {
return `announcement:${announcementId}:`;
}
export async function getVisibleAnnouncements(options?: {
userId?: string | null;
role?: "ADMIN" | "USER" | null;
db?: DbClient;
}) {
const db = options?.db ?? prisma;
const now = new Date();
const audienceConditions = options?.userId
? [
{ audience: "PUBLIC" as const },
...(options?.role === "ADMIN" ? [{ audience: "ADMINS" as const }] : []),
{ audience: "USERS" as const },
{ audience: "SPECIFIC_USER" as const, targetUserId: options.userId },
]
: [
{ audience: "PUBLIC" as const },
...(options?.role === "ADMIN" ? [{ audience: "ADMINS" as const }] : []),
];
const announcements = await db.announcement.findMany({
where: {
isActive: true,
AND: [
{
OR: [
{ startAt: null },
{ startAt: { lte: now } },
],
},
{
OR: [
{ endAt: null },
{ endAt: { gt: now } },
],
},
{
OR: audienceConditions,
},
],
},
orderBy: [{ createdAt: "desc" }],
});
return announcements;
}
export async function dispatchAnnouncementNotifications(
announcementId: string,
db: DbClient = prisma,
) {
const announcement = await db.announcement.findUnique({
where: { id: announcementId },
});
if (!announcement || !announcement.sendNotification || !announcement.isActive) {
return;
}
let users: Array<{ id: string }> = [];
switch (announcement.audience) {
case "SPECIFIC_USER":
if (announcement.targetUserId) {
users = [{ id: announcement.targetUserId }];
}
break;
case "ADMINS":
users = await db.user.findMany({
where: { role: "ADMIN" },
select: { id: true },
});
break;
case "USERS":
users = await db.user.findMany({
where: { role: "USER" },
select: { id: true },
});
break;
case "PUBLIC":
return;
}
for (const user of users) {
await createNotification(
{
userId: user.id,
type: "SYSTEM",
level: "INFO",
title: announcement.title,
body: announcement.body,
link: "/notifications",
dedupeKey: `${announcementNotificationPrefix(announcement.id)}${user.id}`,
},
db,
);
}
}
export async function deleteAnnouncementNotifications(
announcementId: string,
db: DbClient = prisma,
) {
await db.userNotification.deleteMany({
where: {
dedupeKey: {
startsWith: announcementNotificationPrefix(announcementId),
},
},
});
}
export async function syncAnnouncementNotifications(
announcementId: string,
db: DbClient = prisma,
) {
await deleteAnnouncementNotifications(announcementId, db);
await dispatchAnnouncementNotifications(announcementId, db);
}

View File

@@ -0,0 +1,16 @@
import { prisma, type DbClient } from "@/lib/prisma";
export async function getAppConfig(db: DbClient = prisma) {
const existing = await db.appConfig.findUnique({
where: { id: "default" },
});
if (existing) {
return existing;
}
return db.appConfig.create({
data: { id: "default" },
});
}

54
src/services/audit.ts Normal file
View File

@@ -0,0 +1,54 @@
import type { Prisma } from "@prisma/client";
import { prisma, type DbClient } from "@/lib/prisma";
export interface AuditActor {
userId?: string;
email?: string;
role?: "ADMIN" | "USER";
}
export interface AuditEntryInput {
actor?: AuditActor;
action: string;
targetType: string;
targetId?: string | null;
targetLabel?: string | null;
message: string;
metadata?: Prisma.InputJsonValue;
}
export function actorFromSession(session: {
user: {
id: string;
email?: string | null;
role?: string | null;
};
}): AuditActor {
return {
userId: session.user.id,
email: session.user.email ?? undefined,
role:
session.user.role === "ADMIN" || session.user.role === "USER"
? session.user.role
: undefined,
};
}
export async function recordAuditLog(
input: AuditEntryInput,
db: DbClient = prisma,
) {
await db.auditLog.create({
data: {
actorUserId: input.actor?.userId ?? null,
actorEmail: input.actor?.email ?? null,
actorRole: input.actor?.role ?? null,
action: input.action,
targetType: input.targetType,
targetId: input.targetId ?? null,
targetLabel: input.targetLabel ?? null,
message: input.message,
metadata: input.metadata,
},
});
}

225
src/services/commerce.ts Normal file
View File

@@ -0,0 +1,225 @@
import type { Coupon, PromotionRule, SubscriptionPlan } from "@prisma/client";
import { prisma, type DbClient } from "@/lib/prisma";
export interface PurchasePriceSnapshot {
trafficGb: number | null;
unitAmount: number;
amount: number;
label: string;
}
export interface CheckoutDiscountSnapshot {
subtotal: number;
coupon: Coupon | null;
couponDiscount: number;
promotion: PromotionRule | null;
promotionDiscount: number;
totalDiscount: number;
payable: number;
}
export function roundMoney(value: number) {
return Math.max(0, Math.round(value * 100) / 100);
}
export function normalizeCouponCode(code?: string | null) {
const value = (code ?? "").trim().toUpperCase();
return value || null;
}
export function getPlanPurchasePrice(
plan: Pick<
SubscriptionPlan,
| "type"
| "price"
| "pricePerGb"
| "minTrafficGb"
| "maxTrafficGb"
| "pricingMode"
| "fixedTrafficGb"
| "fixedPrice"
| "durationDays"
>,
requestedTrafficGb?: number | null,
): PurchasePriceSnapshot {
if (plan.type === "STREAMING") {
const amount = roundMoney(Number(plan.price ?? 0));
return {
trafficGb: null,
unitAmount: amount,
amount,
label: `${plan.durationDays}`,
};
}
if (plan.pricingMode === "FIXED_PACKAGE") {
if (!plan.fixedTrafficGb || plan.fixedTrafficGb <= 0) {
throw new Error("这款套餐暂时缺少固定流量设置");
}
if (!plan.fixedPrice || Number(plan.fixedPrice) <= 0) {
throw new Error("这款套餐暂时缺少固定价格设置");
}
const amount = roundMoney(Number(plan.fixedPrice));
return {
trafficGb: plan.fixedTrafficGb,
unitAmount: amount,
amount,
label: `${plan.fixedTrafficGb} GB · ${plan.durationDays}`,
};
}
if (!plan.pricePerGb || Number(plan.pricePerGb) <= 0) {
throw new Error("这款套餐暂时缺少价格设置");
}
const min = plan.minTrafficGb ?? 10;
const max = plan.maxTrafficGb ?? 1000;
const trafficGb = requestedTrafficGb ?? min;
if (!Number.isInteger(trafficGb) || trafficGb < min || trafficGb > max) {
throw new Error(`流量范围: ${min}-${max} GB`);
}
const unitAmount = Number(plan.pricePerGb);
const amount = roundMoney(trafficGb * unitAmount);
return {
trafficGb,
unitAmount,
amount,
label: `${trafficGb} GB · ${plan.durationDays}`,
};
}
function isWithinDateWindow(item: { startsAt: Date | null; endsAt: Date | null }, now = new Date()) {
if (item.startsAt && item.startsAt > now) return false;
if (item.endsAt && item.endsAt < now) return false;
return true;
}
function calculateCouponDiscount(
coupon: Pick<Coupon, "discountType" | "discountValue" | "thresholdAmount" | "maxDiscountAmount">,
subtotal: number,
) {
const threshold = coupon.thresholdAmount == null ? 0 : Number(coupon.thresholdAmount);
if (subtotal < threshold) return 0;
let discount = 0;
if (coupon.discountType === "PERCENT_OFF") {
discount = subtotal * (Number(coupon.discountValue) / 100);
if (coupon.maxDiscountAmount != null) {
discount = Math.min(discount, Number(coupon.maxDiscountAmount));
}
} else {
discount = Number(coupon.discountValue);
}
return Math.min(roundMoney(discount), subtotal);
}
async function getCouponForUser(
userId: string,
subtotal: number,
couponCode?: string | null,
db: DbClient = prisma,
) {
const code = normalizeCouponCode(couponCode);
if (!code) return { coupon: null, discount: 0, grantId: null as string | null };
const coupon = await db.coupon.findUnique({ where: { code } });
if (!coupon || !coupon.isActive || !isWithinDateWindow(coupon)) {
throw new Error("这张优惠券暂时不可用");
}
const grant = await db.couponGrant.findFirst({
where: {
couponId: coupon.id,
userId,
usedOrderId: null,
},
orderBy: { createdAt: "asc" },
});
if (!coupon.isPublic && !grant) {
throw new Error("这张优惠券不在你的可用券包里");
}
if (coupon.totalLimit != null) {
const usedTotal = await db.order.count({
where: { couponId: coupon.id, status: { in: ["PENDING", "PAID"] } },
});
if (usedTotal >= coupon.totalLimit) {
throw new Error("这张优惠券已被领完");
}
}
if (coupon.perUserLimit != null) {
const usedByUser = await db.order.count({
where: {
couponId: coupon.id,
userId,
status: { in: ["PENDING", "PAID"] },
},
});
if (usedByUser >= coupon.perUserLimit) {
throw new Error("你已达到这张优惠券的使用次数");
}
}
const discount = calculateCouponDiscount(coupon, subtotal);
if (discount <= 0) {
const threshold = coupon.thresholdAmount == null ? 0 : Number(coupon.thresholdAmount);
throw new Error(threshold > 0 ? `订单满 ¥${threshold.toFixed(2)} 可用这张券` : "这张优惠券暂时不可用于当前订单");
}
return { coupon, discount, grantId: grant?.id ?? null };
}
async function getBestPromotion(subtotal: number, db: DbClient = prisma) {
const rules = await db.promotionRule.findMany({
where: {
isActive: true,
thresholdAmount: { lte: subtotal },
},
orderBy: [{ sortOrder: "asc" }, { thresholdAmount: "desc" }],
});
const valid = rules.filter((rule) => isWithinDateWindow(rule));
if (valid.length === 0) return { promotion: null, discount: 0 };
const best = valid
.map((rule) => ({ rule, discount: Math.min(roundMoney(Number(rule.discountAmount)), subtotal) }))
.sort((a, b) => b.discount - a.discount || a.rule.sortOrder - b.rule.sortOrder)[0];
return { promotion: best.rule, discount: best.discount };
}
export async function calculateCheckoutDiscounts({
userId,
subtotal,
couponCode,
db = prisma,
}: {
userId: string;
subtotal: number;
couponCode?: string | null;
db?: DbClient;
}): Promise<CheckoutDiscountSnapshot & { couponGrantId: string | null }> {
const couponResult = await getCouponForUser(userId, subtotal, couponCode, db);
const promotionResult = await getBestPromotion(subtotal, db);
const rawDiscount = Math.min(
subtotal,
roundMoney(couponResult.discount + promotionResult.discount),
);
const payable = subtotal > 0 ? Math.max(0.01, roundMoney(subtotal - rawDiscount)) : 0;
const totalDiscount = roundMoney(subtotal - payable);
return {
subtotal,
coupon: couponResult.coupon,
couponDiscount: Math.min(couponResult.discount, totalDiscount),
promotion: promotionResult.promotion,
promotionDiscount: Math.max(0, totalDiscount - Math.min(couponResult.discount, totalDiscount)),
totalDiscount,
payable,
couponGrantId: couponResult.grantId,
};
}

View File

@@ -0,0 +1,95 @@
import { mkdtemp, rm, writeFile } from "fs/promises";
import os from "os";
import path from "path";
import { spawn } from "child_process";
function getDatabaseUrl() {
const url = process.env.DATABASE_URL;
if (!url) {
throw new Error("DATABASE_URL 未配置");
}
return url;
}
function runCommand(
command: string,
args: string[],
options?: {
input?: string;
},
) {
return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
const child = spawn(command, args, {
env: process.env,
stdio: "pipe",
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
child.on("error", reject);
child.on("close", (code) => {
if (code === 0) {
resolve({ stdout, stderr });
return;
}
reject(new Error(stderr || `${command} exited with code ${code}`));
});
if (options?.input) {
child.stdin.write(options.input);
}
child.stdin.end();
});
}
export async function createDatabaseBackupSql() {
const databaseUrl = getDatabaseUrl();
const { stdout } = await runCommand("pg_dump", [
"--clean",
"--if-exists",
"--no-owner",
"--no-privileges",
databaseUrl,
]);
return stdout;
}
export async function restoreDatabaseBackupSql(sql: string) {
if (!sql.trim()) {
throw new Error("备份内容不能为空");
}
const databaseUrl = getDatabaseUrl();
const tempDir = await mkdtemp(path.join(os.tmpdir(), "jboard-restore-"));
const filePath = path.join(tempDir, "restore.sql");
try {
await writeFile(filePath, sql, "utf8");
await runCommand("psql", [
databaseUrl,
"-v",
"ON_ERROR_STOP=1",
"-f",
filePath,
]);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
}
export async function restoreDatabaseBackupFile(file: File) {
const sql = Buffer.from(await file.arrayBuffer()).toString("utf8");
if (!sql) {
throw new Error("无法读取备份文件");
}
await restoreDatabaseBackupSql(sql);
}

View File

@@ -0,0 +1,81 @@
import type { Order, User } from "@prisma/client";
import { prisma, type DbClient } from "@/lib/prisma";
import { getAppConfig } from "@/services/app-config";
import { createNotification } from "@/services/notifications";
import { roundMoney } from "@/services/commerce";
export async function issueInviteRewardForOrder(
order: Order & { user: User },
db: DbClient = prisma,
) {
if (!order.user.invitedById || order.status !== "PAID") return;
const config = await getAppConfig(db);
if (!config.inviteRewardEnabled) return;
const inviter = await db.user.findUnique({
where: { id: order.user.invitedById },
select: { id: true, email: true },
});
if (!inviter) return;
const rate = Number(config.inviteRewardRate ?? 0);
const rewardAmount = rate > 0 ? roundMoney(Number(order.amount) * (rate / 100)) : 0;
let couponCode: string | null = null;
if (config.inviteRewardCouponId) {
const coupon = await db.coupon.findUnique({
where: { id: config.inviteRewardCouponId },
select: { id: true, code: true, isActive: true },
});
if (coupon?.isActive) {
couponCode = coupon.code;
await db.couponGrant.create({
data: {
couponId: coupon.id,
userId: inviter.id,
source: "invite_reward",
sourceOrderId: order.id,
},
});
}
}
if (rewardAmount <= 0 && !couponCode) return;
const ledger = await db.inviteRewardLedger.upsert({
where: {
orderId_inviterId: {
orderId: order.id,
inviterId: inviter.id,
},
},
create: {
inviterId: inviter.id,
inviteeId: order.userId,
orderId: order.id,
rewardAmount,
couponCode,
},
update: {
rewardAmount,
couponCode,
status: "ISSUED",
},
});
await createNotification(
{
userId: inviter.id,
type: "SYSTEM",
level: "SUCCESS",
title: "邀请奖励已到账",
body: couponCode
? `你的好友完成了订阅,奖励券 ${couponCode} 已放入你的账户。`
: `你的好友完成了订阅,本次邀请奖励 ¥${rewardAmount.toFixed(2)} 已记录。`,
link: "/account",
dedupeKey: `invite-reward:${ledger.id}`,
},
db,
);
}

View File

@@ -0,0 +1,18 @@
export const RECOMMENDATION_CARRIERS = ["telecom", "unicom", "mobile"] as const;
export const carrierLabels: Record<string, string> = {
telecom: "电信",
unicom: "联通",
mobile: "移动",
};
export interface LatencyRecommendation {
carrier: string;
carrierLabel: string;
nodeId: string;
nodeName: string;
planId: string;
planName: string;
latencyMs: number;
checkedAt: string;
}

View File

@@ -0,0 +1,67 @@
import { prisma } from "@/lib/prisma";
import { normalizeTraceText } from "@/lib/trace-normalize";
import {
RECOMMENDATION_CARRIERS,
carrierLabels,
type LatencyRecommendation,
} from "@/services/latency-recommendation-types";
export async function getLatencyRecommendations(): Promise<LatencyRecommendation[]> {
const rows = await prisma.nodeLatency.findMany({
where: {
carrier: { in: [...RECOMMENDATION_CARRIERS] },
node: {
status: "active",
plans: {
some: {
type: "PROXY",
isActive: true,
},
},
},
},
include: {
node: {
select: {
id: true,
name: true,
plans: {
where: {
type: "PROXY",
isActive: true,
},
select: {
id: true,
name: true,
},
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
take: 1,
},
},
},
},
orderBy: [{ carrier: "asc" }, { latencyMs: "asc" }, { checkedAt: "desc" }],
});
const best = new Map<string, LatencyRecommendation>();
for (const row of rows) {
if (best.has(row.carrier)) continue;
const plan = row.node.plans[0];
if (!plan) continue;
best.set(row.carrier, {
carrier: row.carrier,
carrierLabel: carrierLabels[row.carrier] ?? row.carrier,
nodeId: row.node.id,
nodeName: normalizeTraceText(row.node.name),
planId: plan.id,
planName: normalizeTraceText(plan.name),
latencyMs: row.latencyMs,
checkedAt: row.checkedAt.toISOString(),
});
}
return RECOMMENDATION_CARRIERS
.map((carrier) => best.get(carrier))
.filter((item): item is LatencyRecommendation => item != null);
}

View File

@@ -0,0 +1,48 @@
import { randomBytes, randomUUID } from "crypto";
function getShadowsocks2022KeyLength(method: string): number | null {
const normalized = method.toLowerCase();
if (normalized === "2022-blake3-aes-128-gcm") return 16;
if (normalized === "2022-blake3-aes-256-gcm") return 32;
if (normalized === "2022-blake3-chacha20-poly1305") return 32;
return null;
}
function parseShadowsocksMethod(rawSettings: unknown): string {
if (!rawSettings || typeof rawSettings !== "object") {
return "chacha20-ietf-poly1305";
}
const settings = rawSettings as {
method?: unknown;
clients?: Array<{ method?: unknown }>;
};
if (typeof settings.method === "string" && settings.method.trim()) {
return settings.method.trim();
}
const firstClientMethod = settings.clients?.[0]?.method;
if (typeof firstClientMethod === "string" && firstClientMethod.trim()) {
return firstClientMethod.trim();
}
return "chacha20-ietf-poly1305";
}
export function generateNodeClientCredential(
protocol: string,
inboundSettings: unknown,
): string {
if (protocol !== "SHADOWSOCKS") {
return randomUUID();
}
const method = parseShadowsocksMethod(inboundSettings);
const keyBytes = getShadowsocks2022KeyLength(method);
if (!keyBytes) {
return randomUUID();
}
return randomBytes(keyBytes).toString("base64");
}

View File

@@ -0,0 +1,13 @@
import type { CreateClientParams, PanelClientStat, PanelInbound } from "./types";
export interface NodePanelAdapter {
login(): Promise<boolean>;
getInbounds(): Promise<PanelInbound[]>;
addClient(params: CreateClientParams): Promise<void>;
deleteClient(inboundId: number, clientCredential: string): Promise<void>;
updateClientEnable(inboundId: number, clientCredential: string, enable: boolean): Promise<void>;
updateClient(params: CreateClientParams & { enable?: boolean }): Promise<void>;
getClientTraffic(email: string): Promise<PanelClientStat | null>;
getAllClientTraffics(inboundId: number): Promise<PanelClientStat[]>;
resetClientTraffic(inboundId: number, email: string): Promise<void>;
}

View File

@@ -0,0 +1,14 @@
import type { NodeServer } from "@prisma/client";
import type { NodePanelAdapter } from "./adapter";
import { ThreeXUIAdapter } from "./three-x-ui";
export function createPanelAdapter(server: NodeServer): NodePanelAdapter {
const panelType = server.panelType ?? "3x-ui";
if (panelType !== "3x-ui") {
throw new Error(`Unsupported panel type: ${panelType}`);
}
if (!server.panelUrl || !server.panelUsername || !server.panelPassword) {
throw new Error(`节点 ${server.name} 未配置 3x-ui 面板信息`);
}
return new ThreeXUIAdapter(server.panelUrl, server.panelUsername, server.panelPassword);
}

View File

@@ -0,0 +1,229 @@
import type { NodeServer, Prisma, Protocol } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import { bytesToGb } from "@/lib/utils";
import { createPanelAdapter } from "./factory";
import type { NodePanelAdapter } from "./adapter";
import type { PanelInbound } from "./types";
export interface NodeConnectionSyncResult {
success: boolean;
connected: boolean;
syncedCount: number;
repairedClientCount?: number;
message: string;
}
const protocolMap: Record<string, Protocol> = {
vmess: "VMESS",
vless: "VLESS",
trojan: "TROJAN",
shadowsocks: "SHADOWSOCKS",
hysteria: "HYSTERIA2",
hysteria2: "HYSTERIA2",
};
function normalizeProtocol(raw: string): Protocol | null {
return protocolMap[raw.toLowerCase()] ?? null;
}
function safeJsonParse(raw: string | null | undefined): Prisma.InputJsonValue {
if (!raw) return {};
try {
return JSON.parse(raw) as Prisma.InputJsonValue;
} catch {
return {};
}
}
function withPanelMetadata(
panelSettings: Prisma.InputJsonValue,
inbound: PanelInbound,
): Prisma.InputJsonValue {
const base =
panelSettings && typeof panelSettings === "object" && !Array.isArray(panelSettings)
? (panelSettings as Record<string, unknown>)
: {};
const existingMeta =
base._jboard && typeof base._jboard === "object" && !Array.isArray(base._jboard)
? (base._jboard as Record<string, unknown>)
: {};
return {
...base,
_jboard: {
...existingMeta,
listen: inbound.listen || null,
},
} as Prisma.InputJsonValue;
}
function mergeDisplayName(
panelSettings: Prisma.InputJsonValue,
existingSettings: unknown,
): Prisma.InputJsonValue {
if (!existingSettings || typeof existingSettings !== "object" || Array.isArray(existingSettings)) {
return panelSettings;
}
const displayName = (existingSettings as { displayName?: unknown }).displayName;
if (typeof displayName !== "string" || !displayName.trim()) {
return panelSettings;
}
const base =
panelSettings && typeof panelSettings === "object" && !Array.isArray(panelSettings)
? (panelSettings as Record<string, unknown>)
: {};
return { ...base, displayName: displayName.trim() } as Prisma.InputJsonValue;
}
function errorMessage(error: unknown): string {
if (error instanceof Error) {
const causeCode = (error as Error & { cause?: { code?: string } }).cause?.code;
if (causeCode === "ENOTFOUND") return "无法解析面板地址,请检查域名/IP";
if (causeCode === "ECONNREFUSED") return "连接被拒绝,请检查面板端口或防火墙";
if (causeCode === "ETIMEDOUT") return "连接超时,请检查网络连通性";
if (causeCode === "CERT_HAS_EXPIRED" || causeCode === "DEPTH_ZERO_SELF_SIGNED_CERT") {
return "HTTPS 证书无效,请改用 http:// 地址或修复证书";
}
if (error.message) return error.message;
}
return "未知错误";
}
async function repairJBoardClientSubIds(
server: NodeServer,
adapter: NodePanelAdapter,
): Promise<number> {
const clients = await prisma.nodeClient.findMany({
where: {
inbound: { serverId: server.id },
subscription: { status: "ACTIVE" },
},
include: {
subscription: { select: { id: true, endDate: true, trafficLimit: true } },
inbound: { select: { protocol: true, panelInboundId: true } },
},
});
let repaired = 0;
for (const client of clients) {
const panelInboundId = client.inbound.panelInboundId;
if (panelInboundId == null) continue;
try {
await adapter.updateClient({
inboundId: panelInboundId,
email: client.email,
uuid: client.uuid,
subId: client.subscription.id,
totalGB: client.subscription.trafficLimit ? bytesToGb(client.subscription.trafficLimit) : 0,
expiryTime: client.subscription.endDate.getTime(),
protocol: client.inbound.protocol,
enable: client.isEnabled,
});
repaired += 1;
} catch {
continue;
}
}
return repaired;
}
export async function testAndSyncNodeInbounds(
server: NodeServer,
): Promise<NodeConnectionSyncResult> {
const adapter = createPanelAdapter(server);
let connected = false;
try {
connected = await adapter.login();
} catch (error) {
return {
success: false,
connected: false,
syncedCount: 0,
message: `连接失败:${errorMessage(error)}`,
};
}
if (!connected) {
return {
success: false,
connected: false,
syncedCount: 0,
message: "连接失败:登录被拒绝,请检查面板地址和账号密码",
};
}
try {
const panelInbounds = await adapter.getInbounds();
let syncedCount = 0;
for (const inbound of panelInbounds) {
const protocol = normalizeProtocol(inbound.protocol);
if (!protocol) continue;
const tag = inbound.tag || inbound.remark || `inbound-${inbound.id}`;
const settings = withPanelMetadata(safeJsonParse(inbound.settings), inbound);
const streamSettings = safeJsonParse(inbound.streamSettings);
const existing = await prisma.nodeInbound.findFirst({
where: {
serverId: server.id,
panelInboundId: inbound.id,
},
select: { id: true, settings: true },
});
if (existing) {
await prisma.nodeInbound.update({
where: { id: existing.id },
data: {
protocol,
port: inbound.port,
tag,
settings: mergeDisplayName(settings, existing.settings),
streamSettings,
isActive: true,
},
});
} else {
await prisma.nodeInbound.create({
data: {
serverId: server.id,
panelInboundId: inbound.id,
protocol,
port: inbound.port,
tag,
settings,
streamSettings,
isActive: true,
},
});
}
syncedCount += 1;
}
const repairedClientCount = await repairJBoardClientSubIds(server, adapter);
const repairMessage = repairedClientCount > 0 ? `,已修复 ${repairedClientCount} 个客户端订阅标识` : "";
return {
success: true,
connected: true,
syncedCount,
repairedClientCount,
message: `连接成功,已同步 ${syncedCount} 个入站${repairMessage}`,
};
} catch (error) {
return {
success: false,
connected: true,
syncedCount: 0,
message: `连接成功但同步入站失败:${errorMessage(error)}`,
};
}
}

View File

@@ -0,0 +1,333 @@
import { fetchWithTimeout } from "@/lib/fetch-with-timeout";
import type { NodePanelAdapter } from "./adapter";
import type { CreateClientParams, PanelClientStat, PanelInbound } from "./types";
interface ThreeXUIResponse<T> {
success?: boolean;
msg?: string;
obj?: T;
}
interface PanelClient {
id?: string;
password?: string;
auth?: string;
email?: string;
totalGB?: number;
expiryTime?: number;
enable?: boolean;
alterId?: number;
flow?: string;
method?: string;
security?: string;
subId?: string;
}
function parseInboundSettings(raw: string): { clients?: PanelClient[]; method?: string } {
try {
return JSON.parse(raw) as { clients?: PanelClient[]; method?: string };
} catch {
return {};
}
}
function firstClientValue(inbound: PanelInbound | null, key: keyof PanelClient): string | undefined {
const settings = inbound ? parseInboundSettings(inbound.settings) : {};
return settings.clients?.map((client) => client[key]).find((value): value is string => typeof value === "string" && value.length > 0);
}
export class ThreeXUIAdapter implements NodePanelAdapter {
private panelUrl: string;
private username: string;
private password: string;
private cookie = "";
constructor(panelUrl: string, username: string, password: string) {
this.panelUrl = this.normalizePanelUrl(panelUrl);
this.username = username;
this.password = password;
}
private normalizePanelUrl(raw: string): string {
try {
const withProtocol = /^https?:\/\//i.test(raw) ? raw : `http://${raw}`;
const url = new URL(withProtocol);
let pathname = url.pathname.replace(/\/+$/, "");
pathname = pathname.replace(/\/panel\/login$/i, "");
pathname = pathname.replace(/\/panel$/i, "");
pathname = pathname.replace(/\/login$/i, "");
return `${url.origin}${pathname}`;
} catch {
return raw.replace(/\/+$/, "");
}
}
private parseCookies(headers: Headers): string {
const headersWithCookie = headers as Headers & { getSetCookie?: () => string[] };
if (typeof headersWithCookie.getSetCookie === "function") {
const cookies = headersWithCookie.getSetCookie();
if (cookies.length > 0) {
return cookies.map((cookie) => cookie.split(";")[0]).join("; ");
}
}
const setCookie = headers.get("set-cookie");
if (!setCookie) return "";
return setCookie.split(";")[0];
}
private async request(path: string, options: RequestInit = {}): Promise<Response> {
const res = await fetchWithTimeout(`${this.panelUrl}${path}`, {
...options,
headers: {
Cookie: this.cookie,
"Content-Type": "application/json",
...options.headers,
},
});
if (res.status === 401 || res.status === 307) {
await this.login();
return fetchWithTimeout(`${this.panelUrl}${path}`, {
...options,
headers: {
Cookie: this.cookie,
"Content-Type": "application/json",
...options.headers,
},
});
}
return res;
}
private async jsonRequest<T>(path: string, options: RequestInit = {}): Promise<T> {
const res = await this.request(path, options);
const raw = await res.text();
let data: ThreeXUIResponse<T>;
try {
data = JSON.parse(raw) as ThreeXUIResponse<T>;
} catch {
throw new Error(`3x-ui 接口返回了非 JSON 响应 (HTTP ${res.status})`);
}
if (!res.ok) {
throw new Error(data.msg || `3x-ui API HTTP ${res.status}`);
}
if (!data.success) throw new Error(data.msg || "3x-ui API error");
return data.obj as T;
}
private async loginAttempt(options: {
headers: Record<string, string>;
body: string;
}): Promise<{ success: boolean; status: number; message: string }> {
const res = await fetchWithTimeout(`${this.panelUrl}/login`, {
method: "POST",
headers: options.headers,
body: options.body,
redirect: "manual",
});
const cookie = this.parseCookies(res.headers);
if (cookie) this.cookie = cookie;
const raw = await res.text();
let data: { success?: boolean; msg?: string } | null = null;
try {
data = raw ? JSON.parse(raw) : null;
} catch {
data = null;
}
if (data?.success === true) {
return { success: true, status: res.status, message: "ok" };
}
return { success: false, status: res.status, message: data?.msg || raw || `HTTP ${res.status}` };
}
async login(): Promise<boolean> {
const attempts: Array<{ headers: Record<string, string>; body: string }> = [
{
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: this.username, password: this.password }),
},
{
headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
body: new URLSearchParams({ username: this.username, password: this.password }).toString(),
},
];
let lastMessage = "";
for (const attempt of attempts) {
const result = await this.loginAttempt(attempt);
if (result.success) return true;
lastMessage = result.message;
if (result.status === 404) {
throw new Error("登录接口不存在,请检查面板地址。建议填写面板根地址,例如 http://ip:port");
}
if (result.status >= 500) {
throw new Error(`面板服务异常 (HTTP ${result.status})`);
}
}
if (
lastMessage.toLowerCase().includes("invalid") ||
lastMessage.includes("密码") ||
lastMessage.includes("用户名") ||
lastMessage.toLowerCase().includes("login")
) {
return false;
}
throw new Error(`登录失败:${lastMessage || "未知错误"}`);
}
async getInbounds(): Promise<PanelInbound[]> {
return this.jsonRequest<PanelInbound[]>("/panel/api/inbounds/list");
}
private async getInbound(inboundId: number): Promise<PanelInbound | null> {
const inbounds = await this.getInbounds();
return inbounds.find((item) => item.id === inboundId) ?? null;
}
async addClient(params: CreateClientParams): Promise<void> {
const inbound = await this.getInbound(params.inboundId);
await this.jsonRequest("/panel/api/inbounds/addClient", {
method: "POST",
body: JSON.stringify({
id: params.inboundId,
settings: this.buildClientSettings(params, inbound),
}),
});
}
async deleteClient(inboundId: number, clientCredential: string): Promise<void> {
const inbound = await this.getInbound(inboundId);
const client = inbound ? this.findClient(inbound, clientCredential) : null;
const clientId = client && inbound
? this.getClientPrimaryKey(inbound.protocol, client)
: clientCredential;
await this.jsonRequest(`/panel/api/inbounds/${inboundId}/delClient/${encodeURIComponent(clientId)}`, {
method: "POST",
});
}
async updateClientEnable(
inboundId: number,
clientCredential: string,
enable: boolean,
): Promise<void> {
const inbound = await this.getInbound(inboundId);
if (!inbound) throw new Error("Inbound not found");
const settings = parseInboundSettings(inbound.settings);
const client = settings.clients?.find((item) => {
return item.id === clientCredential
|| item.password === clientCredential
|| item.auth === clientCredential
|| item.email === clientCredential;
});
if (!client) throw new Error("Client not found");
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) }),
});
}
async updateClient(params: CreateClientParams & { enable?: boolean }): Promise<void> {
const inbound = await this.getInbound(params.inboundId);
const clientSettings = JSON.parse(this.buildClientSettings(params, inbound)) as { clients: PanelClient[] };
if (params.enable === false) {
clientSettings.clients = clientSettings.clients.map((client) => ({ ...client, enable: false }));
}
await this.jsonRequest(`/panel/api/inbounds/updateClient/${encodeURIComponent(this.getParamsPrimaryKey(params))}`, {
method: "POST",
body: JSON.stringify({ id: params.inboundId, settings: JSON.stringify(clientSettings) }),
});
}
async getClientTraffic(email: string): Promise<PanelClientStat | null> {
return this.jsonRequest<PanelClientStat | null>(`/panel/api/inbounds/getClientTraffics/${encodeURIComponent(email)}`);
}
async getAllClientTraffics(inboundId: number): Promise<PanelClientStat[]> {
const inbound = await this.getInbound(inboundId);
return inbound?.clientStats || [];
}
async resetClientTraffic(inboundId: number, email: string): Promise<void> {
await this.jsonRequest(`/panel/api/inbounds/${inboundId}/resetClientTraffic/${encodeURIComponent(email)}`, {
method: "POST",
});
}
private findClient(inbound: PanelInbound, credential: string): PanelClient | null {
const settings = parseInboundSettings(inbound.settings);
return settings.clients?.find((client) => {
return client.id === credential
|| client.password === credential
|| client.auth === credential
|| client.email === credential;
}) ?? null;
}
private getClientPrimaryKey(protocol: string, client: PanelClient): string {
switch (protocol.toLowerCase()) {
case "trojan":
return client.password || client.id || client.email || "";
case "shadowsocks":
return client.email || client.password || client.id || "";
case "hysteria":
case "hysteria2":
return client.auth || client.email || "";
default:
return client.id || client.email || "";
}
}
private getParamsPrimaryKey(params: CreateClientParams): string {
switch (params.protocol.toLowerCase()) {
case "shadowsocks":
return params.email;
default:
return params.uuid;
}
}
private buildClientSettings(params: CreateClientParams, inbound: PanelInbound | null): string {
const totalBytes = (params.totalGB || 0) * 1024 * 1024 * 1024;
const expiryTime = params.expiryTime || 0;
const existingClient = inbound ? this.findClient(inbound, params.uuid) ?? this.findClient(inbound, params.email) : null;
const base = {
email: params.email,
totalGB: totalBytes,
expiryTime,
enable: true,
subId: params.subId || existingClient?.subId,
};
switch (params.protocol.toLowerCase()) {
case "vmess":
return JSON.stringify({ clients: [{ ...base, id: params.uuid, security: existingClient?.security || firstClientValue(inbound, "security") || "auto" }] });
case "vless":
return JSON.stringify({ clients: [{ ...base, id: params.uuid, flow: existingClient?.flow || firstClientValue(inbound, "flow") || "" }] });
case "trojan":
return JSON.stringify({ clients: [{ ...base, password: params.uuid }] });
case "shadowsocks":
return JSON.stringify({ clients: [{ ...base, password: params.uuid, method: existingClient?.method || firstClientValue(inbound, "method") || "" }] });
case "hysteria":
case "hysteria2":
return JSON.stringify({ clients: [{ ...base, auth: params.uuid }] });
default:
throw new Error(`Unsupported protocol: ${params.protocol}`);
}
}
}

View File

@@ -0,0 +1,28 @@
export interface PanelClientStat {
email: string;
up: number;
down: number;
enable: boolean;
}
export interface PanelInbound {
id: number;
protocol: string;
port: number;
tag: string;
remark: string;
listen?: string;
settings: string;
streamSettings: string;
clientStats: PanelClientStat[];
}
export interface CreateClientParams {
inboundId: number;
email: string;
uuid: string;
subId?: string;
totalGB?: number;
expiryTime?: number;
protocol: string;
}

View File

@@ -0,0 +1,198 @@
import type {
NotificationLevel,
NotificationType,
Prisma,
} from "@prisma/client";
import { prisma, type DbClient } from "@/lib/prisma";
import { bytesToGb } from "@/lib/utils";
export interface NotificationInput {
userId: string;
type: NotificationType;
level?: NotificationLevel;
title: string;
body: string;
link?: string | null;
dedupeKey?: string | null;
}
export async function createNotification(
input: NotificationInput,
db: DbClient = prisma,
) {
if (input.dedupeKey) {
await db.userNotification.upsert({
where: { dedupeKey: input.dedupeKey },
create: {
userId: input.userId,
type: input.type,
level: input.level ?? "INFO",
title: input.title,
body: input.body,
link: input.link ?? null,
dedupeKey: input.dedupeKey,
},
update: {},
});
return;
}
await db.userNotification.create({
data: {
userId: input.userId,
type: input.type,
level: input.level ?? "INFO",
title: input.title,
body: input.body,
link: input.link ?? null,
},
});
}
export async function markNotificationRead(
notificationId: string,
userId: string,
) {
await prisma.userNotification.updateMany({
where: {
id: notificationId,
userId,
isRead: false,
},
data: {
isRead: true,
readAt: new Date(),
},
});
}
export async function markAllNotificationsRead(userId: string) {
await prisma.userNotification.updateMany({
where: {
userId,
isRead: false,
},
data: {
isRead: true,
readAt: new Date(),
},
});
}
export async function deleteNotification(
notificationId: string,
userId: string,
) {
await prisma.userNotification.deleteMany({
where: {
id: notificationId,
userId,
},
});
}
export async function deleteReadNotifications(userId: string) {
await prisma.userNotification.deleteMany({
where: {
userId,
isRead: true,
},
});
}
function dayKey(date = new Date()) {
return date.toISOString().slice(0, 10);
}
export async function dispatchSubscriptionReminders(db: DbClient = prisma) {
const now = new Date();
const expiryWindow = new Date(now.getTime() + 3 * 24 * 60 * 60 * 1000);
const expiringSubscriptions = await db.userSubscription.findMany({
where: {
status: "ACTIVE",
endDate: {
gt: now,
lte: expiryWindow,
},
},
include: {
plan: {
select: {
name: true,
},
},
},
});
for (const subscription of expiringSubscriptions) {
const daysLeft = Math.max(
0,
Math.ceil((subscription.endDate.getTime() - now.getTime()) / (24 * 60 * 60 * 1000)),
);
await createNotification(
{
userId: subscription.userId,
type: "SUBSCRIPTION",
level: daysLeft <= 1 ? "WARNING" : "INFO",
title: "订阅即将到期",
body: `${subscription.plan.name} 将在 ${daysLeft} 天内到期,请及时续费以避免中断。`,
link: "/subscriptions",
dedupeKey: `expiry:${subscription.id}:${subscription.endDate.toISOString().slice(0, 10)}`,
},
db,
);
}
const highUsageSubscriptions = await db.userSubscription.findMany({
where: {
status: "ACTIVE",
trafficLimit: {
not: null,
},
},
include: {
plan: {
select: {
name: true,
},
},
},
});
for (const subscription of highUsageSubscriptions) {
const limit = Number(subscription.trafficLimit ?? BigInt(0));
if (limit <= 0) {
continue;
}
const used = Number(subscription.trafficUsed);
const ratio = used / limit;
if (ratio < 0.8 || ratio >= 1.0) {
continue;
}
const remainingGb = Math.max(
0,
Math.floor(bytesToGb((subscription.trafficLimit ?? BigInt(0)) - subscription.trafficUsed)),
);
await createNotification(
{
userId: subscription.userId,
type: "TRAFFIC",
level: ratio >= 0.9 ? "WARNING" : "INFO",
title: "流量余额提醒",
body: `${subscription.plan.name} 已使用 ${Math.round(ratio * 100)}%,当前剩余约 ${remainingGb} GB。`,
link: "/subscriptions",
dedupeKey: `traffic:${subscription.id}:${dayKey()}`,
},
db,
);
}
}
export function auditMetadata(data: Record<string, unknown>): Prisma.InputJsonValue {
return data as Prisma.InputJsonValue;
}

View File

@@ -0,0 +1,31 @@
export interface CreatePaymentParams {
tradeNo: string;
amount: number;
subject: string;
notifyUrl: string;
returnUrl: string;
channel?: string;
}
export interface PaymentResult {
success: boolean;
paymentUrl?: string;
qrCode?: string;
tradeNo: string;
raw?: unknown;
}
export interface PaymentNotification {
tradeNo: string;
amount: number;
status: "success" | "failed";
paymentRef?: string;
raw?: unknown;
}
export interface PaymentAdapter {
readonly name: string;
createPayment(params: CreatePaymentParams): Promise<PaymentResult>;
verifyNotification(body: Record<string, string>, headers?: Record<string, string>): Promise<PaymentNotification | null>;
queryOrder(tradeNo: string, createdAfter?: number): Promise<PaymentNotification | null>;
}

View File

@@ -0,0 +1,161 @@
import crypto from "crypto";
import { fetchWithTimeout } from "@/lib/fetch-with-timeout";
import type {
PaymentAdapter,
CreatePaymentParams,
PaymentResult,
PaymentNotification,
} from "./adapter";
function wrapPem(key: string, label: string): string {
const trimmed = key.trim();
if (trimmed.startsWith("-----")) return trimmed;
const body = trimmed.replace(/\s+/g, "");
const lines = body.match(/.{1,64}/g) ?? [body];
return `-----BEGIN ${label}-----\n${lines.join("\n")}\n-----END ${label}-----`;
}
export interface AlipayF2FConfig {
appId: string;
privateKey: string;
alipayPublicKey: string;
gateway: string;
}
export class AlipayF2FAdapter implements PaymentAdapter {
readonly name = "alipay_f2f";
private config: AlipayF2FConfig;
private pemPrivateKey: string;
private pemPublicKey: string;
constructor(config: AlipayF2FConfig) {
this.config = config;
this.pemPrivateKey = wrapPem(config.privateKey, "RSA PRIVATE KEY");
this.pemPublicKey = wrapPem(config.alipayPublicKey, "PUBLIC KEY");
}
async createPayment(params: CreatePaymentParams): Promise<PaymentResult> {
const bizContent = JSON.stringify({
out_trade_no: params.tradeNo,
total_amount: params.amount.toFixed(2),
subject: params.subject,
});
const commonParams: Record<string, string> = {
app_id: this.config.appId,
method: "alipay.trade.precreate",
charset: "utf-8",
sign_type: "RSA2",
timestamp: new Date().toISOString().replace("T", " ").slice(0, 19),
version: "1.0",
notify_url: params.notifyUrl,
biz_content: bizContent,
};
commonParams.sign = this.rsaSign(commonParams);
const res = await fetchWithTimeout(
this.config.gateway,
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(commonParams).toString(),
},
15_000,
);
if (!res.ok) {
throw new Error(`支付宝当面付下单失败 (HTTP ${res.status})`);
}
const json = await res.json();
const response = json.alipay_trade_precreate_response;
if (response?.code !== "10000") {
return { success: false, tradeNo: params.tradeNo, raw: json };
}
return {
success: true,
qrCode: response.qr_code,
tradeNo: params.tradeNo,
raw: json,
};
}
async verifyNotification(
body: Record<string, string>
): Promise<PaymentNotification | null> {
const { sign, sign_type, ...rest } = body;
void sign_type;
if (!this.rsaVerify(rest, sign)) return null;
if (body.trade_status !== "TRADE_SUCCESS") return null;
return {
tradeNo: body.out_trade_no,
amount: parseFloat(body.total_amount),
status: "success",
paymentRef: body.trade_no,
};
}
async queryOrder(tradeNo: string): Promise<PaymentNotification | null> {
const bizContent = JSON.stringify({ out_trade_no: tradeNo });
const commonParams: Record<string, string> = {
app_id: this.config.appId,
method: "alipay.trade.query",
charset: "utf-8",
sign_type: "RSA2",
timestamp: new Date().toISOString().replace("T", " ").slice(0, 19),
version: "1.0",
biz_content: bizContent,
};
commonParams.sign = this.rsaSign(commonParams);
const res = await fetchWithTimeout(
this.config.gateway,
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(commonParams).toString(),
},
15_000,
);
if (!res.ok) {
throw new Error(`支付宝当面付查询失败 (HTTP ${res.status})`);
}
const json = await res.json();
const response = json.alipay_trade_query_response;
if (response?.code !== "10000") return null;
if (response.trade_status !== "TRADE_SUCCESS") return null;
return {
tradeNo: response.out_trade_no,
amount: parseFloat(response.total_amount),
status: "success",
paymentRef: response.trade_no,
};
}
private rsaSign(params: Record<string, string>): string {
const sorted = Object.keys(params)
.filter((k) => params[k] !== "" && k !== "sign")
.sort();
const str = sorted.map((k) => `${k}=${params[k]}`).join("&");
const signer = crypto.createSign("RSA-SHA256");
signer.update(str);
return signer.sign(this.pemPrivateKey, "base64");
}
private rsaVerify(params: Record<string, string>, sign: string): boolean {
const sorted = Object.keys(params)
.filter((k) => params[k] !== "" && k !== "sign" && k !== "sign_type")
.sort();
const str = sorted.map((k) => `${k}=${params[k]}`).join("&");
const verifier = crypto.createVerify("RSA-SHA256");
verifier.update(str);
return verifier.verify(this.pemPublicKey, sign, "base64");
}
}

View File

@@ -0,0 +1,134 @@
import { z } from "zod";
export interface PaymentConfigField {
key: string;
label: string;
placeholder?: string;
secret?: boolean;
type?: "text" | "checkboxes";
options?: { value: string; label: string }[];
}
export interface PaymentProviderDefinition {
id: string;
name: string;
description: string;
fields: PaymentConfigField[];
}
const displayNameField = z.string().trim().optional().transform((v) => v ?? "");
const epaySchema = z.object({
displayName: displayNameField,
apiUrl: z.url("API 地址格式不正确"),
pid: z.string().trim().min(1, "商户 ID 不能为空"),
key: z.string().trim().min(1, "商户密钥不能为空"),
channels: z.string().trim().optional().transform((v) => v ?? "alipay,wxpay"),
});
const alipayF2fSchema = z.object({
displayName: displayNameField,
appId: z.string().trim().min(1, "App ID 不能为空"),
privateKey: z.string().trim().min(1, "应用私钥不能为空"),
alipayPublicKey: z.string().trim().min(1, "支付宝公钥不能为空"),
gateway: z.url("网关地址格式不正确"),
});
const usdtTrc20Schema = z.object({
displayName: displayNameField,
walletAddress: z.string().trim().min(1, "收款钱包地址不能为空"),
exchangeRate: z.coerce.number().positive("汇率必须大于 0"),
tronApiKey: z.string().trim().optional().transform((v) => v ?? ""),
tronApiUrl: z
.union([z.url("Tron API 地址格式不正确"), z.literal("")])
.optional()
.transform((value) => value ?? ""),
});
const paymentConfigSchemas = {
epay: epaySchema,
alipay_f2f: alipayF2fSchema,
usdt_trc20: usdtTrc20Schema,
} as const;
export const PAYMENT_PROVIDER_DEFINITIONS: PaymentProviderDefinition[] = [
{
id: "epay",
name: "易支付",
description: "支持支付宝/微信,通过第三方易支付平台",
fields: [
{ key: "displayName", label: "用户端显示名称", placeholder: "留空则用默认名" },
{ key: "apiUrl", label: "API 地址", placeholder: "https://pay.example.com" },
{ key: "pid", label: "商户 ID", placeholder: "1001" },
{ key: "key", label: "商户密钥", placeholder: "your-secret-key", secret: true },
{
key: "channels",
label: "启用的支付渠道",
type: "checkboxes",
options: [
{ value: "alipay", label: "支付宝" },
{ value: "wxpay", label: "微信支付" },
],
},
],
},
{
id: "alipay_f2f",
name: "支付宝当面付",
description: "支付宝官方当面付,用户扫码支付",
fields: [
{ key: "displayName", label: "用户端显示名称", placeholder: "例如:支付宝扫码" },
{ key: "appId", label: "App ID", placeholder: "2021..." },
{ key: "privateKey", label: "应用私钥", placeholder: "MIIEvQ...", secret: true },
{ key: "alipayPublicKey", label: "支付宝公钥", placeholder: "MIIBIj...", secret: true },
{ key: "gateway", label: "网关地址", placeholder: "https://openapi.alipay.com/gateway.do" },
],
},
{
id: "usdt_trc20",
name: "USDT (TRC20)",
description: "加密货币支付,监听 TRC20 链上转账",
fields: [
{ key: "displayName", label: "用户端显示名称", placeholder: "例如USDT 转账" },
{ key: "walletAddress", label: "收款钱包地址", placeholder: "T..." },
{ key: "exchangeRate", label: "汇率 (1 USDT = ¥?)", placeholder: "7.2" },
{ key: "tronApiKey", label: "TronGrid API Key", placeholder: "免费注册: trongrid.io", secret: true },
{ key: "tronApiUrl", label: "Tron API (可选)", placeholder: "https://api.trongrid.io" },
],
},
];
function normalizeConfig(config: Record<string, unknown>): Record<string, string> {
return Object.fromEntries(
Object.entries(config).map(([key, value]) => [
key,
typeof value === "string" ? value.trim() : String(value ?? ""),
]),
);
}
export function getPaymentProviderDefinition(provider: string) {
return PAYMENT_PROVIDER_DEFINITIONS.find((item) => item.id === provider) ?? null;
}
export function getPaymentProviderName(provider: string): string {
return getPaymentProviderDefinition(provider)?.name ?? provider;
}
export function normalizePaymentConfig(config: Record<string, unknown>) {
return normalizeConfig(config);
}
export function parsePaymentConfig(
provider: string,
config: Record<string, unknown>,
) {
const normalized = normalizeConfig(config);
const schema = paymentConfigSchemas[provider as keyof typeof paymentConfigSchemas];
if (!schema) {
throw new Error(`未知支付方式:${provider}`);
}
return schema.parse(normalized);
}

View File

@@ -0,0 +1,139 @@
import crypto from "crypto";
import { fetchWithTimeout } from "@/lib/fetch-with-timeout";
import type {
PaymentAdapter,
CreatePaymentParams,
PaymentResult,
PaymentNotification,
} from "./adapter";
export interface EasyPayConfig {
apiUrl: string;
pid: string;
key: string;
}
export class EasyPayAdapter implements PaymentAdapter {
readonly name = "epay";
private config: EasyPayConfig;
constructor(config: EasyPayConfig) {
this.config = config;
}
async createPayment(params: CreatePaymentParams): Promise<PaymentResult> {
const type = params.channel === "wxpay" ? "wxpay" : "alipay";
const data: Record<string, string> = {
pid: this.config.pid,
type,
out_trade_no: params.tradeNo,
notify_url: params.notifyUrl,
return_url: params.returnUrl,
name: params.subject,
money: params.amount.toFixed(2),
};
data.sign = this.sign(data);
data.sign_type = "MD5";
// Try mapi.php first (returns JSON with payment URL), fall back to submit.php
try {
const qs = new URLSearchParams(data).toString();
const res = await fetchWithTimeout(
`${this.config.apiUrl}/mapi.php?${qs}`,
undefined,
15_000,
);
if (res.ok) {
const json = await res.json() as Record<string, unknown>;
if (json.code === 1 || json.code === "1") {
const paymentUrl =
(json.payurl as string) ||
(json.pay_url as string) ||
(json.qrcode as string) ||
(json.urlscheme as string) ||
"";
if (paymentUrl) {
return { success: true, paymentUrl, tradeNo: params.tradeNo };
}
}
}
} catch {
// mapi.php not available, fall back to submit.php redirect
}
// Fallback: direct redirect URL via submit.php
const qs = new URLSearchParams(data).toString();
const paymentUrl = `${this.config.apiUrl}/submit.php?${qs}`;
return { success: true, paymentUrl, tradeNo: params.tradeNo };
}
async verifyNotification(
body: Record<string, string>
): Promise<PaymentNotification | null> {
const { sign, sign_type, ...rest } = body;
void sign_type;
const expected = this.sign(rest);
if (sign !== expected) return null;
if (body.trade_status !== "TRADE_SUCCESS") return null;
return {
tradeNo: body.out_trade_no,
amount: parseFloat(body.money),
status: "success",
paymentRef: body.trade_no,
};
}
async queryOrder(tradeNo: string): Promise<PaymentNotification | null> {
const data: Record<string, string> = {
act: "order",
pid: this.config.pid,
out_trade_no: tradeNo,
};
data.sign = this.sign(data);
data.sign_type = "MD5";
const qs = new URLSearchParams(data).toString();
let res: Response;
try {
res = await fetchWithTimeout(
`${this.config.apiUrl}/api.php?${qs}`,
undefined,
15_000,
);
} catch {
// Network error — treat as unavailable, rely on callback notification
return null;
}
if (!res.ok) {
// Many epay platforms don't expose api.php — silently return null
// so the polling loop keeps waiting for the async notification instead
return null;
}
const json = await res.json();
if (json.code !== 1 || json.status !== 1) return null;
return {
tradeNo: json.out_trade_no,
amount: parseFloat(json.money),
status: "success",
paymentRef: json.trade_no,
};
}
private sign(params: Record<string, string>): string {
const sorted = Object.keys(params)
.filter((k) => params[k] !== "" && k !== "sign" && k !== "sign_type")
.sort();
const str = sorted.map((k) => `${k}=${params[k]}`).join("&");
return crypto.createHash("md5").update(str + this.config.key).digest("hex");
}
}

View File

@@ -0,0 +1,84 @@
import { prisma } from "@/lib/prisma";
import type { PaymentAdapter } from "./adapter";
import { EasyPayAdapter, type EasyPayConfig } from "./epay";
import { AlipayF2FAdapter, type AlipayF2FConfig } from "./alipay-f2f";
import { UsdtTrc20Adapter, type UsdtTrc20Config } from "./usdt-trc20";
import {
getPaymentProviderName,
parsePaymentConfig,
} from "./catalog";
export async function getPaymentAdapter(provider: string): Promise<PaymentAdapter> {
// epay_alipay / epay_wxpay both use the epay adapter
const realProvider = provider.startsWith("epay") ? "epay" : provider;
const config = await prisma.paymentConfig.findUnique({
where: { provider: realProvider },
});
if (!config || !config.enabled) {
throw new Error(`Payment provider "${provider}" is not configured or disabled`);
}
const cfg = parsePaymentConfig(
realProvider,
config.config as Record<string, string>,
);
switch (realProvider) {
case "epay":
return new EasyPayAdapter(cfg as EasyPayConfig);
case "alipay_f2f":
return new AlipayF2FAdapter(cfg as AlipayF2FConfig);
case "usdt_trc20":
return new UsdtTrc20Adapter(cfg as UsdtTrc20Config);
default:
throw new Error(`Unknown payment provider: ${provider}`);
}
}
export interface EnabledProvider {
provider: string;
name: string;
channel?: string;
}
export async function getEnabledProviders(): Promise<EnabledProvider[]> {
const configs = await prisma.paymentConfig.findMany({
where: { enabled: true },
select: { provider: true, config: true },
});
const result: EnabledProvider[] = [];
for (const c of configs) {
const cfg = c.config as Record<string, string> | null;
if (c.provider === "epay") {
// Read admin-configured channels (default: both)
const channelsStr = cfg?.channels || "alipay,wxpay";
const channels = channelsStr.split(",").map((s) => s.trim()).filter(Boolean);
const displayName = cfg?.displayName || "";
const channelLabels: Record<string, string> = {
alipay: "支付宝",
wxpay: "微信支付",
};
for (const ch of channels) {
const defaultLabel = channelLabels[ch] ?? ch;
const name = displayName
? channels.length > 1 ? `${displayName} (${defaultLabel})` : displayName
: defaultLabel;
result.push({ provider: "epay", name, channel: ch });
}
} else {
result.push({
provider: c.provider,
name: cfg?.displayName || getPaymentProviderName(c.provider),
});
}
}
return result;
}

View File

@@ -0,0 +1,164 @@
import type { OrderStatus } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import { getErrorMessage } from "@/lib/errors";
import { createNotification } from "@/services/notifications";
import { provisionSubscriptionWithDb } from "@/services/provision";
import { recordTaskFailure } from "@/services/task-center";
import { issueInviteRewardForOrder } from "@/services/invite-rewards";
export interface PaymentProcessResult {
processed: boolean;
finalStatus: OrderStatus | null;
errorMessage?: string;
}
interface PaymentProcessTxnResult extends PaymentProcessResult {
affectedNodeIds: string[];
}
async function processOrderPaymentById(
orderId: string,
paymentRef?: string,
): Promise<PaymentProcessResult> {
try {
const result = await prisma.$transaction<PaymentProcessTxnResult>(async (tx) => {
const claimed = await tx.order.updateMany({
where: {
id: orderId,
status: "PENDING",
},
data: {
status: "PAID",
note: null,
...(paymentRef !== undefined ? { paymentRef } : {}),
},
});
if (claimed.count === 0) {
const current = await tx.order.findUnique({
where: { id: orderId },
select: { status: true },
});
return {
processed: false,
finalStatus: current?.status ?? null,
affectedNodeIds: [],
};
}
const order = await tx.order.findUnique({
where: { id: orderId },
include: { plan: true, user: true },
});
if (!order || order.status !== "PAID") {
return { processed: false, finalStatus: order?.status ?? null, affectedNodeIds: [] };
}
const affectedNodeIds = await provisionSubscriptionWithDb(order, tx);
if (order.kind === "NEW_PURCHASE") {
await issueInviteRewardForOrder({ ...order, status: "PAID" }, tx);
}
return { processed: true, finalStatus: "PAID", affectedNodeIds };
});
return {
processed: result.processed,
finalStatus: result.finalStatus,
errorMessage: result.errorMessage,
};
} catch (error) {
const message = getErrorMessage(error, "开通失败");
const order = await prisma.order.findUnique({
where: { id: orderId },
select: {
id: true,
userId: true,
plan: {
select: {
name: true,
},
},
},
});
if (order) {
await prisma.$transaction(async (tx) => {
await tx.order.update({
where: { id: order.id },
data: {
status: "PENDING",
note: `Provision failed: ${message}`,
},
});
await createNotification(
{
userId: order.userId,
type: "ORDER",
level: "ERROR",
title: "支付已确认,但开通失败",
body: `${order.plan.name} 支付成功,但开通时发生错误:${message}`,
link: `/pay/${order.id}`,
dedupeKey: `provision-failed:${order.id}`,
},
tx,
);
await recordTaskFailure(
{
kind: "ORDER_PROVISION_RETRY",
title: `订单 ${order.id} 开通失败待重试`,
targetType: "Order",
targetId: order.id,
payload: {
orderId: order.id,
},
retryable: true,
errorMessage: message,
},
tx,
);
});
}
return {
processed: false,
finalStatus: "PENDING",
errorMessage: message,
};
}
}
export async function handleVerifiedPaymentSuccess(
tradeNo: string,
paidAmount: number,
paymentRef?: string,
) {
if (!Number.isFinite(paidAmount) || paidAmount <= 0) {
return { processed: false, finalStatus: null } satisfies PaymentProcessResult;
}
const order = await prisma.order.findUnique({
where: { tradeNo },
select: { id: true, amount: true },
});
if (!order) {
return { processed: false, finalStatus: null } satisfies PaymentProcessResult;
}
const expectedAmount = Number(order.amount);
if (!Number.isFinite(expectedAmount) || Math.abs(expectedAmount - paidAmount) > 0.01) {
return {
processed: false,
finalStatus: null,
errorMessage: "支付金额与订单金额不一致",
} satisfies PaymentProcessResult;
}
return processOrderPaymentById(order.id, paymentRef);
}
export async function confirmPendingOrder(orderId: string) {
return processOrderPaymentById(orderId);
}

View File

@@ -0,0 +1,115 @@
import { fetchWithTimeout } from "@/lib/fetch-with-timeout";
import type {
PaymentAdapter,
CreatePaymentParams,
PaymentResult,
PaymentNotification,
} from "./adapter";
export interface UsdtTrc20Config {
walletAddress: string;
tronApiUrl?: string;
tronApiKey?: string;
usdtContract?: string;
exchangeRate: number;
}
const DEFAULT_TRON_API = "https://api.trongrid.io";
const USDT_TRC20_CONTRACT = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t";
export class UsdtTrc20Adapter implements PaymentAdapter {
readonly name = "usdt_trc20";
private config: UsdtTrc20Config;
constructor(config: UsdtTrc20Config) {
this.config = {
walletAddress: config.walletAddress,
exchangeRate: config.exchangeRate,
tronApiUrl: config.tronApiUrl || DEFAULT_TRON_API,
tronApiKey: config.tronApiKey || "",
usdtContract: config.usdtContract || USDT_TRC20_CONTRACT,
};
}
async createPayment(params: CreatePaymentParams): Promise<PaymentResult> {
const usdtAmount = this.config.exchangeRate > 0
? (params.amount / this.config.exchangeRate).toFixed(2)
: params.amount.toFixed(2);
return {
success: true,
tradeNo: params.tradeNo,
qrCode: this.config.walletAddress,
raw: {
walletAddress: this.config.walletAddress,
usdtAmount,
cnyAmount: params.amount.toFixed(2),
exchangeRate: this.config.exchangeRate,
network: "TRC20",
},
};
}
async verifyNotification(): Promise<PaymentNotification | null> {
return null;
}
async queryOrder(tradeNo: string, createdAfter?: number): Promise<PaymentNotification | null> {
const expectedCny = this.parseAmountFromTradeNo(tradeNo);
if (!expectedCny) return null;
const expectedUsdt = this.config.exchangeRate > 0
? expectedCny / this.config.exchangeRate
: expectedCny;
const transfers = await this.getRecentTransfers();
for (const tx of transfers) {
if (createdAfter && tx.timestamp < createdAfter) continue;
const amount = tx.amount / 1e6;
if (Math.abs(amount - expectedUsdt) < 0.01) {
return {
tradeNo,
amount: expectedCny,
status: "success",
paymentRef: tx.txId,
};
}
}
return null;
}
private async getRecentTransfers(): Promise<Array<{ txId: string; amount: number; timestamp: number }>> {
const url = `${this.config.tronApiUrl}/v1/accounts/${this.config.walletAddress}/transactions/trc20?limit=20&contract_address=${this.config.usdtContract}&only_to=true`;
const headers: Record<string, string> = {};
if (this.config.tronApiKey) {
headers["TRON-PRO-API-KEY"] = this.config.tronApiKey;
}
const res = await fetchWithTimeout(url, { headers }, 15_000);
if (!res.ok) return [];
const json = await res.json();
if (!json.data) return [];
return json.data.map((tx: {
transaction_id: string;
value: string;
block_timestamp: number;
}) => ({
txId: tx.transaction_id,
amount: parseInt(tx.value),
timestamp: tx.block_timestamp,
}));
}
private parseAmountFromTradeNo(tradeNo: string): number | null {
const parts = tradeNo.split("-");
const amountStr = parts[parts.length - 1];
const amount = parseFloat(amountStr);
return isNaN(amount) ? null : amount;
}
}

View File

@@ -0,0 +1,245 @@
import { prisma } from "@/lib/prisma";
import type { SubscriptionPlan, SubscriptionType } from "@prisma/client";
import { format } from "date-fns";
type PlanAvailabilityInput = Pick<
SubscriptionPlan,
"id" | "type" | "totalLimit" | "streamingServiceId" | "perUserLimit"
>;
type AvailabilityReason = "PLAN_LIMIT" | "SERVICE_CAPACITY" | "USER_LIMIT";
export interface PlanAvailability {
available: boolean;
reason: AvailabilityReason | null;
activeCount: number;
totalLimit: number | null;
remainingByPlanLimit: number | null;
remainingByServiceCapacity: number | null;
remainingByUserLimit: number | null;
nextAvailableAt: Date | null;
}
export function formatAvailabilityDateTime(date: Date): string {
return format(date, "yyyy-MM-dd HH:mm");
}
export function buildUnavailableMessage(availability: PlanAvailability): string {
if (availability.available) return "当前可购买";
const prefix =
availability.reason === "SERVICE_CAPACITY"
? "当前服务名额已满"
: availability.reason === "USER_LIMIT"
? "你已达到该套餐限购数量"
: "当前套餐名额已满";
if (!availability.nextAvailableAt) {
return `${prefix},暂时无法预测释放时间`;
}
return `${prefix},预计最早可购买时间:${formatAvailabilityDateTime(availability.nextAvailableAt)}`;
}
async function getEarliestPlanExpiry(planId: string): Promise<Date | null> {
const earliest = await prisma.userSubscription.findFirst({
where: {
planId,
status: "ACTIVE",
},
select: { endDate: true },
orderBy: { endDate: "asc" },
});
return earliest?.endDate ?? null;
}
async function getEarliestUserPlanExpiry(
planId: string,
userId: string,
): Promise<Date | null> {
const earliest = await prisma.userSubscription.findFirst({
where: {
planId,
userId,
status: "ACTIVE",
},
select: { endDate: true },
orderBy: { endDate: "asc" },
});
return earliest?.endDate ?? null;
}
async function getEarliestServiceExpiry(serviceIds: string[]): Promise<Date | null> {
if (serviceIds.length === 0) return null;
const earliest = await prisma.userSubscription.findFirst({
where: {
status: "ACTIVE",
streamingSlot: {
is: {
serviceId: { in: serviceIds },
},
},
},
select: { endDate: true },
orderBy: { endDate: "asc" },
});
return earliest?.endDate ?? null;
}
async function evaluateStreamingCapacity(
type: SubscriptionType,
streamingServiceId: string | null,
): Promise<{
blocked: boolean;
remaining: number | null;
nextAvailableAt: Date | null;
}> {
if (type !== "STREAMING") {
return { blocked: false, remaining: null, nextAvailableAt: null };
}
if (streamingServiceId) {
const service = await prisma.streamingService.findUnique({
where: { id: streamingServiceId },
select: {
id: true,
isActive: true,
maxSlots: true,
usedSlots: true,
},
});
if (!service || !service.isActive) {
return { blocked: true, remaining: 0, nextAvailableAt: null };
}
const remaining = Math.max(0, service.maxSlots - service.usedSlots);
if (remaining > 0) {
return { blocked: false, remaining, nextAvailableAt: null };
}
const nextAvailableAt = await getEarliestServiceExpiry([service.id]);
return { blocked: true, remaining: 0, nextAvailableAt };
}
const services = await prisma.streamingService.findMany({
where: { isActive: true },
select: { id: true, maxSlots: true, usedSlots: true },
});
const totalRemaining = services.reduce(
(sum, service) => sum + Math.max(0, service.maxSlots - service.usedSlots),
0,
);
if (totalRemaining > 0) {
return { blocked: false, remaining: totalRemaining, nextAvailableAt: null };
}
const nextAvailableAt = await getEarliestServiceExpiry(services.map((service) => service.id));
return { blocked: true, remaining: 0, nextAvailableAt };
}
function resolveNextAvailability(
blockers: Array<{ reason: AvailabilityReason; nextAt: Date | null }>,
): { reason: AvailabilityReason | null; nextAvailableAt: Date | null } {
if (blockers.length === 0) {
return { reason: null, nextAvailableAt: null };
}
if (blockers.length === 1) {
return {
reason: blockers[0].reason,
nextAvailableAt: blockers[0].nextAt,
};
}
if (blockers.some((item) => !item.nextAt)) {
return {
reason: blockers[0].reason,
nextAvailableAt: null,
};
}
const sorted = [...blockers].sort(
(a, b) => (b.nextAt?.getTime() ?? 0) - (a.nextAt?.getTime() ?? 0),
);
return { reason: sorted[0].reason, nextAvailableAt: sorted[0].nextAt };
}
export async function getPlanAvailability(
plan: PlanAvailabilityInput,
options?: { userId?: string },
): Promise<PlanAvailability> {
const activeCount = await prisma.userSubscription.count({
where: {
planId: plan.id,
status: "ACTIVE",
},
});
const totalLimit = plan.totalLimit ?? null;
const remainingByPlanLimit =
totalLimit == null ? null : Math.max(0, totalLimit - activeCount);
const planBlocked = remainingByPlanLimit !== null && remainingByPlanLimit <= 0;
const planNextAt = planBlocked ? await getEarliestPlanExpiry(plan.id) : null;
let remainingByUserLimit: number | null = null;
let userBlocked = false;
let userNextAt: Date | null = null;
if (plan.perUserLimit != null && options?.userId) {
const userActiveCount = await prisma.userSubscription.count({
where: {
planId: plan.id,
userId: options.userId,
status: "ACTIVE",
},
});
remainingByUserLimit = Math.max(0, plan.perUserLimit - userActiveCount);
userBlocked = remainingByUserLimit <= 0;
if (userBlocked) {
userNextAt = await getEarliestUserPlanExpiry(plan.id, options.userId);
}
}
const streaming = await evaluateStreamingCapacity(
plan.type,
plan.streamingServiceId ?? null,
);
const blockers: Array<{ reason: AvailabilityReason; nextAt: Date | null }> = [];
if (userBlocked) blockers.push({ reason: "USER_LIMIT", nextAt: userNextAt });
if (planBlocked) blockers.push({ reason: "PLAN_LIMIT", nextAt: planNextAt });
if (streaming.blocked) {
blockers.push({ reason: "SERVICE_CAPACITY", nextAt: streaming.nextAvailableAt });
}
const resolution = resolveNextAvailability(blockers);
return {
available: !userBlocked && !planBlocked && !streaming.blocked,
reason: resolution.reason,
activeCount,
totalLimit,
remainingByPlanLimit,
remainingByServiceCapacity: streaming.remaining,
remainingByUserLimit,
nextAvailableAt: resolution.nextAvailableAt,
};
}
export async function getPlanAvailabilityById(planId: string): Promise<PlanAvailability> {
const plan = await prisma.subscriptionPlan.findUniqueOrThrow({
where: { id: planId },
select: {
id: true,
type: true,
totalLimit: true,
streamingServiceId: true,
perUserLimit: true,
},
});
return getPlanAvailability(plan);
}

View File

@@ -0,0 +1,176 @@
import { prisma } from "@/lib/prisma";
import { bytesToGb, gbToBytes } from "@/lib/utils";
import type { Prisma } from "@prisma/client";
type DbClient = Pick<
Prisma.TransactionClient,
"subscriptionPlan" | "userSubscription" | "order" | "orderItem"
>;
const PENDING_ORDER_GRACE_MS = 30 * 60 * 1000;
function getNow() {
return new Date();
}
export interface PlanTrafficPoolState {
enabled: boolean;
totalTrafficGb: number | null;
totalBytes: bigint;
allocatedBytes: bigint;
reservedPendingBytes: bigint;
remainingBytes: bigint;
remainingGb: number;
}
export async function getPlanTrafficPoolState(
planId: string,
options?: {
db?: DbClient;
excludePendingOrderId?: string;
},
): Promise<PlanTrafficPoolState> {
const db = options?.db ?? prisma;
const now = getNow();
const plan = await db.subscriptionPlan.findUnique({
where: { id: planId },
select: {
type: true,
totalTrafficGb: true,
},
});
if (!plan || plan.type !== "PROXY" || plan.totalTrafficGb == null) {
return {
enabled: false,
totalTrafficGb: null,
totalBytes: BigInt(0),
allocatedBytes: BigInt(0),
reservedPendingBytes: BigInt(0),
remainingBytes: BigInt(0),
remainingGb: 0,
};
}
const totalBytes = gbToBytes(plan.totalTrafficGb);
const allocatedAgg = await db.userSubscription.aggregate({
where: {
planId,
status: { in: ["ACTIVE", "SUSPENDED"] },
endDate: { gt: now },
trafficLimit: { not: null },
},
_sum: {
trafficLimit: true,
},
});
const allocatedBytes = allocatedAgg._sum.trafficLimit ?? BigInt(0);
const pendingCutoff = new Date(now.getTime() - PENDING_ORDER_GRACE_MS);
const pendingReservedAgg = await db.order.aggregate({
where: {
planId,
status: "PENDING",
kind: { in: ["NEW_PURCHASE", "TRAFFIC_TOPUP"] },
...(options?.excludePendingOrderId
? { id: { not: options.excludePendingOrderId } }
: {}),
OR: [
{ expireAt: { gt: now } },
{ AND: [{ expireAt: null }, { createdAt: { gt: pendingCutoff } }] },
],
trafficGb: { not: null },
},
_sum: {
trafficGb: true,
},
});
const pendingItemReservedAgg = await db.orderItem.aggregate({
where: {
planId,
trafficGb: { not: null },
order: {
status: "PENDING",
kind: "NEW_PURCHASE",
...(options?.excludePendingOrderId
? { id: { not: options.excludePendingOrderId } }
: {}),
OR: [
{ expireAt: { gt: now } },
{ AND: [{ expireAt: null }, { createdAt: { gt: pendingCutoff } }] },
],
},
},
_sum: {
trafficGb: true,
},
});
const reservedPendingBytes = gbToBytes(
(pendingReservedAgg._sum.trafficGb ?? 0) + (pendingItemReservedAgg._sum.trafficGb ?? 0),
);
const remainingBytesRaw = totalBytes - allocatedBytes - reservedPendingBytes;
const remainingBytes =
remainingBytesRaw > BigInt(0) ? remainingBytesRaw : BigInt(0);
return {
enabled: true,
totalTrafficGb: plan.totalTrafficGb,
totalBytes,
allocatedBytes,
reservedPendingBytes,
remainingBytes,
remainingGb: bytesToGb(remainingBytes),
};
}
export async function ensurePlanTrafficPoolCapacity(
planId: string,
requestedGb: number,
options?: {
db?: DbClient;
excludePendingOrderId?: string;
messagePrefix?: string;
},
) {
if (requestedGb <= 0) return;
const state = await getPlanTrafficPoolState(planId, {
db: options?.db,
excludePendingOrderId: options?.excludePendingOrderId,
});
if (!state.enabled) return;
const requestedBytes = gbToBytes(requestedGb);
if (requestedBytes <= state.remainingBytes) return;
const prefix = options?.messagePrefix ?? "套餐总流量不足";
const remainingGb = Math.max(0, Math.floor(state.remainingGb));
throw new Error(`${prefix},当前剩余约 ${remainingGb} GB`);
}
export async function ensurePlanTrafficPoolCapacityByBytes(
planId: string,
requestedBytes: bigint,
options?: {
db?: DbClient;
excludePendingOrderId?: string;
messagePrefix?: string;
},
) {
if (requestedBytes <= BigInt(0)) return;
const state = await getPlanTrafficPoolState(planId, {
db: options?.db,
excludePendingOrderId: options?.excludePendingOrderId,
});
if (!state.enabled) return;
if (requestedBytes <= state.remainingBytes) return;
const prefix = options?.messagePrefix ?? "套餐总流量不足";
const remainingGb = Math.max(0, Math.floor(state.remainingGb));
throw new Error(`${prefix},当前剩余约 ${remainingGb} GB`);
}

582
src/services/provision.ts Normal file
View File

@@ -0,0 +1,582 @@
import { prisma, type DbClient } from "@/lib/prisma";
import { bytesToGb, gbToBytes } from "@/lib/utils";
import { addDays } from "date-fns";
import {
ensurePlanTrafficPoolCapacityByBytes,
getPlanTrafficPoolState,
} from "@/services/plan-traffic-pool";
import { recordAuditLog } from "@/services/audit";
import { createNotification } from "@/services/notifications";
import { generateNodeClientCredential } from "@/services/node-client-credential";
import { createPanelAdapter } from "@/services/node-panel/factory";
import type {
NodeServer,
Order,
Protocol,
OrderItem,
SubscriptionPlan,
User,
} from "@prisma/client";
type PaidOrder = Order & { plan: SubscriptionPlan; user: User };
type NewOrderItem = Pick<OrderItem, "planId" | "selectedInboundId" | "trafficGb"> & {
plan: SubscriptionPlan;
};
export async function provisionSubscription(order: PaidOrder): Promise<string[]> {
return provisionSubscriptionWithDb(order, prisma);
}
export async function provisionSubscriptionWithDb(
order: PaidOrder,
db: DbClient,
): Promise<string[]> {
if (order.kind === "NEW_PURCHASE") {
return provisionNewSubscription(order, db);
}
if (order.kind === "RENEWAL") {
return applyRenewal(order, db);
}
if (order.kind === "TRAFFIC_TOPUP") {
return applyTrafficTopup(order, db);
}
throw new Error(`Unsupported order kind: ${String(order.kind)}`);
}
async function getNewPurchaseItems(order: PaidOrder, db: DbClient): Promise<NewOrderItem[]> {
const items = await db.orderItem.findMany({
where: { orderId: order.id },
include: { plan: true },
orderBy: { createdAt: "asc" },
});
if (items.length > 0) return items;
return [
{
planId: order.planId,
selectedInboundId: order.selectedInboundId,
trafficGb: order.trafficGb,
plan: order.plan,
},
];
}
async function provisionNewSubscription(order: PaidOrder, db: DbClient): Promise<string[]> {
const items = await getNewPurchaseItems(order, db);
const createdSubscriptionIds: string[] = [];
const nodeIds = new Set<string>();
for (const item of items) {
const trafficBytes = item.trafficGb ? gbToBytes(item.trafficGb) : null;
if (item.plan.type === "PROXY") {
if (!item.trafficGb || item.trafficGb <= 0 || !trafficBytes) {
throw new Error("代理订单缺少可用流量配置");
}
const poolState = await getPlanTrafficPoolState(item.planId, { db });
if (poolState.enabled) {
await ensurePlanTrafficPoolCapacityByBytes(
item.planId,
trafficBytes,
{
db,
excludePendingOrderId: order.id,
messagePrefix: "支付成功但套餐总流量池不足",
},
);
}
}
const subscription = await db.userSubscription.create({
data: {
userId: order.userId,
planId: item.planId,
startDate: new Date(),
endDate: addDays(new Date(), item.plan.durationDays),
trafficLimit: trafficBytes,
status: "ACTIVE",
},
});
createdSubscriptionIds.push(subscription.id);
if (createdSubscriptionIds.length === 1) {
await db.order.update({
where: { id: order.id },
data: { subscriptionId: subscription.id },
});
}
if (item.plan.type === "PROXY") {
const nodeId = await provisionProxyClient(
subscription.id,
order.user,
item.plan,
item.trafficGb,
item.selectedInboundId,
db,
);
if (nodeId) {
nodeIds.add(nodeId);
}
} else {
await provisionStreamingSlot(subscription.id, order.userId, item.plan, db);
}
await recordAuditLog(
{
actor: {
userId: order.userId,
email: order.user.email,
role: order.user.role,
},
action: "subscription.create",
targetType: "UserSubscription",
targetId: subscription.id,
targetLabel: item.plan.name,
message: `开通订阅 ${item.plan.name}`,
metadata: {
orderId: order.id,
planId: item.planId,
},
},
db,
);
}
await createNotification(
{
userId: order.userId,
type: "SUBSCRIPTION",
level: "SUCCESS",
title: items.length > 1 ? "订阅已全部开通" : "订阅已开通",
body:
items.length > 1
? `本次订单的 ${items.length} 个订阅已开通成功,请前往订阅页面查看。`
: `${items[0]?.plan.name ?? order.plan.name} 已开通成功,请前往订阅页面查看。`,
link: "/subscriptions",
dedupeKey: `subscription-created:${order.id}`,
},
db,
);
return [...nodeIds];
}
async function applyRenewal(order: PaidOrder, db: DbClient): Promise<string[]> {
if (!order.plan.allowRenewal) {
throw new Error("该套餐当前未开放续费");
}
if (!order.targetSubscriptionId) {
throw new Error("续费订单缺少目标订阅");
}
const subscription = await db.userSubscription.findUniqueOrThrow({
where: { id: order.targetSubscriptionId },
include: {
nodeClient: {
include: {
inbound: {
include: {
server: true,
},
},
},
},
},
});
if (subscription.userId !== order.userId || subscription.planId !== order.planId) {
throw new Error("续费目标订阅与订单不匹配");
}
if (subscription.status !== "ACTIVE" || subscription.endDate <= new Date()) {
throw new Error("续费失败:目标订阅已过期或不可用");
}
const now = new Date();
const renewalDays = order.durationDays ?? order.plan.renewalDurationDays ?? order.plan.durationDays;
if (!Number.isInteger(renewalDays) || renewalDays <= 0) {
throw new Error("续费订单天数配置缺失");
}
const nextEndDate =
subscription.endDate > now
? addDays(subscription.endDate, renewalDays)
: addDays(now, renewalDays);
await db.userSubscription.update({
where: { id: subscription.id },
data: {
endDate: nextEndDate,
status: "ACTIVE",
},
});
const proxyNodeId =
order.plan.type === "PROXY"
? await syncProxyClientQuota(
subscription,
nextEndDate,
db,
subscription.trafficLimit,
)
: null;
await createNotification(
{
userId: order.userId,
type: "SUBSCRIPTION",
level: "SUCCESS",
title: "订阅续费成功",
body: `${order.plan.name} 已续费成功,新的到期时间已更新。`,
link: "/subscriptions",
dedupeKey: `subscription-renewal:${order.id}`,
},
db,
);
await recordAuditLog(
{
actor: {
userId: order.userId,
email: order.user.email,
role: order.user.role,
},
action: "subscription.renew",
targetType: "UserSubscription",
targetId: subscription.id,
targetLabel: order.plan.name,
message: `续费订阅 ${order.plan.name}`,
metadata: {
orderId: order.id,
},
},
db,
);
return proxyNodeId ? [proxyNodeId] : [];
}
async function applyTrafficTopup(order: PaidOrder, db: DbClient): Promise<string[]> {
if (!order.plan.allowTrafficTopup) {
throw new Error("该套餐当前未开放增流量");
}
if (!order.targetSubscriptionId) {
throw new Error("增流量订单缺少目标订阅");
}
if (!order.trafficGb || order.trafficGb <= 0) {
throw new Error("增流量订单流量无效");
}
const poolState = await getPlanTrafficPoolState(order.planId, { db });
const subscription = await db.userSubscription.findUniqueOrThrow({
where: { id: order.targetSubscriptionId },
include: {
nodeClient: {
include: {
inbound: {
include: {
server: true,
},
},
},
},
},
});
if (subscription.userId !== order.userId || subscription.planId !== order.planId) {
throw new Error("增流量目标订阅与订单不匹配");
}
if (subscription.status !== "ACTIVE" || subscription.endDate <= new Date()) {
throw new Error("增流量仅在当前套餐有效期内生效");
}
const topupBytes = gbToBytes(order.trafficGb);
if (poolState.enabled) {
await ensurePlanTrafficPoolCapacityByBytes(order.planId, topupBytes, {
db,
excludePendingOrderId: order.id,
messagePrefix: "增流量后将超过套餐总流量池",
});
}
const nextTrafficLimit = (subscription.trafficLimit ?? BigInt(0)) + topupBytes;
await db.userSubscription.update({
where: { id: subscription.id },
data: {
trafficLimit: nextTrafficLimit,
status: "ACTIVE",
},
});
const nodeId = await syncProxyClientQuota(
subscription,
subscription.endDate,
db,
nextTrafficLimit,
);
await createNotification(
{
userId: order.userId,
type: "TRAFFIC",
level: "SUCCESS",
title: "流量已到账",
body: `${order.plan.name} 已增加 ${order.trafficGb} GB 流量。`,
link: "/subscriptions",
dedupeKey: `subscription-topup:${order.id}`,
},
db,
);
await recordAuditLog(
{
actor: {
userId: order.userId,
email: order.user.email,
role: order.user.role,
},
action: "subscription.topup",
targetType: "UserSubscription",
targetId: subscription.id,
targetLabel: order.plan.name,
message: `增加订阅流量 ${order.plan.name}`,
metadata: {
orderId: order.id,
trafficGb: order.trafficGb,
},
},
db,
);
return nodeId ? [nodeId] : [];
}
async function syncProxyClientQuota(
subscription: {
id: string;
nodeClient: {
id: string;
uuid: string;
email: string;
inbound: {
tag: string;
protocol: Protocol;
panelInboundId: number | null;
server: NodeServer;
};
} | null;
},
endDate: Date,
db: DbClient,
trafficLimitBytes?: bigint | null,
options?: { resetTraffic?: boolean },
): Promise<string | null> {
if (!subscription.nodeClient) return null;
await db.nodeClient.update({
where: { id: subscription.nodeClient.id },
data: {
expiryTime: endDate,
isEnabled: true,
},
});
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.updateClient({
inboundId: panelInboundId,
email: subscription.nodeClient.email,
uuid: subscription.nodeClient.uuid,
subId: subscription.id,
totalGB: trafficLimitBytes ? bytesToGb(trafficLimitBytes) : 0,
expiryTime: endDate.getTime(),
protocol: subscription.nodeClient.inbound.protocol,
enable: true,
});
if (options?.resetTraffic) {
await adapter.resetClientTraffic(panelInboundId, subscription.nodeClient.email);
await db.nodeClient.update({
where: { id: subscription.nodeClient.id },
data: {
trafficUp: BigInt(0),
trafficDown: BigInt(0),
},
});
}
return subscription.nodeClient.inbound.server.id;
}
async function provisionProxyClient(
subscriptionId: string,
user: User,
plan: SubscriptionPlan,
trafficGb: number | null,
selectedInboundId: string | null,
db: DbClient,
): Promise<string> {
if (!plan.nodeId) throw new Error("Proxy plan has no node assigned");
const server = await db.nodeServer.findUniqueOrThrow({
where: { id: plan.nodeId },
});
let inbound = null;
if (selectedInboundId) {
inbound = await db.nodeInbound.findFirst({
where: {
id: selectedInboundId,
serverId: plan.nodeId,
isActive: true,
},
});
}
if (!inbound && plan.inboundId) {
inbound = await db.nodeInbound.findFirst({
where: {
id: plan.inboundId,
serverId: plan.nodeId,
isActive: true,
},
});
}
if (!inbound) {
const option = await db.planInboundOption.findFirst({
where: { planId: plan.id, inbound: { isActive: true, serverId: plan.nodeId } },
select: { inboundId: true },
orderBy: { createdAt: "asc" },
});
if (option?.inboundId) {
inbound = await db.nodeInbound.findFirst({
where: { id: option.inboundId, serverId: plan.nodeId, isActive: true },
});
}
}
if (!inbound) {
inbound = await db.nodeInbound.findFirst({
where: { serverId: plan.nodeId, isActive: true },
orderBy: { createdAt: "asc" },
});
}
if (!inbound) throw new Error("No active inbound on this node");
const clientUuid = generateNodeClientCredential(inbound.protocol, inbound.settings);
const clientEmail = `${user.email}-${subscriptionId.slice(0, 8)}`;
const sub = await db.userSubscription.findUniqueOrThrow({
where: { id: subscriptionId },
});
if (inbound.panelInboundId == null) {
throw new Error("3x-ui 入站 ID 缺失,请先同步节点入站");
}
const adapter = createPanelAdapter(server);
await adapter.login();
await adapter.addClient({
inboundId: inbound.panelInboundId,
email: clientEmail,
uuid: clientUuid,
subId: subscriptionId,
totalGB: trafficGb || 0,
expiryTime: sub.endDate.getTime(),
protocol: inbound.protocol,
});
await db.nodeClient.create({
data: {
inboundId: inbound.id,
userId: user.id,
subscriptionId,
email: clientEmail,
uuid: clientUuid,
expiryTime: sub.endDate,
},
});
return server.id;
}
async function provisionStreamingSlot(
subscriptionId: string,
userId: string,
plan: SubscriptionPlan,
db: DbClient,
) {
const run = async (client: DbClient) => {
let selectedServiceId: string | null = null;
if (plan.streamingServiceId) {
const service = await client.streamingService.findUnique({
where: { id: plan.streamingServiceId },
select: { id: true, maxSlots: true, isActive: true },
});
if (!service || !service.isActive) {
throw new Error("绑定的流媒体服务不可用");
}
const updated = await client.streamingService.updateMany({
where: {
id: service.id,
isActive: true,
usedSlots: { lt: service.maxSlots },
},
data: { usedSlots: { increment: 1 } },
});
if (updated.count === 0) {
throw new Error("绑定的流媒体服务名额已满");
}
selectedServiceId = service.id;
} else {
const services = await client.streamingService.findMany({
where: { isActive: true },
select: { id: true, maxSlots: true },
orderBy: [{ usedSlots: "asc" }, { createdAt: "asc" }],
});
for (const service of services) {
const updated = await client.streamingService.updateMany({
where: {
id: service.id,
isActive: true,
usedSlots: { lt: service.maxSlots },
},
data: { usedSlots: { increment: 1 } },
});
if (updated.count > 0) {
selectedServiceId = service.id;
break;
}
}
if (!selectedServiceId) {
throw new Error("暂无可用流媒体名额");
}
}
await client.streamingSlot.create({
data: { serviceId: selectedServiceId, userId, subscriptionId },
});
};
if ("$transaction" in db) {
await db.$transaction(async (tx) => {
await run(tx);
});
return;
}
await run(db);
}

95
src/services/site-url.ts Normal file
View File

@@ -0,0 +1,95 @@
import { prisma, type DbClient } from "@/lib/prisma";
import { getAppConfig } from "@/services/app-config";
function stripTrailingSlash(value: string) {
return value.replace(/\/+$/, "");
}
function isLocalHost(hostname: string) {
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
}
export function normalizeSiteUrl(raw: string | null | undefined): string | null {
const value = raw?.trim();
if (!value) return null;
const withProtocol = /^https?:\/\//i.test(value) ? value : `https://${value}`;
let url: URL;
try {
url = new URL(withProtocol);
} catch {
throw new Error("站点域名格式不正确,请填写 https://example.com");
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error("站点域名仅支持 http:// 或 https://");
}
if (!url.hostname) {
throw new Error("站点域名不能为空");
}
url.search = "";
url.hash = "";
return stripTrailingSlash(`${url.origin}${url.pathname === "/" ? "" : url.pathname}`);
}
function safeNormalizeSiteUrl(raw: string | null | undefined): string | null {
try {
return normalizeSiteUrl(raw);
} catch {
return null;
}
}
function getHeaderValue(headers: Headers, name: string) {
return headers.get(name)?.split(",")[0]?.trim() || null;
}
export function getForwardedSiteUrl(headers: Headers): string | null {
const host = getHeaderValue(headers, "x-forwarded-host") ?? getHeaderValue(headers, "host");
if (!host) return null;
const proto = getHeaderValue(headers, "x-forwarded-proto") ?? (host.includes("localhost") ? "http" : "https");
const normalized = safeNormalizeSiteUrl(`${proto}://${host}`);
if (!normalized) return null;
try {
const url = new URL(normalized);
if (isLocalHost(url.hostname)) return null;
} catch {
return null;
}
return normalized;
}
export function getRequestOriginUrl(requestUrl: string | null | undefined): string | null {
if (!requestUrl) return null;
try {
const url = new URL(requestUrl);
if (isLocalHost(url.hostname)) return null;
return safeNormalizeSiteUrl(url.origin);
} catch {
return null;
}
}
export async function getConfiguredSiteUrl(db: DbClient = prisma): Promise<string | null> {
const config = await getAppConfig(db);
return safeNormalizeSiteUrl(config.siteUrl) ?? safeNormalizeSiteUrl(process.env.NEXTAUTH_URL);
}
export async function getSiteBaseUrl(options: {
headers?: Headers;
requestUrl?: string;
db?: DbClient;
allowRequestFallback?: boolean;
} = {}): Promise<string> {
const configured = await getConfiguredSiteUrl(options.db ?? prisma);
if (configured) return configured;
if (!options.allowRequestFallback) return "";
return (
options.headers ? getForwardedSiteUrl(options.headers) : null
) ?? getRequestOriginUrl(options.requestUrl) ?? "";
}

View File

@@ -0,0 +1,661 @@
import { createHmac, timingSafeEqual } from "crypto";
import { prisma } from "@/lib/prisma";
import type { Protocol } from "@prisma/client";
type JsonRecord = Record<string, unknown>;
interface PanelClient {
id?: string;
password?: string;
auth?: string;
email?: string;
flow?: string;
security?: string;
method?: string;
}
export interface ProxyNodeContext {
email?: string;
uuid: string;
inbound: {
protocol: Protocol;
port: number;
tag: string;
settings: unknown;
streamSettings: unknown;
server: {
name: string;
panelUrl: string | null;
};
};
}
interface LinkTarget {
address: string;
port: number;
securityOverride?: string;
remark?: string;
}
function getAggregateSubscriptionSecret() {
const secret = process.env.NEXTAUTH_SECRET ?? process.env.AUTH_SECRET ?? process.env.DATABASE_URL;
if (!secret) {
throw new Error("缺少订阅链接签名密钥,请配置 NEXTAUTH_SECRET");
}
return secret;
}
export function getAggregateSubscriptionToken(userId: string): string {
return createHmac("sha256", getAggregateSubscriptionSecret())
.update(`aggregate-subscription:${userId}`)
.digest("base64url");
}
export function verifyAggregateSubscriptionToken(userId: string, token: string): boolean {
const expected = getAggregateSubscriptionToken(userId);
try {
return timingSafeEqual(Buffer.from(expected), Buffer.from(token));
} catch {
return false;
}
}
function asRecord(value: unknown): JsonRecord | null {
return value && typeof value === "object" && !Array.isArray(value) ? value as JsonRecord : null;
}
function asArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function asString(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
function asBoolean(value: unknown): boolean {
return value === true;
}
function asNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function stringList(value: unknown): string[] {
if (Array.isArray(value)) {
return value.filter((item): item is string => typeof item === "string" && item.length > 0);
}
if (typeof value === "string" && value.length > 0) {
return value.split(",").map((item) => item.trim()).filter(Boolean);
}
return [];
}
function firstString(value: unknown): string | null {
return stringList(value)[0] ?? asString(value);
}
function getDeep(value: unknown, key: string): unknown {
const record = asRecord(value);
if (record) {
if (key in record) return record[key];
for (const child of Object.values(record)) {
const result = getDeep(child, key);
if (result !== undefined) return result;
}
}
for (const child of asArray(value)) {
const result = getDeep(child, key);
if (result !== undefined) return result;
}
return undefined;
}
function addParam(params: URLSearchParams, key: string, value: unknown) {
const normalized = typeof value === "number" ? String(value) : asString(value);
if (normalized) params.set(key, normalized);
}
function addParamList(params: URLSearchParams, key: string, value: unknown) {
const values = stringList(value);
if (values.length > 0) params.set(key, values.join(","));
}
function getDisplayName(nodeClient: ProxyNodeContext, target?: LinkTarget): string {
const settings = asRecord(nodeClient.inbound.settings);
const base = asString(settings?.displayName) ?? `${nodeClient.inbound.server.name}-${nodeClient.inbound.tag}`;
return target?.remark ? `${base}-${target.remark}` : base;
}
function isWildcardListen(value: string | null) {
return !value || value === "0.0.0.0" || value === "::" || value === "::0";
}
function getSettings(nodeClient: ProxyNodeContext): JsonRecord {
return asRecord(nodeClient.inbound.settings) ?? {};
}
function getStream(nodeClient: ProxyNodeContext): JsonRecord {
return asRecord(nodeClient.inbound.streamSettings) ?? {};
}
function getPanelListen(settings: JsonRecord): string | null {
const meta = asRecord(settings._jboard);
return asString(meta?.listen) ?? null;
}
function getServerAddress(nodeClient: ProxyNodeContext): string {
const settings = getSettings(nodeClient);
const listen = getPanelListen(settings);
if (!isWildcardListen(listen)) return listen!;
const server = nodeClient.inbound.server;
if (!server.panelUrl) return server.name;
try {
const withProtocol = /^https?:\/\//i.test(server.panelUrl)
? server.panelUrl
: `http://${server.panelUrl}`;
return new URL(withProtocol).hostname || server.name;
} catch {
return server.name;
}
}
function formatHost(host: string) {
if (host.includes(":") && !host.startsWith("[")) return `[${host}]`;
return host;
}
function getClients(settings: JsonRecord): PanelClient[] {
return asArray(settings.clients)
.map((item) => asRecord(item) as PanelClient | null)
.filter((item): item is PanelClient => item != null);
}
function findClient(nodeClient: ProxyNodeContext): PanelClient | null {
const settings = getSettings(nodeClient);
return getClients(settings).find((client) => {
return client.email === nodeClient.email
|| client.id === nodeClient.uuid
|| client.password === nodeClient.uuid
|| client.auth === nodeClient.uuid;
}) ?? null;
}
function firstClientValue(settings: JsonRecord, key: keyof PanelClient): string | null {
for (const client of getClients(settings)) {
const value = asString(client[key]);
if (value) return value;
}
return null;
}
function getHeaderValue(headers: unknown, headerName: string): string | null {
const record = asRecord(headers);
if (!record) return null;
const matchedKey = Object.keys(record).find((key) => key.toLowerCase() === headerName.toLowerCase());
return matchedKey ? firstString(record[matchedKey]) : null;
}
function applyPathAndHost(settings: unknown, params: URLSearchParams) {
const record = asRecord(settings);
if (!record) return;
addParam(params, "path", record.path);
addParam(params, "host", asString(record.host) ?? getHeaderValue(record.headers, "host"));
}
function applyPathAndHostObj(settings: unknown, obj: JsonRecord) {
const record = asRecord(settings);
if (!record) return;
const path = asString(record.path);
const host = asString(record.host) ?? getHeaderValue(record.headers, "host");
if (path) obj.path = path;
if (host) obj.host = host;
}
function applyTcpParams(stream: JsonRecord, params: URLSearchParams) {
const tcp = asRecord(stream.tcpSettings);
const header = asRecord(tcp?.header);
if (!header || asString(header.type) !== "http") return;
const request = asRecord(header.request);
addParam(params, "path", firstString(request?.path));
addParam(params, "host", getHeaderValue(request?.headers, "host"));
params.set("headerType", "http");
}
function applyTcpObj(stream: JsonRecord, obj: JsonRecord) {
const tcp = asRecord(stream.tcpSettings);
const header = asRecord(tcp?.header);
const type = asString(header?.type) ?? "none";
obj.type = type;
if (!header || type !== "http") return;
const request = asRecord(header.request);
const path = firstString(request?.path);
const host = getHeaderValue(request?.headers, "host");
if (path) obj.path = path;
if (host) obj.host = host;
}
function applyKcpParams(stream: JsonRecord, params: URLSearchParams) {
const kcp = asRecord(stream.kcpSettings);
const header = asRecord(kcp?.header);
addParam(params, "headerType", header?.type);
addParam(params, "seed", kcp?.seed);
addParam(params, "mtu", asNumber(kcp?.mtu));
addParam(params, "tti", asNumber(kcp?.tti));
}
function applyKcpObj(stream: JsonRecord, obj: JsonRecord) {
const kcp = asRecord(stream.kcpSettings);
const header = asRecord(kcp?.header);
const headerType = asString(header?.type);
const seed = asString(kcp?.seed);
if (headerType && headerType !== "none") obj.type = headerType;
if (seed) obj.path = seed;
const mtu = asNumber(kcp?.mtu);
const tti = asNumber(kcp?.tti);
if (mtu) obj.mtu = mtu;
if (tti) obj.tti = tti;
}
function applyGrpcParams(stream: JsonRecord, params: URLSearchParams) {
const grpc = asRecord(stream.grpcSettings);
if (!grpc) return;
addParam(params, "serviceName", grpc.serviceName);
addParam(params, "authority", grpc.authority);
if (asBoolean(grpc.multiMode)) params.set("mode", "multi");
}
function applyGrpcObj(stream: JsonRecord, obj: JsonRecord) {
const grpc = asRecord(stream.grpcSettings);
if (!grpc) return;
const serviceName = asString(grpc.serviceName);
const authority = asString(grpc.authority);
if (serviceName) obj.path = serviceName;
if (authority) obj.authority = authority;
if (asBoolean(grpc.multiMode)) obj.type = "multi";
}
function applyNetworkParams(stream: JsonRecord, network: string, params: URLSearchParams) {
switch (network) {
case "tcp":
applyTcpParams(stream, params);
break;
case "kcp":
applyKcpParams(stream, params);
break;
case "ws":
applyPathAndHost(stream.wsSettings, params);
break;
case "grpc":
applyGrpcParams(stream, params);
break;
case "httpupgrade":
applyPathAndHost(stream.httpupgradeSettings, params);
break;
case "xhttp": {
const xhttp = asRecord(stream.xhttpSettings);
applyPathAndHost(xhttp, params);
addParam(params, "mode", xhttp?.mode);
break;
}
}
}
function applyNetworkObj(stream: JsonRecord, network: string, obj: JsonRecord) {
obj.net = network;
switch (network) {
case "tcp":
applyTcpObj(stream, obj);
break;
case "kcp":
applyKcpObj(stream, obj);
break;
case "ws":
applyPathAndHostObj(stream.wsSettings, obj);
break;
case "grpc":
applyGrpcObj(stream, obj);
break;
case "httpupgrade":
applyPathAndHostObj(stream.httpupgradeSettings, obj);
break;
case "xhttp": {
const xhttp = asRecord(stream.xhttpSettings);
applyPathAndHostObj(xhttp, obj);
const mode = asString(xhttp?.mode);
if (mode) obj.type = mode;
break;
}
default:
obj.type = "none";
}
}
function hasJsonContent(value: unknown): boolean {
if (Array.isArray(value)) return value.some(hasJsonContent);
const record = asRecord(value);
if (record) return Object.values(record).some(hasJsonContent);
if (typeof value === "string") return value.length > 0;
return typeof value === "number" || value === true;
}
function applyFinalMaskParams(stream: JsonRecord, params: URLSearchParams) {
const finalmask = asRecord(stream.finalmask);
if (!finalmask || !hasJsonContent(finalmask)) return;
params.set("fm", JSON.stringify(finalmask));
}
function applyFinalMaskObj(stream: JsonRecord, obj: JsonRecord) {
const finalmask = asRecord(stream.finalmask);
if (!finalmask || !hasJsonContent(finalmask)) return;
obj.fm = JSON.stringify(finalmask);
}
function applyTlsParams(stream: JsonRecord, params: URLSearchParams, includeInsecure = false) {
params.set("security", "tls");
const tls = asRecord(stream.tlsSettings);
const nested = asRecord(tls?.settings);
addParamList(params, "alpn", tls?.alpn);
addParam(params, "sni", tls?.serverName ?? nested?.serverName);
addParam(params, "fp", nested?.fingerprint ?? tls?.fingerprint);
addParam(params, "ech", nested?.echConfigList ?? tls?.echConfigList);
if (includeInsecure && (asBoolean(nested?.allowInsecure) || asBoolean(tls?.allowInsecure))) {
params.set("insecure", "1");
}
}
function applyTlsObj(stream: JsonRecord, obj: JsonRecord) {
const tls = asRecord(stream.tlsSettings);
const nested = asRecord(tls?.settings);
const alpn = stringList(tls?.alpn);
const sni = asString(tls?.serverName) ?? asString(nested?.serverName);
const fp = asString(nested?.fingerprint) ?? asString(tls?.fingerprint);
if (alpn.length > 0) obj.alpn = alpn.join(",");
if (sni) obj.sni = sni;
if (fp) obj.fp = fp;
}
function applyRealityParams(stream: JsonRecord, params: URLSearchParams) {
params.set("security", "reality");
const reality = asRecord(stream.realitySettings);
const nested = asRecord(reality?.settings);
addParam(params, "sni", firstString(reality?.serverNames) ?? reality?.serverName);
addParam(params, "pbk", nested?.publicKey ?? reality?.publicKey);
addParam(params, "sid", firstString(reality?.shortIds) ?? reality?.shortId);
addParam(params, "fp", nested?.fingerprint ?? reality?.fingerprint);
addParam(params, "spx", nested?.spiderX ?? reality?.spiderX);
addParam(params, "pqv", nested?.mldsa65Verify ?? reality?.mldsa65Verify);
}
function buildStreamParams(
stream: JsonRecord,
options: {
settings?: JsonRecord;
includeEncryption?: boolean;
includeSecurityNone?: boolean;
includeInsecure?: boolean;
flow?: string | null;
securityOverride?: string;
} = {},
): URLSearchParams {
const params = new URLSearchParams();
const network = asString(stream.network) ?? "tcp";
const security = options.securityOverride && options.securityOverride !== "same"
? options.securityOverride
: (asString(stream.security) ?? "none");
params.set("type", network);
if (options.includeEncryption) {
params.set("encryption", asString(options.settings?.encryption) ?? "none");
}
applyNetworkParams(stream, network, params);
applyFinalMaskParams(stream, params);
if (security === "tls") {
applyTlsParams(stream, params, options.includeInsecure);
} else if (security === "reality") {
applyRealityParams(stream, params);
} else if (options.includeSecurityNone) {
params.set("security", "none");
}
if ((security === "tls" || security === "reality") && network === "tcp" && options.flow) {
params.set("flow", options.flow);
}
return params;
}
function appendQueryAndHash(base: string, params: URLSearchParams, label: string) {
const query = params.toString();
return `${base}${query ? `?${query}` : ""}#${encodeURIComponent(label)}`;
}
function getTargets(nodeClient: ProxyNodeContext): LinkTarget[] {
const stream = getStream(nodeClient);
const proxies: LinkTarget[] = [];
for (const item of asArray(stream.externalProxy)) {
const record = asRecord(item);
if (!record) continue;
const address = asString(record.dest);
const port = asNumber(record.port);
if (!address || !port) continue;
const securityOverride = asString(record.forceTls);
const remark = asString(record.remark);
proxies.push({
address,
port,
...(securityOverride ? { securityOverride } : {}),
...(remark ? { remark } : {}),
});
}
if (proxies.length > 0) return proxies;
return [{ address: getServerAddress(nodeClient), port: nodeClient.inbound.port }];
}
function getVmessSecurity(settings: JsonRecord, client: PanelClient | null) {
return asString(client?.security) ?? firstClientValue(settings, "security") ?? "auto";
}
function getVlessFlow(settings: JsonRecord, client: PanelClient | null) {
return asString(client?.flow) ?? firstClientValue(settings, "flow");
}
function buildVmessUri(nodeClient: ProxyNodeContext, target: LinkTarget, client: PanelClient | null) {
const stream = getStream(nodeClient);
const settings = getSettings(nodeClient);
const network = asString(stream.network) ?? "tcp";
const security = target.securityOverride && target.securityOverride !== "same"
? target.securityOverride
: (asString(stream.security) ?? "none");
const label = getDisplayName(nodeClient, target);
const obj: JsonRecord = {
v: "2",
ps: label,
add: target.address,
port: target.port,
id: asString(client?.id) ?? nodeClient.uuid,
scy: getVmessSecurity(settings, client),
tls: security,
type: "none",
};
applyNetworkObj(stream, network, obj);
applyFinalMaskObj(stream, obj);
if (security === "tls") applyTlsObj(stream, obj);
return `vmess://${Buffer.from(JSON.stringify(obj, null, 2)).toString("base64")}`;
}
function buildVlessUri(nodeClient: ProxyNodeContext, target: LinkTarget, client: PanelClient | null) {
const stream = getStream(nodeClient);
const settings = getSettings(nodeClient);
const params = buildStreamParams(stream, {
settings,
includeEncryption: true,
includeSecurityNone: true,
flow: getVlessFlow(settings, client),
securityOverride: target.securityOverride,
});
const uuid = asString(client?.id) ?? nodeClient.uuid;
return appendQueryAndHash(
`vless://${uuid}@${formatHost(target.address)}:${target.port}`,
params,
getDisplayName(nodeClient, target),
);
}
function buildTrojanUri(nodeClient: ProxyNodeContext, target: LinkTarget, client: PanelClient | null) {
const stream = getStream(nodeClient);
const params = buildStreamParams(stream, {
includeSecurityNone: true,
flow: asString(client?.flow),
securityOverride: target.securityOverride,
});
const password = asString(client?.password) ?? nodeClient.uuid;
return appendQueryAndHash(
`trojan://${password}@${formatHost(target.address)}:${target.port}`,
params,
getDisplayName(nodeClient, target),
);
}
function buildShadowsocksUri(nodeClient: ProxyNodeContext, target: LinkTarget, client: PanelClient | null) {
const settings = getSettings(nodeClient);
const stream = getStream(nodeClient);
const method = asString(settings.method) ?? asString(client?.method) ?? "chacha20-ietf-poly1305";
const inboundPassword = asString(settings.password) ?? asString(settings.serverKey);
const clientPassword = asString(client?.password) ?? nodeClient.uuid;
const password = method.startsWith("2022-") && inboundPassword
? `${inboundPassword}:${clientPassword}`
: clientPassword;
const encoded = Buffer.from(`${method}:${password}`).toString("base64");
const params = buildStreamParams(stream, {
securityOverride: target.securityOverride,
});
return appendQueryAndHash(
`ss://${encoded}@${formatHost(target.address)}:${target.port}`,
params,
getDisplayName(nodeClient, target),
);
}
function findSalamanderPassword(stream: JsonRecord): string | null {
const finalmask = asRecord(stream.finalmask);
for (const mask of asArray(finalmask?.udp)) {
const record = asRecord(mask);
if (record?.type !== "salamander") continue;
const settings = asRecord(record.settings);
const password = asString(settings?.password);
if (password) return password;
}
return asString(getDeep(stream, "obfsPassword")) ?? null;
}
function buildHysteriaUri(nodeClient: ProxyNodeContext, target: LinkTarget, client: PanelClient | null) {
const stream = getStream(nodeClient);
const settings = getSettings(nodeClient);
const protocol = asNumber(settings.version) === 1 ? "hysteria" : "hysteria2";
const params = new URLSearchParams();
const auth = asString(client?.auth) ?? nodeClient.uuid;
const obfsPassword = findSalamanderPassword(stream);
applyTlsParams(stream, params, true);
applyFinalMaskParams(stream, params);
if (obfsPassword) {
params.set("obfs", "salamander");
params.set("obfs-password", obfsPassword);
}
return appendQueryAndHash(
`${protocol}://${auth}@${formatHost(target.address)}:${target.port}`,
params,
getDisplayName(nodeClient, target),
);
}
export function buildSingleNodeUri(nodeClient: ProxyNodeContext): string {
const protocol = nodeClient.inbound.protocol.toLowerCase();
const client = findClient(nodeClient);
const links = getTargets(nodeClient).map((target) => {
switch (protocol) {
case "vmess":
return buildVmessUri(nodeClient, target, client);
case "vless":
return buildVlessUri(nodeClient, target, client);
case "trojan":
return buildTrojanUri(nodeClient, target, client);
case "shadowsocks":
return buildShadowsocksUri(nodeClient, target, client);
case "hysteria2":
return buildHysteriaUri(nodeClient, target, client);
default:
return "";
}
}).filter(Boolean);
return links.join("\n");
}
export async function generateSingleNodeUri(subscriptionId: string): Promise<string> {
const sub = await prisma.userSubscription.findUniqueOrThrow({
where: { id: subscriptionId },
include: {
nodeClient: {
include: {
inbound: { include: { server: true } },
},
},
},
});
if (sub.status !== "ACTIVE") return "";
if (!sub.nodeClient) return "";
return buildSingleNodeUri(sub.nodeClient);
}
export async function generateSubscriptionContent(subscriptionId: string): Promise<string> {
const uri = await generateSingleNodeUri(subscriptionId);
if (!uri) return "";
return Buffer.from(uri).toString("base64");
}
export async function generateAggregateSubscriptionContent(userId: string): Promise<string> {
const subscriptions = await prisma.userSubscription.findMany({
where: {
userId,
status: "ACTIVE",
endDate: { gt: new Date() },
plan: { type: "PROXY" },
nodeClient: { isNot: null },
},
include: {
nodeClient: {
include: {
inbound: { include: { server: true } },
},
},
},
orderBy: [{ endDate: "asc" }, { createdAt: "asc" }],
});
const content = subscriptions
.map((subscription) => subscription.nodeClient ? buildSingleNodeUri(subscription.nodeClient) : "")
.filter(Boolean)
.join("\n");
return content ? Buffer.from(content).toString("base64") : "";
}

View File

@@ -0,0 +1,15 @@
import type { SupportTicketPriority, SupportTicketStatus } from "@prisma/client";
export const supportTicketStatusLabels: Record<SupportTicketStatus, string> = {
OPEN: "待处理",
USER_REPLIED: "用户已回复",
ADMIN_REPLIED: "管理员已回复",
CLOSED: "已关闭",
};
export const supportTicketPriorityLabels: Record<SupportTicketPriority, string> = {
LOW: "低",
NORMAL: "普通",
HIGH: "高",
URGENT: "紧急",
};

142
src/services/support.ts Normal file
View File

@@ -0,0 +1,142 @@
import type { DbClient } from "@/lib/prisma";
import { prisma } from "@/lib/prisma";
const MAX_ATTACHMENT_COUNT = 3;
const MAX_ATTACHMENT_SIZE = 3 * 1024 * 1024;
const ALLOWED_ATTACHMENT_MIME_TYPES = [
"image/jpeg",
"image/png",
"image/webp",
"image/gif",
"image/avif",
] as const;
const attachmentMimeTypeSet = new Set<string>(ALLOWED_ATTACHMENT_MIME_TYPES);
const extensionMimeTypeMap = new Map<string, string>([
["jpg", "image/jpeg"],
["jpeg", "image/jpeg"],
["png", "image/png"],
["webp", "image/webp"],
["gif", "image/gif"],
["avif", "image/avif"],
]);
export const SUPPORT_ATTACHMENT_ACCEPT = ALLOWED_ATTACHMENT_MIME_TYPES.join(",");
export { supportTicketPriorityLabels, supportTicketStatusLabels } from "./support-labels";
function toFiles(input: FormDataEntryValue[]): File[] {
return input.filter((value): value is File => value instanceof File && value.size > 0);
}
function inferMimeTypeFromName(name: string) {
const extension = name.split(".").pop()?.toLowerCase();
if (!extension) {
return null;
}
return extensionMimeTypeMap.get(extension) ?? null;
}
function normalizeSupportAttachmentMimeType(file: File) {
const directType = file.type.trim().toLowerCase();
if (attachmentMimeTypeSet.has(directType)) {
return directType;
}
return inferMimeTypeFromName(file.name);
}
export function isSupportImageMimeType(mimeType: string) {
return attachmentMimeTypeSet.has(mimeType.trim().toLowerCase());
}
export function buildSupportAttachmentUrl(
attachmentId: string,
options?: { download?: boolean },
) {
const params = new URLSearchParams();
if (options?.download) {
params.set("download", "1");
}
const query = params.toString();
return `/api/support/attachments/${attachmentId}${query ? `?${query}` : ""}`;
}
export function parseSupportAttachments(entries: FormDataEntryValue[]) {
const files = toFiles(entries);
if (files.length > MAX_ATTACHMENT_COUNT) {
throw new Error(`最多上传 ${MAX_ATTACHMENT_COUNT} 张图片`);
}
for (const file of files) {
if (file.size > MAX_ATTACHMENT_SIZE) {
throw new Error(`图片 ${file.name} 超过 ${(MAX_ATTACHMENT_SIZE / 1024 / 1024).toFixed(0)}MB 限制`);
}
if (!normalizeSupportAttachmentMimeType(file)) {
throw new Error(`图片 ${file.name} 格式不支持,仅支持 JPG、PNG、WEBP、GIF、AVIF`);
}
}
return files;
}
export async function createSupportAttachments(
input: {
ticketId: string;
replyId: string;
files: File[];
},
db: DbClient = prisma,
) {
for (const file of input.files) {
const mimeType = normalizeSupportAttachmentMimeType(file);
if (!mimeType) {
throw new Error(`图片 ${file.name} 格式不支持`);
}
await db.supportTicketAttachment.create({
data: {
ticketId: input.ticketId,
replyId: input.replyId,
fileName: file.name,
mimeType,
size: file.size,
content: Buffer.from(await file.arrayBuffer()),
},
});
}
}
export async function deleteSupportTicketRecords(
ticketId: string,
db: DbClient = prisma,
) {
const links = [`/support/${ticketId}`, `/admin/support/${ticketId}`];
await db.userNotification.deleteMany({
where: {
link: {
in: links,
},
},
});
await db.supportTicketAttachment.deleteMany({
where: {
ticketId,
},
});
await db.supportTicketReply.deleteMany({
where: {
ticketId,
},
});
await db.supportTicket.delete({
where: {
id: ticketId,
},
});
}

108
src/services/task-center.ts Normal file
View File

@@ -0,0 +1,108 @@
import type { Prisma, TaskKind, TaskStatus } from "@prisma/client";
import { prisma, type DbClient } from "@/lib/prisma";
export interface TaskRunInput {
kind: TaskKind;
title: string;
targetType?: string | null;
targetId?: string | null;
payload?: Prisma.InputJsonValue;
retryable?: boolean;
triggeredById?: string | null;
}
export async function createTaskRun(
input: TaskRunInput,
db: DbClient = prisma,
) {
return db.taskRun.create({
data: {
kind: input.kind,
title: input.title,
targetType: input.targetType ?? null,
targetId: input.targetId ?? null,
payload: input.payload,
retryable: input.retryable ?? false,
triggeredById: input.triggeredById ?? null,
status: "PENDING",
},
});
}
export async function updateTaskRun(
id: string,
data: {
status?: TaskStatus;
result?: Prisma.InputJsonValue;
errorMessage?: string | null;
retryCountIncrement?: boolean;
startedAt?: Date | null;
finishedAt?: Date | null;
},
db: DbClient = prisma,
) {
return db.taskRun.update({
where: { id },
data: {
status: data.status,
result: data.result,
errorMessage: data.errorMessage,
startedAt: data.startedAt,
finishedAt: data.finishedAt,
...(data.retryCountIncrement ? { retryCount: { increment: 1 } } : {}),
},
});
}
export async function runTask<T>(
input: TaskRunInput,
runner: (taskId: string) => Promise<T>,
) {
const task = await createTaskRun(input);
await updateTaskRun(task.id, {
status: "RUNNING",
startedAt: new Date(),
});
try {
const result = await runner(task.id);
await updateTaskRun(task.id, {
status: "SUCCESS",
finishedAt: new Date(),
result: result as Prisma.InputJsonValue,
errorMessage: null,
});
return { taskId: task.id, result };
} catch (error) {
const message = error instanceof Error ? error.message : "任务失败";
await updateTaskRun(task.id, {
status: "FAILED",
finishedAt: new Date(),
errorMessage: message,
});
throw error;
}
}
export async function recordTaskFailure(
input: TaskRunInput & {
errorMessage: string;
},
db: DbClient = prisma,
) {
return db.taskRun.create({
data: {
kind: input.kind,
title: input.title,
targetType: input.targetType ?? null,
targetId: input.targetId ?? null,
payload: input.payload,
retryable: input.retryable ?? false,
triggeredById: input.triggeredById ?? null,
status: "FAILED",
errorMessage: input.errorMessage,
finishedAt: new Date(),
},
});
}

View File

@@ -0,0 +1,92 @@
import { prisma } from "@/lib/prisma";
import { syncNodeClientTraffic } from "@/services/traffic-sync";
const DEFAULT_INTERVAL_SECONDS = 60;
const MIN_INTERVAL_SECONDS = 10;
const globalForTrafficSync = globalThis as typeof globalThis & {
__jboardTrafficSyncScheduler?: TrafficSyncSchedulerState;
};
type Timer = ReturnType<typeof setTimeout>;
interface TrafficSyncSchedulerState {
started: boolean;
running: boolean;
timer: Timer | null;
}
function getState() {
if (!globalForTrafficSync.__jboardTrafficSyncScheduler) {
globalForTrafficSync.__jboardTrafficSyncScheduler = {
started: false,
running: false,
timer: null,
};
}
return globalForTrafficSync.__jboardTrafficSyncScheduler;
}
function normalizeIntervalSeconds(value: number | null | undefined) {
if (!value || !Number.isFinite(value)) return DEFAULT_INTERVAL_SECONDS;
return Math.max(MIN_INTERVAL_SECONDS, Math.trunc(value));
}
function unrefTimer(timer: Timer) {
if (typeof timer === "object" && timer && "unref" in timer && typeof timer.unref === "function") {
timer.unref();
}
}
async function getTrafficSyncSettings() {
const config = await prisma.appConfig.findUnique({
where: { id: "default" },
select: {
trafficSyncEnabled: true,
trafficSyncIntervalSeconds: true,
},
});
return {
enabled: config?.trafficSyncEnabled ?? true,
intervalSeconds: normalizeIntervalSeconds(config?.trafficSyncIntervalSeconds),
};
}
function scheduleNext(state: TrafficSyncSchedulerState, intervalSeconds: number) {
state.timer = setTimeout(() => {
void runTrafficSyncCycle(state);
}, intervalSeconds * 1000);
unrefTimer(state.timer);
}
async function runTrafficSyncCycle(state: TrafficSyncSchedulerState) {
let intervalSeconds = DEFAULT_INTERVAL_SECONDS;
try {
const settings = await getTrafficSyncSettings();
intervalSeconds = settings.intervalSeconds;
if (settings.enabled && !state.running) {
state.running = true;
try {
await syncNodeClientTraffic({ maxAgeMs: 0 });
} finally {
state.running = false;
}
}
} catch (error) {
console.error("J-Board traffic sync scheduler failed", error);
} finally {
scheduleNext(state, intervalSeconds);
}
}
export function startTrafficSyncScheduler() {
if (process.env.JBOARD_TRAFFIC_SYNC_SCHEDULER === "false") return;
const state = getState();
if (state.started) return;
state.started = true;
scheduleNext(state, 5);
}

View File

@@ -0,0 +1,180 @@
import type { Prisma } from "@prisma/client";
import { prisma, type DbClient } from "@/lib/prisma";
import { createPanelAdapter } from "@/services/node-panel/factory";
import type { PanelClientStat } from "@/services/node-panel/types";
const DEFAULT_STALE_MS = 60 * 1000;
const syncClientInclude = {
inbound: {
include: {
server: true,
},
},
subscription: true,
} satisfies Prisma.NodeClientInclude;
type SyncClient = Prisma.NodeClientGetPayload<{
include: typeof syncClientInclude;
}>;
export interface TrafficSyncResult {
scanned: number;
synced: number;
skipped: number;
failed: number;
uploadDelta: string;
downloadDelta: string;
errors: string[];
}
interface SyncTrafficOptions {
db?: DbClient;
userId?: string;
subscriptionId?: string;
maxAgeMs?: number;
throwOnError?: boolean;
}
function toBytes(value: unknown): bigint {
if (typeof value === "bigint") return value > BigInt(0) ? value : BigInt(0);
if (typeof value === "number" && Number.isFinite(value)) {
return BigInt(Math.max(0, Math.trunc(value)));
}
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed)) return BigInt(Math.max(0, Math.trunc(parsed)));
}
return BigInt(0);
}
function normalizeEmail(value: string) {
return value.trim().toLowerCase();
}
function groupClients(clients: SyncClient[]) {
const groups = new Map<string, SyncClient[]>();
for (const client of clients) {
const panelInboundId = client.inbound.panelInboundId;
if (panelInboundId == null) continue;
const key = `${client.inbound.serverId}:${panelInboundId}`;
groups.set(key, [...(groups.get(key) ?? []), client]);
}
return groups;
}
async function applyClientTraffic(
db: DbClient,
client: SyncClient,
stat: PanelClientStat,
) {
const nextUp = toBytes(stat.up);
const nextDown = toBytes(stat.down);
const previousUp = client.trafficUp;
const previousDown = client.trafficDown;
const uploadDelta = nextUp >= previousUp ? nextUp - previousUp : nextUp;
const downloadDelta = nextDown >= previousDown ? nextDown - previousDown : nextDown;
const trafficUsed = nextUp + nextDown;
await db.nodeClient.update({
where: { id: client.id },
data: {
trafficUp: nextUp,
trafficDown: nextDown,
isEnabled: typeof stat.enable === "boolean" ? stat.enable : client.isEnabled,
},
});
await db.userSubscription.update({
where: { id: client.subscriptionId },
data: { trafficUsed },
});
if (uploadDelta > BigInt(0) || downloadDelta > BigInt(0)) {
await db.trafficLog.create({
data: {
clientId: client.id,
upload: uploadDelta,
download: downloadDelta,
},
});
}
return { uploadDelta, downloadDelta };
}
export async function syncNodeClientTraffic(options: SyncTrafficOptions = {}): Promise<TrafficSyncResult> {
const db = options.db ?? prisma;
const staleMs = options.maxAgeMs ?? DEFAULT_STALE_MS;
const staleBefore = new Date(Date.now() - staleMs);
const clients = await db.nodeClient.findMany({
where: {
...(options.userId ? { userId: options.userId } : {}),
...(options.subscriptionId ? { subscriptionId: options.subscriptionId } : {}),
updatedAt: { lt: staleBefore },
inbound: {
panelInboundId: { not: null },
server: {
panelUrl: { not: null },
panelUsername: { not: null },
panelPassword: { not: null },
},
},
subscription: {
status: "ACTIVE",
endDate: { gt: new Date() },
},
},
include: syncClientInclude,
orderBy: { updatedAt: "asc" },
});
const result: TrafficSyncResult = {
scanned: clients.length,
synced: 0,
skipped: 0,
failed: 0,
uploadDelta: "0",
downloadDelta: "0",
errors: [],
};
const groups = groupClients(clients);
for (const groupClientsForInbound of groups.values()) {
const first = groupClientsForInbound[0];
if (!first || first.inbound.panelInboundId == null) continue;
try {
const adapter = createPanelAdapter(first.inbound.server);
await adapter.login();
const stats = await adapter.getAllClientTraffics(first.inbound.panelInboundId);
const statMap = new Map(stats.map((stat) => [normalizeEmail(stat.email), stat]));
for (const client of groupClientsForInbound) {
let stat = statMap.get(normalizeEmail(client.email));
if (!stat) {
stat = await adapter.getClientTraffic(client.email) ?? undefined;
}
if (!stat) {
result.skipped += 1;
continue;
}
const delta = await applyClientTraffic(db, client, stat);
result.synced += 1;
result.uploadDelta = (BigInt(result.uploadDelta) + delta.uploadDelta).toString();
result.downloadDelta = (BigInt(result.downloadDelta) + delta.downloadDelta).toString();
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
result.failed += groupClientsForInbound.length;
result.errors.push(`${first.inbound.server.name}: ${message}`);
}
}
if (options.throwOnError && result.errors.length > 0) {
throw new Error(result.errors.join(""));
}
return result;
}