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
817 lines
29 KiB
Python
817 lines
29 KiB
Python
"""任务(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="已创建")
|