295 lines
11 KiB
Python
295 lines
11 KiB
Python
"""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,
|
||
)
|