From 1e194aabdb57f8914f79c1c71ff715848a91521a Mon Sep 17 00:00:00 2001 From: JetSprow Date: Thu, 30 Apr 2026 17:10:53 +1000 Subject: [PATCH] feat: add log cleanup controls --- .env.example | 3 + README.md | 4 + docs/API.md | 10 +- prisma/schema.prisma | 3 + src/actions/admin/logs.ts | 111 +++++++++++++ src/actions/admin/settings.ts | 5 + .../_components/audit-logs-table.tsx | 13 ++ src/app/(admin)/admin/settings/page.tsx | 3 + .../(admin)/admin/settings/settings-form.tsx | 119 +++++++++++++- .../subscription-risk-geo-details.tsx | 8 + .../_components/subscription-risk-table.tsx | 17 ++ .../tasks/_components/task-runs-table.tsx | 10 +- src/components/admin/log-delete-button.tsx | 49 ++++++ src/instrumentation.ts | 6 +- src/lib/audit-display.ts | 5 + src/lib/domain-labels.ts | 1 + src/services/log-cleanup-scheduler.ts | 88 +++++++++++ src/services/log-cleanup.ts | 147 ++++++++++++++++++ 18 files changed, 598 insertions(+), 4 deletions(-) create mode 100644 src/actions/admin/logs.ts create mode 100644 src/components/admin/log-delete-button.tsx create mode 100644 src/services/log-cleanup-scheduler.ts create mode 100644 src/services/log-cleanup.ts diff --git a/.env.example b/.env.example index 045faa3..28711fe 100644 --- a/.env.example +++ b/.env.example @@ -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" diff --git a/README.md b/README.md index 515950e..a0d4384 100644 --- a/README.md +++ b/README.md @@ -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`、初始化数据库并启动面板。 diff --git a/docs/API.md b/docs/API.md index 914c171..824578c 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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 修改。 ## 错误处理约定 diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 71ac345..505eb3e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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) diff --git a/src/actions/admin/logs.ts b/src/actions/admin/logs.ts new file mode 100644 index 0000000..4a43a37 --- /dev/null +++ b/src/actions/admin/logs.ts @@ -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 { + 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, "清理日志失败") }; + } +} diff --git a/src/actions/admin/settings.ts b/src/actions/admin/settings.ts index 6e68152..054683f 100644 --- a/src/actions/admin/settings.ts +++ b/src/actions/admin/settings.ts @@ -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, 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, diff --git a/src/app/(admin)/admin/audit-logs/_components/audit-logs-table.tsx b/src/app/(admin)/admin/audit-logs/_components/audit-logs-table.tsx index b7f73fa..ffc1efa 100644 --- a/src/app/(admin)/admin/audit-logs/_components/audit-logs-table.tsx +++ b/src/app/(admin)/admin/audit-logs/_components/audit-logs-table.tsx @@ -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[] }) { 动作 目标 说明 + 操作 @@ -61,6 +63,17 @@ export function AuditLogsTable({ logs }: { logs: AuditLog[] }) { {formatAuditMessage(log.message)} + +
+ +
+
))}
diff --git a/src/app/(admin)/admin/settings/page.tsx b/src/app/(admin)/admin/settings/page.tsx index 0723f3c..a0d6132 100644 --- a/src/app/(admin)/admin/settings/page.tsx +++ b/src/app/(admin)/admin/settings/page.tsx @@ -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, diff --git a/src/app/(admin)/admin/settings/settings-form.tsx b/src/app/(admin)/admin/settings/settings-form.tsx index 12a606a..1ead2ba 100644 --- a/src/app/(admin)/admin/settings/settings-form.tsx +++ b/src/app/(admin)/admin/settings/settings-form.tsx @@ -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; 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("ALL"); + const [manualCleanupDays, setManualCleanupDays] = useState(config.logRetentionDays); const [riskSettingsOpen, setRiskSettingsOpen] = useState(false); const [toggleValues, setToggleValues] = useState(() => initialToggleValues(config)); const [pendingToggles, setPendingToggles] = useState>>({}); @@ -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: +
+
+ 日志清理 +
+

+ 自动清理每天最多执行一次,默认保留 30 天日志;正在生效的用户端风控限制不会被自动清理。 +

