feat: add log cleanup controls

This commit is contained in:
JetSprow
2026-04-30 17:10:53 +10:00
parent 042c5b34ab
commit 1e194aabdb
18 changed files with 598 additions and 4 deletions

View File

@@ -20,6 +20,9 @@ ENCRYPTION_KEY="0123456789abcdef0123456789abcdef"
# Optional GeoIP MMDB path. The repository includes a default City database at data/GeoLite2-City.mmdb.
GEOIP_MMDB_PATH="data/GeoLite2-City.mmdb"
# Optional: set to false to disable the in-process expired log cleanup scheduler.
JBOARD_LOG_CLEANUP_SCHEDULER="true"
# Initial admin account, used by npm run db:seed on first install
ADMIN_EMAIL="admin@jboard.local"
ADMIN_PASSWORD="admin123"

View File

@@ -46,6 +46,7 @@ J-Board Lite 只保存售卖和展示需要的节点镜像数据。入站协议
- 用户、订单、套餐、订阅、流媒体服务、支付配置。
- SMTP 邮件服务设置、注册邮箱验证开关、邮件模板发送。
- 公告、工单、系统设置、审计日志、任务中心、备份恢复。
- 日志清理:审计、任务、流量、延迟和风控日志支持手动删除与自动过期清理。
- 支持工单上限配置,默认每个用户最多开启 2 个未关闭工单。
- 流量视图:基于本地订阅与 3x-ui 同步结果展示客户端用量。
- 订阅访问风控IP、城市、省/地区、国家变化审查。
@@ -119,10 +120,13 @@ J-Board Lite 面板和 Agent 使用相对独立的版本节奏。
| `ENCRYPTION_KEY` | 敏感信息加密密钥 | 至少 32 字节。生产使用后不要更换,否则 3x-ui 密码、探测 Token、SMTP 密码、流媒体凭据等已加密数据会无法解密。 |
| `DATABASE_URL` | SQLite 文件地址 | 本地默认 `file:./storage/jboard.db`Docker 部署时 Compose 会覆盖为容器内 `/app/storage/jboard.db`。 |
| `GEOIP_MMDB_PATH` | GeoIP 城市库 | 默认 `data/GeoLite2-City.mmdb`。可换成自己的 MaxMind City MMDB。 |
| `JBOARD_LOG_CLEANUP_SCHEDULER` | 日志清理定时器 | 默认启用。设为 `false` 可关闭进程内自动清理任务。 |
| `ADMIN_EMAIL` / `ADMIN_PASSWORD` / `ADMIN_NAME` | 初始管理员 | 首次 `db:seed` 创建管理员账号。已有数据库不会强制重置旧管理员密码。 |
SMTP 邮件服务、注册邮箱验证开关、支付方式、3x-ui 节点等业务配置在管理后台填写,不建议写进 `.env`
日志清理在后台“系统设置 -> 日志清理”中配置。默认每天最多自动清理一次 30 天前日志,范围包含审计日志、任务记录、流量日志、节点延迟日志、风控访问日志和风控事件;正在生效的用户端风控限制不会被自动清理。管理员也可以在后台选择日志范围和天数,立即手动清理过期日志。
## 一键部署
适合全新 Linux 服务器。脚本会安装基础依赖、安装 Docker 与 Compose 插件、拉取代码、生成 `.env`、初始化数据库并启动面板。

View File

