"""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])