feat(task): M1 任务域后端与 Web 列表(状态机/乐观锁/视图/搜索)
CI / backend (push) Canceled after 0s

- item/tag/item_tag/comment/activity 五表迁移,RLS 全覆盖,pg_trgm 子串索引
- 状态机 domain/task.py:非法流转回 5002 并给 allowed,done 写 completed_at
- /workspaces/{ws}/items 列表/详情/新建/改/状态/软删/评论/标签
- 乐观锁 UPDATE...WHERE version,冲突 4009 + current_version
- Web 任务页:视图 tab、快速新建、勾选完成、开工/阻塞、详情事件流、游标分页
- 测试 43 全绿,ruff 干净,Web 构建通过
- 文档:2.Design/API.md 新增,ROADMAP 按已验证口径重写
This commit is contained in:
Steven
2026-10-28 03:48:48 +08:00
parent d390b7b58a
commit e25a1a4e31
19 changed files with 1959 additions and 31 deletions
+1
View File
@@ -0,0 +1 @@
"""任务域业务规则(与框架无关,方便单测)。"""
+100
View File
@@ -0,0 +1,100 @@
"""任务状态机与"视图"口径。
两条原则:
1. **状态机是契约**:客户端据此置灰按钮,服务端必须再校验一次。
任何"直接改 status 列"的代码路径都是 bug。
2. **视图是查询口径,不是状态**:`today` / `week` 是筛选条件,不往 item 上落字段,
否则每天都要批量刷库,还会因为时区边界出错。
"""
from __future__ import annotations
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from ..errors import AppError, ErrorCode
# 允许的流转。没列出的就是禁止的(显式白名单,默认拒绝)。
TRANSITIONS: dict[str, frozenset[str]] = {
# 待整理:进来先放着,决定做或不做
"inbox": frozenset({"todo", "canceled"}),
# 待办:可以开工,也可以直接标记完成(写文档类的事不需要"进行中")
"todo": frozenset({"inbox", "doing", "done", "canceled"}),
# 进行中
"doing": frozenset({"todo", "done", "blocked", "canceled"}),
# 被阻塞(M2 起:任务升级成工单会自动进这里)
"blocked": frozenset({"doing", "canceled"}),
# 已完成:只能重开
"done": frozenset({"todo"}),
# 已取消:只能重开
"canceled": frozenset({"todo"}),
}
# 表示"这件事还没完"的状态
OPEN_STATUSES: tuple[str, ...] = ("inbox", "todo", "doing", "blocked")
ACTIVE_STATUSES: tuple[str, ...] = ("todo", "doing", "blocked")
ALL_STATUSES: tuple[str, ...] = tuple(TRANSITIONS)
# 视图 → 状态集合。`today` / `week` 还要叠加截止时间窗口(见 due_window)
VIEW_STATUSES: dict[str, tuple[str, ...]] = {
"inbox": ("inbox",),
"active": ACTIVE_STATUSES,
"today": ACTIVE_STATUSES,
"week": ACTIVE_STATUSES,
"done": ("done",),
"canceled": ("canceled",),
"all": ALL_STATUSES,
}
DEFAULT_VIEW = "active"
def can_transition(current: str, target: str) -> bool:
return target in TRANSITIONS.get(current, frozenset())
def ensure_transition(current: str, target: str) -> None:
"""非法流转直接拒绝,并把"当前能去哪"一起返回,客户端不用自己猜。"""
if current == target:
return
if not can_transition(current, target):
raise AppError(
ErrorCode.ITEM_STATUS_INVALID,
f"状态不能从 {current} 直接变成 {target}",
details={
"from": current,
"to": target,
"allowed": sorted(TRANSITIONS.get(current, ())),
},
)
def apply_status(item: dict, target: str, *, now: datetime) -> None:
"""就地更新 status/completed_at。`completed_at` 只在 done 时有值。"""
item["status"] = target
item["completed_at"] = now if target == "done" else None
def view_statuses(view: str | None) -> tuple[str, ...]:
return VIEW_STATUSES.get(view or DEFAULT_VIEW, ACTIVE_STATUSES)
def due_window(
view: str | None, *, now: datetime, tz: str
) -> tuple[datetime | None, datetime | None]:
"""返回 (due_from, due_to) 半开区间 `[from, to)`。
`today` = 今天 00:00(含)到明天 00:00(不含),按**用户时区**算,
不是 UTC —— 中国用户 23:30 创建"今天到期"的任务,UTC 下已经是明天了。
"""
if view not in ("today", "week"):
return None, None
zone = ZoneInfo(tz)
local_now = now.astimezone(zone)
start = local_now.replace(hour=0, minute=0, second=0, microsecond=0)
if view == "today":
end = start + timedelta(days=1)
else:
# 本周剩余:到今天所在自然周的周日结束
end = start + timedelta(days=7 - start.weekday())
return start, end