M2 最后一块:让"该我办了"能出现在系统里,并且允许不经过任务直接开单。 通知(backend/app/services/notify.py 统一出口) - 七类触发:task_assigned、task_status_changed、item_commented、 ticket_assigned、ticket_submitted、ticket_approved/rejected、task_unblocked - 不通知自己:notify() 按 actor_id 跳过,调用方不用各自判断 - 已读:GET /notifications、/unread-count、POST /read、/read-all 手工开单 - POST /tickets:直接指定人、初始 open,与任务升级单共用同一条 kind='ticket' item, ticket_ext.parent_item_id 为空,验收通过不回流任务 - 指派前 _ensure_member 校验在册成员,越界指派返回 1001(避免"挂在看不到的名字上") 前端 - Web:通知页 /w/:wsId/notifications(未读/全部,点开标已读并跳目标); 工单页"新建"面板;AppShell 每 30 秒对未读数,标已读后 dispatch notifications:changed 立刻刷新; 任务与工单支持 ?open= 深链落地即展开 - 安卓:五个 tab 加"通知",未读数 30 秒刷新;工单动作按需求人/办理人/管理员显隐;手工开单对话框 验证 - pytest 85 passed(新增 backend/tests/test_notifications.py 6 个用例) - ruff check / format --check 全 0 - tsc --noEmit 通过;vite build 成功 - web/e2e/notifications.mjs 12 项断言全绿(手工开单 → B 收到 → 点开跳转 → 未读归零) - 安卓:本机 JDK 25 与 Gradle 8.7 / AGP 8.7.3 不兼容,本次未做编译验证, 装回 JDK 17 后第一件事是 assembleDebug
This commit is contained in:
@@ -27,10 +27,12 @@ from ..domain.task import (
|
||||
view_statuses,
|
||||
)
|
||||
from ..errors import AppError, ErrorCode
|
||||
from ..models import Member
|
||||
from ..models_task import Activity, Comment, Item, ItemTag, Tag
|
||||
from ..models_ticket import TicketExt
|
||||
from ..schemas import Pagination, encode_cursor, ok
|
||||
from ..serializers import item_out, tag_out, tags_of
|
||||
from ..services.notify import TASK_STATUS_CN, notify
|
||||
from .attachments import count_for_items, list_for_item
|
||||
|
||||
router = APIRouter(prefix="/workspaces/{ws_id}/items", tags=["任务"])
|
||||
@@ -98,6 +100,25 @@ async def _load_item(
|
||||
return item
|
||||
|
||||
|
||||
async def _ensure_member(session: AsyncSession, ws_id: uuid.UUID, user_id: uuid.UUID) -> None:
|
||||
"""被指派的人必须是这个工作区的在册成员。
|
||||
|
||||
不校验的话可以指派给任意 uuid:任务从此挂在"不存在的人"名下,
|
||||
既不会出现在任何人的列表里,也不会有人收到通知。
|
||||
"""
|
||||
row = (
|
||||
await session.execute(
|
||||
select(Member.id).where(
|
||||
Member.workspace_id == ws_id,
|
||||
Member.user_id == user_id,
|
||||
Member.status == "active",
|
||||
)
|
||||
)
|
||||
).first()
|
||||
if row is None:
|
||||
raise AppError(ErrorCode.BAD_REQUEST, "被指派的人不在这个工作区里")
|
||||
|
||||
|
||||
async def _tags_of(
|
||||
session: AsyncSession, ws_id: uuid.UUID, item_ids: list[uuid.UUID]
|
||||
) -> dict[uuid.UUID, list[Tag]]:
|
||||
@@ -452,6 +473,9 @@ async def create_item(body: CreateItemIn, ctx: MemberDep, session: SessionDep) -
|
||||
fields: dict[str, Any] = {"status": body.status, "completed_at": None}
|
||||
apply_status(fields, body.status, now=datetime.now(timezone.utc))
|
||||
|
||||
if body.assignee_id is not None:
|
||||
await _ensure_member(session, ctx.ws_id, body.assignee_id)
|
||||
|
||||
item = Item(
|
||||
id=uuid.uuid4(),
|
||||
workspace_id=ctx.ws_id,
|
||||
@@ -470,6 +494,17 @@ async def create_item(body: CreateItemIn, ctx: MemberDep, session: SessionDep) -
|
||||
if body.tag_names:
|
||||
await _set_tags(session, ctx.ws_id, item, body.tag_names)
|
||||
await _log(session, item, ctx.user.id, "created", {"status": item.status})
|
||||
if item.assignee_id is not None:
|
||||
await notify(
|
||||
session,
|
||||
ws_id=ctx.ws_id,
|
||||
user_ids=(item.assignee_id,),
|
||||
kind="task_assigned",
|
||||
item_id=item.id,
|
||||
title=f"有人把「{item.title}」交给你了",
|
||||
body=f"{ctx.user.nickname or '同事'} 指派给你,注意截止时间",
|
||||
actor_id=ctx.user.id,
|
||||
)
|
||||
|
||||
tags = await _tags_of(session, ctx.ws_id, [item.id])
|
||||
return ok(_item_out(item, tags.get(item.id)), message="已创建")
|
||||
@@ -576,6 +611,7 @@ async def update_item(
|
||||
values["assignee_id"] = None
|
||||
changes["assignee_id"] = None
|
||||
elif body.assignee_id is not None and body.assignee_id != item.assignee_id:
|
||||
await _ensure_member(session, ctx.ws_id, body.assignee_id)
|
||||
values["assignee_id"] = body.assignee_id
|
||||
changes["assignee_id"] = str(body.assignee_id)
|
||||
if body.clear_due_at:
|
||||
@@ -605,6 +641,18 @@ async def update_item(
|
||||
)
|
||||
item.version += 1
|
||||
|
||||
if values.get("assignee_id") is not None:
|
||||
await notify(
|
||||
session,
|
||||
ws_id=ctx.ws_id,
|
||||
user_ids=(values["assignee_id"],),
|
||||
kind="task_assigned",
|
||||
item_id=item.id,
|
||||
title=f"「{item.title}」转给你了",
|
||||
body=f"{ctx.user.nickname or '同事'} 把这件事交给你,原来的办理人已经卸下",
|
||||
actor_id=ctx.user.id,
|
||||
)
|
||||
|
||||
tags = await _tags_of(session, ctx.ws_id, [item.id])
|
||||
return ok(_item_out(item, tags.get(item.id)), message="已保存")
|
||||
|
||||
@@ -634,6 +682,20 @@ async def change_status(
|
||||
payload={"from": previous, "to": body.status},
|
||||
)
|
||||
tags = await _tags_of(session, ctx.ws_id, [item.id])
|
||||
await notify(
|
||||
session,
|
||||
ws_id=ctx.ws_id,
|
||||
user_ids=(item.creator_id, item.assignee_id),
|
||||
kind="task_status_changed",
|
||||
item_id=item.id,
|
||||
title=f"「{item.title}」变成{TASK_STATUS_CN.get(body.status, body.status)}了",
|
||||
body=(
|
||||
f"{ctx.user.nickname or '同事'} 把它从 "
|
||||
f"{TASK_STATUS_CN.get(previous, previous)} 改成了 "
|
||||
f"{TASK_STATUS_CN.get(body.status, body.status)}"
|
||||
),
|
||||
actor_id=ctx.user.id,
|
||||
)
|
||||
return ok(_item_out(item, tags.get(item.id)), message="状态已更新")
|
||||
|
||||
|
||||
@@ -671,6 +733,16 @@ async def add_comment(
|
||||
session.add(comment)
|
||||
await session.flush()
|
||||
await _log(session, item, ctx.user.id, "commented", {"comment_id": str(comment.id)})
|
||||
await notify(
|
||||
session,
|
||||
ws_id=ctx.ws_id,
|
||||
user_ids=(item.creator_id, item.assignee_id),
|
||||
kind="item_commented",
|
||||
item_id=item.id,
|
||||
title=f"「{item.title}」有新讨论",
|
||||
body=f"{ctx.user.nickname or '同事'}:{body.body_md[:120]}",
|
||||
actor_id=ctx.user.id,
|
||||
)
|
||||
return ok(
|
||||
{
|
||||
"id": str(comment.id),
|
||||
|
||||
@@ -39,6 +39,7 @@ 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
|
||||
from ..services.notify import notify
|
||||
|
||||
router = APIRouter(prefix="/workspaces/{ws_id}", tags=["工单"])
|
||||
notify_router = APIRouter(prefix="/workspaces/{ws_id}/notifications", tags=["通知"])
|
||||
@@ -60,6 +61,16 @@ class ReassignIn(BaseModel):
|
||||
assignee_id: uuid.UUID
|
||||
|
||||
|
||||
class TicketCreateIn(BaseModel):
|
||||
"""手工开单:不经过任务升级,直接指定谁去办。"""
|
||||
|
||||
title: str = Field(min_length=1, max_length=200)
|
||||
body_md: str | None = Field(default=None, max_length=20000)
|
||||
assignee_id: uuid.UUID
|
||||
due_at: datetime | None = None
|
||||
priority: int = Field(default=0, ge=0, le=3)
|
||||
|
||||
|
||||
class VerifyIn(BaseModel):
|
||||
approve: bool
|
||||
note: str = Field(default="", max_length=MAX_REJECT_NOTE)
|
||||
@@ -139,16 +150,18 @@ async def _notify(
|
||||
item_id: uuid.UUID | None,
|
||||
title: str,
|
||||
body: str = "",
|
||||
actor_id: uuid.UUID | None = None,
|
||||
) -> None:
|
||||
session.add(
|
||||
Notification(
|
||||
workspace_id=ws_id,
|
||||
user_id=user_id,
|
||||
kind=kind,
|
||||
item_id=item_id,
|
||||
title=title[:120],
|
||||
body=body[:300],
|
||||
)
|
||||
"""单人便捷入口,实际写入交给 services.notify(去重与跳过自己在那里做)。"""
|
||||
await notify(
|
||||
session,
|
||||
ws_id=ws_id,
|
||||
user_ids=(user_id,),
|
||||
kind=kind,
|
||||
item_id=item_id,
|
||||
title=title,
|
||||
body=body,
|
||||
actor_id=actor_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -520,6 +533,67 @@ async def reassign(
|
||||
return await _ticket_out(session, ctx.ws_id, ticket, ext)
|
||||
|
||||
|
||||
# -- 手工开单 -----------------------------------------------------------
|
||||
@router.post("/tickets", summary="手工开一张工单(不依赖任务升级)")
|
||||
async def create_ticket(body: TicketCreateIn, ctx: MemberDep, session: SessionDep) -> dict:
|
||||
"""开会、口头上被派活、临时找同事帮忙——这些事从来没有过"任务"。
|
||||
|
||||
工单不是任务的衍生物,只是"要别人办的事"的一种形态:它可以凭空存在,
|
||||
也可以由任务升级而来(那条路径见 promote)。两条路都落在 kind=ticket 的
|
||||
同一条 item 上,所以看板、评论、验收、沉淀知识全都共用。
|
||||
"""
|
||||
await _active_member_id(session, ctx.ws_id, body.assignee_id)
|
||||
|
||||
ticket = Item(
|
||||
workspace_id=ctx.ws_id,
|
||||
kind="ticket",
|
||||
title=body.title.strip(),
|
||||
body_md=body.body_md,
|
||||
status="open",
|
||||
priority=body.priority,
|
||||
creator_id=ctx.user.id,
|
||||
assignee_id=body.assignee_id,
|
||||
due_at=body.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=None,
|
||||
)
|
||||
session.add(ext)
|
||||
await session.flush()
|
||||
|
||||
await _log(session, ticket, ctx.user.id, "ticket_created", {"manual": True})
|
||||
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 '同事'} 直接给你开了张工单,办完提交验收",
|
||||
actor_id=ctx.user.id,
|
||||
)
|
||||
await audit_record(
|
||||
session,
|
||||
workspace_id=ctx.ws_id,
|
||||
actor_user_id=ctx.user.id,
|
||||
action="ticket.create",
|
||||
target_type="item",
|
||||
target_id=str(ticket.id),
|
||||
payload={"manual": True},
|
||||
)
|
||||
await session.flush()
|
||||
|
||||
ticket_out = await _item_out(session, ctx.ws_id, ticket)
|
||||
return ok({**ticket_out, "ticket": _ticket_ext_out(ext)}, message="已创建")
|
||||
|
||||
|
||||
# -- 工单列表 -----------------------------------------------------------
|
||||
@router.get("/tickets", summary="工单列表:我指派的 / 我发起的")
|
||||
async def list_tickets(
|
||||
|
||||
Reference in New Issue
Block a user