后端工作:
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>
153 lines
4.6 KiB
Python
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}")
|