feat: 敏感词管理页面(脱敏词后台化,限 lqq 账号)
- 后端 /api/sensitive-words:全局共享词表,开放读、admin 或 lqq 整体替换写 - 前端 desensitize 种子化:SEED_DESENSITIZE_MAP + 运行时 replaceDesensitizeMap - 启动加载后台配置回灌脱敏映射;管理页支持增删改启停 + 一键导入种子词 - 入口置于左下角设置下拉(全量/简洁两种侧边栏均有),仅用户名 lqq 可见 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
39d7dab3e2
commit
25e4e3dbc9
18 files changed
+788
-5
No files matched your search
@@ -81,6 +81,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
|
||||
- **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks), `LightAppManagementPage`/`LightAppListPage`/`LightAppIframeView` (轻应用中心 — register/list/embed mini-apps)
|
||||
- **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes).
|
||||
- **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-<appId>`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all).
|
||||
- **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape.
|
||||
- **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes`
|
||||
- **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE
|
||||
- **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/`
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
|
||||
import { DesensitizeWordsLoader } from "@/components/desensitize-words-loader";
|
||||
import { FontScaleApplier } from "@/components/font-scale";
|
||||
import { QueryClientProvider } from "@/components/query-client-provider";
|
||||
import { AuthSyncWatcher } from "@/core/auth/sync";
|
||||
@@ -76,6 +77,7 @@ function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
<ThemeProvider>
|
||||
<ChatProvider>
|
||||
<AuthSyncWatcher />
|
||||
<DesensitizeWordsLoader />
|
||||
<FontScaleApplier />
|
||||
{children}
|
||||
</ChatProvider>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { replaceDesensitizeMap } from "@/lib/desensitize";
|
||||
import { useSensitiveWords, wordsToMap } from "@/strategy-components/api/sensitive-words";
|
||||
|
||||
/**
|
||||
* 启动时把后台「敏感词管理」配置加载进运行时脱敏映射表。
|
||||
*
|
||||
* - 后台返回**非空**时:用其中「启用」的词条整体替换运行时映射
|
||||
* ({@link replaceDesensitizeMap}),后台即唯一真源,被删除的词不再生效。
|
||||
* - 后台为空(全新部署还没配置)或拉取失败:什么都不做,保持使用
|
||||
* `src/lib/desensitize.ts` 内置的种子映射。
|
||||
*
|
||||
* 渲染 `null`;挂在 `AppProviders` 里(QueryClientProvider 之内)。与「敏感词管理」
|
||||
* 页共享同一份 React Query 缓存,保存后会自动刷新并回灌运行时映射。
|
||||
*/
|
||||
export function DesensitizeWordsLoader() {
|
||||
const { data } = useSensitiveWords();
|
||||
|
||||
useEffect(() => {
|
||||
if (data && data.length > 0) {
|
||||
replaceDesensitizeMap(wordsToMap(data));
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
FolderOpen,
|
||||
ListTree,
|
||||
Settings,
|
||||
ShieldAlert,
|
||||
User,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
@@ -22,7 +23,12 @@ import {
|
||||
import { NotificationDialog } from "@/components/workspace/notification-dialog";
|
||||
import { SettingsDialog } from "@/components/workspace/settings";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { getAccountDisplayName, getStoredAuth } from "@/core/auth";
|
||||
|
||||
/** 敏感词管理仅对用户名为 lqq 的账号可见(用户名 = 设置→个人信息页展示名)。 */
|
||||
const SENSITIVE_WORDS_USERNAME = "lqq";
|
||||
const canManageSensitiveWords = () =>
|
||||
getAccountDisplayName().toLowerCase() === SENSITIVE_WORDS_USERNAME;
|
||||
import { isWujieEnv } from "@/core/embed/wujie";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useUnreadCount } from "@/core/notifications";
|
||||
@@ -207,6 +213,14 @@ function SecondaryNavSidebar() {
|
||||
菜单管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canManageSensitiveWords() && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/admin/sensitive-words")}
|
||||
>
|
||||
<ShieldAlert className="size-4" />
|
||||
敏感词管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
@@ -479,6 +493,12 @@ function LegacyAccordionSidebar({
|
||||
菜单管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canManageSensitiveWords() && (
|
||||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/sensitive-words")}>
|
||||
<ShieldAlert className="size-4" />
|
||||
敏感词管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
/**
|
||||
* 脱敏词映射表
|
||||
* 脱敏词「种子」映射表(前端内置)。
|
||||
* key: 原始词(支持正则特殊字符,会被转义)
|
||||
* value: 脱敏后替换词
|
||||
*
|
||||
* 这份常量是**部署种子**:新机器首次启动、后台还没有任何敏感词时,运行时直接用它;
|
||||
* 管理员也可在「敏感词管理」页一键「导入种子词」把它写入后台。线上以后台配置为准
|
||||
* (见 {@link replaceDesensitizeMap}),后台为空时回退到这份种子。
|
||||
*
|
||||
* 注意:请勿在运行时修改本对象——它要保持与「出厂种子」一致。运行时可变映射见
|
||||
* {@link DESENSITIZE_MAP}。
|
||||
*/
|
||||
export const DESENSITIZE_MAP: Record<string, string> = {
|
||||
export const SEED_DESENSITIZE_MAP: Readonly<Record<string, string>> = Object.freeze({
|
||||
方案: "FA",
|
||||
设计: "SJ",
|
||||
任务: "RW",
|
||||
@@ -44,8 +51,15 @@ export const DESENSITIZE_MAP: Record<string, string> = {
|
||||
军: "J",
|
||||
美军: "MJ",
|
||||
台军: "TJ",
|
||||
});
|
||||
|
||||
};
|
||||
/**
|
||||
* 运行时实际生效的脱敏词映射表(可变)。
|
||||
*
|
||||
* 初始为 {@link SEED_DESENSITIZE_MAP} 的浅拷贝(保证后台数据加载前 `desensitize`
|
||||
* 立即可用);应用启动后由 {@link replaceDesensitizeMap} 用后台配置整体替换。
|
||||
*/
|
||||
export const DESENSITIZE_MAP: Record<string, string> = { ...SEED_DESENSITIZE_MAP };
|
||||
|
||||
/**
|
||||
* 将 DESENSITIZE_MAP 编译为单个正则,避免多次遍历字符串。
|
||||
@@ -107,3 +121,18 @@ export function addDesensitizeEntries(entries: Record<string, string>): void {
|
||||
Object.assign(DESENSITIZE_MAP, entries);
|
||||
_regex = buildRegex(DESENSITIZE_MAP);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用后台配置**整体替换**运行时脱敏词映射表,替换后自动重编译正则。
|
||||
*
|
||||
* 与 {@link addDesensitizeEntries} 不同,本函数会先清空现有词条,因此后台里被
|
||||
* 删除的词不会因为还残留在种子里而继续生效。应用启动时拉取
|
||||
* `/api/sensitive-words` 后调用;若后台为空则不调用,保持使用种子。
|
||||
*
|
||||
* @param entries 后台返回的「启用」词条,格式同 {@link DESENSITIZE_MAP}
|
||||
*/
|
||||
export function replaceDesensitizeMap(entries: Record<string, string>): void {
|
||||
for (const key of Object.keys(DESENSITIZE_MAP)) delete DESENSITIZE_MAP[key];
|
||||
Object.assign(DESENSITIZE_MAP, entries);
|
||||
_regex = buildRegex(DESENSITIZE_MAP);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { Download, Info, Plus, Save, Search, ShieldAlert, Sparkles, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { getAccountDisplayName } from "@/core/auth";
|
||||
import { SEED_DESENSITIZE_MAP } from "@/lib/desensitize";
|
||||
import {
|
||||
type SensitiveWord,
|
||||
useSensitiveWordActions,
|
||||
useSensitiveWords,
|
||||
} from "@/strategy-components/api/sensitive-words";
|
||||
|
||||
interface EditRow {
|
||||
/** 稳定的行 key(编辑原始词时不丢焦点) */
|
||||
key: number;
|
||||
word: string;
|
||||
replacement: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
let _seq = 1;
|
||||
const nextKey = () => _seq++;
|
||||
|
||||
function toRows(words: SensitiveWord[]): EditRow[] {
|
||||
return words.map((w) => ({ key: nextKey(), word: w.word, replacement: w.replacement, enabled: w.enabled }));
|
||||
}
|
||||
|
||||
/** 归一化用于「脏」判断与重复检测(按原始词去空白后比较)。 */
|
||||
function serialize(rows: { word: string; replacement: string; enabled: boolean }[]): string {
|
||||
return JSON.stringify(
|
||||
rows
|
||||
.map((r) => ({ w: r.word.trim(), r: r.replacement, e: r.enabled }))
|
||||
.filter((r) => r.w !== "")
|
||||
.sort((a, b) => a.w.localeCompare(b.w)),
|
||||
);
|
||||
}
|
||||
|
||||
export default function SensitiveWordsPage() {
|
||||
// 仅用户名为 lqq 的账号可访问(用户名 = 设置→个人信息页展示名)。
|
||||
const canManage = getAccountDisplayName().toLowerCase() === "lqq";
|
||||
const { data: serverWords = [], isLoading } = useSensitiveWords();
|
||||
const { save } = useSensitiveWordActions();
|
||||
|
||||
const [rows, setRows] = useState<EditRow[]>([]);
|
||||
const [loadedKey, setLoadedKey] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
// 服务端数据首次/变化时回填工作副本(以序列化值判定,避免覆盖未保存编辑)。
|
||||
const sourceKey = useMemo(() => serialize(serverWords), [serverWords]);
|
||||
useEffect(() => {
|
||||
if (sourceKey !== loadedKey) {
|
||||
setRows(toRows(serverWords));
|
||||
setLoadedKey(sourceKey);
|
||||
}
|
||||
}, [sourceKey, loadedKey, serverWords]);
|
||||
|
||||
const dirty = useMemo(() => serialize(rows) !== serialize(serverWords), [rows, serverWords]);
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const visibleRows = useMemo(
|
||||
() =>
|
||||
!q
|
||||
? rows
|
||||
: rows.filter(
|
||||
(r) => r.word.toLowerCase().includes(q) || r.replacement.toLowerCase().includes(q),
|
||||
),
|
||||
[rows, q],
|
||||
);
|
||||
|
||||
const enabledCount = rows.filter((r) => r.enabled && r.word.trim()).length;
|
||||
|
||||
// ── 行操作 ──
|
||||
const patchRow = (key: number, patch: Partial<EditRow>) =>
|
||||
setRows((prev) => prev.map((r) => (r.key === key ? { ...r, ...patch } : r)));
|
||||
const removeRow = (key: number) => setRows((prev) => prev.filter((r) => r.key !== key));
|
||||
const addRow = () => setRows((prev) => [{ key: nextKey(), word: "", replacement: "", enabled: true }, ...prev]);
|
||||
|
||||
/** 用前端内置种子补齐当前没有的词(已存在的词不动)。 */
|
||||
const importSeed = () => {
|
||||
const have = new Set(rows.map((r) => r.word.trim()).filter(Boolean));
|
||||
const added: EditRow[] = [];
|
||||
for (const [word, replacement] of Object.entries(SEED_DESENSITIZE_MAP)) {
|
||||
if (!have.has(word)) added.push({ key: nextKey(), word, replacement, enabled: true });
|
||||
}
|
||||
if (added.length === 0) {
|
||||
toast.info("种子词已全部存在,无需导入");
|
||||
return;
|
||||
}
|
||||
setRows((prev) => [...added, ...prev]);
|
||||
toast.success(`已导入 ${added.length} 个种子词(保存后生效)`);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
// 校验:去空白、丢弃空原始词、检测重复。
|
||||
const cleaned = rows
|
||||
.map((r) => ({ ...r, word: r.word.trim() }))
|
||||
.filter((r) => r.word !== "");
|
||||
const seen = new Set<string>();
|
||||
for (const r of cleaned) {
|
||||
if (seen.has(r.word)) {
|
||||
toast.error(`原始词「${r.word}」重复,请合并后再保存`);
|
||||
return;
|
||||
}
|
||||
seen.add(r.word);
|
||||
}
|
||||
const payload: SensitiveWord[] = cleaned.map((r, i) => ({
|
||||
word: r.word,
|
||||
replacement: r.replacement,
|
||||
enabled: r.enabled,
|
||||
sortOrder: i,
|
||||
}));
|
||||
try {
|
||||
await save.mutateAsync(payload);
|
||||
toast.success("敏感词已保存");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
if (!canManage) return <Navigate to="/page/workspace/chats/new" replace />;
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto bg-slate-50/60 px-4 py-6 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
{/* 顶部信息卡 */}
|
||||
<div className="relative mb-6 overflow-hidden rounded-3xl border border-slate-200/60 bg-white p-6 shadow-sm md:p-8">
|
||||
<div className="pointer-events-none absolute right-0 top-0 select-none p-8 opacity-[0.03]">
|
||||
<ShieldAlert className="size-44 text-slate-900" />
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="mb-4 flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||
<div>
|
||||
<span className="mb-3 inline-flex select-none items-center gap-1.5 rounded-full border border-red-100 bg-red-50 px-3 py-1 text-xs font-semibold text-red-600">
|
||||
<Sparkles className="size-3" />
|
||||
系统集成版
|
||||
</span>
|
||||
<h1 className="flex items-center gap-3 text-2xl font-bold tracking-tight text-slate-800 md:text-3xl">
|
||||
敏感词管理
|
||||
<span className="rounded-lg border border-slate-200/60 bg-slate-100 px-2.5 py-0.5 font-mono text-xs font-medium text-slate-500">
|
||||
{rows.length} 词 / {enabledCount} 启用
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={importSeed}
|
||||
className="inline-flex cursor-pointer items-center gap-2 rounded-xl border border-slate-200 bg-white px-3.5 py-2 text-sm font-semibold text-slate-600 shadow-sm transition-all hover:border-slate-300 hover:bg-slate-50 hover:text-slate-800"
|
||||
title="用前端内置种子词补齐当前缺失的词"
|
||||
>
|
||||
<Download className="size-[15px]" />
|
||||
导入种子词
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || save.isPending}
|
||||
className="inline-flex cursor-pointer items-center gap-2 rounded-xl border border-red-600 bg-red-600 px-5 py-2 text-sm font-semibold text-white shadow-sm transition-all hover:bg-red-700 active:scale-95 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{save.isPending ? (
|
||||
<>
|
||||
<div className="size-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
保存中…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="size-[15px]" />
|
||||
保存修改
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="flex max-w-2xl items-start gap-2.5 rounded-2xl border border-slate-200/50 bg-slate-50/70 p-3.5 text-sm leading-relaxed text-slate-600">
|
||||
<Info className="mt-0.5 size-4 shrink-0 text-slate-400" />
|
||||
<span>
|
||||
配置展示层<strong> 脱敏词映射</strong>(原始词 → 替换词)。保存后全站脱敏即时生效;
|
||||
<strong> 停用 </strong>的词会保留但不参与替换。前端内置的种子词仅用于全新机器首次部署,
|
||||
可用<strong>「导入种子词」</strong>一键写入后台。
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 搜索 + 新增栏 */}
|
||||
<div className="mb-6 flex flex-col items-center justify-between gap-4 rounded-2xl border border-slate-200/60 bg-white p-4 shadow-sm md:flex-row md:p-5">
|
||||
<div className="relative w-full md:w-80">
|
||||
<Search className="absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索原始词 / 替换词…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="w-full rounded-xl border border-slate-200 bg-slate-50/50 py-2 pl-10 pr-4 text-sm transition-all hover:border-slate-300 focus:border-red-500 focus:outline-none focus:ring-4 focus:ring-red-500/5"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addRow}
|
||||
className="inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-700 shadow-sm transition-all hover:border-red-300 hover:bg-red-50 hover:text-red-600 md:w-auto"
|
||||
>
|
||||
<Plus className="size-[15px]" />
|
||||
新增敏感词
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 列表 */}
|
||||
<div className="overflow-hidden rounded-2xl border border-slate-200/60 bg-white shadow-sm">
|
||||
<div className="grid grid-cols-[1fr_1fr_auto_auto] items-center gap-3 border-b border-slate-200/60 bg-slate-50/70 px-4 py-2.5 text-xs font-semibold text-slate-500">
|
||||
<span>原始词</span>
|
||||
<span>替换词</span>
|
||||
<span className="px-1 text-center">启用</span>
|
||||
<span className="w-8" />
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="px-4 py-12 text-center text-sm text-slate-400">加载中…</div>
|
||||
) : visibleRows.length === 0 ? (
|
||||
<div className="px-4 py-12 text-center text-sm text-slate-400">
|
||||
{rows.length === 0 ? "暂无敏感词,点击「新增」或「导入种子词」开始配置" : "没有匹配的词"}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{visibleRows.map((row) => (
|
||||
<li
|
||||
key={row.key}
|
||||
className={`grid grid-cols-[1fr_1fr_auto_auto] items-center gap-3 px-4 py-2.5 transition-colors hover:bg-slate-50/60 ${
|
||||
row.enabled ? "" : "opacity-55"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={row.word}
|
||||
placeholder="原始词"
|
||||
onChange={(e) => patchRow(row.key, { word: e.target.value })}
|
||||
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm transition-all focus:border-red-500 focus:outline-none focus:ring-2 focus:ring-red-500/10"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={row.replacement}
|
||||
placeholder="替换词"
|
||||
onChange={(e) => patchRow(row.key, { replacement: e.target.value })}
|
||||
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm transition-all focus:border-red-500 focus:outline-none focus:ring-2 focus:ring-red-500/10"
|
||||
/>
|
||||
<div className="px-1">
|
||||
<Switch
|
||||
checked={row.enabled}
|
||||
onCheckedChange={(v) => patchRow(row.key, { enabled: v })}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRow(row.key)}
|
||||
title="删除"
|
||||
className="flex size-8 items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-red-50 hover:text-red-600"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { Navigate, Route, Routes, useParams } from "react-router-dom";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
import { SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { getAccountDisplayName, getStoredAuth } from "@/core/auth";
|
||||
import { RouteBaseProvider } from "@/core/route-base";
|
||||
import GeneralQA from "@/strategy-components/components/chat-qa/GeneralQA";
|
||||
import SentimentAnalyzes from "@/strategy-components/components/task-components/SentimentAnalyzes";
|
||||
@@ -32,6 +32,7 @@ import LightAppIframeView from "./LightAppIframeView";
|
||||
import LightAppListPage from "./LightAppListPage";
|
||||
import LightAppManagementPage from "./LightAppManagementPage";
|
||||
import MenuManagementPage from "./MenuManagementPage";
|
||||
import SensitiveWordsPage from "./SensitiveWordsPage";
|
||||
import LightAppNewNotePage from "./LightAppNewNotePage";
|
||||
import LightAppNoteListPage from "./LightAppNoteListPage";
|
||||
import LightAppNoteQAPage from "./LightAppNoteQAPage";
|
||||
@@ -321,6 +322,16 @@ export default function StrategyRoutes() {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/sensitive-words"
|
||||
element={
|
||||
getAccountDisplayName().toLowerCase() === "lqq" ? (
|
||||
<SensitiveWordsPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/scheduled-tasks"
|
||||
element={
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* 敏感词(脱敏词)管理 API 客户端 + React Query hooks。
|
||||
*
|
||||
* 后端:`/api/sensitive-words`(见 app/gateway/routers/sensitive_words.py)。
|
||||
* 全局共享、无用户隔离;读对所有登录用户开放(运行时脱敏要用),写仅 admin(整体替换)。
|
||||
* snake_case ⇄ camelCase 在此边界收敛。
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/sensitive-words`;
|
||||
|
||||
/** 一条敏感词配置(前端视图模型)。 */
|
||||
export interface SensitiveWord {
|
||||
/** 原始词 */
|
||||
word: string;
|
||||
/** 脱敏替换词 */
|
||||
replacement: string;
|
||||
/** 是否启用(停用的词不参与运行时脱敏,但保留以便随时恢复) */
|
||||
enabled: boolean;
|
||||
/** 展示/匹配排序 */
|
||||
sortOrder: number;
|
||||
updatedBy?: string | null;
|
||||
updatedAt?: string | null;
|
||||
}
|
||||
|
||||
interface ServerWord {
|
||||
word: string;
|
||||
replacement: string;
|
||||
enabled: boolean;
|
||||
sort_order: number;
|
||||
updated_by?: string | null;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
function toWord(s: ServerWord): SensitiveWord {
|
||||
return {
|
||||
word: s.word,
|
||||
replacement: s.replacement ?? "",
|
||||
enabled: s.enabled ?? true,
|
||||
sortOrder: s.sort_order ?? 0,
|
||||
updatedBy: s.updated_by ?? null,
|
||||
updatedAt: s.updated_at ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function toBody(w: SensitiveWord): Pick<ServerWord, "word" | "replacement" | "enabled" | "sort_order"> {
|
||||
return {
|
||||
word: w.word,
|
||||
replacement: w.replacement ?? "",
|
||||
enabled: w.enabled,
|
||||
sort_order: w.sortOrder,
|
||||
};
|
||||
}
|
||||
|
||||
async function readJson<T>(res: Response, ctx: string): Promise<T> {
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null as unknown);
|
||||
const detail =
|
||||
data && typeof data === "object" && "detail" in data
|
||||
? String((data as { detail: unknown }).detail)
|
||||
: `${ctx} failed (${res.status})`;
|
||||
throw new Error(detail);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export async function listSensitiveWords(): Promise<SensitiveWord[]> {
|
||||
const res = await apiFetch(BASE(), { method: "GET" });
|
||||
const data = await readJson<{ words: ServerWord[] }>(res, "list sensitive words");
|
||||
return (data.words ?? []).map(toWord);
|
||||
}
|
||||
|
||||
export async function replaceSensitiveWords(words: SensitiveWord[]): Promise<SensitiveWord[]> {
|
||||
const res = await apiFetch(BASE(), {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ words: words.map(toBody) }),
|
||||
});
|
||||
const data = await readJson<{ words: ServerWord[] }>(res, "save sensitive words");
|
||||
return (data.words ?? []).map(toWord);
|
||||
}
|
||||
|
||||
/** 把「启用」的词条折叠成运行时脱敏所需的 `原始词 → 替换词` 映射。 */
|
||||
export function wordsToMap(words: SensitiveWord[]): Record<string, string> {
|
||||
const map: Record<string, string> = {};
|
||||
for (const w of words) {
|
||||
if (w.enabled && w.word) map[w.word] = w.replacement ?? "";
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// ── React Query hooks(管理页 + 启动加载共用缓存) ──────────────────────────
|
||||
|
||||
export const SENSITIVE_WORDS_KEY = "sensitive-words";
|
||||
|
||||
export function useSensitiveWords() {
|
||||
return useQuery({
|
||||
queryKey: [SENSITIVE_WORDS_KEY],
|
||||
queryFn: listSensitiveWords,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSensitiveWordActions() {
|
||||
const queryClient = useQueryClient();
|
||||
return {
|
||||
save: useMutation({
|
||||
mutationFn: replaceSensitiveWords,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: [SENSITIVE_WORDS_KEY] }),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -253,6 +253,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
|
||||
| **Public Scheduled Tasks** (`/api/public/scheduled-tasks`) | **Unauthenticated** mirror for embedding. Adds `GET /runs/{rid}/html` and `GET /{task_id}/latest-html` for the `html_page` preview; the run result keeps `result.html` (only file paths are redacted). |
|
||||
| **Light Apps** (`/api/light-apps`) | Light-app registry (????? / ????), **global/shared** (no user scoping; `created_by`/`updated_by` audit-only). Two kinds: `window_open` (launched in a new tab from the ???? card grid) and `iframe` (embedded under a parent sidebar menu via `mount_location` and dynamically injected into the sidebar). Both carry `route_params` ? a list of `{key, paramType: login\|theme, source, customValue, themeValue}` descriptors the **frontend** resolves at launch time (login params read from `localStorage.userInfo`; theme params use a literal value) and appends to `url` as query string. `GET /` - list (filters `app_type`/`mount_location`/`enabled_only`/`search`); `GET /{app_id}` - read one (both open to any authenticated user); `POST /`, `PUT /{app_id}`, `DELETE /{app_id}` - **admin only**; iframe apps must carry a `mount_location` (400 otherwise); the unique `name` collides as HTTP 409. Backed by `deerflow.persistence.light_apps` (table `light_apps`, auto-created by `create_all`); store wired in `deps.py` as `app.state.light_app_store`. Tests: `tests/test_light_apps.py`. |
|
||||
| **Menu Overrides** (`/api/menu-overrides`) | Sidebar-menu overrides (菜单管理), **global/shared** (no user scoping; `updated_by` audit-only). The static menu tree (icons/routes/`adminOnly`) lives in the **frontend** code (`core/page-layout/sidebar-menu.ts`); iframe light-apps are injected at render time. This router persists a per-node **overlay** the frontend merges onto that tree: `parent_id` (re-parent, moves the whole subtree; NULL = top level), `sort_order` (reorder among siblings), `label` (rename; NULL keeps code label), `disabled` (停用 — hides the node **and its subtree**). A node with no row renders exactly as code defines it, so new code menus / newly-registered iframe apps appear automatically and are immediately configurable. `GET /` - list all overrides (open to any authenticated user); `PUT /` - **admin only**, atomically **replaces the whole set** (`{overrides: [...]}`); rejects re-parenting that would exceed **3 levels** or form a cycle (`_validate_depth`, HTTP 400). Backed by `deerflow.persistence.menu_overrides` (table `menu_overrides`, auto-created by `create_all`); store wired in `deps.py` as `app.state.menu_override_store`. Frontend: merge logic `core/page-layout/menu-overrides.ts` (`applyMenuOverrides` in `use-sidebar-menu.ts`), client `strategy-components/api/menu-overrides.ts`, admin page `pages/MenuManagementPage.tsx` (@dnd-kit drag-reorder/re-parent + 「移动到」parent selector + ↑↓ arrows + 改名/停用), entry in the bottom-left settings dropdown (`components/page-sidebar.tsx`, admin-only). Tests: `tests/test_menu_overrides.py`, `frontend-web/src/core/page-layout/menu-overrides.test.ts`. |
|
||||
| **Sensitive Words** (`/api/sensitive-words`) | Sensitive-word / desensitize map (敏感词管理 / 脱敏词), **global/shared** (no user scoping; `updated_by` audit-only). Each row maps an original `word` → `replacement`, with `enabled` (停用 parks a word without deleting) + `sort_order`. The **frontend** ships a seed map in code (`src/lib/desensitize.ts` `SEED_DESENSITIZE_MAP`) used as the default on a fresh deployment; once an admin saves, this table is the source of truth and the frontend loads it at startup (`DesensitizeWordsLoader` → `replaceDesensitizeMap`, **enabled** rows only). `GET /` - list all words (open to any authenticated user — every page desensitizes); `PUT /` - **admin OR the `lqq` account** (`_require_manager`: `system_role == "admin"` or email `@`-prefix `== "lqq"`), atomically **replaces the whole set** (`{words: [...]}`, de-duped by `word`, last wins). Backed by `deerflow.persistence.sensitive_words` (table `sensitive_words`, PK `word` `String(191)`, auto-created by `create_all`); store wired in `deps.py` as `app.state.sensitive_word_store`. Frontend: client `strategy-components/api/sensitive-words.ts`, page `pages/SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, entry in the bottom-left settings dropdown — **gated on username `lqq`, not admin role**, present in both sidebar modes; 「导入种子词」 merges missing seed entries). Tests: `tests/test_sensitive_words.py`. |
|
||||
| **Knowledge** (`/api/knowledge`) | Product knowledge base, **global/shared** (no user isolation; writes record `created_by`/`updated_by` for audit). `POST /ingest/thread/{thread_id}` - sediment one thread into a note (reads messages from the checkpointer, `threads:read` owner-checked); `POST /ingest/threads/batch` - batch-sediment the caller's recent threads (`skip_existing` via `.manifest.json` dedup). **Defaults to `background=true`**: the slow per-thread extraction loop is deferred to a FastAPI background task and the endpoint returns `{queued:true, processed:N}` immediately (each thread's transcript is truncated to `max_input_chars` before the LLM call). Running the loop inline for a large batch outlives the nginx gateway timeout (504), which the frontend's `parseResponse` surfaces as a generic "Request failed"; `background=false` keeps the blocking path (full per-thread `created/skipped/failed` tally) for small batches/scripts. Shared loop: `_run_batch_ingest`. **Optional model selection**: both ingest endpoints accept `model_name` (overrides `knowledge.extract_model_name` for that capture; threaded through `KnowledgeService.capture_thread` ? `extract_thread_llm_multi`). **Model-error surfacing**: when the chosen model produces nothing usable (timeout / API error / non-JSON) capture still **falls back to rule-based extraction and lands a draft** (`llm_fallback:true`); the single-thread (non-background) response and the batch response (`llm_fallback` count) carry this so the UI tells the user "???????". The "??????" chat button stays background/fire-and-forget (passes `model_name`, no per-save error toast); the batch-import dialog is synchronous and shows the fallback count. `POST /ingest/file` - **import an uploaded file** (multipart: `file` + optional `title`/`tags`/`status`/`model_name`) into wiki note(s): the file is converted to Markdown (md/txt read directly, pdf/word/ppt/excel via `convert_file_to_markdown`), distilled by the same wiki-capture LLM pipeline as thread capture (`source_type="file"`), and falls back to a verbatim **draft** note when the model returns nothing usable (`llm_fallback:true`); `POST /ingest/search` - sediment search/tool results as a reference note; `POST /notes` - manual note; `GET /notes` (keyword/tag/source_type/status + pagination); `GET/PUT/DELETE /notes/{id}` (DELETE is soft = archive, `?hard=true` removes the vault file); `POST /search` - `mode=keyword\|vector\|hybrid` (vector?keyword fallback when no embedding endpoint); `GET /notes/{id}/versions` - edit history; `GET /duplicates` + `POST /merge` - dedup; `POST /reindex` - rebuild vector index; `GET /graph` - product graph data (notes + entities, graph-blacklist filtered); `GET/POST /graph/blacklist` + `DELETE /graph/blacklist/{id}` - graph node blacklist (labels = entity names / note titles, case-insensitive match; blacklisted nodes and their edges are dropped from `/graph` and the wiki-export artifacts; table `knowledge_graph_blacklist`, auto-created); `POST /export/graph` + `GET /export/files/{graph.json\|graph.html\|graph.graphml\|cypher.txt}` - obsidian-wiki `wiki-export` artifacts (self-contained `graph.html` for iframe); `POST /resolve` `{targets:[?]}` + `GET /notes/{id}/wikilinks` - resolve `[[wikilink]]` targets to a note id / vault page / missing (powers clickable wikilinks); `GET /vault-page?path=` - read a raw vault meta/hub Markdown page (no DB note). `GET/POST /folders` + `PUT/DELETE /folders/{id}` - user-managed directories (notes carry a `folder` path; `GET /notes?folder=` filters a dir + sub-dirs); `GET/POST /extract-templates` + `GET/PUT/DELETE /extract-templates/{id}` - editable extraction-prompt templates (thread/document ingest accept `template_id`; capture/PUT-note accept `folder`). Backed by the `deerflow.knowledge` module (see below). |
|
||||
|
||||
Proxied through nginx: `/api/langgraph/*` ? LangGraph, all other `/api/*` ? Gateway.
|
||||
|
||||
@@ -63,6 +63,7 @@ from app.gateway.routers import (
|
||||
roundtable_jobs,
|
||||
runs,
|
||||
scheduled_tasks,
|
||||
sensitive_words,
|
||||
skills,
|
||||
suggestions,
|
||||
system_settings,
|
||||
@@ -1343,6 +1344,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
# Sidebar-menu overrides (菜单管理) at /api/menu-overrides
|
||||
app.include_router(menu_overrides.router)
|
||||
|
||||
# Sensitive-word map (敏感词管理 / 脱敏词) at /api/sensitive-words
|
||||
app.include_router(sensitive_words.router)
|
||||
|
||||
# Standalone parallel multi-agent panel: fan-out streaming + saved tasks
|
||||
app.include_router(parallel_agents.router)
|
||||
app.include_router(parallel_agent_tasks.router)
|
||||
|
||||
@@ -243,6 +243,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
app.state.menu_override_store = make_menu_override_store(sf)
|
||||
|
||||
from deerflow.persistence.sensitive_words import make_sensitive_word_store
|
||||
|
||||
app.state.sensitive_word_store = make_sensitive_word_store(sf)
|
||||
|
||||
from deerflow.persistence.parallel_agent_tasks import make_parallel_agent_task_store
|
||||
|
||||
app.state.parallel_agent_task_store = make_parallel_agent_task_store(sf)
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Sensitive-word API (脱敏词 / 敏感词管理).
|
||||
|
||||
The frontend ships a seed desensitize map in code (``src/lib/desensitize.ts``);
|
||||
it is the default on a fresh deployment and the source admins can import from.
|
||||
This router persists a **global / shared** word map that the frontend loads at
|
||||
startup and uses to build its desensitize regex. Each entry maps an original
|
||||
``word`` to its ``replacement``; ``enabled`` parks a word without deleting it.
|
||||
|
||||
Reads are open to any authenticated user (every page desensitizes); the whole
|
||||
map is replaced atomically and only by an admin.
|
||||
|
||||
Routes (prefix ``/api/sensitive-words``):
|
||||
GET "" list all words
|
||||
PUT "" replace the entire word set (admin)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.gateway.deps import get_current_user, get_optional_user_from_request
|
||||
from deerflow.persistence.sensitive_words import SensitiveWordStore
|
||||
|
||||
router = APIRouter(prefix="/api/sensitive-words", tags=["sensitive-words"])
|
||||
|
||||
# 敏感词管理对应的指定账号:用户名(邮箱 @ 前缀)为该值的用户可写,admin 也可写。
|
||||
# 与前端 `getAccountDisplayName() === "lqq"` 的可见性门保持一致。
|
||||
_MANAGER_USERNAME = "lqq"
|
||||
|
||||
|
||||
def _username_of(user) -> str:
|
||||
email = getattr(user, "email", None) or ""
|
||||
return email.split("@")[0].strip().lower()
|
||||
|
||||
|
||||
# ── schemas ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class SensitiveWord(BaseModel):
|
||||
word: str = Field(..., min_length=1, max_length=191)
|
||||
replacement: str = Field(default="", max_length=191)
|
||||
enabled: bool = True
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class SensitiveWordResponse(SensitiveWord):
|
||||
updated_by: str | None = None
|
||||
updated_at: datetime | str | None = None
|
||||
|
||||
|
||||
class SensitiveWordListResponse(BaseModel):
|
||||
words: list[SensitiveWordResponse]
|
||||
|
||||
|
||||
class SensitiveWordReplaceRequest(BaseModel):
|
||||
words: list[SensitiveWord] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_store(request: Request) -> SensitiveWordStore:
|
||||
store = getattr(request.app.state, "sensitive_word_store", None)
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="Sensitive word store not available")
|
||||
return store
|
||||
|
||||
|
||||
async def _require_manager(request: Request) -> None:
|
||||
"""Allow the designated manager username (``lqq``) or an admin.
|
||||
|
||||
Lets the call through when auth is disabled (no user resolved).
|
||||
"""
|
||||
user = await get_optional_user_from_request(request)
|
||||
if user is None:
|
||||
return
|
||||
if getattr(user, "system_role", None) == "admin":
|
||||
return
|
||||
if _username_of(user) == _MANAGER_USERNAME:
|
||||
return
|
||||
raise HTTPException(status_code=403, detail="敏感词管理仅限指定账号")
|
||||
|
||||
|
||||
# ── routes ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("", response_model=SensitiveWordListResponse)
|
||||
async def list_sensitive_words(request: Request) -> SensitiveWordListResponse:
|
||||
store = _get_store(request)
|
||||
rows = await store.list_words()
|
||||
return SensitiveWordListResponse(words=[SensitiveWordResponse(**r) for r in rows])
|
||||
|
||||
|
||||
@router.put("", response_model=SensitiveWordListResponse)
|
||||
async def replace_sensitive_words(request: Request, body: SensitiveWordReplaceRequest) -> SensitiveWordListResponse:
|
||||
await _require_manager(request)
|
||||
store = _get_store(request)
|
||||
user_id = await get_current_user(request)
|
||||
rows = await store.replace_all(
|
||||
[w.model_dump() for w in body.words],
|
||||
updated_by=user_id,
|
||||
)
|
||||
return SensitiveWordListResponse(words=[SensitiveWordResponse(**r) for r in rows])
|
||||
+2
@@ -55,6 +55,7 @@ from deerflow.persistence.roundtable_drafts.model import (
|
||||
)
|
||||
from deerflow.persistence.roundtable_jobs.model import RoundtableJobRow
|
||||
from deerflow.persistence.run.model import RunRow
|
||||
from deerflow.persistence.sensitive_words.model import SensitiveWordRow
|
||||
from deerflow.persistence.scheduled_tasks.model import (
|
||||
ScheduledTaskDeliveryProfileRow,
|
||||
ScheduledTaskRow,
|
||||
@@ -112,6 +113,7 @@ __all__ = [
|
||||
"ScheduledTaskRunRow",
|
||||
"ScheduledTaskSubscriptionRow",
|
||||
"SchedulerThreadRow",
|
||||
"SensitiveWordRow",
|
||||
"SkillRow",
|
||||
"SkillDisplayAdapterRow",
|
||||
"SkillFavoriteRow",
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
"""Sensitive-word (脱敏词 / 敏感词管理) persistence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from deerflow.persistence.sensitive_words.base import SensitiveWordStore
|
||||
from deerflow.persistence.sensitive_words.memory import MemorySensitiveWordStore
|
||||
from deerflow.persistence.sensitive_words.model import SensitiveWordRow
|
||||
from deerflow.persistence.sensitive_words.sql import SensitiveWordRepository
|
||||
|
||||
__all__ = [
|
||||
"MemorySensitiveWordStore",
|
||||
"SensitiveWordRepository",
|
||||
"SensitiveWordRow",
|
||||
"SensitiveWordStore",
|
||||
"make_sensitive_word_store",
|
||||
]
|
||||
|
||||
|
||||
def make_sensitive_word_store(session_factory):
|
||||
if session_factory is None:
|
||||
return MemorySensitiveWordStore()
|
||||
return SensitiveWordRepository(session_factory)
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
"""Abstract sensitive-word (脱敏词) persistence API.
|
||||
|
||||
Global / shared store: no ``user_id`` scoping; every account desensitizes with
|
||||
the same word map. Reads are open to any caller (the frontend needs the map to
|
||||
render desensitized text); the router gates writes behind admin. ``updated_by``
|
||||
is audit-only.
|
||||
|
||||
The whole word map is treated as one document: the management page computes the
|
||||
full desired set and replaces every row atomically via :meth:`replace_all`, so
|
||||
the stored set never drifts out of sync with what the admin sees. This mirrors
|
||||
the menu-override store.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
|
||||
class SensitiveWordStore(ABC):
|
||||
@abstractmethod
|
||||
async def list_words(self) -> list[dict[str, Any]]:
|
||||
"""Return every stored word row, ordered by ``sort_order``."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def replace_all(self, rows: list[dict[str, Any]], *, updated_by: str | None = None) -> list[dict[str, Any]]:
|
||||
"""Atomically replace the entire word set with ``rows``.
|
||||
|
||||
Each row is ``{word, replacement, enabled, sort_order}``. Returns the
|
||||
persisted rows.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
"""In-memory sensitive-word store (used when no DB is configured / tests)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from deerflow.persistence.sensitive_words.base import SensitiveWordStore
|
||||
|
||||
|
||||
def _normalize(data: dict[str, Any], updated_by: str | None) -> dict[str, Any]:
|
||||
return {
|
||||
"word": str(data["word"]),
|
||||
"replacement": str(data.get("replacement") or ""),
|
||||
"enabled": bool(data.get("enabled", True)),
|
||||
"sort_order": int(data.get("sort_order", 0)),
|
||||
"updated_by": updated_by,
|
||||
"updated_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
class MemorySensitiveWordStore(SensitiveWordStore):
|
||||
def __init__(self) -> None:
|
||||
self._rows: dict[str, dict[str, Any]] = {}
|
||||
|
||||
async def list_words(self) -> list[dict[str, Any]]:
|
||||
return [dict(r) for r in sorted(self._rows.values(), key=lambda r: (r.get("sort_order", 0), r.get("word", "")))]
|
||||
|
||||
async def replace_all(self, rows: list[dict[str, Any]], *, updated_by: str | None = None) -> list[dict[str, Any]]:
|
||||
self._rows = {}
|
||||
for r in rows:
|
||||
word = r.get("word")
|
||||
if word in (None, ""):
|
||||
continue
|
||||
self._rows[str(word)] = _normalize(r, updated_by)
|
||||
return await self.list_words()
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
"""ORM model for sensitive words (脱敏词 / 敏感词管理).
|
||||
|
||||
Global / shared — there is no ``user_id`` scoping; every account desensitizes
|
||||
with the same map. The frontend ships a seed map in code (used as the default on
|
||||
a fresh deployment); once an admin saves here, this table is the source of truth.
|
||||
|
||||
Each row maps an original term (``word``, the primary key) to its desensitized
|
||||
replacement (``replacement``). ``enabled`` lets an admin park a word without
|
||||
deleting it; ``sort_order`` controls display order in the management page. The
|
||||
desensitize regex is rebuilt from the **enabled** rows. ``updated_by`` is
|
||||
audit-only.
|
||||
|
||||
``word`` is the primary key at ``String(191)`` so the utf8mb4 index stays within
|
||||
the 767-byte legacy limit (191 × 4 = 764) — sensitive terms are never that long.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import Boolean, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from deerflow.persistence.base import Base
|
||||
from deerflow.persistence.types import BeijingDateTime
|
||||
|
||||
|
||||
class SensitiveWordRow(Base):
|
||||
__tablename__ = "sensitive_words"
|
||||
|
||||
word: Mapped[str] = mapped_column(String(191), primary_key=True)
|
||||
replacement: Mapped[str] = mapped_column(String(191), default="")
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
updated_by: Mapped[str | None] = mapped_column(String(64))
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
BeijingDateTime(), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)
|
||||
)
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
"""SQLAlchemy-backed sensitive-word store (global / shared)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.sensitive_words.base import SensitiveWordStore
|
||||
from deerflow.persistence.sensitive_words.model import SensitiveWordRow
|
||||
|
||||
|
||||
def _to_dict(row: SensitiveWordRow) -> dict[str, Any]:
|
||||
data = row.to_dict()
|
||||
value = data.get("updated_at")
|
||||
if isinstance(value, datetime):
|
||||
data["updated_at"] = value.isoformat()
|
||||
return data
|
||||
|
||||
|
||||
def _row_from(data: dict[str, Any], updated_by: str | None) -> SensitiveWordRow:
|
||||
return SensitiveWordRow(
|
||||
word=str(data["word"]),
|
||||
replacement=str(data.get("replacement") or ""),
|
||||
enabled=bool(data.get("enabled", True)),
|
||||
sort_order=int(data.get("sort_order", 0)),
|
||||
updated_by=updated_by,
|
||||
)
|
||||
|
||||
|
||||
class SensitiveWordRepository(SensitiveWordStore):
|
||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||
self._sf = session_factory
|
||||
|
||||
async def list_words(self) -> list[dict[str, Any]]:
|
||||
stmt = select(SensitiveWordRow).order_by(SensitiveWordRow.sort_order.asc(), SensitiveWordRow.word.asc())
|
||||
async with self._sf() as session:
|
||||
result = await session.execute(stmt)
|
||||
return [_to_dict(row) for row in result.scalars()]
|
||||
|
||||
async def replace_all(self, rows: list[dict[str, Any]], *, updated_by: str | None = None) -> list[dict[str, Any]]:
|
||||
# De-dupe by word (last wins) so a malformed payload can't violate the PK.
|
||||
by_word: dict[str, dict[str, Any]] = {}
|
||||
for r in rows:
|
||||
word = r.get("word")
|
||||
if word not in (None, ""):
|
||||
by_word[str(word)] = r
|
||||
async with self._sf() as session:
|
||||
await session.execute(delete(SensitiveWordRow))
|
||||
new_rows = [_row_from(r, updated_by) for r in by_word.values()]
|
||||
session.add_all(new_rows)
|
||||
await session.commit()
|
||||
for row in new_rows:
|
||||
await session.refresh(row)
|
||||
return [_to_dict(row) for row in new_rows]
|
||||
Reference in new issue
Block a user