日志比较

This commit is contained in:
董宏凯 committed 2026-08-07 11:12:40 +08:00
1 parent aa3742b103
commit c555b92089
13 files changed
+1702 -1

No files matched your search

@@ -57,7 +57,8 @@ from app.gateway.routers import (
menu_overrides,
models,
multi_agent,
notifications,
notification_templates,
notifications,
open_3qfx,
open_chat,
parallel_agent_tasks,
@@ -1609,6 +1610,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Task-workspace configurable jump buttons (按钮管理) at /api/task-buttons
app.include_router(task_buttons.router)
# Notification template management (通知模板管理) at /api/notification-templates
app.include_router(notification_templates.router)
# Microsoft Agent Framework 多智能体会商实验 (independent of the legacy
# roundtable; feature-flag-gated, six isolated tables) at /api/maf-roundtable
app.include_router(maf_roundtable.router)
@@ -358,6 +358,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.task_button_store = make_task_button_store(sf)
from deerflow.persistence.notification_templates import make_notification_template_store
app.state.notification_template_store = make_notification_template_store(sf)
from deerflow.persistence.taskcop_tasks import make_taskcop_task_store
app.state.taskcop_task_store = make_taskcop_task_store(sf)
@@ -0,0 +1,294 @@
"""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,
)
@@ -53,6 +53,7 @@ from deerflow.persistence.maf_roundtable.model import (
from deerflow.persistence.memory_config.model import UserMemoryConfigRow
from deerflow.persistence.menu_overrides.model import MenuOverrideRow
from deerflow.persistence.models.run_event import RunEventRow
from deerflow.persistence.notification_templates.model import NotificationTemplateRow
from deerflow.persistence.notifications.model import NotificationRow
from deerflow.persistence.parallel_agent_tasks.model import ParallelAgentTaskRow
from deerflow.persistence.position_roles.model import PositionRoleRow
@@ -124,6 +125,7 @@ __all__ = [
"MafRoundtableSessionRow",
"MenuOverrideRow",
"NotificationRow",
"NotificationTemplateRow",
"ParallelAgentTaskRow",
"PositionRoundtableNodeRow",
"PositionRoundtableSessionRow",
@@ -0,0 +1,22 @@
"""Notification template persistence (通知模板管理)."""
from __future__ import annotations
from deerflow.persistence.notification_templates.base import NotificationTemplateStore
from deerflow.persistence.notification_templates.memory import MemoryNotificationTemplateStore
from deerflow.persistence.notification_templates.model import NotificationTemplateRow
from deerflow.persistence.notification_templates.sql import NotificationTemplateRepository
__all__ = [
"MemoryNotificationTemplateStore",
"NotificationTemplateRepository",
"NotificationTemplateRow",
"NotificationTemplateStore",
"make_notification_template_store",
]
def make_notification_template_store(session_factory):
if session_factory is None:
return MemoryNotificationTemplateStore()
return NotificationTemplateRepository(session_factory)
@@ -0,0 +1,46 @@
"""Abstract notification template persistence API."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
class NotificationTemplateStore(ABC):
@abstractmethod
async def list_templates(
self,
*,
keyword: str | None = None,
enabled: bool | None = None,
tag: str | None = None,
page: int = 1,
page_size: int = 20,
) -> dict[str, Any]:
raise NotImplementedError
@abstractmethod
async def get_template(self, template_id: str) -> dict[str, Any] | None:
raise NotImplementedError
@abstractmethod
async def create_template(self, data: dict[str, Any], *, created_by: str | None = None) -> dict[str, Any]:
raise NotImplementedError
@abstractmethod
async def update_template(
self, template_id: str, data: dict[str, Any], *, expected_version: int, updated_by: str | None = None
) -> dict[str, Any]:
raise NotImplementedError
@abstractmethod
async def delete_template(self, template_id: str) -> bool:
raise NotImplementedError
@abstractmethod
async def duplicate_template(self, template_id: str, *, created_by: str | None = None) -> dict[str, Any]:
raise NotImplementedError
@abstractmethod
async def name_exists(self, name: str, exclude_id: str | None = None) -> bool:
raise NotImplementedError
@@ -0,0 +1,129 @@
"""In-memory notification template store (used when no DB is configured / tests)."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from typing import Any
from deerflow.persistence.notification_templates.base import NotificationTemplateStore
def _normalize(data: dict[str, Any], *, created_by: str | None = None, updated_by: str | None = None) -> dict[str, Any]:
now = datetime.now(UTC).isoformat()
return {
"id": str(data.get("id") or uuid.uuid4().hex),
"name": str(data["name"]),
"title_template": str(data.get("title_template") or ""),
"content_template": str(data.get("content_template") or ""),
"tags": list(data.get("tags") or []),
"enabled": bool(data.get("enabled", True)),
"version": int(data.get("version", 1)),
"created_by": data.get("created_by") or created_by,
"updated_by": updated_by or data.get("updated_by") or created_by,
"created_at": data.get("created_at") or now,
"updated_at": now,
}
class MemoryNotificationTemplateStore(NotificationTemplateStore):
def __init__(self) -> None:
self._rows: dict[str, dict[str, Any]] = {}
async def list_templates(
self,
*,
keyword: str | None = None,
enabled: bool | None = None,
tag: str | None = None,
page: int = 1,
page_size: int = 20,
) -> dict[str, Any]:
items = list(self._rows.values())
if keyword:
kw = keyword.lower()
items = [
r for r in items
if kw in r["name"].lower() or kw in r["title_template"].lower() or kw in r["content_template"].lower()
]
if enabled is not None:
items = [r for r in items if r["enabled"] == enabled]
if tag:
items = [r for r in items if tag in r.get("tags", [])]
items.sort(key=lambda r: r.get("created_at", ""), reverse=True)
total = len(items)
offset = (page - 1) * page_size
page_items = items[offset : offset + page_size]
return {
"total": total,
"items": [dict(r) for r in page_items],
"page": page,
"page_size": page_size,
}
async def get_template(self, template_id: str) -> dict[str, Any] | None:
row = self._rows.get(template_id)
return dict(row) if row else None
async def create_template(self, data: dict[str, Any], *, created_by: str | None = None) -> dict[str, Any]:
row = _normalize(data, created_by=created_by)
self._rows[row["id"]] = row
return dict(row)
async def update_template(
self, template_id: str, data: dict[str, Any], *, expected_version: int, updated_by: str | None = None
) -> dict[str, Any]:
row = self._rows.get(template_id)
if row is None:
raise ValueError("NOT_FOUND")
if row["version"] != expected_version:
raise ValueError("VERSION_CONFLICT")
for key in ("name", "title_template", "content_template", "tags", "enabled"):
if key in data:
row[key] = data[key]
row["version"] = row["version"] + 1
row["updated_by"] = updated_by
row["updated_at"] = datetime.now(UTC).isoformat()
return dict(row)
async def delete_template(self, template_id: str) -> bool:
if template_id in self._rows:
del self._rows[template_id]
return True
return False
async def duplicate_template(self, template_id: str, *, created_by: str | None = None) -> dict[str, Any]:
row = self._rows.get(template_id)
if row is None:
raise ValueError("NOT_FOUND")
base_name = f"{row['name']} - 副本"
new_name = base_name
counter = 1
existing_names = {r["name"] for r in self._rows.values()}
while new_name in existing_names:
counter += 1
new_name = f"{base_name} {counter}"
new_row = _normalize(
{
"name": new_name,
"title_template": row["title_template"],
"content_template": row["content_template"],
"tags": list(row.get("tags", [])),
"enabled": row["enabled"],
},
created_by=created_by,
)
self._rows[new_row["id"]] = new_row
return dict(new_row)
async def name_exists(self, name: str, exclude_id: str | None = None) -> bool:
for rid, r in self._rows.items():
if r["name"] == name and (exclude_id is None or rid != exclude_id):
return True
return False
@@ -0,0 +1,31 @@
"""ORM model for notification templates."""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import Boolean, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime, PortableJSON
class NotificationTemplateRow(Base):
__tablename__ = "notification_templates"
id: Mapped[str] = mapped_column(String(128), primary_key=True)
name: Mapped[str] = mapped_column(String(100), unique=True, index=True)
title_template: Mapped[str] = mapped_column(String(240), default="")
content_template: Mapped[str] = mapped_column(Text, default="")
tags: Mapped[list] = mapped_column(PortableJSON(), default=list)
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
version: Mapped[int] = mapped_column(Integer, default=1)
created_by: Mapped[str | None] = mapped_column(String(64))
updated_by: Mapped[str | None] = mapped_column(String(64))
created_at: Mapped[datetime] = mapped_column(
BeijingDateTime(), default=lambda: datetime.now(UTC)
)
updated_at: Mapped[datetime] = mapped_column(
BeijingDateTime(), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)
)
@@ -0,0 +1,203 @@
"""SQLAlchemy-backed notification template store."""
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import delete, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.notification_templates.base import NotificationTemplateStore
from deerflow.persistence.notification_templates.model import NotificationTemplateRow
def _to_dict(row: NotificationTemplateRow) -> dict[str, Any]:
data = row.to_dict()
for key in ("created_at", "updated_at"):
value = data.get(key)
if isinstance(value, datetime):
data[key] = value.isoformat()
data["tags"] = data.get("tags") or []
data["enabled"] = bool(data.get("enabled", True))
data["version"] = int(data.get("version", 1))
return data
class NotificationTemplateRepository(NotificationTemplateStore):
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
async def list_templates(
self,
*,
keyword: str | None = None,
enabled: bool | None = None,
tag: str | None = None,
page: int = 1,
page_size: int = 20,
) -> dict[str, Any]:
stmt = select(NotificationTemplateRow)
count_stmt = select(func.count()).select_from(NotificationTemplateRow)
if keyword:
kw = f"%{keyword}%"
filt = or_(
NotificationTemplateRow.name.ilike(kw),
NotificationTemplateRow.title_template.ilike(kw),
NotificationTemplateRow.content_template.ilike(kw),
)
stmt = stmt.where(filt)
count_stmt = count_stmt.where(filt)
if enabled is not None:
stmt = stmt.where(NotificationTemplateRow.enabled == enabled)
count_stmt = count_stmt.where(NotificationTemplateRow.enabled == enabled)
if tag:
all_rows_stmt = stmt
all_rows_count_stmt = count_stmt
# Filter by tag in JSON array — load all and filter in Python for portability
stmt = select(NotificationTemplateRow)
count_stmt = select(func.count()).select_from(NotificationTemplateRow)
if keyword:
kw = f"%{keyword}%"
filt = or_(
NotificationTemplateRow.name.ilike(kw),
NotificationTemplateRow.title_template.ilike(kw),
NotificationTemplateRow.content_template.ilike(kw),
)
stmt = stmt.where(filt)
count_stmt = count_stmt.where(filt)
if enabled is not None:
stmt = stmt.where(NotificationTemplateRow.enabled == enabled)
count_stmt = count_stmt.where(enabled == NotificationTemplateRow.enabled)
async with self._sf() as session:
result = await session.execute(stmt.order_by(NotificationTemplateRow.created_at.desc()))
all_rows = list(result.scalars())
filtered = [r for r in all_rows if tag in (r.tags or [])]
total = len(filtered)
offset = (page - 1) * page_size
page_rows = filtered[offset : offset + page_size]
return {
"total": total,
"items": [_to_dict(r) for r in page_rows],
"page": page,
"page_size": page_size,
}
async with self._sf() as session:
total_result = await session.execute(count_stmt)
total = total_result.scalar() or 0
offset = (page - 1) * page_size
stmt = stmt.order_by(NotificationTemplateRow.created_at.desc()).offset(offset).limit(page_size)
result = await session.execute(stmt)
rows = list(result.scalars())
return {
"total": total,
"items": [_to_dict(r) for r in rows],
"page": page,
"page_size": page_size,
}
async def get_template(self, template_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(NotificationTemplateRow, template_id)
return _to_dict(row) if row else None
async def create_template(self, data: dict[str, Any], *, created_by: str | None = None) -> dict[str, Any]:
row = NotificationTemplateRow(
id=str(data.get("id") or uuid.uuid4().hex),
name=str(data["name"]),
title_template=str(data.get("title_template") or ""),
content_template=str(data.get("content_template") or ""),
tags=list(data.get("tags") or []),
enabled=bool(data.get("enabled", True)),
version=1,
created_by=created_by,
updated_by=created_by,
)
async with self._sf() as session:
session.add(row)
await session.commit()
await session.refresh(row)
return _to_dict(row)
async def update_template(
self, template_id: str, data: dict[str, Any], *, expected_version: int, updated_by: str | None = None
) -> dict[str, Any]:
async with self._sf() as session:
row = await session.get(NotificationTemplateRow, template_id)
if row is None:
raise ValueError("NOT_FOUND")
if row.version != expected_version:
raise ValueError("VERSION_CONFLICT")
if "name" in data:
row.name = str(data["name"])
if "title_template" in data:
row.title_template = str(data["title_template"])
if "content_template" in data:
row.content_template = str(data["content_template"])
if "tags" in data:
row.tags = list(data["tags"])
if "enabled" in data:
row.enabled = bool(data["enabled"])
row.version = row.version + 1
row.updated_by = updated_by
await session.commit()
await session.refresh(row)
return _to_dict(row)
async def delete_template(self, template_id: str) -> bool:
async with self._sf() as session:
row = await session.get(NotificationTemplateRow, template_id)
if row is None:
return False
await session.delete(row)
await session.commit()
return True
async def duplicate_template(self, template_id: str, *, created_by: str | None = None) -> dict[str, Any]:
async with self._sf() as session:
row = await session.get(NotificationTemplateRow, template_id)
if row is None:
raise ValueError("NOT_FOUND")
base_name = f"{row.name} - 副本"
new_name = base_name
counter = 1
while True:
existing = await session.execute(
select(NotificationTemplateRow).where(NotificationTemplateRow.name == new_name)
)
if existing.scalar_one_or_none() is None:
break
counter += 1
new_name = f"{base_name} {counter}"
new_row = NotificationTemplateRow(
id=uuid.uuid4().hex,
name=new_name,
title_template=row.title_template,
content_template=row.content_template,
tags=list(row.tags or []),
enabled=row.enabled,
version=1,
created_by=created_by,
updated_by=created_by,
)
session.add(new_row)
await session.commit()
await session.refresh(new_row)
return _to_dict(new_row)
async def name_exists(self, name: str, exclude_id: str | None = None) -> bool:
async with self._sf() as session:
stmt = select(NotificationTemplateRow).where(NotificationTemplateRow.name == name)
if exclude_id:
stmt = stmt.where(NotificationTemplateRow.id != exclude_id)
result = await session.execute(stmt)
return result.scalar_one_or_none() is not None