Files
2026-08-07 11:12:40 +08:00

295 lines
11 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.
"""Notification template management API (通知模板管理).
Admin-only CRUD for message notification templates with variable validation,
optimistic locking, and live preview.
Routes (prefix ``/api/notification-templates``):
GET "" list templates (admin, paginated)
GET "/{id}" get single template (admin)
POST "" create template (admin)
PUT "/{id}" update template (admin, optimistic lock)
DELETE "/{id}" delete template (admin)
POST "/{id}/duplicate" duplicate template (admin)
POST "/preview" preview template with variables (admin)
"""
from __future__ import annotations
import re
from datetime import datetime
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field
from app.gateway.deps import get_current_user, get_optional_user_from_request
from deerflow.persistence.notification_templates import NotificationTemplateStore
router = APIRouter(prefix="/api/notification-templates", tags=["notification-templates"])
ALLOWED_VARIABLES = {"userName", "taskName", "taskId", "dueDate"}
_VAR_PATTERN = re.compile(r"\{\{([^}]*)\}\}")
def _validate_template_variables(text: str, field_name: str) -> None:
for m in _VAR_PATTERN.finditer(text):
inner = m.group(1)
if not inner or inner != inner.strip():
raise HTTPException(
status_code=422,
detail=f"{field_name}: 变量格式错误 '{m.group(0)}',变量名不能为空或包含空格",
)
var_name = inner.strip()
if var_name not in ALLOWED_VARIABLES:
raise HTTPException(
status_code=422,
detail=f"{field_name}: 不支持的变量 '{{{{{var_name}}}}}',"
f"仅支持: {', '.join(sorted(ALLOWED_VARIABLES))}",
)
def _validate_tags(tags: list[str]) -> list[str]:
cleaned: list[str] = []
seen: set[str] = set()
for t in tags:
t = t.strip()
if not t:
continue
if len(t) > 20:
raise HTTPException(status_code=422, detail=f"标签长度超过 20: '{t}'")
if t not in seen:
seen.add(t)
cleaned.append(t)
if len(cleaned) > 5:
raise HTTPException(status_code=422, detail="标签数量不能超过 5 个")
return cleaned
# ── schemas ──────────────────────────────────────────────────────────────
class TemplateCreateRequest(BaseModel):
name: str = Field(..., min_length=2, max_length=50)
title_template: str = Field(..., min_length=1, max_length=120)
content_template: str = Field(..., min_length=1, max_length=5000)
tags: list[str] = Field(default_factory=list)
enabled: bool = True
class TemplateUpdateRequest(BaseModel):
name: str = Field(..., min_length=2, max_length=50)
title_template: str = Field(..., min_length=1, max_length=120)
content_template: str = Field(..., min_length=1, max_length=5000)
tags: list[str] = Field(default_factory=list)
enabled: bool = True
expected_version: int = Field(..., ge=1)
class TemplateResponse(BaseModel):
id: str
name: str
title_template: str
content_template: str
tags: list[str]
enabled: bool
version: int
created_by: str | None = None
updated_by: str | None = None
created_at: datetime | str | None = None
updated_at: datetime | str | None = None
class TemplateListResponse(BaseModel):
total: int
items: list[TemplateResponse]
page: int
page_size: int
class PreviewRequest(BaseModel):
title_template: str = Field(default="", max_length=120)
content_template: str = Field(default="", max_length=5000)
variables: dict[str, str] = Field(default_factory=dict)
class PreviewResponse(BaseModel):
title: str
content: str
missing_variables: list[str]
# ── helpers ──────────────────────────────────────────────────────────────
def _get_store(request: Request) -> NotificationTemplateStore:
store = getattr(request.app.state, "notification_template_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Notification template store not available")
return store
async def _require_admin(request: Request) -> str | None:
user = await get_optional_user_from_request(request)
if user is not None and getattr(user, "system_role", None) != "admin":
raise HTTPException(status_code=403, detail="通知模板管理仅限管理员")
return await get_current_user(request)
# ── routes ───────────────────────────────────────────────────────────────
@router.get("", response_model=TemplateListResponse)
async def list_templates(
request: Request,
keyword: str | None = Query(default=None),
enabled: bool | None = Query(default=None),
tag: str | None = Query(default=None),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
) -> TemplateListResponse:
await _require_admin(request)
store = _get_store(request)
result = await store.list_templates(
keyword=keyword, enabled=enabled, tag=tag, page=page, page_size=page_size
)
return TemplateListResponse(
total=result["total"],
items=[TemplateResponse(**r) for r in result["items"]],
page=result["page"],
page_size=result["page_size"],
)
@router.get("/{template_id}", response_model=TemplateResponse)
async def get_template(request: Request, template_id: str) -> TemplateResponse:
await _require_admin(request)
store = _get_store(request)
row = await store.get_template(template_id)
if row is None:
raise HTTPException(status_code=404, detail="模板不存在")
return TemplateResponse(**row)
@router.post("", response_model=TemplateResponse, status_code=201)
async def create_template(request: Request, body: TemplateCreateRequest) -> TemplateResponse:
user_id = await _require_admin(request)
store = _get_store(request)
name = body.name.strip()
if len(name) < 2:
raise HTTPException(status_code=422, detail="name 去除首尾空格后长度不得少于 2")
if await store.name_exists(name):
raise HTTPException(status_code=409, detail=f"模板名称已存在: {name}")
_validate_template_variables(body.title_template, "title_template")
_validate_template_variables(body.content_template, "content_template")
tags = _validate_tags(body.tags)
row = await store.create_template(
{
"name": name,
"title_template": body.title_template,
"content_template": body.content_template,
"tags": tags,
"enabled": body.enabled,
},
created_by=user_id,
)
return TemplateResponse(**row)
@router.put("/{template_id}", response_model=TemplateResponse)
async def update_template(request: Request, template_id: str, body: TemplateUpdateRequest) -> TemplateResponse:
user_id = await _require_admin(request)
store = _get_store(request)
name = body.name.strip()
if len(name) < 2:
raise HTTPException(status_code=422, detail="name 去除首尾空格后长度不得少于 2")
if await store.name_exists(name, exclude_id=template_id):
raise HTTPException(status_code=409, detail=f"模板名称已存在: {name}")
_validate_template_variables(body.title_template, "title_template")
_validate_template_variables(body.content_template, "content_template")
tags = _validate_tags(body.tags)
try:
row = await store.update_template(
template_id,
{
"name": name,
"title_template": body.title_template,
"content_template": body.content_template,
"tags": tags,
"enabled": body.enabled,
},
expected_version=body.expected_version,
updated_by=user_id,
)
except ValueError as exc:
msg = str(exc)
if msg == "NOT_FOUND":
raise HTTPException(status_code=404, detail="模板不存在")
if msg == "VERSION_CONFLICT":
latest = await store.get_template(template_id)
raise HTTPException(
status_code=409,
detail={
"message": "版本冲突,服务器版本已更新",
"server": TemplateResponse(**latest).model_dump() if latest else None,
},
)
raise
return TemplateResponse(**row)
@router.delete("/{template_id}", status_code=204)
async def delete_template(request: Request, template_id: str) -> None:
await _require_admin(request)
store = _get_store(request)
deleted = await store.delete_template(template_id)
if not deleted:
raise HTTPException(status_code=404, detail="模板不存在")
@router.post("/{template_id}/duplicate", response_model=TemplateResponse, status_code=201)
async def duplicate_template(request: Request, template_id: str) -> TemplateResponse:
user_id = await _require_admin(request)
store = _get_store(request)
try:
row = await store.duplicate_template(template_id, created_by=user_id)
except ValueError as exc:
if str(exc) == "NOT_FOUND":
raise HTTPException(status_code=404, detail="模板不存在")
raise
return TemplateResponse(**row)
@router.post("/preview", response_model=PreviewResponse)
async def preview_template(request: Request, body: PreviewRequest) -> PreviewResponse:
await _require_admin(request)
_validate_template_variables(body.title_template, "title_template")
_validate_template_variables(body.content_template, "content_template")
all_vars = set()
for text in (body.title_template, body.content_template):
for m in _VAR_PATTERN.finditer(text):
all_vars.add(m.group(1).strip())
missing = sorted(all_vars - set(body.variables.keys()))
def _replace(text: str) -> str:
def _sub(m: re.Match) -> str:
var_name = m.group(1).strip()
return body.variables.get(var_name, m.group(0))
return _VAR_PATTERN.sub(_sub, text)
return PreviewResponse(
title=_replace(body.title_template),
content=_replace(body.content_template),
missing_variables=missing,
)