feat(memory): P1 长期记忆升级 —— 异步攒批 Consolidate + 软删 + importance/last_seen #1

Merged
Blizzard merged 181 commits from feat/wails3 into main 2026-07-17 01:12:32 +00:00
4 changed files with 190 additions and 3 deletions
Showing only changes of commit a830ae2a04 - Show all commits
+3 -3
View File
@@ -165,12 +165,12 @@ RBAC 未做,暂以单管理员账号代理;概览口径必须是**系统级*
- [ ] 用户管理接口:列举 / 禁用 / 改角色(现仅注册/登录/查我,handler/auth.go| M - [ ] 用户管理接口:列举 / 禁用 / 改角色(现仅注册/登录/查我,handler/auth.go| M
- [ ] 多租户:Task/Eval/KB/Agent 加 tenant_idowner_id 之上补租户隔离边界 + 租户表/关联表(store 全表,单租户假设)| L - [ ] 多租户:Task/Eval/KB/Agent 加 tenant_idowner_id 之上补租户隔离边界 + 租户表/关联表(store 全表,单租户假设)| L
### [~] 🔴 T4.B 审计 / 可溯源 + 管理端聚合(推荐先做,内聚且喂管理端 ### [x] 🔴 T4.B 审计 / 可溯源 + 管理端聚合 ✅(大头全 live;剩 HITL 明细可选
- [x] `GET /api/v1/admin/overview`(RequireAdmin) 系统级聚合 ✅ —— 全平台用户/KB/任务/评测/模型态/prompt 覆盖/健康;概览页已切过去(5调用→2)live 验证 users=7/kb 22/50 全平台口径。 - [x] `GET /api/v1/admin/overview`(RequireAdmin) 系统级聚合 ✅ —— 全平台用户/KB/任务/评测/模型态/prompt 覆盖/健康;概览页已切过去(5调用→2)live 验证 users=7/kb 22/50 全平台口径。
- [x] `audit_log` 表 + 中间件 ✅ —— `store.AuditLog` + `middleware.Audit(db)` 挂管理组 + prompt 激活/撤销 + HITL 审批;只审计变更类(POST/PUT/DELETE/PATCH)best-effort 落库不拖垮主流程;`GET /admin/audit` 列表(倒序翻页)。livePUT pricing / POST deactivate 留痕,GET 不记。 - [x] `audit_log` 表 + 中间件 ✅ —— `store.AuditLog` + `middleware.Audit(db)` 挂管理组 + prompt 激活/撤销 + HITL 审批;只审计变更类(POST/PUT/DELETE/PATCH)best-effort 落库不拖垮主流程;`GET /admin/audit` 列表(倒序翻页)。livePUT pricing / POST deactivate 留痕,GET 不记。
- [x] 护栏拦截事件落库 `guardrail_event` ✅ —— Guardrail 中间件命中(blocked/suspect)best-effort 落库(actor/kind/reason/signals/path/ip) + `GET /admin/guardrail-events`live:注入 payload→422 硬拦+事件留痕。 - [x] 护栏拦截事件落库 `guardrail_event` ✅ —— Guardrail 中间件命中(blocked/suspect)best-effort 落库(actor/kind/reason/signals/path/ip) + `GET /admin/guardrail-events`live:注入 payload→422 硬拦+事件留痕。
- [ ] HITL 审批决定明细落库(现审批已进 audit_log 但只有 who/when/status,无 approve/reject 决定与理由;可 enrich audit Detail 或单独表)| S - [x] 前端「审计 & 安全」页 ✅ —— `/audit`(运维组)接 /admin/audit + /admin/guardrail-events:安全事件(护栏拦截/灰区)+ 操作审计(方法配色/状态码/actor/ip/时间)+ 顶栏统计 + 30s 自刷;live 验证。
- [ ] 前端:管理端加「审计流 / 安全事件」页或概览接 /admin/audit(把留痕可视化)| S - [ ] HITL 审批决定明细落库(现审批已进 audit_log 但只有 who/when/status,无 approve/reject 决定与理由;可 enrich audit Detail 或单独表)| S ← T4.B 唯一剩项(可选小项)
### [ ] T4.C 真实成本计费 ### [ ] T4.C 真实成本计费
- [ ] token×单价 落库 `token_usage`(现仅 Redis 按天计数 TTL48htask_handler.go:532| L - [ ] token×单价 落库 `token_usage`(现仅 Redis 按天计数 TTL48htask_handler.go:532| L
+36
View File
@@ -242,6 +242,42 @@ export async function adminOverview(): Promise<AdminOverview> {
return (await res.json()) as AdminOverview; return (await res.json()) as AdminOverview;
} }
// —— 审计 / 安全事件(敏感操作留痕 + 护栏命中)——
export interface AuditEntry {
id: string;
actor: string; // 操作者 uid
action: string; // POST / PUT / DELETE / PATCH
route: string;
path: string;
status: number;
ip: string;
detail: string;
at: string;
}
export interface GuardrailEventItem {
id: string;
actor: string;
kind: string; // blocked / suspect
reason: string;
signals: string; // JSON 数组字符串
method: string;
path: string;
ip: string;
at: string;
}
export async function listAudit(limit = 50, offset = 0): Promise<AuditEntry[]> {
const res = guard(await fetch(`${ADMIN}/audit?limit=${limit}&offset=${offset}`, { headers: authHeaders() }));
if (!res.ok) throw new Error(`audit failed: ${res.status}`);
return ((await res.json()) as { logs?: AuditEntry[] }).logs ?? [];
}
export async function listGuardrailEvents(limit = 50, offset = 0): Promise<GuardrailEventItem[]> {
const res = guard(await fetch(`${ADMIN}/guardrail-events?limit=${limit}&offset=${offset}`, { headers: authHeaders() }));
if (!res.ok) throw new Error(`guardrail events failed: ${res.status}`);
return ((await res.json()) as { events?: GuardrailEventItem[] }).events ?? [];
}
// —— Prompt 控制面(建版本 → 激活 → 控制面热下发各服务,不重启即生效)—— // —— Prompt 控制面(建版本 → 激活 → 控制面热下发各服务,不重启即生效)——
// 注意:prompt 路由在 RequireAuth 组下(/api/v1/prompts),不在 /admin 前缀内。 // 注意:prompt 路由在 RequireAuth 组下(/api/v1/prompts),不在 /admin 前缀内。
const PROMPTS = `${GATEWAY}/api/v1/prompts`; const PROMPTS = `${GATEWAY}/api/v1/prompts`;
+143
View File
@@ -0,0 +1,143 @@
import { useEffect, useState } from "react";
import { listAudit, listGuardrailEvents, type AuditEntry, type GuardrailEventItem } from "../api";
// 操作方法配色。
const ACTION_STYLE: Record<string, string> = {
POST: "bg-emerald-50 text-emerald-600",
PUT: "bg-amber-50 text-amber-600",
DELETE: "bg-rose-50 text-rose-600",
PATCH: "bg-violet-50 text-violet-600",
};
const statusTone = (s: number) => (s < 300 ? "text-emerald-600" : s < 500 ? "text-amber-600" : "text-rose-500");
// uid 太长,展示时截断(保留首尾,hover 看全)。
const shortId = (id: string) => (id && id.length > 10 ? `${id.slice(0, 4)}${id.slice(-4)}` : id || "系统");
const fmt = (at: string) => new Date(at).toLocaleString("zh-CN", { hour12: false });
export function AuditPage() {
const [audit, setAudit] = useState<AuditEntry[]>([]);
const [events, setEvents] = useState<GuardrailEventItem[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [updatedAt, setUpdatedAt] = useState<Date | null>(null);
const [err, setErr] = useState("");
const load = async () => {
setRefreshing(true);
try {
const [a, e] = await Promise.all([listAudit(80), listGuardrailEvents(80)]);
setAudit(a);
setEvents(e);
setUpdatedAt(new Date());
setErr("");
} catch (er) {
setErr((er as Error).message);
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => {
void load();
const t = setInterval(() => void load(), 30000);
return () => clearInterval(t);
}, []);
if (loading) return <div className="text-sm text-gray-400"></div>;
if (err) return <div className="text-sm text-rose-500">{err}</div>;
const blocked = events.filter((e) => e.kind === "blocked").length;
const suspect = events.filter((e) => e.kind === "suspect").length;
return (
<div className="space-y-5">
{/* 顶栏 */}
<div className="flex flex-wrap items-center gap-4 rounded-2xl border border-gray-200/70 bg-white p-5">
<div className="mr-auto">
<h3 className="text-sm font-semibold tracking-tight text-gray-900"> & </h3>
<p className="text-xs text-gray-400"> + · </p>
</div>
<div className="grid grid-cols-3 divide-x divide-gray-100">
<Stat value={audit.length} label="操作留痕" />
<Stat value={blocked} label="护栏拦截" bad={blocked > 0} />
<Stat value={suspect} label="灰区放行" warn={suspect > 0} />
</div>
<div className="flex items-center gap-2 text-xs text-gray-400">
{updatedAt && <span> {updatedAt.toLocaleTimeString("zh-CN", { hour12: false })}</span>}
<button
onClick={() => void load()}
disabled={refreshing}
className="flex items-center gap-1 rounded border border-gray-200 px-2.5 py-1 text-gray-500 hover:bg-gray-50 disabled:opacity-40"
>
<svg viewBox="0 0 24 24" className={`h-3.5 w-3.5 ${refreshing ? "animate-spin" : ""}`} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M23 4v6h-6 M1 20v-6h6 M3.51 9a9 9 0 0 1 14.85-3.36L23 10 M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
</svg>
</button>
</div>
</div>
{/* 安全事件(护栏命中) */}
<section className="rounded-2xl border border-gray-200/70 bg-white p-5">
<div className="mb-3 flex items-baseline gap-2.5">
<h3 className="text-sm font-semibold tracking-tight text-gray-900"></h3>
<span className="text-xs text-gray-400"> / {events.length}</span>
</div>
{events.length === 0 ? (
<div className="rounded-xl bg-gray-50/70 py-8 text-center text-xs text-gray-400"> 🎉</div>
) : (
<div className="divide-y divide-gray-100">
{events.map((e) => (
<div key={e.id} className="flex items-center gap-3 py-2.5 text-xs">
<span className={`rounded px-1.5 py-0.5 text-[10px] font-semibold ${e.kind === "blocked" ? "bg-rose-50 text-rose-600" : "bg-amber-50 text-amber-600"}`}>
{e.kind === "blocked" ? "拦截" : "灰区"}
</span>
<span className="min-w-0 flex-1 truncate text-gray-700" title={e.reason}>{e.reason || "(无原因)"}</span>
<code className="hidden shrink-0 font-mono text-[10px] text-gray-400 sm:inline">{e.method} {e.path}</code>
<span className="shrink-0 font-mono text-[10px] text-gray-300" title={e.actor}>{shortId(e.actor)}</span>
<span className="hidden shrink-0 text-[10px] text-gray-300 md:inline">{e.ip}</span>
<span className="shrink-0 text-[10px] text-gray-300">{fmt(e.at)}</span>
</div>
))}
</div>
)}
</section>
{/* 操作审计 */}
<section className="rounded-2xl border border-gray-200/70 bg-white p-5">
<div className="mb-3 flex items-baseline gap-2.5">
<h3 className="text-sm font-semibold tracking-tight text-gray-900"></h3>
<span className="text-xs text-gray-400"> / / / {audit.length}</span>
</div>
{audit.length === 0 ? (
<div className="rounded-xl bg-gray-50/70 py-8 text-center text-xs text-gray-400"></div>
) : (
<div className="divide-y divide-gray-100">
{audit.map((a) => (
<div key={a.id} className="flex items-center gap-3 py-2.5 text-xs">
<span className={`w-14 shrink-0 rounded px-1.5 py-0.5 text-center text-[10px] font-semibold ${ACTION_STYLE[a.action] ?? "bg-gray-100 text-gray-500"}`}>
{a.action}
</span>
<code className="min-w-0 flex-1 truncate font-mono text-[11px] text-gray-600" title={a.path}>{a.path}</code>
<span className={`shrink-0 font-mono text-[10px] ${statusTone(a.status)}`}>{a.status}</span>
<span className="shrink-0 font-mono text-[10px] text-gray-300" title={a.actor}>{shortId(a.actor)}</span>
<span className="hidden shrink-0 text-[10px] text-gray-300 md:inline">{a.ip}</span>
<span className="shrink-0 text-[10px] text-gray-300">{fmt(a.at)}</span>
</div>
))}
</div>
)}
</section>
</div>
);
}
function Stat({ value, label, bad, warn }: { value: number; label: string; bad?: boolean; warn?: boolean }) {
return (
<div className="px-4 first:pl-0">
<div className={`text-xl font-semibold tracking-tight ${bad ? "text-rose-500" : warn ? "text-amber-600" : "text-gray-900"}`}>{value}</div>
<div className="text-[10px] text-gray-400">{label}</div>
</div>
);
}
+8
View File
@@ -14,6 +14,7 @@ const EvalsPage = lazy(() => import("./pages/EvalsPage").then((m) => ({ default:
const TenantsPage = lazy(() => import("./pages/TenantsPage").then((m) => ({ default: m.TenantsPage }))); const TenantsPage = lazy(() => import("./pages/TenantsPage").then((m) => ({ default: m.TenantsPage })));
const GuardrailsPage = lazy(() => import("./pages/GuardrailsPage").then((m) => ({ default: m.GuardrailsPage }))); const GuardrailsPage = lazy(() => import("./pages/GuardrailsPage").then((m) => ({ default: m.GuardrailsPage })));
const PromptsPage = lazy(() => import("./pages/PromptsPage").then((m) => ({ default: m.PromptsPage }))); const PromptsPage = lazy(() => import("./pages/PromptsPage").then((m) => ({ default: m.PromptsPage })));
const AuditPage = lazy(() => import("./pages/AuditPage").then((m) => ({ default: m.AuditPage })));
export interface RouteDef { export interface RouteDef {
path: string; path: string;
@@ -73,6 +74,13 @@ export const routes: RouteDef[] = [
ready: true, ready: true,
element: <EvalsPage />, element: <EvalsPage />,
}, },
{
path: "/audit",
label: "审计 & 安全",
group: "运维",
ready: true,
element: <AuditPage />,
},
{ {
path: "/tenants", path: "/tenants",
label: "租户 & 用户", label: "租户 & 用户",