deerflow-code/offline-backend-20260512/backend/app/gateway/routers/sensitive_words.py
2026-09-07 18:24:55 +08:00

107 lines
4.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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