Files
DadaoTask/2.Design/ROADMAP-M0-M1.md
T
Steven b40ea77eec
CI / backend (push) Canceled after 0s
docs(m0): 用实测结果重写 M0 清单 + 补 PR 模板
- 0.1–0.9 全部标为完成,但不是"代码写完"就算完成:
  33 条测试全绿、ruff/format 通过、真机 uvicorn 冒烟(/health /version 登录 建工作区 列表 /auth/me)通过
  之后才敢打勾;没跑过的(downgrade、mypy、CI 在 Gitea 实跑、Web/Android 联调)保持未勾并写明原因。
- 记录 5 处"计划与实际不符"的偏差及理由:
  JWT 30min/14d、refresh 无状态不可撤销、权限用 ROLE_RANK 而非 PERMISSIONS 字典、
  app_user 故意不加 RLS(登录发生在租户绑定之前)、minio 与 pgvector 推迟到 M3。
- 新增「环境现状与阻塞」一节:Docker 起不来用便携 PG、外网 TLS 被拦走 HTTP 镜像、
  pip 必须 --no-cache-dir(否则写缓存卡死)、pg_ctl restricted token 报错、fnsvr-01 只有 8418/3306。
- 验收用例表加"现状"列,区分已验证 / 部分 / 未做。

顺带修:
- alembic downgrade 之前直接失败:workspace 的 RLS 策略引用了 member,
  不先 DROP POLICY 就 drop table,报 "cannot drop table member because other objects depend on it"。
  现已按策略先删后删表,downgrade base → upgrade head 全通。
- npm 依赖必须 --ignore-scripts 才能装(沙箱禁止 lifecycle 脚本派生进程),
  已写进 DEVELOPMENT.md;web 首次构建成功(tsc -b + vite,177KB/gzip 58KB)。

验收:pytest 33 passed / ruff 干净 / alembic 升降级往返通过 / web build 通过。
2026-10-28 03:28:06 +08:00

212 lines
13 KiB
Markdown
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.
# M0–M1 实施清单
> 来源:`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);5432/5433/22 全关 | 该机没有可用的 PostgreSQL 对外端口;SSH 也不通 |
| Android | 本机无 Android SDK、无 gradle-wrapper.jar | 交给 Android Studio 打开 `android/` 自动补全 |
便携 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 天不别扭。
### 1.1 数据(1d)
- [ ] `item` `item_assignee` `tag` `item_tag` `comment` `activity` 迁移
- [ ] 索引:`(ws_id, status, due_at)`、`(ws_id, owner_id, status)`、`deleted_at` 部分索引
- [ ] 全文索引:`zhparser` 或 `pg_bigm` 建在 `title + body_md`
### 1.2 领域(1.5d)
- [ ] 状态机 `inbox→todo→doing→done`(+ blocked/canceled),非法流转拒绝并给提示
- [ ] 所有变更写 `activity`(含 `changed_fields` 增量)
- [ ] `version` 乐观锁,冲突返回 409 带当前版本
### 1.3 API(1.5d)
- [ ] 增删改查、批量状态变更、排序(手动 sort_order)
- [ ] 视图查询:inbox / today / week / all,支持标签与关键词过滤
- [ ] 搜索接口(标题 + 正文,返回高亮片段)
### 1.4 Web(3d)
- [ ] 四视图 + 看板拖拽(列 = 状态)
- [ ] 详情侧栏:优先级/截止/标签/备注/子任务(一层)/评论
- [ ] 全局快速录入(`Ctrl+K` 或顶部输入框,支持 `#标签` `!优先级` `@人` 语法)
- [ ] 键盘流:`j/k` 上下、`Enter` 完成、`e` 编辑
### 1.5 安卓(1.5d)
- [ ] 列表 + 详情 + 勾选完成
- [ ] 下拉刷新、只读缓存(Room,仅离线展示,改动必须联网)
### 1.6 通知与提醒(0.8d)
- [ ] 站内通知表与未读角标
- [ ] 每日摘要邮件(可关)
### 1.7 测试与验收(0.7d)
- [ ] 每个接口一条 happy path + 一条越权用例
- [ ] 前端关键交互 e2e(Playwright 起 3 条:登录、建任务、拖动状态)
### M1 完成定义(DOD)
- [ ] 连续 3 天所有待办都记在里面,没有回到微信/备忘录
- [ ] 冷启动到"建完第一个任务"< 90 秒(新账号实测)
- [ ] 1000 条任务下四视图首屏 < 300ms
- [ ] 跨租户测试、越权测试全绿
---
## 关键验收用例(M0–M1 必须通过)
| # | 用例 | 期望 | 现状 |
|---|---|---|---|
| 1 | 租户 A 用户请求租户 B 的 workspace id | 3003 未加入(不泄露存在性) | ✅ 已覆盖 |
| 2 | 同一手机号登录两次 | 同一个 user_id,不重复建号 | ✅ 已覆盖 |
| 3 | 被移出团队的成员拿旧 token 请求 | 3003;refresh 也失效 | [~] access 已拒,refresh 不可撤销(见 0.6) |
| 4 | 并发两次提交同一任务的不同状态 | 一次成功,一次 409 并可重试 | [ ] M1 |
| 5 | 网络中断后重新提交 | 幂等键保证不产生两条 | ✅ 已覆盖 |
| 6 | 删除任务后全局搜索 | 搜不到,且回收站可恢复 | [ ] M3 |
| 7 | 停掉 Redis | `/health` 变 503,业务接口给明确错误而非 500 | [ ] dev 用内存实现,未做 |
| 8 | 部署后 `/version` | commit 与仓库 HEAD 一致 | [~] 机制已有,等部署验证 |
---
## 下一步排期建议
```
已完成 0.1–0.9(骨架、认证、租户隔离、接口规范)+ 0.13 两端壳
待补 PR 模板 / CI 在 Gitea 实跑 / 0.14 微信 spike / Web 构建验证
第3周 0.11 部署 + 0.12 契约 + 1.1–1.3 任务域后端
第4周 1.4 Web 四视图 + 自己开始 dogfooding
```