feat: 新增资产拦截规则(全局黑名单)增删改查

新增独立于命令拦截(intercept_rules)的资产拦截功能,仅提供规则的
存储与管理;具体匹配/拦截逻辑另行实现。

- db: 新增 asset_intercept_rules 表(schema.sql,幂等)与 CRUD 层
  (db/asset_intercept.go);kind 支持全等/模糊的 域名·IP·URL 及 CIDR
- db: 首启动 seed 内置模糊拦截政府(.gov/.gov.cn)与教育(.edu/.edu.cn)
  网站,settings 标记位门控,可禁用/删除且重启不复活
- server: /api/asset-intercept/rules 的 list/create/update/delete/toggle;
  cidr、exact_ip 做格式校验
- web: 新增「资产拦截」页(/system/intercept/assets)与侧边栏入口、
  api 与类型定义
This commit is contained in:
Autumn-27 committed 2026-09-19 07:58:23 -04:00
1 parent a590b415ba
commit dfc9d2fa2e
9 files changed
+687

No files matched your search

@@ -0,0 +1,352 @@
"use client";
import * as React from "react";
import { BanIcon, PencilIcon, PlusIcon, Trash2Icon } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Switch } from "@/components/ui/switch";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/lib/api";
import type { AssetInterceptKind, AssetInterceptRule } from "@/lib/types";
// ---- kind 元信息 ----
const KIND_OPTIONS: { value: AssetInterceptKind; label: string; group: string; placeholder: string }[] = [
{ value: "exact_domain", label: "域名(全等)", group: "全等匹配", placeholder: "example.gov.cn" },
{ value: "exact_ip", label: "IP(全等)", group: "全等匹配", placeholder: "203.0.113.10" },
{ value: "exact_url", label: "URL(全等)", group: "全等匹配", placeholder: "https://example.gov.cn/login" },
{ value: "fuzzy_domain", label: "域名(模糊)", group: "模糊匹配", placeholder: ".gov.cn" },
{ value: "fuzzy_ip", label: "IP(模糊)", group: "模糊匹配", placeholder: "203.0.113." },
{ value: "fuzzy_url", label: "URL(模糊)", group: "模糊匹配", placeholder: "/admin" },
{ value: "cidr", label: "CIDR 网段", group: "网段", placeholder: "192.168.0.0/16" },
];
const KIND_LABEL: Record<AssetInterceptKind, string> = Object.fromEntries(
KIND_OPTIONS.map((o) => [o.value, o.label]),
) as Record<AssetInterceptKind, string>;
const KIND_GROUPS = ["全等匹配", "模糊匹配", "网段"];
function KindBadge({ kind }: { kind: AssetInterceptKind }) {
const fuzzy = kind.startsWith("fuzzy_");
const cidr = kind === "cidr";
return (
<Badge
variant="outline"
className={
cidr
? "border-sky-400 text-sky-600"
: fuzzy
? "border-amber-400 text-amber-600"
: "border-emerald-400 text-emerald-600"
}
>
{KIND_LABEL[kind]}
</Badge>
);
}
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div className="space-y-1.5">
<Label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{label}</Label>
{children}
{hint && <p className="text-[11px] text-muted-foreground">{hint}</p>}
</div>
);
}
// ---- form state ----
type RuleForm = {
enabled: boolean;
kind: AssetInterceptKind;
pattern: string;
note: string;
};
const defaultForm = (): RuleForm => ({ enabled: true, kind: "fuzzy_domain", pattern: "", note: "" });
// 前端轻校验(与后端一致:仅 exact_ip / cidr 做格式校验,其余交后端)。
function frontValidate(form: RuleForm): string | null {
const p = form.pattern.trim();
if (!p) return "匹配内容不能为空";
if (form.kind === "cidr" && !/^[0-9a-fA-F:.]+\/\d{1,3}$/.test(p)) {
return "CIDR 格式无效,形如 192.168.0.0/16";
}
return null;
}
// ---- page ----
export default function AssetInterceptPage() {
const [rules, setRules] = React.useState<AssetInterceptRule[]>([]);
const [loading, setLoading] = React.useState(true);
const [open, setOpen] = React.useState(false);
const [editing, setEditing] = React.useState<AssetInterceptRule | null>(null);
const [form, setForm] = React.useState<RuleForm>(defaultForm());
const [saving, setSaving] = React.useState(false);
const load = React.useCallback(async () => {
try {
setRules(await api.assetInterceptRules());
} catch {
toast.error("加载资产拦截规则失败");
} finally {
setLoading(false);
}
}, []);
React.useEffect(() => {
load();
}, [load]);
function set(patch: Partial<RuleForm>) {
setForm((f) => ({ ...f, ...patch }));
}
function openNew() {
setEditing(null);
setForm(defaultForm());
setOpen(true);
}
function openEdit(rule: AssetInterceptRule) {
setEditing(rule);
setForm({ enabled: rule.enabled, kind: rule.kind, pattern: rule.pattern, note: rule.note });
setOpen(true);
}
async function handleSave() {
const err = frontValidate(form);
if (err) {
toast.error(err);
return;
}
const payload = { ...form, pattern: form.pattern.trim() };
setSaving(true);
try {
if (editing) {
await api.updateAssetInterceptRule(editing.id, payload);
toast.success("规则已更新");
} else {
await api.createAssetInterceptRule(payload);
toast.success("规则已创建");
}
setOpen(false);
load();
} catch (e) {
toast.error((e as Error).message);
} finally {
setSaving(false);
}
}
async function handleDelete(rule: AssetInterceptRule) {
if (!window.confirm(`确定删除资产拦截规则「${rule.pattern}」?`)) return;
try {
await api.deleteAssetInterceptRule(rule.id);
toast.success("规则已删除");
load();
} catch (e) {
toast.error((e as Error).message);
}
}
async function handleToggle(rule: AssetInterceptRule) {
try {
await api.toggleAssetInterceptRule(rule.id, !rule.enabled);
load();
} catch (e) {
toast.error((e as Error).message);
}
}
const placeholder = KIND_OPTIONS.find((o) => o.value === form.kind)?.placeholder ?? "";
return (
<div className="flex flex-1 flex-col gap-5 p-6">
{/* header */}
<div className="flex items-center gap-2.5">
<BanIcon className="h-5 w-5 shrink-0" />
<div>
<h1 className="text-lg font-semibold leading-tight">资产拦截</h1>
<p className="text-sm text-muted-foreground mt-0.5">
全局资产黑名单:命中的域名 / IP / URL / 网段将被拦截,不对其执行任何操作
</p>
</div>
</div>
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">
支持全等与模糊匹配的域名 / IP / URL,以及 CIDR 网段;默认内置模糊拦截政府(.gov / .gov.cn)与教育(.edu /
.edu.cn)网站
</p>
<Button onClick={openNew} size="sm" className="shrink-0">
<PlusIcon className="h-4 w-4" />
新建规则
</Button>
</div>
<Card>
<CardContent className="p-0">
{loading ? (
<p className="p-6 text-sm text-muted-foreground">加载中…</p>
) : rules.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center">
<BanIcon className="h-8 w-8 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">暂无资产拦截规则</p>
<Button size="sm" variant="outline" onClick={openNew}>
<PlusIcon className="h-4 w-4" />
新建第一条规则
</Button>
</div>
) : (
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-[130px]">类型</TableHead>
<TableHead>匹配内容</TableHead>
<TableHead>备注</TableHead>
<TableHead className="w-[64px] text-center">启用</TableHead>
<TableHead className="w-[80px]" />
</TableRow>
</TableHeader>
<TableBody>
{rules.map((rule) => (
<TableRow key={rule.id} className={!rule.enabled ? "opacity-40" : ""}>
<TableCell>
<KindBadge kind={rule.kind} />
</TableCell>
<TableCell className="max-w-[280px]">
<code className="block truncate rounded bg-muted px-1.5 py-0.5 text-xs font-mono">
{rule.pattern}
</code>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
<div className="flex items-center gap-1.5">
{rule.builtin && (
<Badge variant="secondary" className="shrink-0 px-1 py-0 text-[10px]">
内置
</Badge>
)}
<span className="truncate">{rule.note}</span>
</div>
</TableCell>
<TableCell className="text-center">
<Switch checked={rule.enabled} onCheckedChange={() => handleToggle(rule)} />
</TableCell>
<TableCell>
<div className="flex items-center justify-end gap-0.5">
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => openEdit(rule)}>
<PencilIcon className="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => handleDelete(rule)}
>
<Trash2Icon className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* editor sheet */}
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent side="right" className="flex flex-col gap-0 p-0 sm:max-w-md">
<SheetHeader className="border-b px-6 py-4">
<SheetTitle>{editing ? "编辑资产拦截规则" : "新建资产拦截规则"}</SheetTitle>
<SheetDescription className="text-xs">命中此规则的目标资产会被全局拦截</SheetDescription>
</SheetHeader>
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-5 space-y-5">
<Field label="匹配类型">
<Select value={form.kind} onValueChange={(v) => set({ kind: v as AssetInterceptKind })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{KIND_GROUPS.map((g) => (
<React.Fragment key={g}>
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{g}
</div>
{KIND_OPTIONS.filter((o) => o.group === g).map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</React.Fragment>
))}
</SelectContent>
</Select>
</Field>
<Field
label="匹配内容"
hint={
form.kind === "cidr"
? "CIDR 网段,形如 192.168.0.0/16"
: form.kind.startsWith("fuzzy_")
? "模糊匹配:目标包含此内容即命中"
: "全等匹配:目标需与此内容完全一致"
}
>
<Input
placeholder={placeholder}
value={form.pattern}
onChange={(e) => set({ pattern: e.target.value })}
/>
</Field>
<Field label="备注(可选)">
<Textarea
placeholder="说明这条规则的用途"
value={form.note}
onChange={(e) => set({ note: e.target.value })}
rows={2}
className="resize-none"
/>
</Field>
<Separator />
<div className="flex items-center gap-3">
<Switch id="asset-rule-enabled" checked={form.enabled} onCheckedChange={(v) => set({ enabled: v })} />
<Label htmlFor="asset-rule-enabled" className="cursor-pointer">
启用此规则
</Label>
</div>
</div>
<SheetFooter className="border-t px-6 py-4 flex-row justify-end gap-2">
<Button variant="outline" onClick={() => setOpen(false)}>
取消
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? "保存中…" : "保存"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</div>
);
}
+11
View File
@@ -14,6 +14,7 @@ import type {
AgentTrigger,
ArchiveBatchItem,
Asset,
AssetInterceptRule,
Audit,
BatchCategoryItem,
BatchControlItem,
@@ -1099,6 +1100,16 @@ export const api = {
toggleInterceptRule: (id: number, enabled: boolean) =>
post<{ ok: boolean; enabled: boolean }>(`/intercept/rules/${id}/toggle`, { enabled }),
// ---- asset intercept rules(资产拦截:全局黑名单) ----
assetInterceptRules: () => get<{ rules: AssetInterceptRule[] }>("/asset-intercept/rules").then((r) => arr(r.rules)),
createAssetInterceptRule: (rule: Pick<AssetInterceptRule, "enabled" | "kind" | "pattern" | "note">) =>
post<AssetInterceptRule>("/asset-intercept/rules", rule),
updateAssetInterceptRule: (id: number, rule: Pick<AssetInterceptRule, "enabled" | "kind" | "pattern" | "note">) =>
put<AssetInterceptRule>(`/asset-intercept/rules/${id}`, rule),
deleteAssetInterceptRule: (id: number) => del<{ deleted: number }>(`/asset-intercept/rules/${id}`),
toggleAssetInterceptRule: (id: number, enabled: boolean) =>
post<{ ok: boolean; enabled: boolean }>(`/asset-intercept/rules/${id}/toggle`, { enabled }),
// ---- intercept pending (ask) ----
interceptPending: () => get<{ pending: InterceptPending[] }>("/intercept/pending").then((r) => arr(r.pending)),
interceptGetOne: (id: number) => get<InterceptPending>(`/intercept/pending/${id}`),
+21
View File
@@ -1191,6 +1191,27 @@ export interface InterceptRule {
updated_at: string;
}
// ---- Asset Intercept Rules(资产拦截:全局黑名单) ----
export type AssetInterceptKind =
| "exact_domain"
| "exact_ip"
| "exact_url"
| "fuzzy_domain"
| "fuzzy_ip"
| "fuzzy_url"
| "cidr";
export interface AssetInterceptRule {
id: number;
enabled: boolean;
kind: AssetInterceptKind;
pattern: string;
note: string;
builtin: boolean;
created_at: string;
updated_at: string;
}
export interface InterceptPending {
decision_source?: "rule" | "model" | "unknown" | "";
id: number;
@@ -1,5 +1,6 @@
import {
Activity,
Ban,
Bot,
Brain,
Bug,
@@ -86,6 +87,7 @@ export const sidebarItems: NavGroup[] = [
{ id: "skills", title: "Skill", url: "/system/skills", icon: Sparkles },
{ id: "tools", title: "工具", url: "/system/tools", icon: Wrench },
{ id: "intercept", title: "拦截规则", url: "/system/intercept", icon: ShieldAlert },
{ id: "asset-intercept", title: "资产拦截", url: "/system/intercept/assets", icon: Ban },
{ id: "approvals", title: "审批记录", url: "/system/intercept/approvals", icon: ClipboardList },
{ id: "logs", title: "日志", url: "/system/logs", icon: ScrollText },
{ id: "settings", title: "系统配置", url: "/system/settings", icon: Settings2 },