Files
DadaoTask/backend/app/api/items.py
T
Steven 91b3dde670
CI / backend (push) Canceled after 0s
feat(notify): 站内通知与手工开单,指派/改状态/评论/验收都有人收到
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
2026-10-30 02:19:42 +08:00

817 lines
29 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.
"""任务(item)接口。
写操作的并发控制统一走**乐观锁**:请求必须带 `version`,落库时用
`UPDATE ... WHERE id = ? AND version = ?` 并检查 rowcount。
不用"先查再改"——两个请求同时读到 version=3 时,两边都会认为自己是对的,
后写的静默覆盖先写的,而且日志里看不出异常。
"""
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, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from ..config import get_settings
from ..deps import MemberDep, SessionDep
from ..domain.task import (
ALL_STATUSES,
apply_status,
due_window,
ensure_transition,
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=["任务"])
tag_router = APIRouter(prefix="/workspaces/{ws_id}/tags", tags=["标签"])
MAX_TITLE = 200
MAX_BODY = 100_000
# ── 入参 ────────────────────────────────────────────────────────────────
class CreateItemIn(BaseModel):
title: str = Field(min_length=1, max_length=MAX_TITLE)
body_md: str = Field(default="", max_length=MAX_BODY)
status: str = Field(default="inbox")
priority: int = Field(default=0, ge=0, le=3)
assignee_id: uuid.UUID | None = None
due_at: datetime | None = None
tag_names: list[str] = Field(default_factory=list, max_length=10)
class UpdateItemIn(BaseModel):
"""只传要改的字段。注意:`None` 表示"不动",想把 due_at 清空请传 `clear_due_at`。"""
version: int = Field(ge=1)
title: str | None = Field(default=None, min_length=1, max_length=MAX_TITLE)
body_md: str | None = Field(default=None, max_length=MAX_BODY)
priority: int | None = Field(default=None, ge=0, le=3)
assignee_id: uuid.UUID | None = None
clear_assignee: bool = False
due_at: datetime | None = None
clear_due_at: bool = False
tag_names: list[str] | None = Field(default=None, max_length=10)
class StatusIn(BaseModel):
version: int = Field(ge=1)
status: str
class CommentIn(BaseModel):
body_md: str = Field(min_length=1, max_length=20_000)
class TagIn(BaseModel):
name: str = Field(min_length=1, max_length=32)
color: str = Field(default="", max_length=16)
# ── 序列化(与工单接口共用一份,两份拼装迟早会不一致)─────────────────────
_tag_out = tag_out
_item_out = item_out
# ── 内部工具 ────────────────────────────────────────────────────────────
async def _load_item(
session: AsyncSession,
ws_id: uuid.UUID,
item_id: uuid.UUID,
kinds: tuple[str, ...] = ("task",),
) -> 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, "任务不存在")
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]]:
return await tags_of(session, ws_id, item_ids)
def _normalize_tag_names(names: list[str]) -> list[str]:
result: list[str] = []
seen: set[str] = set()
for raw in names:
name = raw.strip()
if not name or name.lower() in seen:
continue
seen.add(name.lower())
result.append(name)
return result
async def _set_tags(session: AsyncSession, ws_id: uuid.UUID, item: Item, names: list[str]) -> None:
"""整组替换:客户端传什么就是什么,不做增量(增量语义在弱网下容易和用户预期错位)。"""
names = _normalize_tag_names(names)
existing = {
tag.name.lower(): tag
for tag in (await session.execute(select(Tag).where(Tag.workspace_id == ws_id))).scalars()
}
keep: list[Tag] = []
for name in names:
tag = existing.get(name.lower())
if tag is None:
tag = Tag(workspace_id=ws_id, name=name)
session.add(tag)
await session.flush()
existing[name.lower()] = tag
keep.append(tag)
await session.execute(
ItemTag.__table__.delete().where(ItemTag.workspace_id == ws_id, ItemTag.item_id == item.id)
)
for tag in keep:
session.add(ItemTag(workspace_id=ws_id, item_id=item.id, tag_id=tag.id))
await session.flush()
async def _log(
session: AsyncSession, item: Item, actor_id: uuid.UUID, 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 _apply_update(
session: AsyncSession,
item: Item,
*,
version: int,
values: dict[str, Any],
actor_id: uuid.UUID,
action: str,
payload: dict | None = None,
) -> Item:
"""乐观锁写库:rowcount=0 说明别人先改了,别默默覆盖。"""
values = {**values, "version": version + 1, "updated_at": datetime.now(timezone.utc)}
result = await session.execute(
update(Item)
.where(Item.id == item.id, Item.workspace_id == item.workspace_id, Item.version == version)
.values(**values)
)
if result.rowcount == 0:
await session.refresh(item)
raise AppError(
ErrorCode.VERSION_CONFLICT,
"这条任务已被别人修改,请刷新后重试",
details={"current_version": item.version},
)
await session.refresh(item)
await _log(session, item, actor_id, action, payload)
return item
# ── 列表 ────────────────────────────────────────────────────────────────
def _apply_filters(
stmt: Select,
*,
ws_id: uuid.UUID,
me: uuid.UUID,
view: str | None,
status: list[str] | None,
assignee: str | None,
q: str | None,
tag: str | None,
include_deleted: bool,
now: datetime,
tz: str,
) -> Select:
stmt = stmt.where(Item.workspace_id == ws_id, Item.kind == "task")
if view == "deleted":
stmt = stmt.where(Item.deleted_at.is_not(None))
elif not include_deleted:
stmt = stmt.where(Item.deleted_at.is_(None))
if status:
invalid = [s for s in status if s not in ALL_STATUSES]
if invalid:
raise AppError(ErrorCode.BAD_REQUEST, f"未知状态:{','.join(invalid)}")
stmt = stmt.where(Item.status.in_(status))
elif view and view != "deleted":
stmt = stmt.where(Item.status.in_(view_statuses(view)))
due_from, due_to = due_window(view, now=now, tz=tz)
if due_from is not None:
stmt = stmt.where(Item.due_at.is_not(None), Item.due_at >= due_from, Item.due_at < due_to)
if assignee:
if assignee == "me":
stmt = stmt.where(Item.assignee_id == me)
else:
try:
stmt = stmt.where(Item.assignee_id == uuid.UUID(assignee))
except ValueError as exc:
raise AppError(ErrorCode.BAD_REQUEST, "assignee 需要是 me 或用户 id") from exc
if q and q.strip():
needle = f"%{q.strip()}%"
stmt = stmt.where(or_(Item.title.ilike(needle), Item.body_md.ilike(needle)))
if tag and tag.strip():
stmt = stmt.where(
Item.id.in_(
select(ItemTag.item_id)
.join(Tag, Tag.id == ItemTag.tag_id)
.where(ItemTag.workspace_id == ws_id, func.lower(Tag.name) == tag.strip().lower())
)
)
return stmt
@router.get("", summary="任务列表(视图 / 状态 / 负责人 / 关键字 / 标签)")
async def list_items(
ctx: MemberDep,
session: SessionDep,
view: Annotated[
str | None, Query(description="inbox|active|today|week|done|canceled|all|deleted")
] = None,
status: Annotated[list[str] | None, Query(description="精确状态,可重复传")] = None,
assignee: Annotated[str | None, Query(description="me 或用户 uuid")] = None,
q: Annotated[str | None, Query(max_length=100, description="标题/正文关键字")] = None,
tag: Annotated[str | None, Query(max_length=32)] = None,
sort: Annotated[str, Query(description="updated|due|created")] = "updated",
include_deleted: bool = False,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[str | None, Query()] = None,
) -> dict:
if sort not in ("updated", "due", "created"):
raise AppError(ErrorCode.BAD_REQUEST, "sort 只能是 updated / due / created")
settings = get_settings()
now = datetime.now(timezone.utc)
page = Pagination(limit=limit, cursor=cursor)
limit, parsed_cursor = page.parsed()
base = _apply_filters(
select(Item),
ws_id=ctx.ws_id,
me=ctx.user.id,
view=view,
status=status,
assignee=assignee,
q=q,
tag=tag,
include_deleted=include_deleted,
now=now,
tz=settings.default_timezone,
)
if sort == "due":
# 截止时间排序涉及 NULLS LAST,keyset 分页会把 NULL 行切碎。
# 逾期/今日清单本来就是短列表,这里不分页(上限 100 条),语义更清楚。
rows = (
(
await session.execute(
base.order_by(Item.due_at.asc().nulls_last(), Item.sort_order, Item.id).limit(
limit
)
)
)
.scalars()
.all()
)
tags = await _tags_of(session, ctx.ws_id, [r.id for r in rows])
counts = await count_for_items(session, ctx.ws_id, [r.id for r in rows])
return ok(
{
"items": [
dict(_item_out(r, tags.get(r.id)), attachment_count=counts.get(str(r.id), 0))
for r in rows
],
"next_cursor": None,
"has_more": False,
}
)
if sort == "created":
order = (Item.created_at.desc(), Item.id.desc())
sort_col, sort_attr = Item.created_at, "created_at"
else:
order = (Item.updated_at.desc(), Item.id.desc())
sort_col, sort_attr = Item.updated_at, "updated_at"
stmt = base.order_by(*order).limit(limit + 1)
if parsed_cursor is not None:
stmt = stmt.where(
or_(
sort_col < parsed_cursor.created_at,
(sort_col == parsed_cursor.created_at) & (Item.id < parsed_cursor.id),
)
)
rows = (await session.execute(stmt)).scalars().all()
has_more = len(rows) > limit
rows = list(rows[:limit])
next_cursor = (
encode_cursor(getattr(rows[-1], sort_attr), rows[-1].id) if has_more and rows else None
)
tags = await _tags_of(session, ctx.ws_id, [r.id for r in rows])
counts = await count_for_items(session, ctx.ws_id, [r.id for r in rows])
return ok(
{
"items": [
dict(_item_out(r, tags.get(r.id)), attachment_count=counts.get(str(r.id), 0))
for r in rows
],
"next_cursor": next_cursor,
"has_more": has_more,
}
)
@router.get("/board", summary="看板:按状态分列的最近任务")
async def board(
ctx: MemberDep,
session: SessionDep,
per_column: Annotated[int, Query(ge=1, le=100)] = 30,
) -> dict:
"""一屏拖动需要的数据,一次拿全。
每列按"截止时间早的在前、没有截止时间的靠后"排,其次按更新时间。
只取 `per_column` 条:看板是"手上正在做的",不是全量列表。
"""
columns: dict[str, list[dict[str, Any]]] = {}
statuses = ("todo", "doing", "blocked", "done")
rows = (
(
await session.execute(
select(Item)
.where(
Item.workspace_id == ctx.ws_id,
Item.status.in_(statuses),
Item.deleted_at.is_(None),
)
.order_by(
Item.due_at.asc().nulls_last(),
Item.updated_at.desc(),
Item.id.desc(),
)
.limit(per_column * len(statuses))
)
)
.scalars()
.all()
)
by_id = {row.id: row for row in rows}
tag_map = await _tags_of(session, ctx.ws_id, list(by_id))
for status in statuses:
columns[status] = [
_item_out(row, tag_map.get(row.id)) for row in rows if row.status == status
][:per_column]
return ok(
{
"columns": columns,
"order": list(statuses),
"labels": {
"todo": "待办",
"doing": "进行中",
"blocked": "被阻塞",
"done": "已完成",
},
}
)
@router.get("/{item_id}", summary="事项详情(任务或工单,含最近 50 条事件)")
async def get_item(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict:
item = await _load_item(session, ctx.ws_id, item_id, ("task", "ticket"))
tags = await _tags_of(session, ctx.ws_id, [item.id])
activities = (
(
await session.execute(
select(Activity)
.where(Activity.workspace_id == ctx.ws_id, Activity.item_id == item.id)
.order_by(Activity.created_at.desc(), Activity.id.desc())
.limit(50)
)
)
.scalars()
.all()
)
data = _item_out(item, tags.get(item.id))
data["attachments"] = await list_for_item(session, ctx.ws_id, item.id)
data["attachment_count"] = len(data["attachments"])
data["activities"] = [
{
"id": str(a.id),
"action": a.action,
"actor_id": str(a.actor_id) if a.actor_id else None,
"payload": a.payload,
"created_at": a.created_at,
}
for a in activities
]
if item.kind == "ticket":
ext = await session.get(TicketExt, item.id)
if ext is not None:
data["ticket"] = {
"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,
}
return ok(data)
# ── 写操作 ──────────────────────────────────────────────────────────────
@router.post("", summary="新建任务")
async def create_item(body: CreateItemIn, ctx: MemberDep, session: SessionDep) -> dict:
if body.status not in ALL_STATUSES:
raise AppError(ErrorCode.BAD_REQUEST, f"未知状态:{body.status}")
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,
kind="task",
title=body.title.strip(),
body_md=body.body_md,
priority=body.priority,
creator_id=ctx.user.id,
assignee_id=body.assignee_id,
due_at=body.due_at,
status=fields["status"],
completed_at=fields["completed_at"],
)
session.add(item)
await session.flush()
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="已创建")
class BatchStatusItemIn(BaseModel):
id: uuid.UUID
version: int
class BatchStatusIn(BaseModel):
status: str
items: list[BatchStatusItemIn] = Field(min_length=1, max_length=50)
@router.post("/batch-status", summary="批量改状态(逐条返回成败)")
async def batch_status(body: BatchStatusIn, ctx: MemberDep, session: SessionDep) -> dict:
"""看板多选拖拽用。
不做"全成功或全回滚":拖 10 条里有 1 条被同事改过,其余 9 条应当照样生效,
失败的那条单独告诉前端原因。全回滚会让用户白干一次,而且更难解释。
"""
if body.status not in ALL_STATUSES:
raise AppError(ErrorCode.BAD_REQUEST, f"未知状态:{body.status}")
now = datetime.now(timezone.utc)
updated: list[dict[str, Any]] = []
failed: list[dict[str, Any]] = []
for entry in body.items:
try:
item = await _load_item(session, ctx.ws_id, entry.id)
except AppError as exc:
failed.append({"id": str(entry.id), "code": int(exc.code), "message": exc.message})
continue
if item.status == body.status:
updated.append({"id": str(item.id), "version": item.version, "unchanged": True})
continue
try:
ensure_transition(item.status, body.status)
except AppError as exc:
failed.append(
{
"id": str(entry.id),
"code": int(exc.code),
"message": exc.message,
"allowed": (exc.details or {}).get("allowed"),
}
)
continue
previous = item.status
fields: dict[str, Any] = {"status": body.status, "completed_at": None}
apply_status(fields, body.status, now=now)
try:
item = await _apply_update(
session,
item,
version=entry.version,
values={"status": fields["status"], "completed_at": fields["completed_at"]},
actor_id=ctx.user.id,
action="status_changed",
payload={"from": previous, "to": body.status, "batch": True},
)
except AppError as exc:
failed.append(
{
"id": str(entry.id),
"code": int(exc.code),
"message": exc.message,
"current_version": (exc.details or {}).get("current_version"),
}
)
continue
updated.append({"id": str(item.id), "version": item.version, "unchanged": False})
return ok(
{"updated": updated, "failed": failed},
message=f"已更新 {len(updated)} 条,{len(failed)} 条未生效",
)
@router.patch("/{item_id}", summary="修改事项字段(乐观锁)")
async def update_item(
item_id: uuid.UUID, body: UpdateItemIn, ctx: MemberDep, session: SessionDep
) -> dict:
# 工单也能改标题/优先级/截止/标签;状态必须走 /ticket/* 的动作接口
item = await _load_item(session, ctx.ws_id, item_id, ("task", "ticket"))
values: dict[str, Any] = {}
changes: dict[str, Any] = {}
if body.title is not None and body.title.strip() != item.title:
values["title"] = body.title.strip()
changes["title"] = values["title"]
if body.body_md is not None and body.body_md != item.body_md:
values["body_md"] = body.body_md
changes["body_md"] = "changed"
if body.priority is not None and body.priority != item.priority:
values["priority"] = body.priority
changes["priority"] = body.priority
if body.clear_assignee:
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:
values["due_at"] = None
changes["due_at"] = None
elif body.due_at is not None and body.due_at != item.due_at:
values["due_at"] = body.due_at
changes["due_at"] = body.due_at.isoformat()
item = await _apply_update(
session,
item,
version=body.version,
values=values,
actor_id=ctx.user.id,
action="updated",
payload={"changes": changes} if changes else None,
)
if body.tag_names is not None:
await _set_tags(session, ctx.ws_id, item, body.tag_names)
await _log(
session,
item,
ctx.user.id,
"tags_changed",
{"tags": _normalize_tag_names(body.tag_names)},
)
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="已保存")
@router.post("/{item_id}/status", summary="改状态(状态机 + 乐观锁)")
async def change_status(
item_id: uuid.UUID, body: StatusIn, ctx: MemberDep, session: SessionDep
) -> dict:
item = await _load_item(session, ctx.ws_id, item_id)
if body.status not in ALL_STATUSES:
raise AppError(ErrorCode.BAD_REQUEST, f"未知状态:{body.status}")
if body.status == item.status:
tags = await _tags_of(session, ctx.ws_id, [item.id])
return ok(_item_out(item, tags.get(item.id)), message="状态未变化")
ensure_transition(item.status, body.status)
previous = item.status
fields: dict[str, Any] = {"status": body.status, "completed_at": None}
apply_status(fields, body.status, now=datetime.now(timezone.utc))
item = await _apply_update(
session,
item,
version=body.version,
values={"status": fields["status"], "completed_at": fields["completed_at"]},
actor_id=ctx.user.id,
action="status_changed",
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="状态已更新")
@router.delete("/{item_id}", summary="删除任务(软删;带 version 才防并发)")
async def delete_item(
item_id: uuid.UUID,
ctx: MemberDep,
session: SessionDep,
version: Annotated[int, Query(ge=1)],
) -> dict:
item = await _load_item(session, ctx.ws_id, item_id)
item = await _apply_update(
session,
item,
version=version,
values={"deleted_at": datetime.now(timezone.utc)},
actor_id=ctx.user.id,
action="deleted",
)
return ok({"id": str(item.id), "deleted_at": item.deleted_at}, message="已删除")
# ── 评论 ────────────────────────────────────────────────────────────────
@router.post("/{item_id}/comments", summary="加评论")
async def add_comment(
item_id: uuid.UUID, body: CommentIn, ctx: MemberDep, session: SessionDep
) -> dict:
item = await _load_item(session, ctx.ws_id, item_id, ("task", "ticket"))
comment = Comment(
workspace_id=ctx.ws_id,
item_id=item.id,
author_id=ctx.user.id,
body_md=body.body_md,
)
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),
"item_id": str(item.id),
"author_id": str(comment.author_id),
"body_md": comment.body_md,
"created_at": comment.created_at,
},
message="已评论",
)
@router.get("/{item_id}/comments", summary="评论列表")
async def list_comments(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict:
item = await _load_item(session, ctx.ws_id, item_id, ("task", "ticket"))
rows = (
(
await session.execute(
select(Comment)
.where(
Comment.workspace_id == ctx.ws_id,
Comment.item_id == item.id,
Comment.deleted_at.is_(None),
)
.order_by(Comment.created_at.asc(), Comment.id.asc())
.limit(200)
)
)
.scalars()
.all()
)
return ok(
{
"items": [
{
"id": str(c.id),
"author_id": str(c.author_id),
"body_md": c.body_md,
"created_at": c.created_at,
}
for c in rows
]
}
)
# ── 标签 ────────────────────────────────────────────────────────────────
@tag_router.get("", summary="标签列表")
async def list_tags(ctx: MemberDep, session: SessionDep) -> dict:
rows = (
(await session.execute(select(Tag).where(Tag.workspace_id == ctx.ws_id).order_by(Tag.name)))
.scalars()
.all()
)
return ok({"items": [_tag_out(t) for t in rows]})
@tag_router.post("", summary="建标签(同名返回已有)")
async def create_tag(body: TagIn, ctx: MemberDep, session: SessionDep) -> dict:
name = body.name.strip()
exists = (
await session.execute(
select(Tag).where(Tag.workspace_id == ctx.ws_id, func.lower(Tag.name) == name.lower())
)
).scalar_one_or_none()
if exists is not None:
return ok(_tag_out(exists), message="已存在")
tag = Tag(workspace_id=ctx.ws_id, name=name, color=body.color)
session.add(tag)
await session.flush()
return ok(_tag_out(tag), message="已创建")