@@ -313,6 +313,13 @@ Server Actions 是后台和用户端写操作的主要入口。它们不是公
后台“订阅风控”页面依赖 `src/services/subscription-risk-review.ts` 整理地图、IP、分析日志和报告文本。
#### 日志清理:`src/actions/admin/logs.ts`
- `deleteAdminLogEntry({ target, id })`:删除单条审计、任务、流量、节点延迟、风控访问或风控事件日志。
- `cleanupExpiredAdminLogs({ target, cutoffDays })`:按日志范围和保留天数手动清理过期日志,并写入审计日志。
自动清理由 `src/services/log-cleanup-scheduler.ts` 启动,默认每天最多执行一次,读取 `AppConfig.logCleanupEnabled``AppConfig.logRetentionDays`。自动清理和手动过期清理都会保留仍处于用户端限制中的风控事件。
#### 订单:`src/actions/admin/orders.ts`
- `confirmOrder(orderId)`:手动确认订单并触发开通。
@@ -329,6 +336,7 @@ Server Actions 是后台和用户端写操作的主要入口。它们不是公
- 公告:`src/actions/admin/announcements.ts`
- 工单:`src/actions/admin/support.ts`
- 系统设置:`src/actions/admin/settings.ts`
- 日志清理:`src/actions/admin/logs.ts`
- 备份恢复:`src/actions/admin/backups.ts`
- 任务重试:`src/actions/admin/tasks.ts`
- 流量视图刷新:`src/actions/admin/traffic.ts`
@@ -349,7 +357,7 @@ Server Actions 是后台和用户端写操作的主要入口。它们不是公
- `SubscriptionAccessLog`:保存订阅 API 访问和节点真实连接证据。
- `SubscriptionRiskEvent`:保存风控事件、复核状态、报告、用户端限制和最终处理动作。
- `SubscriptionRiskReason`:包含城市、省/地区、国家变化,以及节点高频、目标分散等原因。
- `AppConfig`:保存订阅风控总控、自动暂停开关、阈值、节点日志风控阈值。
- `AppConfig`:保存订阅风控总控、自动暂停开关、阈值、节点日志风控阈值,以及日志清理开关、保留天数和上次清理时间
- `NodeClient.email`:用于匹配 Xray access log 中的 `email:`。它可能形如 `user@example.com-cmojtnp3`,不要手动在 3x-ui 修改。
## 错误处理约定

View File

