Files
xuhdandClaude Opus 4.7 cf191ef874 feat(skills/notifications): 多租户阶段 2 — 路由 + 级联 + 启动迁移
后端工作:

deps.py
- 挂载 skill_store / notification_store 到 app.state
- 启动时 set_notification_store(...) 注入到 harness sender;
  关闭时清空,保证测试和 embedded 模式不留状态
- 加 get_skill_store / get_notification_store FastAPI 依赖

routers/skills.py
- 加 _current_user_id / _is_admin_user / _get_owned_skill_or_raise /
  _get_editable_skill_or_raise(对齐 routers/agents.py)
- SkillResponse 加 owner_user_id / published / builtin 字段,
  _skill_to_response 接受 SkillStore 记录映射
- GET /api/skills 与 GET /api/skills/custom 改 per-user 可见性过滤
- POST /api/skills/install + /install-upload 预解析 archive 名校验
  全局 name 不被他人持有;安装后 ensure_legacy 注册 owner=current
- DELETE /api/skills/custom/{name} 走 _get_editable_skill_or_raise,
  调 cascade(owner 自删 = delete,admin 跨用户 = admin_takedown),
  SkillStore.delete / delete_admin 同步清除 DB 记录
- 新增 PUT /api/skills/custom/{name}/published 切换发布状态;
  从 published → unpublished 时触发 cascade(unpublish / admin_takedown)

skills/cascade.py(新)
- 扫所有 .deer-flow/agents/{id}/config.yaml,把指定 skill 从其 skills
  数组移除,并通过 deerflow.persistence.notifications.notify 给受
  影响 agent 的 owner 发站内信;admin 强制下架时另给 skill owner 发
  一条
- 操作类型:unpublish / delete / admin_takedown,对应不同文案

routers/notifications.py(新)+ app.include_router
- GET /api/notifications(unread_only / limit)
- GET /api/notifications/unread_count(红点用)
- POST /api/notifications/{id}/read
- POST /api/notifications/read_all
- DELETE /api/notifications/{id}

app.py lifespan
- _sync_legacy_skills(app):每次启动扫 skills/custom/ 把没在 DB
  的 skill 落库为 admin owner + published=True,幂等、非阻塞失败
- 调用顺序:_ensure_admin_user → _sync_legacy_skills → 启动 curator

测试:19 个旧用例继续通过;阶段 4 会写 test_skills_multitenancy.py

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 16:24:42 +08:00

153 lines
4.6 KiB
Python

"""In-app notification inbox API."""
from __future__ import annotations
import logging
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field
from app.gateway.deps import get_notification_store
from deerflow.persistence.notifications import NotificationStore
from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["notifications"])
def _current_user_id(request: Request) -> str:
user = getattr(request.state, "user", None)
if user is not None:
return str(user.id)
return get_effective_user_id()
class NotificationItem(BaseModel):
id: str
user_id: str
type: str
title: str
body: str
payload: dict[str, Any]
read: bool
created_at: str | None
class NotificationListResponse(BaseModel):
items: list[NotificationItem]
class NotificationUnreadCountResponse(BaseModel):
unread: int = Field(..., description="Number of unread notifications for the current user")
class MarkReadResponse(BaseModel):
success: bool
class MarkAllReadResponse(BaseModel):
updated: int
@router.get(
"/notifications",
response_model=NotificationListResponse,
summary="List Notifications",
)
async def list_notifications(
request: Request,
unread_only: bool = Query(default=False),
limit: int = Query(default=50, ge=1, le=200),
store: NotificationStore = Depends(get_notification_store),
) -> NotificationListResponse:
try:
items = await store.list_for_user(
_current_user_id(request),
unread_only=unread_only,
limit=limit,
)
return NotificationListResponse(items=[NotificationItem(**item) for item in items])
except Exception as e:
logger.error("Failed to list notifications: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to list notifications: {e}")
@router.get(
"/notifications/unread_count",
response_model=NotificationUnreadCountResponse,
summary="Unread Notification Count",
)
async def unread_count(
request: Request,
store: NotificationStore = Depends(get_notification_store),
) -> NotificationUnreadCountResponse:
try:
count = await store.unread_count(_current_user_id(request))
return NotificationUnreadCountResponse(unread=count)
except Exception as e:
logger.error("Failed to read unread notification count: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to read unread count: {e}")
@router.post(
"/notifications/{notification_id}/read",
response_model=MarkReadResponse,
summary="Mark Notification Read",
)
async def mark_read(
notification_id: str,
request: Request,
store: NotificationStore = Depends(get_notification_store),
) -> MarkReadResponse:
try:
ok = await store.mark_read(notification_id, _current_user_id(request))
if not ok:
raise HTTPException(status_code=404, detail="Notification not found")
return MarkReadResponse(success=True)
except HTTPException:
raise
except Exception as e:
logger.error("Failed to mark notification read: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to mark read: {e}")
@router.post(
"/notifications/read_all",
response_model=MarkAllReadResponse,
summary="Mark All Notifications Read",
)
async def mark_all_read(
request: Request,
store: NotificationStore = Depends(get_notification_store),
) -> MarkAllReadResponse:
try:
count = await store.mark_all_read(_current_user_id(request))
return MarkAllReadResponse(updated=count)
except Exception as e:
logger.error("Failed to mark all notifications read: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to mark all read: {e}")
@router.delete(
"/notifications/{notification_id}",
response_model=MarkReadResponse,
summary="Delete Notification",
)
async def delete_notification(
notification_id: str,
request: Request,
store: NotificationStore = Depends(get_notification_store),
) -> MarkReadResponse:
try:
ok = await store.delete(notification_id, _current_user_id(request))
if not ok:
raise HTTPException(status_code=404, detail="Notification not found")
return MarkReadResponse(success=True)
except HTTPException:
raise
except Exception as e:
logger.error("Failed to delete notification: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to delete: {e}")