Files
2026-11-06 12:41:59 +08:00

907 lines
73 KiB
Markdown
Raw Permalink 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–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|<uuid>`、`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;`<img>` 直链走**短期签名** `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`,渲染/装载前补回;`<img>` 带不了 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/<pkg>/files/updates/`(先写 `.part` 再改名)→ 校验 sha256 → 经 `FileProvider` 交给系统安装器
- [x] `ui/UpdateDialog.kt`:当前/新版本号、更新说明、下载进度、失败重试;「允许安装未知应用」只跳系统设置,不代点
- [x] 权限回来那一跳要接住:用户在系统设置里开完开关回到 App,再点同一个按钮时先复查权限,开了就直接装——否则按钮点了没反应
- [x] 启动时自动查一次;首页底部有「检查更新」手动入口,结果用一句话回给用户
- [x] 图标换成与 Web 的 `<Logo/>` 同一套图形(靛蓝圆角底 + 三条白色圆头线 + 淡紫点),生成 `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`
### 6.5 发版记录
| 版本 | 日期 | 这一版带上了什么 |
|---|---|---|
| 0.3.0(`versionCode=3`) | 2026-11-06 | 知识是备忘录(分类 / 标签 / 筛选 / 搜索)、知识能删、公众号链接抓成知识后手机上读得到、登录改成手机号或邮箱 + 密码(生产已关短信,旧包登不进去)。模拟器上从 0.2.0 走应用内升级到 0.3.0 全程通过:弹窗「有新版 0.3.0」→ 下载 7.5 MB → 系统安装器 → `versionCode=3` |
| 0.2.0(`versionCode=2`) | 2026-10-31 | 第一个带应用内升级的包,图标换成与 Web 同一套 |
升级签名用同一把(`CN=Dadao Task`,SHA-256 `2555c7908f7d…`),所以老版本能直接覆盖安装;
换签名的话只能卸了重装,OTA 也会失败。
---
## 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=<repo>/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/<ws>/knowledge/<id>")`;删掉 `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/<id>` 再点「返回知识库」。
- 侧栏分类行里的色点是按钮但没文字,`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. **走腾讯官方 iLink Bot 协议**:`get_bot_qrcode` 拿二维码 → 手机扫码确认 →
拿到 `bot_token` → 后端**长轮询** `getupdates` 收消息 → 用 `context_token` 回话。
这是 clawbot / openclaw / dsh-im 走的同一条路,扫码是配对的前提。
2. **不依赖 DSH**:协议自己实现(`backend/app/services/wechat_ilink.py`),
因为达道Task 是独立项目,不该为了收消息去依赖另一个产品的运行环境。
3. **登录态加密存库**:`bot_channel.bot_token_enc` 用 Fernet(密钥由 `JWT_SECRET` 派生),
`bot_token` 不落明文、不回调给任何接口、日志里也没有。
4. **轮询者是我们自己**:每个已连接的通道一个 asyncio 任务,进程启动时按库里的
`status='active'` 恢复;token 失效(微信 errcode `-14`)就把通道转 `expired`,让用户重新扫。
5. **一条消息只落一次**:按 `message_id` 去重,微信重推不会变成第二条任务。
6. **每条消息都留痕**(`bot_message`,含文本、动作、item_id、回复、raw),
出问题时能直接回答"那条任务到底进没进"。
7. **一个工作区一条微信通道**:同一时间只允许一条 `kind='wechat_clawbot'` 的记录承载登录态;
断开就是清登录态回到 `pending`,再连就重新扫。
### 13.3 协议要点(实现对照)
| 用途 | 请求 |
|---|---|
| 取二维码 | `POST https://ilinkai.weixin.qq.com/ilink/bot/get_bot_qrcode?bot_type=3` → `{qrcode, qrcode_img_content}` |
| 问扫码结果 | `POST /ilink/bot/get_qrcode_status?qrcode=<id>` → `waiting`/`scaned`/`confirmed`/`expired` |
| 收消息 | `POST /ilink/bot/getupdates` body `{get_updates_buf, base_info:{channel_version}}`,长轮询 70s |
| 回消息 | `POST /ilink/bot/sendmessage` body `{msg:{to_user_id, context_token, item_list:[{type:1,text_item:{text}}]}}` |
请求头固定带 `iLink-App-ClientVersion: 1` 与 `X-WECHAT-UIN`(随机数 base64);
登录后加 `Authorization: Bearer <bot_token>` + `AuthorizationType: ilink_bot_token`。
二维码不是图片地址,`qrcode_img_content` 是待渲染的 payload,服务端用 `qrcode` 库画成 SVG data URL。
### 13.4 后端
| 位置 | 内容 |
|---|---|
| `backend/app/services/wechat_ilink.py` | 只封协议:取码、问状态、长轮询、回话、消息解析、token 加解密 |
| `backend/app/services/wechat_intake.py` | 文本 → 事项:前缀识别、`#标签`、标题/正文切分、建任务/知识、回复文案 |
| `backend/app/services/wechat_bot.py` | `BotManager`:起停轮询任务、去重、留痕、游标、`-14` 转 `expired` |
| `backend/app/api/integrations.py` | `POST /workspaces/{ws}/integrations/wechat/qr`、`GET …/qr/{id}`、`PATCH`、`DELETE`、`GET …/messages` |
| `backend/app/models_integrations.py` | `BotChannel`(`bot_token_enc`、`ilink_user_id`、`sync_buf`、`context_token`、`last_seen_at`、`last_error`)、`BotMessage` |
| `backend/alembic/versions/0013_wechat_bot_login.py` | 加登录态相关列,`status` 默认 `pending` |
| `backend/alembic/versions/0014_bot_channel_worker_policy.py` | 轮询入口没有工作区上下文,按 `app.bot_channel` 主键开一条 permissive 策略 |
| `backend/app/main.py` | lifespan 里 `manager.start_all()` / `manager.shutdown()` |
正文解析:首行是标题(180 字封顶),其余是正文;`#标签` 摘出来挂事项上(纯数字不算);
前缀 `知识:`/`笔记:` → 知识草稿,`任务:`/`todo:` → 任务,没有前缀就是任务,`帮助`/`?` 只回用法。
### 13.5 Web
新增 `/w/:wsId/integrations`「机器人」页(左侧栏最后一项;栏目名从「接入」改过来,因为用户要的是「机器人」这件事,而不是「接入」这个后台动作):
- 「扫码连接微信」→ 页面上直接画出二维码,每 2 秒问一次状态(待扫 → 已扫待确认 → 成功/过期);
- 连上之后显示通道卡片:接收中/登录已失效/已停用、最后收信时间、"微信用户 xxx"、
`最近消息`(最近 5 条,标"建了任务/建了知识")、重新扫码、断开;
- 只有 owner/admin 能扫码与断开,其他成员只读。
![机器人页](screens/web-integrations.png)
### 13.6 验证
- `backend/tests/test_wechat_bot.py` 9 例:取二维码(SVG data URL、通道 `pending`)、
扫码确认后登录态加密入库且能解回、过期二维码回 `expired`、断开清登录态、
纯文本成任务、`知识:`前缀成草稿带标签、`帮助`不建事项、同一 `message_id` 只落一条并只回一次、
登录态失效后通道转 `expired` 且带原因。
- `web/e2e/integrations.mjs` 7 项(真跑,拿的是微信真二维码):接入页打开 → 点扫码 →
二维码是页面里的 SVG → 提示用微信扫 → 后端通道 `pending` → 没登录态不轮询 → 控制台零报错。
- 全量 `pytest` 151 passed;`ruff check` / `ruff format --check` 干净。
### 13.6.1 上线后修的问题
1. **扫码确认那一刻页面报「服务器内部错误」**:`bot_channel` 的轮询口子(迁移 `0014`)直接
`current_setting('app.bot_channel', true)::uuid`,而事务级 `set_config` 提交后会把连接上的
变量还原成**空串**而不是 NULL,空串一 cast 就
`InvalidTextRepresentationError: invalid input syntax for type uuid: ""`。
迁移 `0015` 改成 `nullif(current_setting(...), '')::uuid`,与 `app_ws_id()` 同一写法。
2. **回话别再带站点链接**:`PUBLIC_BASE_URL` 是内网地址,手机上打不开;
现在只回「已保存任务:…」/「已保存知识草稿:…」。
3. **栏目名从「接入」改成「机器人」**:用户要的是「我和这个机器人说话」,不是「配置接入」。
4. **重启之后机器人静默不收消息**:生产是 `gunicorn -w 2`,两个进程各跑一遍 lifespan,
同一个通道会被起两条长轮询(消息被一个消费、回复从另一个发出,游标互相覆盖);
而且 `start_all()` 是"没有工作区上下文"的查询,被 RLS 全藏起来,一行都查不到。
- 租约:会话级 `pg_try_advisory_lock`,抢到的进程收,另一个每 10 秒重试;进程挂了连接断、
锁自动释放,备用进程接上。页面「接收中」只认抢到租约的那个。
- 迁移 `0016` 补一条只读策略:`current_setting('app.bot_list') = 'on'` 时允许 SELECT,
`start_all()` 先设这个 GUC 再列通道。
### 13.7 教训:第一版是猜的
第一版没找到资料,按"webhook + 令牌在 URL 里"自己编了一套(用户看到的回调地址那版)。
用户当场指出"微信要先扫码配对",一查才发现协议在
`~/.dsh/profiles/desktop/node_modules/@michengai/dsh-im-connect/src/channels/weixin.ts` 里写着。
**教训:接入第三方协议,先去已有实现里找协议,别从直觉倒推。** 那一版的迁移 `0012` 保留
(`bot_channel` / `bot_message` 两张表还在用),`token_hash` 列留着但不再使用。
### 13.8 发一条链接,抓成知识
需求原话:「如果发送给机器人的是一个链接,那么就需要把链接内的文章内容给抓取下来,然后保存为知识;
就算是微信公众号的链接也需要抓取下来」。
- 选型 `trafilatura==2.0.0`(中文正文抽取最稳)+ `lxml`;公众号单独走 DOM(`#js_content`、图片在
`data-src`),因为通用抽取器会把公众号的图全丢掉。
- 抓下来的图下载进自己的存储(最多 12 张、单张 8 MB),正文改写成本地 `raw?签名` 地址——
外链图在公众号侧一换就全红叉。
- 只允许公网地址:谁都能给机器人发消息,不拦住内网就是 SSRF 跳板(`127.0.0.1`、`192.168.x`、链路本地、
保留段、解析不出的域名一律拒绝)。
- 失败要说话:反爬验证页、Cloudflare 302、SPA 空壳统一回「这个链接没抓下来(原因)。想直接记成任务
的话,加「任务:」前缀再发一次。」
- **装依赖的坑**:`pip install trafilatura` 在本机会卡死(依赖解析完就挂住),要逐个装
`lxml / courlan / htmldate / jusText / lxml_html_clean / …` 再 `pip install --no-deps trafilatura`;
`lxml` 6 起 `lxml.html.clean` 拆成 `lxml_html_clean`,不装就 `ImportError`。
#### 13.8.1 用户发来真链接,结果只抓到一个「。」
上线后用户发了 `https://mp.weixin.qq.com/s/ZEOsi4R4JcmZ_mhS6qpcww`,库里只有 67 个字符的正文。
复盘:**公众号换了渲染方式**——新版页面(`window.appmsg_type = '9'` / `'10002'`)整篇塞进
`window.cgiDataNew` 这个 JS 对象、由前端渲染,页面里根本没有 `id="js_content"`;
三种 UA(桌面 / iPhone Safari / MicroMessenger)拿到的字节数完全一样,所以也不是 UA 的问题。
修法(`backend/app/services/article.py`):
- 从 JS 字符串字面量里读正文:`content_noencode` / `desc`,按 `\xNN`、`\uNNNN` 反转义;
话题标签是双重转义的 HTML(`\x26lt;a class=\x26quot;wx_topic_link\x26quot;\x26gt;#调料…`),
先 `html.unescape` 再走 DOM。
- 配图字段(`cdn_url` / `img_url` / `pic_url`)**排在文字字段之后**,所以配图要前后各找 2500 字符;
剩下没用过的 `mmbiz.qpic.cn` 图按 12 张上限补在末尾(图文型「小绿书」的图不在文字附近)。
- 解析顺序:旧 DOM(`#js_content`)→ 新版 JS 数据 → 通用抽取,一层层退。
- 作者兜底从 `nick_name` 取,标题取 `og:title`。
结果:那篇 2373 字符 + 12 张图,蒜蓉酱那篇 2772 字符 + 13 张图,作者都取到了。
**教训:抓取器写死在一种页面结构上,等于给自己埋一个定时炸弹;线上先看真实 HTML 再改。**
### 13.9 知识库:标签清单与删除
用户提两件:"文章都缺少删除功能"、"分类有列表显示,但是分类的下面应该还有标签的列表"。
- `GET /knowledge/tags`:按标签统计"有多少条知识用了它",带 `status` 就跟着 tab 数;
侧栏在分类树下面渲染成一排 `#标签 + 条数`,点一下筛选、再点取消。
- `DELETE /knowledge/{item_id}?confirm=true`:软删 + 真删检索切片;只有作者或 `owner`/`admin` 能删;
列表卡片(悬停出现)和阅读页动作行各有一个入口。删完详情也回 `7001`,
否则收藏的链接还能打开一篇"已经删掉的知识"。
![知识页的标签清单](screens/web-knowledge-tags.png)
### 13.10 还没做
- 只做了"进"(收消息建事项),没做"出"(任务有进展回一条微信通知)。
- 消息不认人:按通道创建者记账;要认人得先做微信身份绑定。
- 语音只在服务端给了转写时使用,图片/文件/视频只留痕不搬。