feat(notify): 站内通知与手工开单,指派/改状态/评论/验收都有人收到
CI / backend (push) Canceled after 0s

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:
Steven
2026-10-30 02:19:42 +08:00
parent d079564d6c
commit 91b3dde670
23 changed files with 1674 additions and 60 deletions
+72
View File
@@ -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),
+83 -9
View File
@@ -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(
+73
View File
@@ -0,0 +1,73 @@
"""站内通知(M2 起用,M4 会接入外部推送)。
三条原则:
1. **不通知自己**。绝大多数通知都是由自己的操作触发的,
不排除自己会让"未读"迅速变成一个没人看的数字。
2. **通知里写清"谁、做了什么、在哪件事上"**。只写"状态已更新"等于没写,
用户还得点进去猜。
3. **通知只是提示,不是凭证**。点开后仍要按 `item_id` 重新拉数据,
因为通知生成之后事情可能又被改过。
"""
from __future__ import annotations
import uuid
from collections.abc import Iterable
from sqlalchemy.ext.asyncio import AsyncSession
from ..models_ticket import Notification
TICKET_STATUS_CN = {
"open": "待接收",
"accepted": "待办理",
"in_progress": "办理中",
"pending_verify": "待验收",
"done": "已完成",
"canceled": "已取消",
}
TASK_STATUS_CN = {
"inbox": "收件箱",
"todo": "待办",
"doing": "进行中",
"blocked": "被阻塞",
"done": "已完成",
"canceled": "已取消",
}
async def notify(
session: AsyncSession,
*,
ws_id: uuid.UUID,
user_ids: Iterable[uuid.UUID | None],
kind: str,
item_id: uuid.UUID | None,
title: str,
body: str = "",
actor_id: uuid.UUID | None = None,
) -> int:
"""给若干人各写一条通知,返回实际写入条数。
去重与"跳过自己"在这里统一做一次——每个调用点都手写一遍迟早漏一个。
"""
seen: set[uuid.UUID] = set()
count = 0
for user_id in user_ids:
if user_id is None or user_id == actor_id or user_id in seen:
continue
seen.add(user_id)
session.add(
Notification(
workspace_id=ws_id,
user_id=user_id,
kind=kind,
item_id=item_id,
title=title[:120],
body=body[:300],
)
)
count += 1
return count
+271
View File
@@ -0,0 +1,271 @@
"""通知与手工开单。
这两个功能都有一个共同的翻车方式:**看起来有,实际没送到**。
所以要断言的从来不是"接口 200",而是"该收到的人收到了、不该收到的人没收到"。
1. 自己改自己的任务不该给自己发通知(否则未读数两天就没人看了);
2. 通知必须落到具体的人头上——被指派人、创建人,而不是"整个工作区";
3. 手工开的工单要和升级来的工单走同一条路:能接受、能提交、能验收;
4. 指派给工作区外的人必须当场拒绝,否则任务会挂在一个没人看得到的名字上。
"""
from __future__ import annotations
from typing import Any
import pytest
from httpx import AsyncClient
from .conftest import create_workspace, login, unwrap
V1 = "/api/v1"
async def _member(
client: AsyncClient, owner: dict[str, Any], ws: dict[str, Any], role: str = "member"
) -> dict[str, Any]:
user = await login(client)
inv = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/invitations", json={"role": role}, headers=owner["headers"]
)
)
unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/join",
json={"code": inv["code"]},
headers=user["headers"],
)
)
return user
async def _inbox(client: AsyncClient, user: dict[str, Any], ws_id: str) -> list[dict]:
page = unwrap(
await client.get(
f"{V1}/workspaces/{ws_id}/notifications",
params={"unread_only": "false"},
headers=user["headers"],
)
)
return page["notifications"]
async def _task_assigned_to(
client: AsyncClient, owner: dict[str, Any], ws_id: str, assignee_id: str, title: str
) -> dict:
return unwrap(
await client.post(
f"{V1}/workspaces/{ws_id}/items",
json={"title": title, "status": "todo", "assignee_id": assignee_id},
headers=owner["headers"],
)
)
@pytest.mark.asyncio
async def test_task_status_change_notifies_the_other_side(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "通知测试")
helper = await _member(client, owner, ws, "member")
item = await _task_assigned_to(client, owner, ws["id"], helper["user_id"], "月度对账")
# owner 指派给 helper 时,helper 应该收到通知
helper_inbox = await _inbox(client, helper, ws["id"])
assert any(n["kind"] == "task_assigned" and n["item_id"] == item["id"] for n in helper_inbox)
# owner 自己指派的,不该收到
assert not [n for n in await _inbox(client, owner, ws["id"]) if n["kind"] == "task_assigned"]
# helper 把状态改成进行中 → owner(创建人)应该收到
unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/items/{item['id']}/status",
json={"status": "doing", "version": item["version"]},
headers=helper["headers"],
)
)
owner_inbox = await _inbox(client, owner, ws["id"])
changed = [n for n in owner_inbox if n["kind"] == "task_status_changed"]
assert len(changed) == 1
assert "进行中" in changed[0]["body"]
assert changed[0]["item_title"] == "月度对账"
# helper 是操作者本人,不该收到自己那次改动的通知
assert not [
n for n in await _inbox(client, helper, ws["id"]) if n["kind"] == "task_status_changed"
]
@pytest.mark.asyncio
async def test_assignee_must_be_a_member(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "越界指派")
outsider = await login(client) # 没加入这个工作区
resp = await client.post(
f"{V1}/workspaces/{ws['id']}/items",
json={"title": "指派给外人", "assignee_id": outsider["user_id"]},
headers=owner["headers"],
)
assert resp.status_code == 400, resp.text
assert resp.json()["code"] == 1001
item = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/items",
json={"title": "不指派"},
headers=owner["headers"],
)
)
resp = await client.patch(
f"{V1}/workspaces/{ws['id']}/items/{item['id']}",
json={"assignee_id": outsider["user_id"], "version": item["version"]},
headers=owner["headers"],
)
assert resp.status_code == 400, resp.text
@pytest.mark.asyncio
async def test_manual_ticket_runs_the_whole_flow(client: AsyncClient) -> None:
"""手工开的工单不是"半个工单":接受、提交、验收都得能走。"""
owner = await login(client)
ws = await create_workspace(client, owner, "手工开单")
helper = await _member(client, owner, ws, "member")
ticket = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/tickets",
json={
"title": "帮忙核对一遍报价单",
"body_md": "客户催得急,今天下班前要",
"assignee_id": helper["user_id"],
"priority": 2,
},
headers=owner["headers"],
)
)
assert ticket["status"] == "open"
assert ticket["kind"] == "ticket"
assert ticket["ticket"]["requester_id"] == owner["user_id"]
assert ticket["ticket"]["assignee_id"] == helper["user_id"]
assert ticket["ticket"]["parent_item_id"] is None # 没有来源任务
# 出现在被指派人的工单列表里
assigned = unwrap(
await client.get(
f"{V1}/workspaces/{ws['id']}/tickets",
params={"role": "assigned"},
headers=helper["headers"],
)
)
assert [t["id"] for t in assigned["tickets"]] == [ticket["id"]]
# 被指派人收到通知
inbox = await _inbox(client, helper, ws["id"])
assert any(n["kind"] == "ticket_assigned" and n["item_id"] == ticket["id"] for n in inbox)
# 接受 → 开工 → 提交 → 验收通过
for action, body in (
("accept", {}),
("start", {}),
("submit", {"note": "核对完了"}),
):
unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/items/{ticket['id']}/ticket/{action}",
json=body,
headers=helper["headers"],
)
)
done = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/items/{ticket['id']}/ticket/verify",
json={"approve": True, "resolution_md": "确认了三处差异"},
headers=owner["headers"],
)
)
assert done["status"] == "done"
# 验收是需求人做的,通知该发给办理人;需求人自己不该收到"你通过了"这种通知
helper_inbox = await _inbox(client, helper, ws["id"])
assert any(n["kind"] == "ticket_approved" for n in helper_inbox)
assert not [n for n in await _inbox(client, owner, ws["id"]) if n["kind"] == "ticket_approved"]
@pytest.mark.asyncio
async def test_manual_ticket_rejects_outsider(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "手工开单越界")
outsider = await login(client)
resp = await client.post(
f"{V1}/workspaces/{ws['id']}/tickets",
json={"title": "给外人开单", "assignee_id": outsider["user_id"]},
headers=owner["headers"],
)
assert resp.status_code == 400, resp.text
assert resp.json()["code"] == 1001
@pytest.mark.asyncio
async def test_comment_notifies_the_other_party(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "评论通知")
helper = await _member(client, owner, ws, "member")
item = await _task_assigned_to(client, owner, ws["id"], helper["user_id"], "整理发票")
unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/items/{item['id']}/comments",
json={"body_md": "发票抬头要写公司全称"},
headers=helper["headers"],
)
)
owner_inbox = await _inbox(client, owner, ws["id"])
assert any(n["kind"] == "item_commented" for n in owner_inbox)
# 操作者本人不该收到
assert not [n for n in await _inbox(client, helper, ws["id"]) if n["kind"] == "item_commented"]
@pytest.mark.asyncio
async def test_unread_count_and_read_all(client: AsyncClient) -> None:
owner = await login(client)
ws = await create_workspace(client, owner, "未读数")
helper = await _member(client, owner, ws, "member")
await _task_assigned_to(client, owner, ws["id"], helper["user_id"], "第一件")
await _task_assigned_to(client, owner, ws["id"], helper["user_id"], "第二件")
count = unwrap(
await client.get(
f"{V1}/workspaces/{ws['id']}/notifications/unread-count", headers=helper["headers"]
)
)
assert count["unread"] == 2
first = (await _inbox(client, helper, ws["id"]))[0]
unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/notifications/{first['id']}/read",
headers=helper["headers"],
)
)
count = unwrap(
await client.get(
f"{V1}/workspaces/{ws['id']}/notifications/unread-count", headers=helper["headers"]
)
)
assert count["unread"] == 1
marked = unwrap(
await client.post(
f"{V1}/workspaces/{ws['id']}/notifications/read-all", headers=helper["headers"]
)
)
assert marked["marked"] == 1
count = unwrap(
await client.get(
f"{V1}/workspaces/{ws['id']}/notifications/unread-count", headers=helper["headers"]
)
)
assert count["unread"] == 0