@@ -786,6 +786,9 @@ model AppConfig {
reminderDispatchIntervalMinutes Int @default(60)
trafficSyncEnabled Boolean @default(true)
trafficSyncIntervalSeconds Int @default(60)
logCleanupEnabled Boolean @default(true)
logRetentionDays Int @default(30)
logCleanupLastRunAt DateTime?
networkRecommendationsEnabled Boolean @default(false)
networkInsightsEnabled Boolean @default(false)
subscriptionRiskEnabled Boolean @default(true)

111
src/actions/admin/logs.ts Normal file
View File

@@ -0,0 +1,111 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { requireAdmin } from "@/lib/require-auth";
import { actorFromSession, recordAuditLog } from "@/services/audit";
import {
cleanupExpiredLogs,
cutoffFromDays,
deleteLogEntry,
logCleanupTargetLabels,
logCleanupTargets,
summarizeLogCleanup,
type LogCleanupSummary,
} from "@/services/log-cleanup";
import { getErrorMessage } from "@/lib/errors";
const deleteTargets = logCleanupTargets.filter((target) => target !== "ALL") as [
Exclude<(typeof logCleanupTargets)[number], "ALL">,
...Exclude<(typeof logCleanupTargets)[number], "ALL">[],
];
const deleteLogSchema = z.object({
target: z.enum(deleteTargets),
id: z.string().trim().min(1),
});
const cleanupExpiredLogsSchema = z.object({
target: z.enum(logCleanupTargets),
cutoffDays: z.coerce.number().int().min(1).max(3650),
});
export type CleanupExpiredLogsResult =
| { ok: true; summary: LogCleanupSummary; message: string }
| { ok: false; error: string };
function revalidateLogViews() {
revalidatePath("/admin/audit-logs");
revalidatePath("/admin/tasks");
revalidatePath("/admin/traffic");
revalidatePath("/admin/subscription-risk");
revalidatePath("/admin/settings");
revalidatePath("/admin/subscriptions");
}
export async function deleteAdminLogEntry(input: {
target: Exclude<(typeof logCleanupTargets)[number], "ALL">;
id: string;
}) {
const session = await requireAdmin();
const actor = actorFromSession(session);
const { target, id } = deleteLogSchema.parse(input);
await deleteLogEntry({ target, id });
await recordAuditLog({
actor,
action: "logs.delete",
targetType: "LogEntry",
targetId: id,
targetLabel: logCleanupTargetLabels[target],
message: `删除${logCleanupTargetLabels[target]}记录`,
metadata: { target, deletedId: id },
});
revalidateLogViews();
}
export async function cleanupExpiredAdminLogs(input: {
target: (typeof logCleanupTargets)[number];
cutoffDays: number;
}): Promise<CleanupExpiredLogsResult> {
try {
const session = await requireAdmin();
const actor = actorFromSession(session);
const parsed = cleanupExpiredLogsSchema.parse(input);
const cutoff = cutoffFromDays(parsed.cutoffDays);
const summary = await cleanupExpiredLogs({
target: parsed.target,
cutoff,
keepActiveRiskRestrictions: true,
});
const message = summarizeLogCleanup(summary);
await prisma.appConfig.update({
where: { id: "default" },
data: { logCleanupLastRunAt: new Date() },
}).catch(() => null);
await recordAuditLog({
actor,
action: "logs.cleanup",
targetType: "LogCleanup",
targetLabel: logCleanupTargetLabels[parsed.target],
message: `手动清理 ${parsed.cutoffDays} 天前的${logCleanupTargetLabels[parsed.target]}${message}`,
metadata: {
target: parsed.target,
cutoffDays: parsed.cutoffDays,
cutoff: cutoff.toISOString(),
summary,
},
});
revalidateLogViews();
return { ok: true, summary, message };
} catch (error) {
return { ok: false, error: getErrorMessage(error, "清理日志失败") };
}
}

View File

@@ -36,6 +36,8 @@ const settingsSchema = z.object({
reminderDispatchIntervalMinutes: z.coerce.number().int().positive().optional(),
trafficSyncEnabled: z.string().optional(),
trafficSyncIntervalSeconds: z.coerce.number().int().min(10).optional(),
logCleanupEnabled: z.string().optional(),
logRetentionDays: z.coerce.number().int().min(1).max(3650).optional(),
networkRecommendationsEnabled: z.string().optional(),
networkInsightsEnabled: z.string().optional(),
subscriptionRiskEnabled: z.string().optional(),
@@ -119,6 +121,7 @@ function booleanSettingData(field: BooleanSettingField, value: boolean) {
requireInviteCode: { requireInviteCode: value },
autoReminderDispatchEnabled: { autoReminderDispatchEnabled: value },
trafficSyncEnabled: { trafficSyncEnabled: value },
logCleanupEnabled: { logCleanupEnabled: value },
networkRecommendationsEnabled: { networkRecommendationsEnabled: value },
networkInsightsEnabled: { networkInsightsEnabled: value },
subscriptionRiskEnabled: { subscriptionRiskEnabled: value },
@@ -189,6 +192,8 @@ function buildSettingsUpdate(parsed: z.infer<typeof settingsSchema>, current: Aw
trafficSyncEnabled: optionalBoolean(parsed.trafficSyncEnabled, current.trafficSyncEnabled),
trafficSyncIntervalSeconds:
parsed.trafficSyncIntervalSeconds ?? current.trafficSyncIntervalSeconds,
logCleanupEnabled: optionalBoolean(parsed.logCleanupEnabled, current.logCleanupEnabled),
logRetentionDays: parsed.logRetentionDays ?? current.logRetentionDays,
networkRecommendationsEnabled: optionalBoolean(
parsed.networkRecommendationsEnabled,
current.networkRecommendationsEnabled,

View File

@@ -16,6 +16,7 @@ import {
formatAuditTargetLabel,
formatAuditTargetType,
} from "@/lib/audit-display";
import { LogDeleteButton } from "@/components/admin/log-delete-button";
import { formatDate } from "@/lib/utils";
export function AuditLogsTable({ logs }: { logs: AuditLog[] }) {
@@ -33,6 +34,7 @@ export function AuditLogsTable({ logs }: { logs: AuditLog[] }) {
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell></DataTableHeadCell>
<DataTableHeadCell className="text-right"></DataTableHeadCell>
</DataTableHeaderRow>
</DataTableHead>
<DataTableBody>
@@ -61,6 +63,17 @@ export function AuditLogsTable({ logs }: { logs: AuditLog[] }) {
<DataTableCell className="max-w-xl whitespace-pre-wrap break-words text-muted-foreground">
{formatAuditMessage(log.message)}
</DataTableCell>
<DataTableCell>
<div className="flex justify-end">
<LogDeleteButton
id={log.id}
target="AUDIT_LOGS"
title="删除这条审计日志?"
description="删除后无法恢复。系统会记录一条新的删除审计,用于保留后台操作痕迹。"
successMessage="审计日志已删除"
/>
</div>
</DataTableCell>
</DataTableRow>
))}
</DataTableBody>

View File

@@ -37,6 +37,9 @@ export default async function AdminSettingsPage() {
reminderDispatchIntervalMinutes: config.reminderDispatchIntervalMinutes,
trafficSyncEnabled: config.trafficSyncEnabled,
trafficSyncIntervalSeconds: config.trafficSyncIntervalSeconds,
logCleanupEnabled: config.logCleanupEnabled,
logRetentionDays: config.logRetentionDays,
logCleanupLastRunAt: config.logCleanupLastRunAt,
networkRecommendationsEnabled: config.networkRecommendationsEnabled,
networkInsightsEnabled: config.networkInsightsEnabled,
subscriptionRiskEnabled: config.subscriptionRiskEnabled,

View File

@@ -2,7 +2,9 @@
import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
import { Bell, ChevronDown, Clock3, Gift, LifeBuoy, Mail, RadioTower, Send, Settings2, ShieldAlert, ShieldCheck } from "lucide-react";
import { Bell, ChevronDown, Clock3, Gift, LifeBuoy, Mail, RadioTower, Send, Settings2, ShieldAlert, ShieldCheck, Trash2 } from "lucide-react";
import { cleanupExpiredAdminLogs } from "@/actions/admin/logs";
import { ConfirmActionButton } from "@/components/shared/confirm-action-button";
import { BooleanToggle } from "@/components/ui/boolean-toggle";
import { Button, buttonVariants } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -16,10 +18,12 @@ import {
} from "@/actions/admin/settings";
import { toast } from "sonner";
import { getErrorMessage } from "@/lib/errors";
import { formatDate } from "@/lib/utils";
import {
booleanAppSettingLabels,
type BooleanAppSettingField,
} from "@/lib/domain-labels";
import type { LogCleanupTarget } from "@/services/log-cleanup";
interface AppConfig {
siteName: string;
@@ -36,6 +40,9 @@ interface AppConfig {
reminderDispatchIntervalMinutes: number;
trafficSyncEnabled: boolean;
trafficSyncIntervalSeconds: number;
logCleanupEnabled: boolean;
logRetentionDays: number;
logCleanupLastRunAt: Date | string | null;
networkRecommendationsEnabled: boolean;
networkInsightsEnabled: boolean;
subscriptionRiskEnabled: boolean;
@@ -76,6 +83,16 @@ interface CouponOption {
const selectClassName = "premium-input w-full appearance-none px-3.5 py-2 text-sm outline-none";
const logCleanupTargetOptions = [
{ value: "ALL", label: "全部日志" },
{ value: "AUDIT_LOGS", label: "审计日志" },
{ value: "TASK_RUNS", label: "任务记录" },
{ value: "TRAFFIC_LOGS", label: "流量日志" },
{ value: "NODE_LATENCY_LOGS", label: "节点延迟日志" },
{ value: "SUBSCRIPTION_ACCESS_LOGS", label: "风控访问日志" },
{ value: "SUBSCRIPTION_RISK_EVENTS", label: "风控事件" },
] satisfies Array<{ value: LogCleanupTarget; label: string }>;
type ToggleValues = Record<BooleanAppSettingField, boolean>;
const booleanSettingLabels = booleanAppSettingLabels;
@@ -87,6 +104,7 @@ function initialToggleValues(config: AppConfig): ToggleValues {
requireInviteCode: config.requireInviteCode,
autoReminderDispatchEnabled: config.autoReminderDispatchEnabled,
trafficSyncEnabled: config.trafficSyncEnabled,
logCleanupEnabled: config.logCleanupEnabled,
networkRecommendationsEnabled: config.networkRecommendationsEnabled,
networkInsightsEnabled: config.networkInsightsEnabled,
subscriptionRiskEnabled: config.subscriptionRiskEnabled,
@@ -102,6 +120,9 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
const router = useRouter();
const [saving, setSaving] = useState(false);
const [testingEmail, setTestingEmail] = useState(false);
const [cleaningLogs, setCleaningLogs] = useState(false);
const [cleanupTarget, setCleanupTarget] = useState<LogCleanupTarget>("ALL");
const [manualCleanupDays, setManualCleanupDays] = useState(config.logRetentionDays);
const [riskSettingsOpen, setRiskSettingsOpen] = useState(false);
const [toggleValues, setToggleValues] = useState<ToggleValues>(() => initialToggleValues(config));
const [pendingToggles, setPendingToggles] = useState<Partial<Record<BooleanSettingField, boolean>>>({});
@@ -225,6 +246,24 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
}
}
async function handleCleanupExpiredLogs() {
if (cleaningLogs) return;
const cutoffDays = Math.min(3650, Math.max(1, Math.trunc(Number(manualCleanupDays) || config.logRetentionDays || 30)));
setCleaningLogs(true);
try {
const result = await cleanupExpiredAdminLogs({ target: cleanupTarget, cutoffDays });
if (!result.ok) {
throw new Error(result.error);
}
setManualCleanupDays(cutoffDays);
router.refresh();
toast.success(`日志清理完成:${result.message}`);
} finally {
setCleaningLogs(false);
}
}
function clearPasswordField(form: HTMLFormElement) {
const password = form.elements.namedItem("smtpPassword");
if (password instanceof HTMLInputElement) {
@@ -331,6 +370,84 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
</div>
</section>
<section className="space-y-4 rounded-lg border border-border bg-muted/25 p-3">
<div className="flex items-center gap-2 text-sm font-semibold">
<Trash2 className="size-4 text-primary" />
</div>
<p className="text-xs leading-5 text-muted-foreground">
30
</p>
<div className="grid gap-5 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="logCleanupEnabled"></Label>
{renderImmediateToggle("logCleanupEnabled", { id: "logCleanupEnabled" })}
</div>
<div className="space-y-2">
<Label htmlFor="logRetentionDays"></Label>
<Input
id="logRetentionDays"
name="logRetentionDays"
type="number"
min={1}
max={3650}
step={1}
defaultValue={config.logRetentionDays}
/>
</div>
<div className="space-y-2">
<Label></Label>
<div className="flex min-h-10 items-center rounded-lg border border-border bg-background px-3 text-sm text-muted-foreground">
{config.logCleanupLastRunAt ? formatDate(config.logCleanupLastRunAt) : "尚未执行"}
</div>
</div>
</div>
<div className="border-t border-border/60 pt-4">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_12rem_auto] lg:items-end">
<div className="space-y-2">
<Label htmlFor="manualCleanupTarget"></Label>
<select
id="manualCleanupTarget"
value={cleanupTarget}
onChange={(event) => setCleanupTarget(event.target.value as LogCleanupTarget)}
className={selectClassName}
>
{logCleanupTargetOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div className="space-y-2">
<Label htmlFor="manualCleanupDays"></Label>
<Input
id="manualCleanupDays"
type="number"
min={1}
max={3650}
step={1}
value={manualCleanupDays}
onChange={(event) => setManualCleanupDays(Number(event.target.value))}
/>
</div>
<ConfirmActionButton
title="清理过期日志?"
description={`将删除 ${manualCleanupDays || config.logRetentionDays || 30} 天前的${logCleanupTargetOptions.find((option) => option.value === cleanupTarget)?.label ?? "日志"}。删除后无法恢复。`}
confirmLabel="开始清理"
errorMessage="清理日志失败"
disabled={saving || hasPendingToggle || cleaningLogs}
onConfirm={handleCleanupExpiredLogs}
>
<Trash2 className="size-4" />
{cleaningLogs ? "清理中..." : "清理过期日志"}
</ConfirmActionButton>
</div>
<p className="mt-3 text-xs leading-5 text-muted-foreground">
</p>
</div>
</section>
<section className="space-y-4 rounded-lg border border-border bg-muted/25 p-3">
<div className="flex items-center gap-2 text-sm font-semibold">
<RadioTower className="size-4 text-primary" /> 线

View File

@@ -1,4 +1,5 @@
import { ChevronDown, Globe2, MapPin, ScrollText } from "lucide-react";
import { LogDeleteButton } from "@/components/admin/log-delete-button";
import { StatusBadge } from "@/components/shared/status-badge";
import { WORLD_COUNTRY_PATHS } from "@/components/shared/world-map-paths";
import { formatDate } from "@/lib/utils";
@@ -132,6 +133,13 @@ function AnalysisLogDetails({ summary }: { summary: SubscriptionRiskGeoSummary }
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
<StatusBadge tone={log.source === "节点 Xray 日志" ? "info" : "neutral"}>{log.source}</StatusBadge>
<StatusBadge tone={log.allowed ? "success" : "warning"}>{log.allowed ? "放行" : "拦截"}</StatusBadge>
<LogDeleteButton
id={log.id}
target="SUBSCRIPTION_ACCESS_LOGS"
title="删除这条风控访问日志?"
description="删除后无法恢复,只会移除这条访问或节点连接证据,不会删除用户、订阅或风控事件。"
successMessage="风控访问日志已删除"
/>
</div>
</div>
<p className="mt-2 break-words text-xs leading-5 text-muted-foreground">{log.location}</p>

View File

@@ -1,6 +1,7 @@
import Link from "next/link";
import type { SubscriptionRiskEvent } from "@prisma/client";
import { ChevronDown } from "lucide-react";
import { LogDeleteButton } from "@/components/admin/log-delete-button";
import {
SubscriptionStatusBadge,
SubscriptionTypeBadge,
@@ -251,6 +252,22 @@ function RiskEventCard({ event }: { event: SubscriptionRiskEventRow }) {
finalAction={event.finalAction}
/>
</div>
<div className="border-t border-border/60 pt-4">
<LogDeleteButton
id={event.id}
target="SUBSCRIPTION_RISK_EVENTS"
label="删除事件"
title="删除这条风控事件?"
description={
event.userRestrictionActive
? "删除后无法恢复。此事件当前仍有用户端限制标记,请先确认是否需要在处理动作里解除限制。"
: "删除后无法恢复,只会移除这条风控事件,不会删除用户、订阅或访问日志。"
}
successMessage="风控事件已删除"
className="w-full justify-center text-destructive hover:text-destructive"
size="sm"
/>
</div>
</aside>
</div>
</div>

View File

@@ -1,6 +1,7 @@
import { batchRetryTaskRuns, retryTaskRun } from "@/actions/admin/tasks";
import { BatchActionBar, BatchActionButton } from "@/components/admin/batch-action-bar";
import { DataTableShell } from "@/components/admin/data-table-shell";
import { LogDeleteButton } from "@/components/admin/log-delete-button";
import {
DataTable,
DataTableBody,
@@ -77,7 +78,7 @@ export function TaskRunsTable({ tasks }: TaskRunsTableProps) {
{task.errorMessage || "—"}
</DataTableCell>
<DataTableCell>
<div className="flex justify-end">
<div className="flex justify-end gap-2">
{task.retryable && task.status === "FAILED" && (
<form
action={async () => {
@@ -88,6 +89,13 @@ export function TaskRunsTable({ tasks }: TaskRunsTableProps) {
<PendingSubmitButton size="sm" variant="outline" pendingLabel="重试中..."></PendingSubmitButton>
</form>
)}
<LogDeleteButton
id={task.id}
target="TASK_RUNS"
title="删除这条任务记录?"
description="删除后无法恢复,只会移除任务执行记录,不会撤销任务已经产生的业务结果。"
successMessage="任务记录已删除"
/>
</div>
</DataTableCell>
</DataTableRow>

View File

@@ -0,0 +1,49 @@
"use client";
import { useRouter } from "next/navigation";
import { Trash2 } from "lucide-react";
import { deleteAdminLogEntry } from "@/actions/admin/logs";
import { ConfirmActionButton } from "@/components/shared/confirm-action-button";
import type { LogDeleteTarget } from "@/services/log-cleanup";
interface LogDeleteButtonProps {
id: string;
target: LogDeleteTarget;
label?: string;
title?: string;
description?: string;
successMessage?: string;
className?: string;
size?: "xs" | "sm" | "default";
}
export function LogDeleteButton({
id,
target,
label = "删除",
title = "删除这条日志?",
description = "删除后无法恢复,只会移除这条日志记录,不会删除关联业务数据。",
successMessage = "日志已删除",
className = "text-destructive hover:text-destructive",
size = "xs",
}: LogDeleteButtonProps) {
const router = useRouter();
return (
<ConfirmActionButton
size={size}
variant="ghost"
className={className}
title={title}
description={description}
confirmLabel="删除日志"
successMessage={successMessage}
errorMessage="删除日志失败"
onConfirm={() => deleteAdminLogEntry({ target, id })}
onSuccess={() => router.refresh()}
>
<Trash2 className="size-3.5" />
{label}
</ConfirmActionButton>
);
}

View File

@@ -1,6 +1,10 @@
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
const { startTrafficSyncScheduler } = await import("./services/traffic-sync-scheduler");
const [{ startTrafficSyncScheduler }, { startLogCleanupScheduler }] = await Promise.all([
import("./services/traffic-sync-scheduler"),
import("./services/log-cleanup-scheduler"),
]);
startTrafficSyncScheduler();
startLogCleanupScheduler();
}
}

View File

@@ -32,6 +32,7 @@ export const auditActionFilterOptions = [
{ label: "满减规则", value: "promotion." },
{ label: "备份恢复", value: "backup." },
{ label: "流量同步", value: "traffic." },
{ label: "日志清理", value: "logs." },
{ label: "流媒体槽位", value: "streaming-slot." },
];
@@ -46,6 +47,8 @@ const auditActionLabels: Record<string, string> = {
"coupon.toggle": "切换优惠券状态",
"inbound.delete": "删除线路入口",
"inbound.display_name.update": "更新线路名称",
"logs.cleanup": "清理过期日志",
"logs.delete": "删除日志记录",
"node.create": "创建节点",
"node.update": "更新节点",
"node.delete": "删除节点",
@@ -111,6 +114,8 @@ const auditTargetTypeLabels: Record<string, string> = {
AppConfig: "系统设置",
Coupon: "优惠券",
Database: "数据库",
LogCleanup: "日志清理",
LogEntry: "日志记录",
NodeInbound: "线路入口",
NodeServer: "节点",
Order: "订单",

View File

@@ -86,6 +86,7 @@ export const booleanAppSettingLabels = {
requireInviteCode: "邀请码注册",
autoReminderDispatchEnabled: "自动提醒派发",
trafficSyncEnabled: "3x-ui 流量定时同步",
logCleanupEnabled: "自动清理日志",
networkRecommendationsEnabled: "三网推荐",
networkInsightsEnabled: "线路体验",
subscriptionRiskEnabled: "订阅访问风控",

View File

@@ -0,0 +1,88 @@
import { prisma } from "@/lib/prisma";
import { getAppConfig } from "@/services/app-config";
import { cleanupExpiredLogs, cutoffFromDays, normalizeRetentionDays, summarizeLogCleanup } from "@/services/log-cleanup";
const DEFAULT_INTERVAL_SECONDS = 6 * 60 * 60;
const MIN_RUN_GAP_MS = 23 * 60 * 60 * 1000;
const globalForLogCleanup = globalThis as typeof globalThis & {
__jboardLogCleanupScheduler?: LogCleanupSchedulerState;
};
type Timer = ReturnType<typeof setTimeout>;
interface LogCleanupSchedulerState {
started: boolean;
running: boolean;
timer: Timer | null;
}
function getState() {
if (!globalForLogCleanup.__jboardLogCleanupScheduler) {
globalForLogCleanup.__jboardLogCleanupScheduler = {
started: false,
running: false,
timer: null,
};
}
return globalForLogCleanup.__jboardLogCleanupScheduler;
}
function unrefTimer(timer: Timer) {
if (typeof timer === "object" && timer && "unref" in timer && typeof timer.unref === "function") {
timer.unref();
}
}
function scheduleNext(state: LogCleanupSchedulerState, intervalSeconds = DEFAULT_INTERVAL_SECONDS) {
state.timer = setTimeout(() => {
void runLogCleanupCycle(state);
}, intervalSeconds * 1000);
unrefTimer(state.timer);
}
async function runLogCleanupCycle(state: LogCleanupSchedulerState) {
try {
if (state.running) return;
state.running = true;
const config = await getAppConfig();
if (!config?.logCleanupEnabled) return;
if (config.logCleanupLastRunAt && Date.now() - config.logCleanupLastRunAt.getTime() < MIN_RUN_GAP_MS) {
return;
}
const retentionDays = normalizeRetentionDays(config.logRetentionDays);
const summary = await cleanupExpiredLogs({
target: "ALL",
cutoff: cutoffFromDays(retentionDays),
keepActiveRiskRestrictions: true,
});
await prisma.appConfig.update({
where: { id: "default" },
data: { logCleanupLastRunAt: new Date() },
});
const cleaned = Object.values(summary).some((count) => count > 0);
if (cleaned) {
console.info(`J-Board log cleanup finished: ${summarizeLogCleanup(summary)}`);
}
} catch (error) {
console.error("J-Board log cleanup scheduler failed", error);
} finally {
state.running = false;
scheduleNext(state);
}
}
export function startLogCleanupScheduler() {
if (process.env.JBOARD_LOG_CLEANUP_SCHEDULER === "false") return;
const state = getState();
if (state.started) return;
state.started = true;
scheduleNext(state, 60);
}

147
src/services/log-cleanup.ts Normal file
View File

@@ -0,0 +1,147 @@
import type { Prisma } from "@prisma/client";
import { prisma, type DbClient } from "@/lib/prisma";
export const logCleanupTargets = [
"ALL",
"AUDIT_LOGS",
"TASK_RUNS",
"TRAFFIC_LOGS",
"NODE_LATENCY_LOGS",
"SUBSCRIPTION_ACCESS_LOGS",
"SUBSCRIPTION_RISK_EVENTS",
] as const;
export type LogCleanupTarget = (typeof logCleanupTargets)[number];
export const logCleanupTargetLabels: Record<LogCleanupTarget, string> = {
ALL: "全部日志",
AUDIT_LOGS: "审计日志",
TASK_RUNS: "任务记录",
TRAFFIC_LOGS: "流量日志",
NODE_LATENCY_LOGS: "节点延迟日志",
SUBSCRIPTION_ACCESS_LOGS: "风控访问日志",
SUBSCRIPTION_RISK_EVENTS: "风控事件",
};
export type LogDeleteTarget = Exclude<LogCleanupTarget, "ALL">;
export type LogCleanupSummary = Record<LogDeleteTarget, number>;
export const emptyLogCleanupSummary: LogCleanupSummary = {
AUDIT_LOGS: 0,
TASK_RUNS: 0,
TRAFFIC_LOGS: 0,
NODE_LATENCY_LOGS: 0,
SUBSCRIPTION_ACCESS_LOGS: 0,
SUBSCRIPTION_RISK_EVENTS: 0,
};
const targetOrder: LogDeleteTarget[] = [
"AUDIT_LOGS",
"TASK_RUNS",
"TRAFFIC_LOGS",
"NODE_LATENCY_LOGS",
"SUBSCRIPTION_ACCESS_LOGS",
"SUBSCRIPTION_RISK_EVENTS",
];
export function normalizeRetentionDays(value: number | null | undefined) {
if (!value || !Number.isFinite(value)) return 30;
return Math.min(3650, Math.max(1, Math.trunc(value)));
}
export function cutoffFromDays(days: number, now = new Date()) {
const normalizedDays = normalizeRetentionDays(days);
return new Date(now.getTime() - normalizedDays * 24 * 60 * 60 * 1000);
}
export function summarizeLogCleanup(summary: LogCleanupSummary) {
const parts = targetOrder
.map((target) => [logCleanupTargetLabels[target], summary[target]] as const)
.filter(([, count]) => count > 0)
.map(([label, count]) => `${label} ${count}`);
return parts.length > 0 ? parts.join("") : "没有可清理的日志";
}
function selectedTargets(target: LogCleanupTarget): LogDeleteTarget[] {
return target === "ALL" ? targetOrder : [target];
}
export async function cleanupExpiredLogs(
{
target,
cutoff,
keepActiveRiskRestrictions = true,
}: {
target: LogCleanupTarget;
cutoff: Date;
keepActiveRiskRestrictions?: boolean;
},
db: DbClient = prisma,
): Promise<LogCleanupSummary> {
const summary = { ...emptyLogCleanupSummary };
for (const item of selectedTargets(target)) {
if (item === "AUDIT_LOGS") {
const result = await db.auditLog.deleteMany({ where: { createdAt: { lt: cutoff } } });
summary[item] = result.count;
}
if (item === "TASK_RUNS") {
const result = await db.taskRun.deleteMany({ where: { createdAt: { lt: cutoff } } });
summary[item] = result.count;
}
if (item === "TRAFFIC_LOGS") {
const result = await db.trafficLog.deleteMany({ where: { timestamp: { lt: cutoff } } });
summary[item] = result.count;
}
if (item === "NODE_LATENCY_LOGS") {
const result = await db.nodeLatencyLog.deleteMany({ where: { checkedAt: { lt: cutoff } } });
summary[item] = result.count;
}
if (item === "SUBSCRIPTION_ACCESS_LOGS") {
const result = await db.subscriptionAccessLog.deleteMany({ where: { createdAt: { lt: cutoff } } });
summary[item] = result.count;
}
if (item === "SUBSCRIPTION_RISK_EVENTS") {
const where: Prisma.SubscriptionRiskEventWhereInput = {
createdAt: { lt: cutoff },
...(keepActiveRiskRestrictions ? { userRestrictionActive: false } : {}),
};
const result = await db.subscriptionRiskEvent.deleteMany({ where });
summary[item] = result.count;
}
}
return summary;
}
export async function deleteLogEntry(
{
target,
id,
}: {
target: LogDeleteTarget;
id: string;
},
db: DbClient = prisma,
) {
if (target === "AUDIT_LOGS") {
await db.auditLog.delete({ where: { id } });
}
if (target === "TASK_RUNS") {
await db.taskRun.delete({ where: { id } });
}
if (target === "TRAFFIC_LOGS") {
await db.trafficLog.delete({ where: { id } });
}
if (target === "NODE_LATENCY_LOGS") {
await db.nodeLatencyLog.delete({ where: { id } });
}
if (target === "SUBSCRIPTION_ACCESS_LOGS") {
await db.subscriptionAccessLog.delete({ where: { id } });
}
if (target === "SUBSCRIPTION_RISK_EVENTS") {
await db.subscriptionRiskEvent.delete({ where: { id } });
}
}