+
+
+ + {renderImmediateToggle("logCleanupEnabled", { id: "logCleanupEnabled" })} +
+
+ + +
+
+ +
+ {config.logCleanupLastRunAt ? formatDate(config.logCleanupLastRunAt) : "尚未执行"} +
+
+
+
+
+
+ + +
+
+ + setManualCleanupDays(Number(event.target.value))} + /> +
+ option.value === cleanupTarget)?.label ?? "日志"}。删除后无法恢复。`} + confirmLabel="开始清理" + errorMessage="清理日志失败" + disabled={saving || hasPendingToggle || cleaningLogs} + onConfirm={handleCleanupExpiredLogs} + > + + {cleaningLogs ? "清理中..." : "清理过期日志"} + +
+

+ 手动清理会按所选时间立即执行,并记录一条审计日志;风控事件中的用户端限制标记会被保留,除非你单独删除对应事件。 +

+
+
+
商城线路展示 diff --git a/src/app/(admin)/admin/subscription-risk/_components/subscription-risk-geo-details.tsx b/src/app/(admin)/admin/subscription-risk/_components/subscription-risk-geo-details.tsx index ab7dd78..24cc6b2 100644 --- a/src/app/(admin)/admin/subscription-risk/_components/subscription-risk-geo-details.tsx +++ b/src/app/(admin)/admin/subscription-risk/_components/subscription-risk-geo-details.tsx @@ -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 }
{log.source} {log.allowed ? "放行" : "拦截"} +

{log.location}

diff --git a/src/app/(admin)/admin/subscription-risk/_components/subscription-risk-table.tsx b/src/app/(admin)/admin/subscription-risk/_components/subscription-risk-table.tsx index 679e3bd..957a0ea 100644 --- a/src/app/(admin)/admin/subscription-risk/_components/subscription-risk-table.tsx +++ b/src/app/(admin)/admin/subscription-risk/_components/subscription-risk-table.tsx @@ -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} /> +
+ +
diff --git a/src/app/(admin)/admin/tasks/_components/task-runs-table.tsx b/src/app/(admin)/admin/tasks/_components/task-runs-table.tsx index 03a5f28..b05812d 100644 --- a/src/app/(admin)/admin/tasks/_components/task-runs-table.tsx +++ b/src/app/(admin)/admin/tasks/_components/task-runs-table.tsx @@ -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 || "—"} -
+
{task.retryable && task.status === "FAILED" && (
{ @@ -88,6 +89,13 @@ export function TaskRunsTable({ tasks }: TaskRunsTableProps) { 重试
)} +
diff --git a/src/components/admin/log-delete-button.tsx b/src/components/admin/log-delete-button.tsx new file mode 100644 index 0000000..47d6ba8 --- /dev/null +++ b/src/components/admin/log-delete-button.tsx @@ -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 ( + deleteAdminLogEntry({ target, id })} + onSuccess={() => router.refresh()} + > + + {label} + + ); +} diff --git a/src/instrumentation.ts b/src/instrumentation.ts index b33f2d1..463b764 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -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(); } } diff --git a/src/lib/audit-display.ts b/src/lib/audit-display.ts index 016bbc9..369b4d5 100644 --- a/src/lib/audit-display.ts +++ b/src/lib/audit-display.ts @@ -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 = { "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 = { AppConfig: "系统设置", Coupon: "优惠券", Database: "数据库", + LogCleanup: "日志清理", + LogEntry: "日志记录", NodeInbound: "线路入口", NodeServer: "节点", Order: "订单", diff --git a/src/lib/domain-labels.ts b/src/lib/domain-labels.ts index 10f3182..62fe53d 100644 --- a/src/lib/domain-labels.ts +++ b/src/lib/domain-labels.ts @@ -86,6 +86,7 @@ export const booleanAppSettingLabels = { requireInviteCode: "邀请码注册", autoReminderDispatchEnabled: "自动提醒派发", trafficSyncEnabled: "3x-ui 流量定时同步", + logCleanupEnabled: "自动清理日志", networkRecommendationsEnabled: "三网推荐", networkInsightsEnabled: "线路体验", subscriptionRiskEnabled: "订阅访问风控", diff --git a/src/services/log-cleanup-scheduler.ts b/src/services/log-cleanup-scheduler.ts new file mode 100644 index 0000000..cae1cfa --- /dev/null +++ b/src/services/log-cleanup-scheduler.ts @@ -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; + +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); +} diff --git a/src/services/log-cleanup.ts b/src/services/log-cleanup.ts new file mode 100644 index 0000000..cc9aab2 --- /dev/null +++ b/src/services/log-cleanup.ts @@ -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 = { + ALL: "全部日志", + AUDIT_LOGS: "审计日志", + TASK_RUNS: "任务记录", + TRAFFIC_LOGS: "流量日志", + NODE_LATENCY_LOGS: "节点延迟日志", + SUBSCRIPTION_ACCESS_LOGS: "风控访问日志", + SUBSCRIPTION_RISK_EVENTS: "风控事件", +}; + +export type LogDeleteTarget = Exclude; + +export type LogCleanupSummary = Record; + +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 { + 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 } }); + } +}