mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
Initial commit
This commit is contained in:
125
src/services/announcements.ts
Normal file
125
src/services/announcements.ts
Normal 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);
|
||||
}
|
||||
16
src/services/app-config.ts
Normal file
16
src/services/app-config.ts
Normal 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
54
src/services/audit.ts
Normal 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
225
src/services/commerce.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
95
src/services/database-backup.ts
Normal file
95
src/services/database-backup.ts
Normal 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);
|
||||
}
|
||||
81
src/services/invite-rewards.ts
Normal file
81
src/services/invite-rewards.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
18
src/services/latency-recommendation-types.ts
Normal file
18
src/services/latency-recommendation-types.ts
Normal 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;
|
||||
}
|
||||
67
src/services/latency-recommendations.ts
Normal file
67
src/services/latency-recommendations.ts
Normal 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);
|
||||
}
|
||||
48
src/services/node-client-credential.ts
Normal file
48
src/services/node-client-credential.ts
Normal 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");
|
||||
}
|
||||
13
src/services/node-panel/adapter.ts
Normal file
13
src/services/node-panel/adapter.ts
Normal 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>;
|
||||
}
|
||||
14
src/services/node-panel/factory.ts
Normal file
14
src/services/node-panel/factory.ts
Normal 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);
|
||||
}
|
||||
229
src/services/node-panel/sync-inbounds.ts
Normal file
229
src/services/node-panel/sync-inbounds.ts
Normal 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)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
333
src/services/node-panel/three-x-ui.ts
Normal file
333
src/services/node-panel/three-x-ui.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
28
src/services/node-panel/types.ts
Normal file
28
src/services/node-panel/types.ts
Normal 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;
|
||||
}
|
||||
198
src/services/notifications.ts
Normal file
198
src/services/notifications.ts
Normal 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;
|
||||
}
|
||||
31
src/services/payment/adapter.ts
Normal file
31
src/services/payment/adapter.ts
Normal 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>;
|
||||
}
|
||||
161
src/services/payment/alipay-f2f.ts
Normal file
161
src/services/payment/alipay-f2f.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
134
src/services/payment/catalog.ts
Normal file
134
src/services/payment/catalog.ts
Normal 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);
|
||||
}
|
||||
139
src/services/payment/epay.ts
Normal file
139
src/services/payment/epay.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
84
src/services/payment/factory.ts
Normal file
84
src/services/payment/factory.ts
Normal 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;
|
||||
}
|
||||
164
src/services/payment/process.ts
Normal file
164
src/services/payment/process.ts
Normal 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);
|
||||
}
|
||||
115
src/services/payment/usdt-trc20.ts
Normal file
115
src/services/payment/usdt-trc20.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
245
src/services/plan-availability.ts
Normal file
245
src/services/plan-availability.ts
Normal 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);
|
||||
}
|
||||
176
src/services/plan-traffic-pool.ts
Normal file
176
src/services/plan-traffic-pool.ts
Normal 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
582
src/services/provision.ts
Normal 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
95
src/services/site-url.ts
Normal 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) ?? "";
|
||||
}
|
||||
661
src/services/subscription.ts
Normal file
661
src/services/subscription.ts
Normal 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") : "";
|
||||
}
|
||||
15
src/services/support-labels.ts
Normal file
15
src/services/support-labels.ts
Normal 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
142
src/services/support.ts
Normal 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
108
src/services/task-center.ts
Normal 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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
92
src/services/traffic-sync-scheduler.ts
Normal file
92
src/services/traffic-sync-scheduler.ts
Normal 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);
|
||||
}
|
||||
180
src/services/traffic-sync.ts
Normal file
180
src/services/traffic-sync.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user