Files
DadaoTask/backend/app/api/tickets.py
T
dadao e9addeed25 feat(knowledge): 工单验收自动生成草稿,核对后发布,来源可追
- knowledge_doc 与 item 1:1,除 source_kind=manual 外必须有来源(库级约束)
- 状态机 draft/in_review/published/archived,发布要 owner/admin;非法动作回 allowed_from 与 allowed_actions
- 工单验收通过自动落 postmortem 草稿(generated_by=rule),同一来源只生成一次
- 接口:列表/详情/手记/改内容/状态动作/从事项沉淀;已发布禁改,改内容带乐观锁
- Web 知识页 + 详情抽屉;任务与工单详情里可「沉淀成知识」
- 8 个后端用例 + e2e 7 项断言(沉淀 → 待核对 → 发布)
2026-10-28 14:02:35 +08:00

804 lines
27 KiB
Python
Raw 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.
"""工单域接口(M2):升级、指派、办理、验收、回流、通知、事项关系。
一段流程长什么样:
任务卡住了
-> POST /items/{task_id}/promote {assignee_id} 任务变 blocked,生成一条工单(open)
-> 被指派人 POST /items/{ticket_id}/ticket/accept accepted
-> POST /items/{ticket_id}/ticket/start in_progress
-> POST /items/{ticket_id}/ticket/submit pending_verify(提需求的人收到通知)
-> POST /items/{ticket_id}/ticket/verify {approve} done,原任务自动 blocked -> todo
驳回走同一个入口:verify {approve: false, note: "..."} -> 回到 in_progress,reject_count + 1。
每一步都写 activity;通知只是提醒,状态永远以 item 为准。
"""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from typing import Annotated, Any
from fastapi import APIRouter, Query
from pydantic import BaseModel, Field
from sqlalchemy import Select, and_, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from ..audit import record as audit_record
from ..deps import MemberDep, SessionDep, TenantCtx
from ..domain.ticket import (
CANCELABLE,
NOW_FIELD,
ensure_can_act,
returned_task_status,
target_status,
)
from ..errors import AppError, ErrorCode
from ..models import Member
from ..models_task import Activity, Item
from ..models_ticket import ItemLink, Notification, TicketExt
from ..schemas import Pagination, encode_cursor, ok
from ..serializers import item_out, tags_of
router = APIRouter(prefix="/workspaces/{ws_id}", tags=["工单"])
notify_router = APIRouter(prefix="/workspaces/{ws_id}/notifications", tags=["通知"])
MAX_REJECT_NOTE = 500
# -- 入参 ---------------------------------------------------------------
class PromoteIn(BaseModel):
assignee_id: uuid.UUID
title: str | None = Field(default=None, max_length=200)
body_md: str | None = Field(default=None, max_length=20000)
due_at: datetime | None = None
priority: int | None = Field(default=None, ge=0, le=3)
version: int | None = Field(default=None, description="原任务的版本,给了就做乐观锁")
class ReassignIn(BaseModel):
assignee_id: uuid.UUID
class VerifyIn(BaseModel):
approve: bool
note: str = Field(default="", max_length=MAX_REJECT_NOTE)
resolution_md: str = Field(
default="", max_length=20000, description="验收通过时写的结论,M3 用它长知识"
)
class ActionIn(BaseModel):
note: str = Field(default="", max_length=MAX_REJECT_NOTE)
version: int | None = None
class LinkIn(BaseModel):
to_item_id: uuid.UUID
link_type: str = "related"
# -- 内部工具 -----------------------------------------------------------
async def _load_item(
session: AsyncSession, ws_id: uuid.UUID, item_id: uuid.UUID, kinds: tuple[str, ...]
) -> Item:
item = await session.get(Item, item_id)
if item is None or item.workspace_id != ws_id or item.kind not in kinds:
# 跨租户 / 不存在 / 类型不符:对外一律"不存在"
raise AppError(ErrorCode.ITEM_NOT_FOUND, "事项不存在")
if item.deleted_at is not None:
raise AppError(ErrorCode.ITEM_NOT_FOUND, "事项不存在")
return item
async def _load_ext(session: AsyncSession, item_id: uuid.UUID) -> TicketExt:
ext = await session.get(TicketExt, item_id)
if ext is None:
raise AppError(ErrorCode.TICKET_NOT_FOUND, "这不是一条工单")
return ext
async def _active_member_id(session: AsyncSession, ws_id: uuid.UUID, user_id: uuid.UUID) -> None:
exists = (
await session.execute(
select(Member.id).where(
Member.workspace_id == ws_id,
Member.user_id == user_id,
Member.status == "active",
)
)
).first()
if exists is None:
raise AppError(ErrorCode.BAD_REQUEST, "被指派的人不在这个工作区里")
async def _log(
session: AsyncSession,
item: Item,
actor_id: uuid.UUID | None,
action: str,
payload: dict | None = None,
) -> None:
session.add(
Activity(
workspace_id=item.workspace_id,
item_id=item.id,
actor_id=actor_id,
action=action,
payload=payload or {},
)
)
async def _notify(
session: AsyncSession,
*,
ws_id: uuid.UUID,
user_id: uuid.UUID,
kind: str,
item_id: uuid.UUID | None,
title: str,
body: str = "",
) -> None:
session.add(
Notification(
workspace_id=ws_id,
user_id=user_id,
kind=kind,
item_id=item_id,
title=title[:120],
body=body[:300],
)
)
def _ticket_ext_out(ext: TicketExt) -> dict[str, Any]:
return {
"requester_id": str(ext.requester_id),
"assignee_id": str(ext.assignee_id),
"parent_item_id": str(ext.parent_item_id) if ext.parent_item_id else None,
"accepted_at": ext.accepted_at,
"submitted_at": ext.submitted_at,
"verified_at": ext.verified_at,
"verified_by": str(ext.verified_by) if ext.verified_by else None,
"reject_count": ext.reject_count,
"resolution_md": ext.resolution_md,
}
async def _item_out(session: AsyncSession, ws_id: uuid.UUID, item: Item) -> dict[str, Any]:
"""单条序列化。标签必须一起带上——前端把 `item.tags` 当数组用,
少这个字段整页会白屏(M2 首版就是这么炸的)。"""
grouped = await tags_of(session, ws_id, [item.id])
return item_out(item, grouped.get(item.id))
async def _items_out(
session: AsyncSession, ws_id: uuid.UUID, items: list[Item]
) -> list[dict[str, Any]]:
grouped = await tags_of(session, ws_id, [i.id for i in items])
return [item_out(i, grouped.get(i.id)) for i in items]
async def _ticket_out(
session: AsyncSession, ws_id: uuid.UUID, ticket: Item, ext: TicketExt
) -> dict:
return ok({**await _item_out(session, ws_id, ticket), "ticket": _ticket_ext_out(ext)})
async def _patch_item(
session: AsyncSession,
item: Item,
values: dict[str, Any],
*,
expect_version: int | None,
) -> None:
"""带版本的 UPDATE:给了 version 就必须匹配,否则 409 让客户端刷新。"""
where = [Item.id == item.id, Item.workspace_id == item.workspace_id]
if expect_version is not None:
where.append(Item.version == expect_version)
result = await session.execute(
update(Item)
.where(*where)
.values(**values, version=Item.version + 1, updated_at=datetime.now(timezone.utc))
)
if result.rowcount == 0:
await session.refresh(item)
raise AppError(
ErrorCode.VERSION_CONFLICT,
"这条工单刚被改过,请刷新后重试",
details={"current_version": item.version},
)
await session.refresh(item)
# -- 升级 ---------------------------------------------------------------
@router.post("/items/{item_id}/promote", summary="把任务升级成工单")
async def promote(item_id: uuid.UUID, body: PromoteIn, ctx: MemberDep, session: SessionDep) -> dict:
task = await _load_item(session, ctx.ws_id, item_id, ("task",))
if task.status in ("done", "canceled"):
raise AppError(
ErrorCode.ITEM_STATUS_INVALID,
f"「{task.status}」的任务不能再升级成工单",
details={"status": task.status},
)
existing = (
await session.execute(
select(TicketExt.item_id).where(
TicketExt.workspace_id == ctx.ws_id, TicketExt.parent_item_id == task.id
)
)
).first()
if existing is not None:
raise AppError(ErrorCode.TICKET_ALREADY_EXISTS, "这条任务已经升级过工单了")
await _active_member_id(session, ctx.ws_id, body.assignee_id)
ticket = Item(
workspace_id=ctx.ws_id,
kind="ticket",
title=(body.title or task.title)[:200],
body_md=body.body_md if body.body_md is not None else task.body_md,
status="open",
priority=body.priority if body.priority is not None else task.priority,
creator_id=ctx.user.id,
assignee_id=body.assignee_id,
due_at=body.due_at or task.due_at,
)
session.add(ticket)
await session.flush()
ext = TicketExt(
item_id=ticket.id,
workspace_id=ctx.ws_id,
requester_id=ctx.user.id,
assignee_id=body.assignee_id,
parent_item_id=task.id,
)
session.add(ext)
await session.flush()
# 原任务确实卡住了,进 blocked;不能静默把用户的待办改成别的状态
await _patch_item(session, task, {"status": "blocked"}, expect_version=body.version)
await _log(session, task, ctx.user.id, "promoted_to_ticket", {"ticket_id": str(ticket.id)})
await _log(session, ticket, ctx.user.id, "ticket_created", {"parent_item_id": str(task.id)})
await _notify(
session,
ws_id=ctx.ws_id,
user_id=body.assignee_id,
kind="ticket_assigned",
item_id=ticket.id,
title=f"你被指派了一条工单:{ticket.title}",
body=f"{ctx.user.nickname or '同事'} 请你处理,完成后提交验收",
)
await audit_record(
session,
workspace_id=ctx.ws_id,
actor_user_id=ctx.user.id,
action="ticket.promote",
target_type="item",
target_id=str(ticket.id),
payload={"parent_item_id": str(task.id)},
)
await session.flush()
task_out, ticket_out = await _items_out(session, ctx.ws_id, [task, ticket])
return ok({"task": task_out, "ticket": {**ticket_out, "ticket": _ticket_ext_out(ext)}})
# -- 办理/验收 ----------------------------------------------------------
async def _draft_knowledge(
session: AsyncSession,
ctx: TenantCtx,
ticket: Item,
ext: TicketExt,
resolution_md: str,
now: datetime,
) -> None:
"""验收通过时自动整理一份知识草稿(M3 闭环沉淀)。
只落 draft 不发布:自动整理的东西得有人扫一眼才敢进团队检索。
同一张工单只沉淀一次(重复点验收、历史数据补跑都不会重复产出)。
"""
from ..services.knowledge import create_draft_from_source, find_doc_by_source
if await find_doc_by_source(session, ctx.ws_id, ticket.id) is not None:
return
doc_item, _doc = await create_draft_from_source(
session,
ws_id=ctx.ws_id,
source=ticket,
author_id=ctx.user.id,
generated_by="rule",
resolution_md=resolution_md or ext.resolution_md or "",
doc_type="postmortem",
)
await _log(
session,
doc_item,
ctx.user.id,
"knowledge_created",
{"source_item_id": str(ticket.id), "generated_by": "rule"},
)
async def _advance(
ctx: TenantCtx,
session: AsyncSession,
item_id: uuid.UUID,
*,
action: str,
note: str = "",
approve: bool | None = None,
resolution_md: str = "",
version: int | None = None,
) -> dict:
ticket = await _load_item(session, ctx.ws_id, item_id, ("ticket",))
ext = await _load_ext(session, ticket.id)
target = target_status(action, ticket.status)
ensure_can_act(
action=action,
actor_id=ctx.user.id,
role=ctx.role,
assignee_id=ext.assignee_id,
requester_id=ext.requester_id,
)
now = datetime.now(timezone.utc)
previous = ticket.status
values: dict[str, Any] = {"status": target}
if target == "done":
values["completed_at"] = now
ext.verified_at = now
ext.verified_by = ctx.user.id
if resolution_md:
ext.resolution_md = resolution_md
if target == "in_progress" and action == "reject":
ext.reject_count = ext.reject_count + 1
stamp_field = NOW_FIELD.get(target)
if stamp_field and action != "reject":
setattr(ext, stamp_field, now)
await _patch_item(session, ticket, values, expect_version=version)
payload: dict[str, Any] = {"action": action, "from": previous, "to": target}
if note:
payload["note"] = note
await _log(session, ticket, ctx.user.id, f"ticket_{action}", payload)
# 通知对家:验收找提需求的人,其余找办理人
if action == "submit":
await _notify(
session,
ws_id=ctx.ws_id,
user_id=ext.requester_id,
kind="ticket_submitted",
item_id=ticket.id,
title=f"工单待你验收:{ticket.title}",
body=note or "办理人已提交,请确认结果",
)
elif action == "approve":
await _notify(
session,
ws_id=ctx.ws_id,
user_id=ext.assignee_id,
kind="ticket_approved",
item_id=ticket.id,
title=f"工单已通过验收:{ticket.title}",
body=note or "谢谢,这事结了",
)
elif action == "reject":
await _notify(
session,
ws_id=ctx.ws_id,
user_id=ext.assignee_id,
kind="ticket_rejected",
item_id=ticket.id,
title=f"工单被退回:{ticket.title}",
body=note or "请按反馈再改一版",
)
# 验收通过 -> 原任务闭环回流
returned = returned_task_status(target)
if returned and ext.parent_item_id:
parent = await session.get(Item, ext.parent_item_id)
if parent is not None and parent.workspace_id == ctx.ws_id and parent.status == "blocked":
parent.status = returned
parent.version += 1
parent.updated_at = now
await _log(
session,
parent,
ctx.user.id,
"ticket_done",
{"ticket_id": str(ticket.id), "status": returned},
)
await _notify(
session,
ws_id=ctx.ws_id,
user_id=parent.creator_id,
kind="task_unblocked",
item_id=parent.id,
title=f"任务可以继续了:{parent.title}",
body="它对应的工单已验收通过",
)
# 验收通过 -> 顺手整理一份知识草稿(闭环沉淀)。
# 只落草稿不发布:自动整理的东西得有人扫一眼才敢进团队检索。
if action == "approve":
await _draft_knowledge(session, ctx, ticket, ext, resolution_md, now)
await session.flush()
return await _ticket_out(session, ctx.ws_id, ticket, ext)
@router.post("/items/{item_id}/ticket/accept", summary="接受工单")
async def accept(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict:
return await _advance(ctx, session, item_id, action="accept")
@router.post("/items/{item_id}/ticket/start", summary="开始办理")
async def start(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict:
return await _advance(ctx, session, item_id, action="start")
@router.post("/items/{item_id}/ticket/submit", summary="提交验收")
async def submit(item_id: uuid.UUID, body: ActionIn, ctx: MemberDep, session: SessionDep) -> dict:
return await _advance(
ctx, session, item_id, action="submit", note=body.note, version=body.version
)
@router.post("/items/{item_id}/ticket/verify", summary="验收:通过或驳回")
async def verify(item_id: uuid.UUID, body: VerifyIn, ctx: MemberDep, session: SessionDep) -> dict:
return await _advance(
ctx,
session,
item_id,
action="approve" if body.approve else "reject",
note=body.note,
approve=body.approve,
resolution_md=body.resolution_md,
)
@router.post("/items/{item_id}/ticket/cancel", summary="取消工单")
async def cancel(item_id: uuid.UUID, body: ActionIn, ctx: MemberDep, session: SessionDep) -> dict:
return await _advance(
ctx, session, item_id, action="cancel", note=body.note, version=body.version
)
@router.post("/items/{item_id}/ticket/reassign", summary="改派给同事")
async def reassign(
item_id: uuid.UUID, body: ReassignIn, ctx: MemberDep, session: SessionDep
) -> dict:
ticket = await _load_item(session, ctx.ws_id, item_id, ("ticket",))
ext = await _load_ext(session, ticket.id)
ensure_can_act(
action="reassign",
actor_id=ctx.user.id,
role=ctx.role,
assignee_id=ext.assignee_id,
requester_id=ext.requester_id,
)
if ticket.status not in CANCELABLE:
raise AppError(
ErrorCode.TICKET_STATUS_INVALID,
f"「{ticket.status}」的工单不能改派",
details={"allowed_from": list(CANCELABLE)},
)
if body.assignee_id == ext.assignee_id:
return await _ticket_out(session, ctx.ws_id, ticket, ext)
await _active_member_id(session, ctx.ws_id, body.assignee_id)
await _patch_item(
session,
ticket,
{"status": "open", "assignee_id": body.assignee_id, "completed_at": None},
expect_version=None,
)
ext.assignee_id = body.assignee_id
ext.accepted_at = None
ext.submitted_at = None
await _log(session, ticket, ctx.user.id, "ticket_reassigned", {"to": str(body.assignee_id)})
await _notify(
session,
ws_id=ctx.ws_id,
user_id=body.assignee_id,
kind="ticket_assigned",
item_id=ticket.id,
title=f"转给你一条工单:{ticket.title}",
body="请先接收,再开始办理",
)
await session.flush()
return await _ticket_out(session, ctx.ws_id, ticket, ext)
# -- 工单列表 -----------------------------------------------------------
@router.get("/tickets", summary="工单列表:我指派的 / 我发起的")
async def list_tickets(
ctx: MemberDep,
session: SessionDep,
role: Annotated[str, Query(description="assigned|requested|all")] = "assigned",
status: Annotated[list[str] | None, Query(description="按状态过滤,可重复")] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[str | None, Query()] = None,
) -> dict:
page = Pagination(limit=limit, cursor=cursor)
limit, parsed_cursor = page.parsed()
stmt: Select = (
select(Item, TicketExt)
.join(TicketExt, TicketExt.item_id == Item.id)
.where(Item.workspace_id == ctx.ws_id, Item.kind == "ticket", Item.deleted_at.is_(None))
)
if role == "assigned":
stmt = stmt.where(TicketExt.assignee_id == ctx.user.id)
elif role == "requested":
stmt = stmt.where(TicketExt.requester_id == ctx.user.id)
elif role != "all":
raise AppError(ErrorCode.BAD_REQUEST, "role 只能是 assigned / requested / all")
if status:
stmt = stmt.where(Item.status.in_(status))
stmt = stmt.order_by(Item.updated_at.desc(), Item.id.desc()).limit(limit + 1)
if parsed_cursor is not None:
stmt = stmt.where(
or_(
Item.updated_at < parsed_cursor.created_at,
and_(Item.updated_at == parsed_cursor.created_at, Item.id < parsed_cursor.id),
)
)
rows = (await session.execute(stmt)).all()
has_more = len(rows) > limit
rows = rows[:limit]
next_cursor = (
encode_cursor(rows[-1][0].updated_at, rows[-1][0].id) if has_more and rows else None
)
tickets = await _items_out(session, ctx.ws_id, [item for item, _ in rows])
return ok(
{
"tickets": [
{**out, "ticket": _ticket_ext_out(ext)}
for out, (_, ext) in zip(tickets, rows, strict=True)
],
"next_cursor": next_cursor,
"has_more": has_more,
}
)
# -- 事项关系(子任务 / 相关)------------------------------------------
@router.get("/items/{item_id}/links", summary="这条事项的子项与相关项")
async def list_links(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict:
await _load_item(session, ctx.ws_id, item_id, ("task", "ticket"))
# parent 边的方向:from 是 to 的子项。所以"我的子项"= 指向我、且类型为 parent 的 from。
children = (
await session.execute(
select(ItemLink, Item)
.join(Item, Item.id == ItemLink.from_item_id)
.where(
ItemLink.workspace_id == ctx.ws_id,
ItemLink.to_item_id == item_id,
ItemLink.link_type == "parent",
)
)
).all()
parents = (
await session.execute(
select(ItemLink, Item)
.join(Item, Item.id == ItemLink.to_item_id)
.where(
ItemLink.workspace_id == ctx.ws_id,
ItemLink.from_item_id == item_id,
ItemLink.link_type == "parent",
)
)
).all()
related = (
await session.execute(
select(ItemLink, Item)
.join(
Item,
or_(
and_(ItemLink.from_item_id == item_id, Item.id == ItemLink.to_item_id),
and_(ItemLink.to_item_id == item_id, Item.id == ItemLink.from_item_id),
),
)
.where(
ItemLink.workspace_id == ctx.ws_id,
ItemLink.link_type == "related",
or_(ItemLink.from_item_id == item_id, ItemLink.to_item_id == item_id),
)
)
).all()
async def _pack(rows: list[Any]) -> list[dict[str, Any]]:
packed = await _items_out(session, ctx.ws_id, [item for _, item in rows])
return [
{"link_id": str(link.id), "link_type": link.link_type, "item": out}
for (link, _), out in zip(rows, packed, strict=True)
]
return ok(
{
"children": await _pack(children),
"parents": await _pack(parents),
"related": await _pack(related),
}
)
@router.post("/items/{item_id}/links", summary="把另一条事项挂成子项/相关项")
async def create_link(
item_id: uuid.UUID, body: LinkIn, ctx: MemberDep, session: SessionDep
) -> dict:
if body.link_type not in ("parent", "related"):
raise AppError(ErrorCode.BAD_REQUEST, "link_type 只能是 parent / related")
if body.to_item_id == item_id:
raise AppError(ErrorCode.LINK_CONFLICT, "不能把自己挂到自己下面")
await _load_item(session, ctx.ws_id, body.to_item_id, ("task", "ticket"))
await _load_item(session, ctx.ws_id, item_id, ("task", "ticket"))
existing = (
await session.execute(
select(ItemLink).where(
ItemLink.from_item_id == body.to_item_id,
ItemLink.to_item_id == item_id,
ItemLink.link_type == body.link_type,
)
)
).scalar_one_or_none()
if existing is not None:
raise AppError(ErrorCode.LINK_CONFLICT, "这条关系已经存在")
link = ItemLink(
workspace_id=ctx.ws_id,
from_item_id=body.to_item_id,
to_item_id=item_id,
link_type=body.link_type,
)
session.add(link)
await session.flush()
return ok({"id": str(link.id), "link_type": link.link_type})
@router.delete("/items/{item_id}/links/{link_id}", summary="解除关系")
async def delete_link(
item_id: uuid.UUID, link_id: uuid.UUID, ctx: MemberDep, session: SessionDep
) -> dict:
link = await session.get(ItemLink, link_id)
if (
link is None
or link.workspace_id != ctx.ws_id
or item_id
not in (
link.from_item_id,
link.to_item_id,
)
):
raise AppError(ErrorCode.LINK_NOT_FOUND, "关系不存在")
await session.delete(link)
return ok({"deleted": True})
# -- 通知 ---------------------------------------------------------------
def _notification_out(n: Notification, item_title: str | None = None) -> dict[str, Any]:
return {
"id": str(n.id),
"kind": n.kind,
"item_id": str(n.item_id) if n.item_id else None,
"item_title": item_title,
"title": n.title,
"body": n.body,
"read_at": n.read_at,
"created_at": n.created_at,
}
@notify_router.get("", summary="我的通知(默认只看未读)")
async def list_notifications(
ctx: MemberDep,
session: SessionDep,
unread_only: bool = True,
limit: Annotated[int, Query(ge=1, le=100)] = 30,
cursor: Annotated[str | None, Query()] = None,
) -> dict:
page = Pagination(limit=limit, cursor=cursor)
limit, parsed_cursor = page.parsed()
stmt = select(Notification).where(
Notification.workspace_id == ctx.ws_id, Notification.user_id == ctx.user.id
)
if unread_only:
stmt = stmt.where(Notification.read_at.is_(None))
stmt = stmt.order_by(Notification.created_at.desc(), Notification.id.desc()).limit(limit + 1)
if parsed_cursor is not None:
stmt = stmt.where(
or_(
Notification.created_at < parsed_cursor.created_at,
and_(
Notification.created_at == parsed_cursor.created_at,
Notification.id < parsed_cursor.id,
),
)
)
rows = (await session.execute(stmt)).scalars().all()
has_more = len(rows) > limit
rows = list(rows[:limit])
titles: dict[uuid.UUID, str] = {}
item_ids = [n.item_id for n in rows if n.item_id]
if item_ids:
for item_id, title in (
await session.execute(select(Item.id, Item.title).where(Item.id.in_(item_ids)))
).all():
titles[item_id] = title
next_cursor = encode_cursor(rows[-1].created_at, rows[-1].id) if has_more and rows else None
return ok(
{
"notifications": [
_notification_out(n, titles.get(n.item_id) if n.item_id else None) for n in rows
],
"next_cursor": next_cursor,
"has_more": has_more,
}
)
@notify_router.get("/unread-count", summary="未读数(导航角标用)")
async def unread_count(ctx: MemberDep, session: SessionDep) -> dict:
from sqlalchemy import func as sa_func
total = (
await session.execute(
select(sa_func.count(Notification.id)).where(
Notification.workspace_id == ctx.ws_id,
Notification.user_id == ctx.user.id,
Notification.read_at.is_(None),
)
)
).scalar_one()
return ok({"unread": int(total)})
@notify_router.post("/{notification_id}/read", summary="标记已读")
async def mark_read(notification_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict:
n = await session.get(Notification, notification_id)
if n is None or n.workspace_id != ctx.ws_id or n.user_id != ctx.user.id:
raise AppError(ErrorCode.NOTIFICATION_NOT_FOUND, "通知不存在")
if n.read_at is None:
n.read_at = datetime.now(timezone.utc)
return ok({"read_at": n.read_at})
@notify_router.post("/read-all", summary="全部标记已读")
async def mark_all_read(ctx: MemberDep, session: SessionDep) -> dict:
from sqlalchemy import update as sa_update
result = await session.execute(
sa_update(Notification)
.where(
Notification.workspace_id == ctx.ws_id,
Notification.user_id == ctx.user.id,
Notification.read_at.is_(None),
)
.values(read_at=datetime.now(timezone.utc))
)
return ok({"marked": result.rowcount})
__all__ = ["notify_router", "router"]