# 实施清单(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) - [x] 手机号 + **密码**登录(可选,第二把钥匙:设过密码就能用密码进,没设过的一律回 `2013`;scrypt 哈希,15 分钟连错 10 次 → `2016`) - [ ] 邮箱 + 密码(**未做**,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) - [x] 站内通知表与未读角标 —— 随 M2 一起落地,七类触发(指派、改状态、评论、工单全流程); 未读角标 30 秒轮询,标已读立刻刷新。原计划"推迟到 M2",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] `POST /tickets` 手工开单:不经过任务直接指定人,初始 `open`,与升级单共用同一条 item - [x] 指派前校验"在册成员"(`_ensure_member`),否则任务会挂在一个没人看得到的名字上 - [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] 通知页 `/w/:wsId/notifications`:未读 / 全部两档,点开即标已读并跳到目标;深链 `?open=` 落地即展开那一条 - [x] 工单页"新建"面板:手工开单(选人、优先级、说明),成功切到"我发起的"并展开 - [x] 登录支持 `?next=` 回跳,邀请链接不再"登录完掉回首页" ### 2.5 安卓(1.5d) - [x] 工单列表与动作 —— 仅按"我是需求人 / 办理人 / 管理员"显隐动作按钮,不再人人可见 - [x] 通知页与手工开单:五个 tab、未读数 30 秒刷新、通知点开跳列表并清标记 - [ ] 本次未编译验证:本机 JDK 已升级到 25,Gradle 8.7 / AGP 8.7.3 不兼容,需装回 JDK 17 再 `assembleDebug` ### 2.6 测试与验收(0.7d) - [x] `backend/tests/test_tickets.py` 11 个用例:升级建单+原任务阻塞+通知、全流程验收回流、驳回、 自验 403、非法流转、重复升级、非成员指派、角色作用域、跨租户、关系挂载、通知已读、**响应必带 tags** - [x] `backend/tests/test_notifications.py` 6 个用例:改状态只通知对方(自己不动自己)、指派必须是成员、 手工单全流程(开出→被指派人收到→受理/提交→验收,且没有来源任务)、越界指派 400/1001、 评论通知对方、未读数与全部已读 - [x] 全量 `pytest -q` → **85 passed**;`ruff check` / `format --check` 全 0 - [x] `web/e2e/tickets.mjs` 双浏览器上下文双用户 e2e,8 项断言全绿: 升级 → B 受理/开工/提交 → A 验收通过 → 原任务回流"待办" → 两端控制台零报错 - [x] `web/e2e/notifications.mjs` 12 项断言全绿:A 建工作区邀 B → B 自动加入 → A 手工开单 → 这张单没有来源任务 → B 收到"派给你的工单" → 通知列表有条目 → 点通知跳到工单并展开 → 未读数归零 → 两端控制台零报错 - [x] `web/e2e/smoke.mjs` 回归复跑(抽屉多了"选人"下拉后修正选择器)7 项全绿 ### 2.7 通知真实投递 - [ ] 站内通知已有;微信/飞书推送推迟到 M6(M2 只要"在系统里能看到该我办了") ### M2 完成定义(DOD) - [x] 两个人能跑完一次真实协助:升级 → 受理 → 办理 → 提交 → 验收 → 原任务自动解锁 - [x] 办理人无法自验、跨租户看不到工单、通知只给当事人 - [ ] 真实用一周:所有"需要别人帮忙"的事都走工单,不再私聊催(需要真实团队) --- ## M3 · 知识库(第 7–8 周,10 人日) | # | 任务 | 人日 | 状态 | |---|---|---|---| | 3.1 | `knowledge_doc`(1:1 于 item,`source_kind` + `source_item_id` 必填约束)+ 迁移 0004 + RLS | 1.0 | [x] 已跑通,隔离测试覆盖 | | 3.2 | 状态机 `draft / in_review / published / archived`,发布要 owner/admin | 0.5 | [x] 5 个动作,非法动作回 `allowed_from` + `allowed_actions` | | 3.3 | 结构化编辑:标题 / 正文 / 摘要 / 类型,乐观锁,已发布禁改 | 1.0 | [x] 含 `4009` 冲突用例 | | 3.4 | **闭环沉淀**:工单验收通过自动生成草稿(`generated_by=rule`),同一来源只生成一次 | 1.5 | [x] 只转述不推断,正文带"请先核对再发布" | | 3.5 | 手动沉淀:任务/工单详情里"沉淀成知识" | 0.5 | [x] 有 e2e | | 3.6 | 手记:没有来源的知识(唯一允许 `source_kind=manual` 的入口) | 0.5 | [x] 手记页 + 列表 | | 3.7 | 检索:标题/正文/摘要子串 + 类型/状态筛选 + 游标分页 | 1.0 | [x] trigram 索引在 0002 已建 | | 3.8 | 切片与向量索引(`chunk` 表、pgvector HNSW) | 2.0 | [x] **用 `real[]` + SQL 余弦先跑通**(见下方说明) | | 3.8.1 | 切片:按标题归段、代码块整块保留、超长按句切、相邻片重叠 80 字 | 0.5 | [x] `app/domain/chunking.py` | | 3.8.2 | 向量:`hash`(字符二元组)与 `openai` 两种提供方,换实现不动业务 | 0.5 | [x] `app/services/embedding.py` | | 3.8.3 | 混合检索:向量 0.6 + trigram 0.4,低于阈值不返回,只认 `published` | 1.0 | [x] 5 个用例 + `e2e/retrieval.mjs` 6 项 | | 3.8.4 | 索引生命周期:发布自动建、改正文需 `reindex`、换模型整体重建 | 0.5 | [x] `POST /knowledge/{id}/reindex` | | 3.9 | 安卓知识列表 | 1.0 | [x] 只读列表 + 详情动作(编辑正文走安卓编辑器) | | 3.10 | 文档与 e2e | 1.0 | [x] `knowledge.mjs` 7 项 + `retrieval.mjs` 6 项 | | 3.11 | 知识分类:工作区三级小树(`knowledge_category` + 迁移 0007 + RLS),知识可归一类,列表按分类子树 / 未分类 / 标签筛选 | 1.0 | [x] 增删改移 + 重名 `7008`、超三级 `7007`、删分类要 `force`;列表按树的**先序**返回(父分类后紧跟子分类),侧栏照 `depth` 缩进就是一棵树 | | 3.12 | 备忘录形态:新建知识一次写全(标题/类型/分类/标签/正文),不再是"填个标题就完成";抽屉加宽 + 可整屏 | 1.0 | [x] Web `KnowledgeComposer` + 安卓新建后直进编辑器;正文就是详情同款富文本编辑器(Vditor / Compose 编辑器);`Sheet` 增 `size="md/lg/xl"` 与展开按钮;截图 `2.Design/screens/knowledge-composer.png`、`knowledge-categories.png` | | 3.13 | 知识能挂附件:新建面板里直接上传(懒建草稿拿到条目 id 再传),详情里能看、能下载/打开 | 1.0 | [x] Web `AttachmentPanel` + `RichTextEditor.ensureItemId`(贴图/传附件前自动存草稿);安卓详情页附件列表 + `OpenDocument` 上传 + 浏览器打开;附件仍落在 `item` 上(`ALLOWED_KINDS` 含 knowledge) | | 3.14 | 点开一条知识默认是「看」:Markdown 渲染成正文样式;要改再点「编辑」进编辑器,取消即丢弃未保存改动 | 1.0 | [x] `KnowledgeDrawer` 分 `editing` 两态(只读用 `Markdown` + `prose-dadao`,编辑才挂 `RichTextEditor`);**已发布也能编辑**(PATCH 后自动重建检索切片),归档的要先「恢复成草稿」;e2e 断言默认 `.vditor-ir` 为 0 且已发布也有「编辑」;截图 `2.Design/screens/knowledge-read-view.png` | | 3.15 | 知识列表 tab 顺序与编辑区高度:「全部」排第一并默认选中,正文编辑器占满剩余屏高 | 1.0 | [x] `TABS` 改 `all/draft/published/archived`,`tab` 默认 `"all"`;`useEditorHeight(reserve)` 按窗口高度算正文区(新建 420、编辑 340 留白,最低 360、最高 960),新建与编辑两处共用;截图 `2.Design/screens/knowledge-tab-all.png`、`knowledge-edit-tall.png` | ### M3 完成定义(DOD) - [x] 一件事办完 → 自动出现一份草稿 → 核对 → 发布,全程不用手动复制粘贴 - [x] 每条知识都能追到来源(除手记),点开能看到来源事项 - [x] 非管理员发布被拒;跨租户看不到别人的知识 - [x] 知识是备忘录:标题、正文、标签、三级分类、搜索;新建时就能把这些一次填完(Web 与安卓一致) - [x] 点开知识默认看到的是排好版的正文,不是编辑器的原文;要改才点「编辑」 - [x] 切片与语义检索跑通(`real[]` + SQL 余弦;pgvector 仍待独立实例) - [ ] 真实用两周:新同事靠知识库自己解决过一次问题(需要真实团队) **关于 3.8 的说明(2026-10-29 已落地,但与计划不同)**:计划里写的是 pgvector + HNSW, 实际本机 Docker 起不来、fnsvr-01 上也没有 pgvector 扩展且用户明确要求别动那台机器, 所以先用 `chunk` 表(`embedding real[]`)+ SQL 函数 `app_cosine()` 把整条链路跑通: 切片 → 向量(内置 hash 提供方)→ 混合打分(向量 0.6 + trigram 0.4)→ 阈值过滤 → 带来源返回。 `deploy/docker-compose.vector.yml` 与 `VECTOR_DATABASE_URL` 保留,等向量实例真起来之后再切过去, 换 pgvector 只改列类型与索引,应用层接口不变。 两条必须记住的约束: 1. **内置 hash 向量不是语义模型**,只是"零依赖、可复现"的基线;上线前换 `EMBEDDING_PROVIDER=openai` 并整体重建(`reindex_workspace`),否则新旧向量混表、分数不可比。 2. **检索只认 `published`**,草稿永远搜不到——这条写在 SQL 的 WHERE 里,不靠调用方自觉。 --- ## 关键验收用例(M0–M3 必须通过) | # | 用例 | 期望 | 现状 | |---|---|---|---| | 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) | | 11 | 工单验收通过后知识库 | 自动出现一份草稿,且只生成一次 | ✅ 已覆盖 | | 12 | 成员发布知识 | 403 `7004`;owner 发布成功且记录发布时间 | ✅ 已覆盖 | | 13 | 知识有没有来源 | 非手记必须有 `source_item_id`,否则 7005 / 库级约束拒绝 | ✅ 已覆盖 | --- ## 下一步排期建议 ``` 已完成 0.1–0.9 骨架与认证 + 1.1–1.7 任务域 + 2.1–2.6 工单协同 + 3.1–3.7、3.10 知识库 + 4.1–4.6 界面重做(Web 与安卓)+ 安卓编译链 待补 0.11 部署 / 0.12 契约 / 0.14 微信 spike / CI 在 Gitea 实跑 / 3.8 向量索引(等 Docker 或独立实例) 第9–10周 M4 AI:结构化录入、人物工作侧写、主动提醒、20 条黄金用例回归 ``` --- ## 4. 界面重做(Web + 安卓,2 人日,2026-10-28) 用户原话:"现在不管是 web 还是 app 的 UI 样式太丑了,非常的简陋,不符合当下的设计美学。" 重做不是换配色,而是**先立设计系统、再让页面去用它**,否则下次还是东拼西凑。 ### 4.1 设计系统(两端共用一套 token) - [x] 色板:冷白 `paper`、靛蓝 `primary`(`#4F46E5`)、陶土橙 `clay`,语义色 sage/wheat/rust/sky(Web `tailwind.config.js`,安卓 `ui/Theme.kt` 同名同值)(2026-11-01 主色由墨绿改蓝系;2026-11-02 再由沉靛蓝改为高饱和靛蓝 `#4F46E5`,中性色转 Slate 轴,语义色不变) - [x] 字号阶:11/12/13/14/15/18/22/26/36,正文 14、辅助 11/12;细描边 + 极轻投影 + 14dp 圆角 - [x] 组件:卡片 / 按钮(primary、ghost、subtle、clay、danger)/ 状态 pill / 输入 / 空态 / 骨架屏 / 抽屉 - [x] 图标:Web 用自绘线性图标集(`components/icons.tsx`),安卓用 Material Icons + 少量自绘,线宽一致 ### 4.2 Web - [x] `AppShell`:左侧固定侧栏(工作区切换、五项导航、快捷键提示、用户),移动端转顶部横条 + 横向导航 - [x] 六个页面全部重做:登录(品牌面板 + 表单)、首页(问候 + 工作区卡片)、任务(视图 tab + 快速录入 + 圆勾选 + 骨架屏)、看板(四列 + 拖拽高亮 + 空列提示)、工单、知识、成员、邀请落地 - [x] 详情抽屉:`Sheet` 组件承载,工单态显示需求人/办理人/退回次数/验收结论 + 动作按钮 - [x] 验证:`tsc -b` 0 错、`vite build` 45 modules(`241.82 kB │ gzip 76.70 kB`)、三套 e2e(7 + 8 + 7 项)全绿 - [ ] 深色模式:未做(token 已留出语义位,等有用户提需求) - [ ] 无障碍:只做了焦点环与 `aria-label`,未做完整键盘可达性与对比度审计 ### 4.3 安卓 - [x] `WorkspaceScaffold`:顶部工作区 + 未读角标,底部四 tab(看板不占 tab,屏幕放不下) - [x] 任务(列表/看板切换、快速录入、勾选、详情底部抽屉)、工单(角色 chip + 按状态动作 + 验收对话框)、知识(四 tab + 记一条 + 详情动作)、成员(邀请链接分享) - [x] 任务详情抽屉里可**升级成工单**、**沉淀成知识**(此前安卓缺这两个入口,是功能欠账) - [x] 编译链固化:`android/scripts/emulator.ps1` 一键构建 + 装机 + 起模拟器;`assembleDebug` → APK 10.1 MB - [x] 模拟器(API 35)实跑:登录 → 工作区 → 任务勾选 → 工单 → 知识 → 成员,截图存 `2.Design/screens/android-*.png` - [~] 看板不做拖拽:手机屏幕上一列已占 76% 宽,拖拽命中率太差;改为点卡片选目标列,功能等价 - [ ] 离线缓存(Room)、图片附件、消息推送:均未做,M5 再议 --- ## 5. 富文本与附件(M2.5) ### 5.1 决策:正文存 Markdown,编辑用成熟组件 Vditor - [x] 任务备注、工单说明与验收结论、知识正文、评论,统一 `*_md` 字段存 Markdown - [x] 渲染用 `markdown-it` + `dompurify`(先渲染再消毒),链接协议白名单 `http(s)/mailto//` - [x] **编辑器不自研**(最初那版 textarea + 正则工具栏已删除):改用 [Vditor](https://github.com/Vanessa219/vditor) 4.0.1(MIT,中文优先,`ir` 模式所见即所得),落盘仍是 Markdown - [x] 选型依据:Vditor 11.4k★ / 依赖仅 `diff-match-patch` / 中文界面与文档齐全;TipTap 是 headless 且存 JSON 要另做序列化;`@toast-ui/react-editor` 停更在 2023 且 peer 依赖锁 React 17 - [x] 运行时资源自托管(`web/scripts/vendor-vditor.mjs` → `public/vditor`,由 `predev`/`prebuild` 自动生成),**不走 unpkg CDN**——内网/离线环境下编辑器会一直卡在加载 - [x] 表格:Vditor 里能插能编;只读渲染(评论、动态、预览)走 `markdown-it` 的 GFM 表,`display:block + overflow-x:auto` 让宽表在抽屉里横向滚动、不撑破布局(e2e 断言表格宽度 ≤ 900px,实测 561px) - [ ] HTML 片段:仍按文本显示(不开放 raw HTML 是有意的,避免把消毒责任交给调用方) ### 5.2 附件 - [x] 后端:`attachment` 表 + RLS + 迁移 0005;`POST/GET/DELETE` 与 `download`/`raw` 两个读取口 - [x] 本地磁盘存储(`backend/app/services/storage.py`),key 由内容 sha256 决定;换对象存储只改这一个文件 - [x] 上限 `MAX_UPLOAD_MB=20`,声明大小与流式读取双重拦截,超限 `8002` - [x] 列表带 `attachment_count`,详情带 `attachments`,避免一屏 N 次请求 - [x] 下载走带 `Authorization` 的 fetch 转 blob;`` 直链走**短期签名** `raw?exp=&sig=`(HMAC 密钥由 `JWT_SECRET` 域分离派生,默认 30 分钟,跨工作区拒绝) - [x] 图片直链改成签名 URL 后:正文里只存不带凭据的相对路径 `/raw`,凭据每次渲染现签,不再把会过期的令牌写进库 - [ ] 缩略图、图片压缩、病毒扫描:未做 ### 5.3 编辑器 - [x] `RichTextEditor` = Vditor 封装:`mode: "ir"`、工具栏(标题/粗体/斜体/删除线/链接/列表/任务列表/引用/代码/行内代码/图片/表格/撤销/重做/全屏)、关掉 `cache`(草稿由我们自己的保存按钮负责) - [x] 粘贴与拖拽上传图片:`upload.handler` 里逐张落到附件接口,再 `insertMD` 插入 `![](attachmentUrl(...))`。**Vditor 不会替我们插入 handler 的返回值**(它只把字符串当提示语 `tip.show`),这点踩过坑 - [x] 令牌只在浏览器内存里存在:落库前 `stripAttachmentTokens`,渲染/装载前补回;`` 带不了 Authorization 头,所以 `raw?token=` 是必要折中 - [x] `AttachmentPanel`:上传/下载/删除(删除二次确认,对应后端 `?confirm=true`) - [x] 安卓:`RichTextEditorScreen`(Kotlin 手写 Markdown 工具栏:B/I/标题/列表/引用/代码/链接/图片,选区包裹与行首前缀;预览用 **Markwon** 渲染,图片用 **Coil** 加载签名直链),从任务详情与知识点打开,保存按 `body_md` 回写 - [ ] 安卓编辑器保存后回到任务列表而不是原详情抽屉(体验缺口:用户要重新点开那条任务) ### 5.4 验证 - [x] 后端 `pytest` → **74 passed**(附件 8 个用例:上传下载、超限、跨租户、删除确认与权限、raw 签名校验、签名篡改与过期、跨工作区签名) - [x] `web/e2e/richtext.mjs` → **15 项全绿**(Vditor 与 lute 就绪、`**粗体**`/`` `代码` ``/`- 列表` 就地渲染、落库正文不含凭据、贴图后 `/raw` 带签名且返回 200、附件重开仍在、表格渲染且不撑破抽屉、控制台无报错) - [x] 既有三套 e2e 回归通过(smoke 7 / tickets 8 / knowledge 7) - [x] `tsc -b` 0 错;`vite build` 主包 299.42 kB │ gzip 74.96 kB,Vditor 独立 chunk 385.24 kB │ gzip 136.05 kB(按需加载,只进详情抽屉) - [x] 安卓 `assembleDebug` 成功(APK 10.1 MB),模拟器上走通「详情 → 编辑正文 → 预览 → 保存」,截图 `2.Design/screens/android-editor{,-preview}.png` ## 6. 安卓分发:应用内升级(OTA,2026-10-31) 要解决的是"改完安卓端怎么送到手机上":不发商店,也不能每次让人手动传 APK。 ### 6.1 后端 - [x] `backend/app/api/app_release.py`:`GET /app/release/latest?version_code=` 读 `latest.json` 清单,`GET /app/release/download/{file}` 只认清单里登记的文件名(含 `..%2F` 一律 9001) - [x] 两个接口都免登录:还没装上新包的客户端未必带着有效令牌,检查更新不该被登录挡住 - [x] `download_url` 是相对路径,客户端用自己配置的 API 地址拼接——同一份清单要在内网/公网/模拟器之间共用 - [x] `has_update` 只比对 `version_code`,不看版本名字符串(`0.10.0` 与 `0.9.0` 的字典序是坑) - [x] `backend/scripts/publish_apk.py`:复制成 `dadaotask-<版本>.apk` 并写 `latest.json`(sha256/size/published_at);`backend/static/apk/` 已在 `.gitignore` - [x] `backend/tests/test_app_release.py` 5 个用例:未发布 9001、清单字段与 download_url、`has_update` 判据、下载字节与 content-type、路径穿越 ### 6.2 安卓 - [x] `data/AppUpdater.kt`:查清单 → 下到 `Android/data//files/updates/`(先写 `.part` 再改名)→ 校验 sha256 → 经 `FileProvider` 交给系统安装器 - [x] `ui/UpdateDialog.kt`:当前/新版本号、更新说明、下载进度、失败重试;「允许安装未知应用」只跳系统设置,不代点 - [x] 权限回来那一跳要接住:用户在系统设置里开完开关回到 App,再点同一个按钮时先复查权限,开了就直接装——否则按钮点了没反应 - [x] 启动时自动查一次;首页底部有「检查更新」手动入口,结果用一句话回给用户 - [x] 图标换成与 Web 的 `` 同一套图形(靛蓝圆角底 + 三条白色圆头线 + 淡紫点),生成 `mipmap-*/ic_launcher{,_round}.png` 与自适应图标 `mipmap-anydpi-v26/ic_launcher{,_round}.xml`,512px 预览存 `2.Design/screens/android-icon-512.png` - [x] 版本 `versionCode = 2` / `versionName = "0.2.0"`;发布签名从 `android/keystore.properties` 读,无该文件时回落调试签名(保证别人 clone 下来也能 `assembleRelease`) ### 6.3 构建环境(本机踩过的坑) - Gradle 8.7 被 AGP 8.7.3 拒绝(`Minimum supported Gradle version is 8.9`),用 **Gradle 8.9**(腾讯镜像下载) - 系统 `~/.gradle` 的 native 库坏了(`Failed to load native library 'native-platform.dll'`),必须 `GRADLE_USER_HOME` 指到一个干净目录 - JDK 25 编译不过,用 **Temurin JDK 17**;Kotlin 编译还要把 `java.io.tmpdir` 指到工程内可写目录,否则报 `AccessDeniedException: kotlin-compiler-*.alive` - 完整命令见 `2.Design/DEPLOY.md` 第 5.1 节 ### 6.4 验证 - [x] 后端 `pytest` → **103 passed**;`ruff check` / `ruff format --check` 全绿 - [x] `assembleRelease` 成功:`app-release.apk` 7.4 MB,`apksigner verify --print-certs` 显示证书 `CN=Dadao Task` - [x] 本地 `GET /api/v1/app/release/latest?version_code=1` → `has_update=true`、`download_url` 可下(200,7 762 912 字节),`version_code=2` → `false` - [x] 生产(`http://192.168.8.188:8200`)同上,安装包已随部署上传 - [x] 模拟器上走通全流程:装 debug 包 → 登录 → 启动即弹「有新版」→ 下载 7 762 912 字节(与清单 `size` 一致,`.part` 已改名)→ 无安装权限时提示去开 → 拉系统安装器「是否更新此应用」→ 装完 `versionCode=2 / versionName=0.2.0`;截图 `2.Design/screens/android-update-{dialog,permission,installer}.png` 与 `android-home-check-update.png` --- ## 7. 账号与外壳统一(Web,2026-10-31) ### 7.1 两个问题(用户提的) 1. 首页是"居中窄栏",点进工作区却变成全屏——同一套产品两种壳,进出像换了个网站。 2. 用户相关功能缺失:没有基本信息、没有改密码、没有一个统一的"退出登录"入口。 ### 7.2 决定 - **外壳统一到全宽 + 左侧栏**(不是把工作区收回窄栏):工作区里本来就要放下看板四列、知识分类树, 收窄是倒退。首页跟着做同样的壳,宽度就不再跳。 - **加密码登录,作为验证码之外的第二条路**。理由:验证码依赖短信通道,通道挂了人就进不来; 密码是自助的第二把钥匙。但**不强制**:没设密码的人照旧只用验证码,少一道门槛。 - 外壳抽成 `web/src/components/SideRail.tsx`,首页与账号页共用;工作区的 `AppShell` 保持自己的侧栏(有工作区切换与页签)。 两处宽度一致(`w-[248px]` 级别的同一份样式),所以进出不跳。 ### 7.3 后端 - `app_user.password_hash`(迁移 `0008_user_password`,加列不加表) - `app/security.py`:scrypt(`n=2^14, r=8, p=1`,标准库 `hashlib`),存 `scrypt$n$r$p$salt$hash`; `password_problem()` 一处定义强度(8–64 位、字母 + 数字) - `POST /auth/login/password`、`POST /auth/password`(设/改共用,改要先验旧)、`PATCH /auth/me`(改昵称) - 用户输出统一走 `_user_out()`:**白名单字段**,`password_hash` 一个字节都不出去;对外只给 `has_password` - 错误码:`2013` 密码错/没设过、`2014` 未设密码(保留)、`2015` 太弱、`2016` 连错太多 ### 7.4 Web - 首页:左侧栏 + 全宽内容区,工作区从列表改成卡片网格(`sm:2 / xl:3 / 2xl:4` 列), "新建工作区"是网格里的一张虚线卡片,点开就地展开表单 - 新增 `/account`:基本信息(头像首字母、昵称、只读手机号、加入时间)、登录密码(设置/修改 + 确认输入)、退出登录 - 登录页:顶部「验证码 / 密码」两段切换,同一个表单两种提交 - 工作区 `AppShell` 底部用户块可点进 `/account`,旁边一个齿轮图标 ### 7.5 验证 - 后端 `pytest` → **113 passed**(新增 `tests/test_account.py` 10 项);`ruff check` / `ruff format --check` 全绿 - Web `tsc --noEmit` 通过、`vite build` 通过 - 新增 e2e `web/e2e/account.mjs`(**13 项全绿**):登录进首页 → 首页有侧栏 → 进工作区**侧栏宽度不变(差值 < 1px)** → 账号页复用同一条侧栏 → 改昵称 → 设/改密码 → 刷新后仍显示"已设置密码" → 退出 → 错密码被拒(`2013`)→ 密码登录成功 → `/auth/me` 里 `has_password=true` 且昵称是刚改的 → 验证码登录仍可用 → 控制台零报错 - 截图:`2.Design/screens/web-home-rail.png`、`web-account-settings.png`、`web-home-mobile.png` - 脚本里两个坑写进了注释:① 短信 60 秒节流会让"发码→登录"整段失败(先看 429,别怪登录); ② 改密码要先给旧密码,脚本把上一轮的密码记在 `.local/e2e-account-password.txt` 里自己维护 - **上生产时抓到一个真 bug**(`web/src/lib/client.ts`):`crypto.randomUUID()` 与 `navigator.clipboard` 只在**安全上下文**(https / localhost)里存在,内网入口是纯 http,于是所有写请求在拼 `Idempotency-Key` 时抛 TypeError——登录页只显示"登录失败",GET 一切正常,所以一直没暴露; 复制邀请链接也永远失败。改成自拼 v4(`getRandomValues` 不受限,退回 `Math.random`)+ `execCommand("copy")` 兜底。生产核对:首页 / 工作区 / 账号页侧栏都是 **264px**,差值 0。 ### 7.6 没做的 - 安卓端登录页仍只有验证码,账号页也没有——下一步对齐(密码登录 + 改昵称 + 退出) - 手机号换绑、头像上传未做(头像现在只有首字母) ## 8. Web 装到桌面(PWA,2026-10-31) ### 8.1 要解决的问题 "web版本帮我封装成PWA,这样方便安装到手机桌面和电脑桌面。"(用户原话) 现在每次用都要敲地址、开浏览器、找标签页;手机上还给不了"点了图标就进来"的感觉。 ### 8.2 决定(写进注释了,别以后翻案) - 用 `vite-plugin-pwa`(本就在 devDependencies 里,2.0.0)生成 SW 与 manifest,不自己写 - `registerType: "prompt"` + `injectRegister: null`:**有新版只提示,点「刷新」才换**。 理由:Vditor、图表是懒加载分片,`autoUpdate` 会在用户开着页面时把 chunk 换掉, 下一次点开面板就是 404(编辑器白屏) - 预缓存只放构建产物与图标(约 0.8MB);`/api/` 一律 NetworkOnly——离线宁可报错,也不端旧数据 - 图标复用 Web Logo 的 32 单位坐标生成,和页头、安卓桌面图标同一枚 ### 8.3 实现 | 位置 | 内容 | |---|---| | `web/vite.config.ts` | manifest(standalone、主题色 `#4f46e5`、三个图标)+ workbox(`navigateFallback` 回退 `index.html`,`/vditor/` 走 CacheFirst,`/api/` NetworkOnly) | | `web/index.html` | favicon 改成同款 Logo(内联 SVG)、`theme-color`、`apple-touch-icon`、`viewport-fit=cover` | | `web/src/lib/pwa.ts` | 生产环境注册 SW、`dadao:pwa-update-ready` 事件、`applyServiceWorkerUpdate()`、`isStandalone()` | | `web/src/components/UpdatePrompt.tsx` | 底部卡片「有新版了」+ 稍后 / 刷新(**居中用 flex 包一层**:`animate-slide-up` 的 keyframes 自带 transform,会和 `-translate-x-1/2` 抢属性,手机窄屏上卡片会被推出屏幕右侧) | | `web/src/components/InstallHint.tsx` | 首页底部安装引导:能装就给「安装」按钮(拦 `beforeinstallprompt`),iOS 给"分享 → 添加到主屏幕",关掉记在 localStorage | | `web/public/icons/` | `icon-192` / `icon-512`(any)、`maskable-512`、`apple-touch-icon`(180,iOS 不吃透明) | ### 8.4 验证 - `web/e2e/pwa.mjs`(**19 项全绿**):manifest 名字/standalone/三个图标 → 图标都能下载 200 → SW 已 `activated` 且作用域是整站 → 页面骨架已进预缓存 → 主题色 → 桌面尺寸控制台零报错 → 窄屏有安装引导 → iOS 文案是「分享 → 添加到主屏幕」→ 关掉后刷新不再出现 → 有新版会提示 → 点「稍后」收起 → **断网后页面骨架仍能打开** - 跑法:后端用 `WEB_DIST_DIR=/web/dist` 起一个同构站点(生产同形), 然后 `node web/e2e/pwa.mjs`;`BASE` 可换环境 - 截图:`2.Design/screens/web-pwa-install.png`、`web-pwa-update.png`;断网那张留在 `web/e2e/artifacts/pwa-offline.png` ### 8.5 还没解开的 - **生产入口还是 http**:Service Worker 只在 https / localhost 注册,所以内网 IP 上装不了、 也看不到安装提示(桌面用 `127.0.0.1` 打开可以)。要么加域名 + 证书反代(`443 → 127.0.0.1:8200`), 要么用 Cloudflare Tunnel 之类拿个 https 域名;应用代码不用改。已写进 `DEPLOY.md` 第 6 节 - 安卓端还在走自己的 OTA(原生包),和 PWA 是两条路;要不要让安卓也改成加载 Web,等产品定 --- ## 9. 登录方式与系统设置(2026-11-02) ### 9.1 需求 用户原话:"手机验证码我暂时不启动,所以把12345这种测试验证码取消了吧,我用账号密码来登陆…… 再增加一种登陆方式就是邮箱……并且这个邮箱是可以后台配置的啊;"(同时要求三个列表的标题样式统一) ### 9.2 决定 - **短信登录默认关**(`SMS_LOGIN_ENABLED=false`):dev 环境的固定码等于"知道手机号就能进任何账号"。 关掉后 `/auth/sms/send` 与 `/auth/login` 统一回 `2017`,不是静默失败;登录页按 `GET /auth/methods` 渲染,关着的路根本不显示(少一次"点进去才发现不能用")。 - **邮箱是账号名**,不是通知地址:绑了就能用「邮箱 + 密码」登录,`lower(email)` 唯一。 - **SMTP 存数据库、后台可改**:发信账号是平台级运维参数,改它不该要人登服务器改 env 再重启。 只有平台管理员(`PLATFORM_ADMIN_PHONES` 白名单)能读写,因为系统设置是跨租户的。 - 密码哈希继续用标准库 scrypt,不引 passlib/bcrypt(多一个升级债,换不来安全)。 ### 9.3 后端 | 位置 | 内容 | |---|---| | `app/config.py` | `sms_login_enabled=False`、`platform_admin_phones`(`NoDecode` + CSV 拆分,别让 pydantic 按 JSON 解析)、`smtp_*` 兜底字段;生产校验改为"开着短信登录就必须是真通道" | | `app/security.py` | `hash_password` / `verify_password` / `password_problem`(scrypt,8–64 位且字母+数字) | | `app/models.py` | `AppUser.email`;新表 `system_setting(key, value JSONB, updated_by)`,**不挂租户、无 RLS**,写权限靠 `deps.platform_admin` | | `alembic/versions/0010_email_and_settings.py` | `system_setting` 建表 + `app_user.email` + `uq_app_user_email`(`lower(email)` 部分索引)+ GRANT | | `app/services/mail.py` | 读库优先、环境变量兜底;465 走 SSL、587 走 STARTTLS;认证失败/收件人被拒/网络失败都归 `2020` 但消息不同 | | `app/api/auth.py` | `GET /auth/methods`、`POST /auth/login/password`(`phone`/`email` 二选一)、`PATCH /auth/me`(改昵称/绑解绑邮箱)、`sms` 两条加开关闸门 | | `app/api/settings.py` | `GET/PUT /settings/smtp`、`POST /settings/smtp/test`,全部 `PlatformAdminDep` | | `app/deps.py` | `platform_admin` / `PlatformAdminDep` | ### 9.4 Web - `pages/Login.tsx`:先问 `/auth/methods` 再渲染;账号档「手机号 / 邮箱」,密码框;短信关着时不出现「验证码」档 - `pages/Settings.tsx`(新):SMTP 表单(服务器/端口/账号/授权码/显示名/SSL)+ 当前状态卡(能不能发信、授权码设没设、上次修改、来源 db/env)+ 测试发信 - `pages/Account.tsx`:基本信息加「邮箱」(说明"留空保存就是解绑")、按钮改「保存资料」、平台管理员多一个「系统设置」入口卡 - `components/SideRail.tsx`:平台管理员才多一条「系统设置」(移动端顶条同理),图标 `Sliders` - `pages/Tasks.tsx:262` / `pages/Tickets.tsx:248`:列表标题统一成 `text-[15px] font-semibold leading-6`(与知识一致) ### 9.5 验证 - `backend/tests/test_login_email.py`(7 例)+ `test_settings_smtp.py`(6 例)+ 改写的 `test_config_prod.py`; 全量 `pytest` 130 passed、`ruff check` / `format --check` 全绿 - `web/e2e/login-email-smtp.mjs`(**15 项全绿**):methods 说短信关着 → 发码回 2017 → 登录页没有验证码档 → 手机号密码进首页 → 绑邮箱 → 邮箱登录(大小写不敏感)→ 解绑后回 2013 → 侧栏出现「系统设置」 → 存授权码(接口不回显)→ **真发一封测试邮件成功** → 账号页回填邮箱 → 控制台零报错 - 截图:`2.Design/screens/web-login-password.png`、`web-account-email.png`、`web-settings-smtp.png` ### 9.6 踩过的坑 - `platform_admin_phones` 是 `list[str]`,pydantic-settings 会把它当 JSON 解析,`A,B` 直接启动失败: 用 `Annotated[list[str], NoDecode]` 交给 before-validator 拆逗号 - 迁移号写成 `0009` 会撞 `0009_knowledge_category_color.py`,pytest 报 `Multiple head revisions` - e2e 里 `getByLabel("账号")` 会同时命中侧栏 `aria-label="账号设置"`,要用 `input[aria-label="账号"]`; 侧栏的「系统设置」要等 `/auth/me` 落地才渲染,断言得 `waitForFunction` 而不是立刻 `count()` ### 9.7 上线情况(2026-11-02) - 生产 `~/.config/dadaotask/env.production` 追加 `SMS_LOGIN_ENABLED=false`、 `PLATFORM_ADMIN_PHONES=13605513683`;重启后 `/auth/methods` 回 `sms_code:false`,发码接口回 `2017`。 - 生产账号 13605513683 设了密码、绑了 `shufenhf@126.com`:手机号登录、全大写邮箱登录都通。 - 生产 SMTP 从「系统设置」页存进库(`configured:true, source:"db"`),当场发测试信成功。 - **短信登录只在本机开着**(`backend/.env` 的 `SMS_LOGIN_ENABLED=true`):新账号目前只能靠"验证码登录 自动注册"造出来,关掉连 e2e 都造不出测试用户。生产是关的,所以生产**暂时没有注册入口**—— 要开放注册得先做「邮箱验证码注册 / 邀请注册」,这是下一步的事。 - 生产实拍:`2.Design/screens/prod-account-email.png`、`prod-settings-smtp.png`。 ## 10. 邮箱验证码注册(2026-11-03) ### 10.1 需求 手机上短信登录关掉后,生产没有注册入口。用户原话:"默认用邮件验证码注册吧;" ### 10.2 决定 - **先发码再注册**:`POST /auth/email/code` → `POST /auth/register`,两步而不是"注册时顺带发信"。 理由:用户填完密码才被告知"这个邮箱注册过了"最气人,所以发码前就查重(`2019`)。 - **邮箱既是账号名也是唯一联系方式**:注册必须真验一次码,"随手填一个"会让账号变成没人能找回的孤岛。 验证通过时间落 `app_user.email_verified_at`,`/auth/me` 回 `email_verified`。 - **`app_user.phone` 改为可空**(迁移 0011):邮箱注册的账号本来就没有手机号。 老账号不受影响;`user.phone` 对这类账号回空串而不是星号串(成员列表同理)。 - **防刷**:同邮箱 60 秒内只能发一次(`2022`);验证码 10 分钟有效;错 5 次即作废(`2021`)—— 6 位数可以被刮,不设上限等于把注册口子交给脚本。 - **本机联调开关** `EMAIL_CODE_DEV_ECHO`:为 true 时把码直接回给调用方且**不发真信**, 否则每跑一次 e2e 就往真实收件箱塞一封。生产打开会让 `validate_runtime()` 拒绝启动。 ### 10.3 后端 | 位置 | 内容 | |---|---| | `app/config.py` | `email_register_enabled`、`email_code_ttl_seconds=600`、`email_code_throttle_seconds=60`、`email_code_max_attempts=5`、`email_code_dev_echo`(生产校验拒绝) | | `app/errors.py` | `EMAIL_CODE_INVALID=2021`(400)、`EMAIL_CODE_TOO_FREQUENT=2022`(429) | | `app/models.py` | `AppUser.phone` 可空、`email_verified_at`;`AuthUser.phone: str \| None` | | `alembic/versions/0011_email_signup.py` | `phone DROP NOT NULL` + `email_verified_at` + GRANT(downgrade 先给空手机号补占位值) | | `app/stores.py` | `email_code_key` / `email_tries_key` / `email_throttle_key` | | `app/services/mail.py` | `send_verify_code(session, to=…, code=…, minutes=…)`,纯文本 + HTML 两版 | | `app/api/auth.py` | `POST /auth/email/code`、`POST /auth/register`、`/auth/methods` 加 `email_register` | ### 10.4 Web - `pages/Register.tsx`(新):邮箱 / 验证码(60 秒倒计时)/ 密码(前端先按同一套规则提示)/ 确认密码 / 昵称(可不填,留空用邮箱前缀); 注册成功直接写令牌进首页。服务端关了注册时整页只显示"联系管理员开通"。 - `pages/Login.tsx`:`/auth/methods` 里 `email_register` 为真才显示「还没有账号?用邮箱注册」。 - `App.tsx`:新增 `/register` 路由。 ### 10.5 验证 - `backend/tests/test_email_signup.py`(9 例):真走 SMTP(monkeypatch 记账)、dev echo 不发信、 注册即登录、码用完不能重放、错码/过期、试错上限、邮箱占用、发码节流、弱密码、注册关闭时两条接口都回 2017。 - e2e `web/e2e/register.mjs`:登录页进注册页 → 拿码(dev echo)→ 注册 → 落进首页 → 退出用「邮箱 + 密码」登回来 → 同一个邮箱再发码回 `2019`。7 项全绿。 - `pytest` 139 passed、`ruff check` / `format --check` 全绿、`tsc --noEmit` 0、`vite build` 通过; 回归 `account.mjs`(14 项)、`knowledge.mjs`、`login-email-smtp.mjs`(15 项,含真发一封测试信)全绿。 ### 10.6 上线情况(2026-11-03,commit b5170ad) - 生产 `/auth/methods` → `{"password":true,"email_password":true,"sms_code":false,"email_register":true}`。 - 已注册的邮箱再发码回 `2019`;未注册的地址 `POST /auth/email/code` 返回 `sent:true`(真走了 126 的 SMTP)。 - 生产登录页出现「用邮箱注册」,注册页渲染正常、控制台零报错(`web/e2e/prod-register-check.mjs`, 截图 `web/e2e/artifacts/prod-register.png`)。 - 生产 env **不带** `EMAIL_CODE_DEV_ECHO`:码只发到邮箱,页面不回显。 - 踩到的坑:dev 库要先 `alembic upgrade head` 到 0011,否则注册接口报 `column "email_verified_at" of relation "app_user" does not exist` 直接 500——迁移写好不等于跑过。 ## 11. 知识的阅读与编辑分开(2026-11-03) 用户原话:"知识页,浏览内容时是打开抽屉页然后展示,这不符合人类的阅读习惯,因为这样的内容完全偏右, 长时间浏览很难受。抽屉效果只能用于编辑,因为这是短时间的,也不会经常编辑同一篇文章。" ### 11.1 决定 - **阅读走整页**:`/w/:wsId/knowledge/:itemId`。抽屉在屏幕右侧,正文永远偏在右边, 长文读十几屏眼睛一直在右半边;整页可以把正文列限宽居中,行宽回到五十来个汉字。 - **抽屉只留给编辑**:编辑是短时动作,改完就走,抽屉"压在当前列表上"反而合适, 也省掉从编辑页退回列表时的滚动位置丢失。 - 阅读页复用列表页那套文案常量,避免两处各写一份以后对不上(`lib/knowledge.ts`)。 ### 11.2 Web | 位置 | 内容 | |---|---| | `lib/knowledge.ts`(新) | `DOC_TYPE_LABEL` / `STATUS_LABEL` / `SOURCE_LABEL` / `ACTION_LABEL` / `CATEGORY_PALETTE` / `categoryColor()`,从列表页搬出来 | | `pages/KnowledgeDoc.tsx`(新) | 阅读页:面包屑(返回知识库 / 分类路径)、类型+状态+分类 chip、28px 标题、`更新于 / 发布于 / 检索切片 N 段`、标签、动作行(编辑/发布/归档/恢复)、`Markdown` 渲染、来源卡片、附件(只读) | | `pages/Knowledge.tsx` | 卡片与「打开原文」改成 `navigate("/w//knowledge/")`;删掉 `openId` 状态与抽屉渲染;`KnowledgeDrawer` 加 `initialEditing`(进来就是编辑器,保存或取消都直接关) | | `App.tsx` | 新增 `/w/:wsId/knowledge/:itemId` 路由 | 卡片 `max-w-[78rem]`、正文列 `max-w-[56rem]`(约 55 个汉字一行)居中;换一篇自动回顶。 第一版按 40rem / 46rem 做的,1546 宽的屏幕上一量,留白比正文还宽——用户直接发了截图。 留给以后的界线:正文再宽眼睛就要来回扫行,56rem 是"宽屏不空、又不至于扫行"中间那条线。 编辑仍然是抽屉:`initialEditing` 时没有「看」这一态,点「取消」直接关掉,不留半截改动。 ### 11.3 验证 - e2e `web/e2e/knowledge-read.mjs`(新,8 项):点卡片进独立页、`h1` 是标题、正文是渲染后的 `.prose-dadao` 且 `.vditor-ir` 计数为 0、正文列宽度落在 720–920px(宽屏不留大片空白)、正文列在 `main` 里左右留白差 < 40px、点「编辑」才出现编辑器、 取消后编辑器 detach、控制台零报错。 - 回归:`knowledge-notebook.mjs`(17 项,含"侧栏分类按树排"与"抽屉默认渲染样式")、`knowledge.mjs`(9 项)、 `knowledge-paging.mjs`(5 项)全绿。 - `tsc --noEmit` 0、`vite build` 通过。 - 截图:`2.Design/screens/knowledge-read-page.png`(长文整页)、`knowledge-edit-drawer.png`(抽屉编辑态)。 ### 11.4 顺带修掉的脚本坑 - `knowledge-notebook.mjs` 里"关掉抽屉"那一步在新版里没有按钮可点(保存后直接进阅读页), 改成等 URL 落到 `/knowledge/` 再点「返回知识库」。 - 侧栏分类行里的色点是按钮但没文字,`allInnerTexts()` 会多出一个空串,比较树顺序前先 `.filter(Boolean)`。 - `knowledge-paging.mjs` 原来靠短信发码登录,生产短信已关;改成优先用 `PASSWORD` (或 `.local/e2e-account-password.txt`)走密码登录,没密码时才回落到短信。 ### 11.5 上线情况(2026-11-03,commit d34a6ac) - 生产 `http://192.168.8.188:8200` 已切到整页阅读:正文列居中限宽、`编辑` 才开抽屉。 - `web/e2e/knowledge-read.mjs` 直接打生产(106 的手机号 + 密码登录)8 项全绿, 截图 `2.Design/screens/prod-knowledge-read-page.png`。 - 脚本都换成了密码登录(`PASSWORD` env 优先),生产短信登录关着,别再指望发验证码。 ## 12. 登录态续期(2026-11-03) 用户反馈:"web端登陆后好像没多久就退出登陆了,现在系统对登陆时限是怎么设计的?" ### 12.1 原来的设计(也就是问题所在) - access 30 分钟、refresh 14 天,服务端两个都签好、都发给了客户端。 - 但**没有任何客户端用过 refresh**:web 与安卓拿到 `401/2002` 都是直接清令牌、回登录页。 - 结果"14 天有效"只是纸面参数:真实登录寿命就是 30 分钟,到点下一次请求就被扔出去, 正在写的工单、正在编辑的知识都不会留下。 ### 12.2 改法 - `web/src/lib/client.ts`:新增 `refreshSession()`(并发只发一次),请求前若 access 剩不到 90 秒先换; 收到 401 先换再重放一次原请求,换不动才清令牌发 `authExpired`。`uploadFile` 走同一条路。 - `android/app/src/main/java/com/dadao/task/data/Api.kt`:`call()` 拆成 `call` + `callOnce`, 2002 时 `refreshTokens(stale)` 用 `Mutex` 串行刷新(等锁期间别人换好了就直接用新的),重放一次。 - 后端 `/auth/refresh` 顺手补一条:账号被停用 → `2012`,别让停用账号靠 refresh 一直续。 ### 12.3 验证 - `backend/tests/test_auth.py::test_disabled_account_cannot_refresh`(停用账号换令牌 → 403/2012), 全量 **140 passed**、`ruff check` / `format --check` 全绿。 - 新增 `web/e2e/session-refresh.mjs`(6 项):access 换成假串但 refresh 是真的 → 不被踢、首页正常、 拿到新令牌、新令牌能调接口;access 与 refresh 都假 → 才回登录页;控制台零异常。 - 安卓 `assembleDebug` 通过(JDK 17 + Gradle 8.9)。 ## 13. 微信接入(clawbot,2026-11-05) ### 13.1 要解决的问题 记事的动作发生在手机上,而任务和知识都在浏览器里。人正在外面、手上只有微信的时候, "回去再记"通常等于"再也想不起来"。所以要有一条从微信直接落到工作区里的路。 ### 13.2 决定 1. **一条通道一张令牌,令牌在 URL 里**。机器人不会登我们的账号;它只会照着配置好的地址 POST。令牌是 32 字节随机串,库里只存 `sha256`,页面上"创建/重新生成"时显示一次。 2. **一个工作区可以有多个通道**,各自能单独停用。塞进 `system_setting` 的 JSON 里就做不到 "停这个、留那个"。 3. **收消息用宽松解析**。clawbot 的消息壳子没有公开的固定 schema,与其猜错就 422, 不如按常见字段名依次摸一遍,摸不到正文就记一条 `ignored` 留痕。 4. **同一 `msg_id` 只处理一次**。微信会重推,重推变成第二条任务是最难解释的脏数据。 5. **每条消息都留痕**(`bot_message`,含原始 JSON、处理结果、 reply)。 出问题时能直接回答"那条任务到底进没进"。 ### 13.3 后端 | 位置 | 内容 | |---|---| | `backend/app/models_integrations.py` | `BotChannel`(令牌哈希、状态、最后收信时间)、`BotMessage`(文本、动作、item_id、reply、原始 JSON) | | `backend/alembic/versions/0012_bot_channel.py` | 建两张表 + RLS;`bot_channel` 策略额外放行 `token_hash = current_setting('app.bot_token_hash')` | | `backend/app/api/integrations.py` | 收消息 `GET/POST /integrations/clawbot/{token}`;管理接口 `/workspaces/{ws}/integrations` | | `backend/app/errors.py` | `9002` 令牌无效、`9003` 通道停用 | 正文解析:首行是标题(180 字封顶),其余是正文;`#标签` 摘出来挂事项上(纯数字不算); 前缀 `知识:`/`笔记:` → 知识草稿,`任务:`/`todo:` → 任务,没有前缀就是任务。 ### 13.4 Web 新增 `/w/:wsId/integrations`「接入」页(左侧栏最后一项): - 「怎么用」三步说明; - 「新建通道」→ 一次性展示**回调地址 + 令牌**,各带复制按钮(clawbot 后台要粘的就是这里); - 每条通道:状态、最后收信时间、`最近消息`(最近 5 条,带"建了任务/建了知识"标签)、 停用/启用、重新生成令牌、删除; - 只有 owner/admin 看得到写操作。 ![接入页](screens/web-integrations.png) ### 13.5 验证 - `backend/tests/test_clawbot.py` 10 例:纯文本成任务、`知识:`前缀成草稿并带标签、 `帮助`只回复不建事项、错令牌 404/9002、停用通道 403/9003、同 `msg_id` 幂等、 GET `echostr` 原样回、令牌只属于一个工作区、换令牌后旧地址失效、普通成员不能建通道。 - `web/e2e/integrations.mjs` 7 项(真跑):页面建通道 → 取回调地址 → 冒充 clawbot POST → 任务出现在任务列表 → 重复推送不建第二条 → 「最近消息」看得到 → 控制台零报错。 ### 13.6 还没做 - 只做了"进"(收消息建事项),没做"出"(改完任务回一条微信通知)。 - 消息不认人:机器人只知道"这是这个工作区的消息",按通道创建者记账;要认人得先做微信身份绑定。 - 语音、图片、文件只留痕不处理。