# 实施清单(M0–M2) > 来源:`2.Design/PRODUCT-PLAN.md` v1.0 第 12 节 > 粒度:每项 0.3–1.5 人日,2 人并行 ≈ 2 周一个里程碑 > 状态标记:`[ ]` 未开始 `[~]` 进行中 `[x]` 完成 > > **最后一次核对:M0 骨架已于本机跑通(33 条测试全绿、ruff 通过、真机 HTTP 冒烟通过)。** > 标 `[x]` 的都是**真跑过**的,不是"代码写完了"。写代码不等于完成,代码没有义务在第一次运行时正确。 --- ## M0 · 骨架(第 1–2 周,10 人日) **里程碑目标**:一个能注册、能建团队、能证明"两个租户互相看不见"的空壳,部署在服务器上,CI 全绿。 ### 0.1 仓库与约定(0.5d) - [x] 目录结构:`backend/ web/ android/ deploy/ 2.Design/` - [x] `AGENTS.md`:提交纪律(改完必 push)、前端产物只能由 HEAD 构建、文档落点 - [x] `.gitignore` / `.editorconfig` / `README.md` - [x] 分支策略:`main` 受保护,功能走 `feat/*`;提交信息 `feat(module): 中文描述` - [ ] PR 模板(`.gitea/PULL_REQUEST_TEMPLATE.md`) - **验收**:Gitea 上能看到仓库 ✅(`http://192.168.8.160:8418/Tatta/DadaoTask.git`),PR 模板待补 ### 0.2 本地环境(1d) - [x] `deploy/docker-compose.dev.yml`:`pgvector/pgvector:pg16` + `redis:7`(**未含 minio**,M0 不用对象存储) - [x] `postgres` 初始化脚本:建 `dadao_owner` / `dadao_app` 两个角色 + 库 owner + `REVOKE CREATE` - [x] `.env.example` 全量注释 - [ ] `CREATE EXTENSION vector, pg_trgm, pgcrypto`(**推迟到 M3 知识库**:M0 的迁移不依赖任何扩展) - **偏差(重要)**:本机 Docker Desktop 起不来(`com.docker.service` 未运行、需管理员、WSL 报 `E_ACCESSDENIED`), M0 是用**便携版 PostgreSQL 16.4** 跑通的:解压到 `.local/pg`,`initdb` 后由后台作业前台运行 `postgres.exe -p 5433`。 步骤见 §「环境现状与阻塞」。换机器/换人仍以 docker-compose 为准。 ### 0.3 配置与日志(0.5d) - [x] `pydantic-settings` 分层配置(dev/test/prod),`validate_runtime()` 启动即校验 - [x] 结构化日志(JSON)+ `request_id` 贯穿 + 敏感字段脱敏(`password`/`token`/`api_key`/`secret`) - **验收**:✅ request_id 出现在响应头与所有日志行;`ENV=prod` 且密钥仍是默认值 → 启动直接失败 ### 0.4 数据库基座(1d) - [x] `alembic init`,`env.py` 读 `MIGRATION_DATABASE_URL`(owner 角色) - [x] 基础表迁移:`workspace` `app_user` `member` `invitation` `audit_log` - [x] 通用列约定:`id uuid`、`created_at`、`updated_at`、`version`;`deleted_at` 按表按需(`workspace` 有) - [x] 空库 `alembic upgrade head` 一次成功 - [ ] `downgrade` 回滚实测(代码写了,没跑过) - **踩坑记录**:asyncpg 走预备语句,**一条 `execute` 只能一条 SQL**。多语句 DDL 必须拆开逐条执行,否则报 `cannot insert multiple commands into a prepared statement`。 ### 0.5 租户上下文 + RLS(1.5d)★ 核心 - [x] 请求依赖:解析 JWT → 校验成员身份 → `set_config('app.ws_id', …, true)`(事务级,自动随事务失效) - [x] 租户表启用 RLS:`workspace` `member` `invitation` `audit_log`(策略见 `backend/alembic/versions/0001_init.py`) - [x] 仓储基类 `Repo(session, ws_id)` 强制 `ws_id` 必填、无默认值 - [x] 应用角色 `dadao_app` **非 owner、非 superuser、无 BYPASSRLS**,且无 DDL 权限 - [x] 测试:跨租户读 → 报 `3003 NOT_A_MEMBER`(不泄露对方工作区是否存在);DB 层未绑上下文 → 0 行 - [ ] 变异测试:手工删掉一条 where 条件,确认测试会变红(验证测试真的有牙) - **已知取舍**:`app_user` 表不加 RLS——登录时按手机号查用户发生在任何租户绑定之前,加了策略会直接查不到。 代价是"手机号是否已注册"在应用层可探测;缓解手段:错误信息统一、加登录限流。**这条记在 D4 风险里,M4 前必须处理。** ### 0.6 认证(1.5d) - [x] 手机号 + 验证码注册/登录(dev 固定码 `123456` 并回显;`ENV=prod` 禁止 `SMS_PROVIDER=dev`) - [x] JWT:**access 30min / refresh 14d**(计划里写的 15min/30d,实际取宽了一点,理由是移动端弱网刷新成本高) - [x] 发码节流(`SMS_THROTTLE_SECONDS`,默认 60s,测试可置 0) - [ ] 邮箱 + 密码(argon2)(**未做**,M1 有空再说;手机号登录先行) - [ ] refresh 令牌落库可撤销(**未做**,当前是无状态 JWT;被移出团队的成员靠"每次请求查 member 表"兜底,refresh 本身不可撤销) - **验收**:✅ 注册 → 登录 → 刷新 → `/auth/me` 全链路通;错误码 2010/2011/2012 语义清晰 ### 0.7 权限守卫(0.5d) - [x] 四个角色:owner / admin / member / guest,`ROLE_RANK = {owner:4, admin:3, member:2, guest:1}` - [x] 依赖注入统一执行:`require_role(min)`、`AdminDep`、`OwnerDep` - [x] 细则:只有 owner 能邀请 admin;guest 不能邀请;非成员一律 `3003` - **偏差**:没做 `(资源, 动作) → 最低角色` 的 `PERMISSIONS` 字典,M0 只有"工作区"一个资源域, 字典是过度设计。M1 引入 item 时再上。 - **验收**:✅ member 调邀请接口 → 3004;跨租户 → 3003 ### 0.8 接口规范(0.5d) - [x] 统一响应体 `{code, message, data, request_id}` - [x] 错误码表(**暂存在 `backend/app/errors.py`**,M1 抽到 `2.Design/API.md`;已按 1xxx 通用 / 2xxx 认证 / 3xxx 租户 / 4xxx 并发 分段) - [x] 写接口支持 `Idempotency-Key`(存储 TTL 24h;只缓存 <400,5xx 与 4xx 都不缓存) - [x] 分页:cursor(`(created_at, id)` 复合游标)+ `limit ≤ 100` - **已知限制**:同 key 并发请求有竞态窗口,靠后续唯一键约束兜底;M0 不解决。 ### 0.9 健康与版本(0.3d) - [x] `/health`(DB 探活)、`/version`(commit / build_time / db_role 自检) - **偏差**:`commit` 来自 `BUILD_COMMIT` 环境变量(部署时注入),本机默认 `unknown`;Redis 未探活(dev 不用 Redis) - **验收**:真机 `curl /health` → `{"status":"ok","db":true}`;`/version` 显示 `db_role` 为 `dadao_app` 且三项全 false ✅ ### 0.10 CI(0.7d) - [x] `ruff` + `pytest`(`.github/workflows/ci.yml`,起 pgvector 服务 + 建角色 + 跑迁移 + 跑测试) - [ ] `mypy`(宽松起步)——未接 - [ ] `alembic check` 迁移一致性 - [ ] **CI 在 Gitea 上真正跑过一次**(当前只写了配置;Gitea Actions runner 是否启用未确认) - **验收**:坏代码推上去 CI 变红 ❌ 未验证 ### 0.11 部署(1d) - [ ] `deploy/docker-compose.prod.yml` + nginx 反代 + TLS - [ ] 端口分配登记到 `2.Design/DEPLOY.md`(避开 PosSync 的 5888 / 5176) - [ ] 备份脚本(pg_dump + 保留 14 天) - **验收**:从零一台机器 30 分钟内起完;重启后数据在;备份可恢复 ### 0.12 三端契约(0.5d) - [ ] 导出 `openapi.json`,生成 TS 与 Kotlin 客户端 - [ ] `make sdk` 一键重生成 - **验收**:改一个后端字段,两端编译报错 ### 0.13 客户端壳(1d) - [x] Web:Vite + React18 + TS + Tailwind,路由(`/login` `/` `/w/:wsId` `/join/:wsId/:code`)、请求封装(信封解包 / 幂等键 / 401 清理) - [x] Android:Compose 工程(AGP 8.7.3 / Kotlin 2.0.21 / minSdk 26)、登录页、工作区页、明文流量只对 `10.0.2.2` 放行 - [ ] Web 真实构建验证(`npm run build`)——依赖正在通过镜像安装,待跑 - [ ] Android 真实编译(本机无 Android SDK;`gradle-wrapper.jar` 未入库,需 Studio 补) - [ ] 两端联调"登录 → 建工作区 → 列表" ### 0.14 微信 iLink 通道 spike(0.5d)★ 决策前置 > 不阻塞 M0 交付,但必须在写 `integration` 表之前做完,否则接法选错要返工。详见 `PRODUCT-PLAN.md` §10.3。 - [ ] 用 DSH 插件(`@michengai/dsh-im-connect`)实际扫码绑定一个微信号,观察 bot 身份归属 - [ ] 用第二个微信号私聊该 bot,确认多用户共用一号时消息是否互不串 - [ ] 压一下:连续 20 条消息的延迟与是否触发风控;断网 5 分钟后 `syncBuf` 能否续上 - [ ] 记录 `bot_token` 失效表现(多久失效、失效后是报错还是静默) - **产出**:`2.Design/SPIKE-WECHAT-ILINK.md` - **验收**:文档里三个问题都有实测结论,不是推测 --- ## 环境现状与阻塞(2026-10-28 实测) 换机器前先读这一节,能省半天。 | 项 | 结论 | 绕过办法 | |---|---|---| | Docker Desktop | 起不来:服务未运行需管理员、WSL `E_ACCESSDENIED` | 用便携 PG 16.4(`.local/pg`,见下) | | 外网 TLS | 被拦:`schannel SEC_E_NO_CREDENTIALS`、`基础连接已经关闭` | 走 HTTP 镜像:PyPI 用 `http://mirrors.aliyun.com/pypi/simple/`,npm 用 `http://mirrors.huaweicloud.com/repository/npm/` | | pip 缓存 | 装包时写缓存会**卡死**(无输出、CPU 空转) | 必须加 `--no-cache-dir`;装包命令输出要重定向到文件再 tail,不要管道截断 | | `pg_ctl` | `could not create restricted token: error code 87` | 直接跑 `postgres.exe`,且必须放在**后台作业**里(父进程退出会连带杀死) | | fnsvr-01 (192.168.8.160) | 只开放 8418(Gitea) / 3306(MySQL) / **8432(PG 17.6,dbadmin/superuser)** | 用途定死:只有 `dadaotask` 与 `dadaotask_test` 两个库,**不再往上装扩展**;能力:无 pgvector,只有 pg_trgm/pgcrypto | | Android | **无 JDK、无 Gradle**(`java -version`/`gradle -v` 都不存在),SDK 平台目录在但缺构建链 | 装 JDK 17 + Gradle 8.x 后 `./gradlew assembleDebug`;当前安卓代码未被编译器验证过 | | 向量库 | Docker 起不来 → `deploy/docker-compose.vector.yml` 写了但没跑过 | 等 Docker 可用(或换一台机器)再 `up -d`,端口 5434 | 便携 PG 起法(本机专用): ```powershell # 后台作业里跑(id 会变),别用 Start-Process —— 父进程退出会把它带走 .\.local\pg\pgsql\bin\postgres.exe -D .\.local\pg\pgdata -p 5433 ` -c listen_addresses=127.0.0.1 -c fsync=off -c synchronous_commit=off ``` --- ## M1 · 任务域(第 3–4 周,10 人日) **里程碑目标**:自己开始每天用它记待办,连续 3 天不别扭。 > 进度口径:`[x]` = 已实现**且验证过**(测试/构建/冒烟),`[ ]` = 未做。每项后面写证据或偏差原因。 ### 1.1 数据(1d) - [x] `item` `tag` `item_tag` `comment` `activity` 迁移(`backend/alembic/versions/0002_tasks.py`,revision 0002 ← 0001) - [x] 索引:`ix_item_ws_status_due (ws,status,due_at)`、`ix_item_ws_assignee`、`ix_item_ws_updated`、`ix_item_ws_sort` - [~] 中文全文:**没用 zhparser/pg_bigm,改用 `pg_trgm` + `ILIKE`**。理由:服务器是 Alpine 上的 PG 17,`pg_available_extensions` 只有 `pg_trgm`/`pgcrypto`,装 zhparser 要编译 + 改镜像;trigram 对中文按 3 字符切,子串匹配够用,且 `gin_trgm_ops` 索引能救性能。真正需要"相关度排序"时再上 zhparser(M3 知识库阶段重评)。 - [x] 偏差:**没有 `item_assignee` 关联表**,`item.assignee_id` 单列。理由:M1 只有"一个任务一个负责人",多人协作由工单阶段(M2)的 `item_link` 承担;提前上关联表会让每条查询都多一次 join。 - [x] RLS:五张新表全部 `ENABLE ROW LEVEL SECURITY` + `workspace_id = app_ws_id()`,并 GRANT 给 `dadao_app` ### 1.2 领域(1.5d) - [x] 状态机 `backend/app/domain/task.py`:`inbox→todo→doing→done`(+ blocked/canceled,`done→todo` 可重开);非法流转抛 `5002` 并回 `allowed`("你可以去 canceled/todo"),不用前端猜 - [x] 所有变更写 `activity`(`created` / `updated` / `status_changed{from,to}` / `commented`,payload 是 JSONB) - [x] `version` 乐观锁:`UPDATE ... WHERE version = ?` 看 `rowcount`,冲突回 `4009` + `current_version`(实测 stale 请求拿到 `current=4`) - [x] `done` 写 `completed_at`,重开清空;`deleted_at` 软删,`view=deleted` 才看得到 ### 1.3 API(1.5d) - [x] 增删改查(`backend/app/api/items.py`)、`POST /{id}/status`、软删(带 version) - [x] 视图:`inbox|active|today|week|done|canceled|all|deleted`;`today/week` 按 `Asia/Shanghai` 切日界(`settings.default_timezone`),不按 UTC - [x] 过滤:`status`(可重复)、`assignee=me|`、`tag`、`q`(标题+正文 ILIKE,实测中文 `周报` 命中) - [x] **批量状态变更**(`POST /items/batch-status`,逐条返回成败,不做全回滚)+ 看板数据 `GET /items/board` - [~] 搜索返回高亮片段:未做,只返回命中行。理由:高亮要在客户端按 query 二次切词,纯展示逻辑,等 Web 有真实使用反馈再补 - [x] 分页:`(sort_key DESC, id DESC)` keyset 游标;`sort=due` 不分页(NULLS LAST 会把 NULL 行切碎,只有今日/逾期短列表用) ### 1.4 Web(3d) - [x] 视图 tab(今天/进行中/收件箱/已完成/全部)+ 搜索框 + 回车快速新建(`web/src/pages/Tasks.tsx`) - [x] 勾选完成/重开、`收下`(inbox→todo)、`开工`(todo→doing)、`阻塞` - [x] **看板拖拽**(`web/src/pages/Board.tsx`):HTML5 拖放,四列,非法流转当场拦下并说明原因;勾选后可批量移动 - [x] **详情侧栏可编辑**(`web/src/components/TaskDetail.tsx`):标题/优先级/截止时间/标签/备注/评论,全部落库;409 时自动刷新为最新内容 - [x] 快速录入语法:`#标签` `!高/!中/!低/!1..!3`(`web/src/lib/parse.ts`,语法从标题里剥离) - [x] 键盘流:`j/k` 选择、`Enter` 完成、`e` 编辑、`Esc` 关闭 - [x] `npm run build` 通过:41 modules,`195.20 kB │ gzip 63.73 kB` - [~] `@人` 指派:未做。理由:重名的人比标签多,悄悄指错负责人比不支持更糟;等 M2 有成员选择器再做 - [~] 子任务(一层):未做,排到 M2 与工单的 `item_link` 一起做——两者本质是同一种"事项之间的边" ### 1.5 安卓(1.5d) - [x] 任务列表 + 详情弹窗 + 勾选完成 + 快速新建(`android/.../ui/TaskListScreen.kt`) - [x] 视图切换(今天/进行中/收件箱/已完成)、乐观锁冲突提示与自动刷新 - [ ] **未编译**:本机没有 JDK、没有 Gradle(`java -version` / `gradle -v` 均不存在), Android SDK 平台目录存在但缺构建工具链。代码按 Compose 常规写法写,**尚未经过编译器验证**。 补 JDK 17 + Gradle 后第一件事就是 `./gradlew assembleDebug`,那时的报错才算数。 - [ ] 下拉刷新、只读缓存(Room)未做 ### 1.6 通知与提醒(0.8d) - [ ] 站内通知表与未读角标 —— **主动推迟到 M2**:通知的绝大多数触发源是工单(被指派、被催、验收), M1 只有自己的任务,做出来也没人收到东西。表随 M2 的工单一起建,避免建两次。 - [ ] 每日摘要邮件(同上推迟) ### 1.7 测试与验收(0.7d) - [x] `backend/tests/test_items.py` 13 个用例:默认收件箱、状态机 happy path 与非法流转、版本冲突、 跨租户 3003 且他人列表为空、搜索+标签、`today` 时区边界、软删、评论与事件流、游标翻页、 批量改状态的部分成功、批量非法流转、看板分列 - [x] 全量 `pytest -q` → **46 passed**;`ruff check` + `ruff format --check` 全 0 - [x] **Playwright 真浏览器 e2e**(`web/e2e/smoke.mjs`,7 项断言全绿):登录 → 建工作区 → 快速录入解析 `#标签`/`!优先级` → 详情抽屉保存并回写列表 → 看板拖拽改状态 → 非法拖拽被拦 → 控制台无报错 - [x] 性能:1000 条任务下 P50 —— 今天 43.1ms / 进行中 42.4ms / 收件箱 42.9ms / 全部 38.4ms / 搜索 45.0ms / 看板 55.4ms(含本机网络与鉴权中间件,是**下限**) ### M1 完成定义(DOD) - [ ] 连续 3 天所有待办都记在里面,没有回到微信/备忘录 —— 只有真实使用能证伪,代码侧无法替代 - [ ] 冷启动到"建完第一个任务"< 90 秒(新账号实测) - [x] 1000 条任务下四视图首屏 < 300ms —— 实测 P50 38–55ms,最慢的看板 55.4ms - [x] 跨租户测试、越权测试全绿(`test_tenant_isolation.py` + `test_items.py` 的跨租户与批量用例) --- ## M2 · 工单协同(第 5–6 周,10 人日) ### 2.1 数据(1d) - [x] `item.kind='ticket'` 落地;迁移 `0003_tickets.py` **重写 item 的状态约束**为任务∪工单并集 (旧约束只允许任务六态,工单的 `open/accepted/...` 会被直接拦下) - [x] `ticket_ext`(`item_id` 既是主键又是外键,天然 1:1):需求人/办理人/原任务指针/受理·提交·验收时间/退回次数/验收结论 - [x] `item_link`(父子、相关,禁止自环)与 `notification`(只发给当事人) - [x] 三张表全部 `ENABLE ROW LEVEL SECURITY` + 同租户策略 + GRANT 给应用角色 ### 2.2 领域(1.5d) - [x] 工单状态机 `open→accepted→in_progress→pending_verify→done`(可 `canceled`),默认拒绝 - [x] 动作→角色规则:受理/开工/提交限办理人;**验收限需求人,办理人不能自验**(`6004`);改派/取消限需求人或办理人 - [x] 驳回回 `in_progress` 而不是 `open`,`reject_count` 累加 - [x] 升级不搬数据:原任务变 `blocked`,验收通过后自动回流 `todo` ### 2.3 API(1.5d) - [x] `POST /items/{id}/promote`、`/ticket/{accept,start,submit,verify,reassign,cancel}` - [x] `GET /tickets?role=assigned|requested|all&status=` - [x] `GET|POST /items/{id}/links`、`DELETE /items/{id}/links/{link_id}` - [x] `GET /notifications`、`/unread-count`、`/read`、`/read-all` - [x] 抽出 `app/serializers.py`:任务与工单共用一份序列化,**根除"工单响应缺 tags 导致前端白屏"** ### 2.4 Web(3d) - [x] 工单页 `/w/:wsId/tickets`:派给我的 / 我发起的 / 全部,状态过滤,列表内联快操作 - [x] 详情抽屉:按角色显示受理、开工、提交、验收、驳回、改派、取消;工单态顶部显示需求人/办理人/退回次数 - [x] 任务详情底部"升级成工单"面板(从成员里选人,成功后提示"这条任务现在处于被阻塞") - [x] 导航加未读角标(30 秒对齐一次,操作后立刻刷新) - [x] 登录支持 `?next=` 回跳,邀请链接不再"登录完掉回首页" ### 2.5 安卓(1.5d) - [ ] 工单列表与动作 —— 只做到任务列表;本机无 JDK/Gradle,仍是**未编译**状态 ### 2.6 测试与验收(0.7d) - [x] `backend/tests/test_tickets.py` 11 个用例:升级建单+原任务阻塞+通知、全流程验收回流、驳回、 自验 403、非法流转、重复升级、非成员指派、角色作用域、跨租户、关系挂载、通知已读、**响应必带 tags** - [x] 全量 `pytest -q` → **57 passed**;`ruff check` / `format --check` 全 0 - [x] `web/e2e/tickets.mjs` 双浏览器上下文双用户 e2e,8 项断言全绿: 升级 → B 受理/开工/提交 → A 验收通过 → 原任务回流"待办" → 两端控制台零报错 - [x] `web/e2e/smoke.mjs` 回归复跑(抽屉多了"选人"下拉后修正选择器)7 项全绿 ### 2.7 通知真实投递 - [ ] 站内通知已有;微信/飞书推送推迟到 M6(M2 只要"在系统里能看到该我办了") ### M2 完成定义(DOD) - [x] 两个人能跑完一次真实协助:升级 → 受理 → 办理 → 提交 → 验收 → 原任务自动解锁 - [x] 办理人无法自验、跨租户看不到工单、通知只给当事人 - [ ] 真实用一周:所有"需要别人帮忙"的事都走工单,不再私聊催(需要真实团队) --- ## 关键验收用例(M0–M2 必须通过) | # | 用例 | 期望 | 现状 | |---|---|---|---| | 1 | 租户 A 用户请求租户 B 的 workspace id | 3003 未加入(不泄露存在性) | ✅ 已覆盖 | | 2 | 同一手机号登录两次 | 同一个 user_id,不重复建号 | ✅ 已覆盖 | | 3 | 被移出团队的成员拿旧 token 请求 | 3003;refresh 也失效 | [~] access 已拒,refresh 不可撤销(见 0.6) | | 4 | 并发两次提交同一任务的不同状态 | 一次成功,一次 409 并可重试 | ✅ 已覆盖(版本冲突用例) | | 5 | 网络中断后重新提交 | 幂等键保证不产生两条 | ✅ 已覆盖 | | 6 | 删除任务后全局搜索 | 搜不到,且回收站可恢复 | [ ] M3 | | 7 | 停掉 Redis | `/health` 变 503,业务接口给明确错误而非 500 | [ ] dev 用内存实现,未做 | | 8 | 部署后 `/version` | commit 与仓库 HEAD 一致 | [~] 机制已有,等部署验证 | | 9 | 办理人验收自己的工单 | 403 `6004`,不能自己给自己过 | ✅ 已覆盖 | | 10 | 工单验收通过后原任务 | 自动从"被阻塞"回到"待办",事件流留痕 | ✅ 已覆盖(含 e2e) | --- ## 下一步排期建议 ``` 已完成 0.1–0.9 骨架与认证 + 1.1–1.7 任务域 + 2.1–2.6 工单协同(含双人 e2e) 待补 0.11 部署 / 0.12 契约 / 0.14 微信 spike / CI 在 Gitea 实跑 / 安卓编译链 第7–8周 M3 知识:knowledge_doc + chunk、切片与索引、工单关闭生成草稿、带引用问答 第9–10周 M4 AI:结构化录入、人物工作侧写、主动提醒、20 条黄金用例回归 ```