mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
feat: add log cleanup controls
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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`、初始化数据库并启动面板。
|
||||
|
||||
10
docs/API.md
10
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 修改。
|
||||
|
||||
## 错误处理约定
|
||||
|
||||
@@ -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
111
src/actions/admin/logs.ts
Normal 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, "清理日志失败") };
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" /> 商城线路展示
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
49
src/components/admin/log-delete-button.tsx
Normal file
49
src/components/admin/log-delete-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: "订单",
|
||||
|
||||
@@ -86,6 +86,7 @@ export const booleanAppSettingLabels = {
|
||||
requireInviteCode: "邀请码注册",
|
||||
autoReminderDispatchEnabled: "自动提醒派发",
|
||||
trafficSyncEnabled: "3x-ui 流量定时同步",
|
||||
logCleanupEnabled: "自动清理日志",
|
||||
networkRecommendationsEnabled: "三网推荐",
|
||||
networkInsightsEnabled: "线路体验",
|
||||
subscriptionRiskEnabled: "订阅访问风控",
|
||||
|
||||
88
src/services/log-cleanup-scheduler.ts
Normal file
88
src/services/log-cleanup-scheduler.ts
Normal 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
147
src/services/log-cleanup.ts
Normal 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 } });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user