- primary 换成 Indigo 阶(600 即 #4F46E5),中性色转 Slate 轴、纸面更冷更白 - 实心主色面(主按钮、工作区选中)用 600,链接/浅底文字仍用 700 保对比度 - 信息色 sky 调深到 #16697C,与靛蓝同屏能分清 - Logo / favicon / PWA 图标 / 安卓自适应图标统一为靛蓝底 + 白线 + 淡紫点,tools/make-icons.py 重新生成 - 文档与 e2e 断言同步(主题色 #4f46e5)
46 KiB
实施清单(M0–M2)
来源:
2.Design/PRODUCT-PLAN.mdv1.0 第 12 节 粒度:每项 0.3–1.5 人日,2 人并行 ≈ 2 周一个里程碑 状态标记:[ ]未开始[~]进行中[x]完成最后一次核对:M0 骨架已于本机跑通(33 条测试全绿、ruff 通过、真机 HTTP 冒烟通过)。 标
[x]的都是真跑过的,不是"代码写完了"。写代码不等于完成,代码没有义务在第一次运行时正确。
M0 · 骨架(第 1–2 周,10 人日)
里程碑目标:一个能注册、能建团队、能证明"两个租户互相看不见"的空壳,部署在服务器上,CI 全绿。
0.1 仓库与约定(0.5d)
- 目录结构:
backend/ web/ android/ deploy/ 2.Design/ AGENTS.md:提交纪律(改完必 push)、前端产物只能由 HEAD 构建、文档落点.gitignore/.editorconfig/README.md- 分支策略:
main受保护,功能走feat/*;提交信息feat(module): 中文描述 - PR 模板(
.gitea/PULL_REQUEST_TEMPLATE.md) - 验收:Gitea 上能看到仓库 ✅(
http://192.168.8.160:8418/Tatta/DadaoTask.git),PR 模板待补
0.2 本地环境(1d)
deploy/docker-compose.dev.yml:pgvector/pgvector:pg16+redis:7(未含 minio,M0 不用对象存储)postgres初始化脚本:建dadao_owner/dadao_app两个角色 + 库 owner +REVOKE CREATE.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)
pydantic-settings分层配置(dev/test/prod),validate_runtime()启动即校验- 结构化日志(JSON)+
request_id贯穿 + 敏感字段脱敏(password/token/api_key/secret) - 验收:✅ request_id 出现在响应头与所有日志行;
ENV=prod且密钥仍是默认值 → 启动直接失败
0.4 数据库基座(1d)
alembic init,env.py读MIGRATION_DATABASE_URL(owner 角色)- 基础表迁移:
workspaceapp_usermemberinvitationaudit_log - 通用列约定:
id uuid、created_at、updated_at、version;deleted_at按表按需(workspace有) - 空库
alembic upgrade head一次成功 downgrade回滚实测(代码写了,没跑过)- 踩坑记录:asyncpg 走预备语句,一条
execute只能一条 SQL。多语句 DDL 必须拆开逐条执行,否则报cannot insert multiple commands into a prepared statement。
0.5 租户上下文 + RLS(1.5d)★ 核心
- 请求依赖:解析 JWT → 校验成员身份 →
set_config('app.ws_id', …, true)(事务级,自动随事务失效) - 租户表启用 RLS:
workspacememberinvitationaudit_log(策略见backend/alembic/versions/0001_init.py) - 仓储基类
Repo(session, ws_id)强制ws_id必填、无默认值 - 应用角色
dadao_app非 owner、非 superuser、无 BYPASSRLS,且无 DDL 权限 - 测试:跨租户读 → 报
3003 NOT_A_MEMBER(不泄露对方工作区是否存在);DB 层未绑上下文 → 0 行 - 变异测试:手工删掉一条 where 条件,确认测试会变红(验证测试真的有牙)
- 已知取舍:
app_user表不加 RLS——登录时按手机号查用户发生在任何租户绑定之前,加了策略会直接查不到。 代价是"手机号是否已注册"在应用层可探测;缓解手段:错误信息统一、加登录限流。这条记在 D4 风险里,M4 前必须处理。
0.6 认证(1.5d)
- 手机号 + 验证码注册/登录(dev 固定码
123456并回显;ENV=prod禁止SMS_PROVIDER=dev) - JWT:access 30min / refresh 14d(计划里写的 15min/30d,实际取宽了一点,理由是移动端弱网刷新成本高)
- 发码节流(
SMS_THROTTLE_SECONDS,默认 60s,测试可置 0) - 手机号 + 密码登录(可选,第二把钥匙:设过密码就能用密码进,没设过的一律回
2013;scrypt 哈希,15 分钟连错 10 次 →2016) - 邮箱 + 密码(未做,M1 有空再说;手机号登录先行)
- refresh 令牌落库可撤销(未做,当前是无状态 JWT;被移出团队的成员靠"每次请求查 member 表"兜底,refresh 本身不可撤销)
- 验收:✅ 注册 → 登录 → 刷新 →
/auth/me全链路通;错误码 2010/2011/2012 语义清晰
0.7 权限守卫(0.5d)
- 四个角色:owner / admin / member / guest,
ROLE_RANK = {owner:4, admin:3, member:2, guest:1} - 依赖注入统一执行:
require_role(min)、AdminDep、OwnerDep - 细则:只有 owner 能邀请 admin;guest 不能邀请;非成员一律
3003 - 偏差:没做
(资源, 动作) → 最低角色的PERMISSIONS字典,M0 只有"工作区"一个资源域, 字典是过度设计。M1 引入 item 时再上。 - 验收:✅ member 调邀请接口 → 3004;跨租户 → 3003
0.8 接口规范(0.5d)
- 统一响应体
{code, message, data, request_id} - 错误码表(暂存在
backend/app/errors.py,M1 抽到2.Design/API.md;已按 1xxx 通用 / 2xxx 认证 / 3xxx 租户 / 4xxx 并发 分段) - 写接口支持
Idempotency-Key(存储 TTL 24h;只缓存 <400,5xx 与 4xx 都不缓存) - 分页:cursor(
(created_at, id)复合游标)+limit ≤ 100 - 已知限制:同 key 并发请求有竞态窗口,靠后续唯一键约束兜底;M0 不解决。
0.9 健康与版本(0.3d)
/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)
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)
- Web:Vite + React18 + TS + Tailwind,路由(
/login//w/:wsId/join/:wsId/:code)、请求封装(信封解包 / 幂等键 / 401 清理) - 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 起法(本机专用):
# 后台作业里跑(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)
itemtagitem_tagcommentactivity迁移(backend/alembic/versions/0002_tasks.py,revision 0002 ← 0001)- 索引:
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 知识库阶段重评)。 - 偏差:没有
item_assignee关联表,item.assignee_id单列。理由:M1 只有"一个任务一个负责人",多人协作由工单阶段(M2)的item_link承担;提前上关联表会让每条查询都多一次 join。 - RLS:五张新表全部
ENABLE ROW LEVEL SECURITY+workspace_id = app_ws_id(),并 GRANT 给dadao_app
1.2 领域(1.5d)
- 状态机
backend/app/domain/task.py:inbox→todo→doing→done(+ blocked/canceled,done→todo可重开);非法流转抛5002并回allowed("你可以去 canceled/todo"),不用前端猜 - 所有变更写
activity(created/updated/status_changed{from,to}/commented,payload 是 JSONB) version乐观锁:UPDATE ... WHERE version = ?看rowcount,冲突回4009+current_version(实测 stale 请求拿到current=4)done写completed_at,重开清空;deleted_at软删,view=deleted才看得到
1.3 API(1.5d)
- 增删改查(
backend/app/api/items.py)、POST /{id}/status、软删(带 version) - 视图:
inbox|active|today|week|done|canceled|all|deleted;today/week按Asia/Shanghai切日界(settings.default_timezone),不按 UTC - 过滤:
status(可重复)、assignee=me|<uuid>、tag、q(标题+正文 ILIKE,实测中文周报命中) - 批量状态变更(
POST /items/batch-status,逐条返回成败,不做全回滚)+ 看板数据GET /items/board - [~] 搜索返回高亮片段:未做,只返回命中行。理由:高亮要在客户端按 query 二次切词,纯展示逻辑,等 Web 有真实使用反馈再补
- 分页:
(sort_key DESC, id DESC)keyset 游标;sort=due不分页(NULLS LAST 会把 NULL 行切碎,只有今日/逾期短列表用)
1.4 Web(3d)
- 视图 tab(今天/进行中/收件箱/已完成/全部)+ 搜索框 + 回车快速新建(
web/src/pages/Tasks.tsx) - 勾选完成/重开、
收下(inbox→todo)、开工(todo→doing)、阻塞 - 看板拖拽(
web/src/pages/Board.tsx):HTML5 拖放,四列,非法流转当场拦下并说明原因;勾选后可批量移动 - 详情侧栏可编辑(
web/src/components/TaskDetail.tsx):标题/优先级/截止时间/标签/备注/评论,全部落库;409 时自动刷新为最新内容 - 快速录入语法:
#标签!高/!中/!低/!1..!3(web/src/lib/parse.ts,语法从标题里剥离) - 键盘流:
j/k选择、Enter完成、e编辑、Esc关闭 npm run build通过:41 modules,195.20 kB │ gzip 63.73 kB- [~]
@人指派:未做。理由:重名的人比标签多,悄悄指错负责人比不支持更糟;等 M2 有成员选择器再做 - [~] 子任务(一层):未做,排到 M2 与工单的
item_link一起做——两者本质是同一种"事项之间的边"
1.5 安卓(1.5d)
- 任务列表 + 详情弹窗 + 勾选完成 + 快速新建(
android/.../ui/TaskListScreen.kt) - 视图切换(今天/进行中/收件箱/已完成)、乐观锁冲突提示与自动刷新
- 未编译:本机没有 JDK、没有 Gradle(
java -version/gradle -v均不存在), Android SDK 平台目录存在但缺构建工具链。代码按 Compose 常规写法写,尚未经过编译器验证。 补 JDK 17 + Gradle 后第一件事就是./gradlew assembleDebug,那时的报错才算数。 - 下拉刷新、只读缓存(Room)未做
1.6 通知与提醒(0.8d)
- 站内通知表与未读角标 —— 随 M2 一起落地,七类触发(指派、改状态、评论、工单全流程); 未读角标 30 秒轮询,标已读立刻刷新。原计划"推迟到 M2",M2 已兑现。
- 每日摘要邮件(同上推迟)
1.7 测试与验收(0.7d)
backend/tests/test_items.py13 个用例:默认收件箱、状态机 happy path 与非法流转、版本冲突、 跨租户 3003 且他人列表为空、搜索+标签、today时区边界、软删、评论与事件流、游标翻页、 批量改状态的部分成功、批量非法流转、看板分列- 全量
pytest -q→ 46 passed;ruff check+ruff format --check全 0 - Playwright 真浏览器 e2e(
web/e2e/smoke.mjs,7 项断言全绿):登录 → 建工作区 → 快速录入解析#标签/!优先级→ 详情抽屉保存并回写列表 → 看板拖拽改状态 → 非法拖拽被拦 → 控制台无报错 - 性能:1000 条任务下 P50 —— 今天 43.1ms / 进行中 42.4ms / 收件箱 42.9ms / 全部 38.4ms / 搜索 45.0ms / 看板 55.4ms(含本机网络与鉴权中间件,是下限)
M1 完成定义(DOD)
- 连续 3 天所有待办都记在里面,没有回到微信/备忘录 —— 只有真实使用能证伪,代码侧无法替代
- 冷启动到"建完第一个任务"< 90 秒(新账号实测)
- 1000 条任务下四视图首屏 < 300ms —— 实测 P50 38–55ms,最慢的看板 55.4ms
- 跨租户测试、越权测试全绿(
test_tenant_isolation.py+test_items.py的跨租户与批量用例)
M2 · 工单协同(第 5–6 周,10 人日)
2.1 数据(1d)
item.kind='ticket'落地;迁移0003_tickets.py重写 item 的状态约束为任务∪工单并集 (旧约束只允许任务六态,工单的open/accepted/...会被直接拦下)ticket_ext(item_id既是主键又是外键,天然 1:1):需求人/办理人/原任务指针/受理·提交·验收时间/退回次数/验收结论item_link(父子、相关,禁止自环)与notification(只发给当事人)- 三张表全部
ENABLE ROW LEVEL SECURITY+ 同租户策略 + GRANT 给应用角色
2.2 领域(1.5d)
- 工单状态机
open→accepted→in_progress→pending_verify→done(可canceled),默认拒绝 - 动作→角色规则:受理/开工/提交限办理人;验收限需求人,办理人不能自验(
6004);改派/取消限需求人或办理人 - 驳回回
in_progress而不是open,reject_count累加 - 升级不搬数据:原任务变
blocked,验收通过后自动回流todo
2.3 API(1.5d)
POST /items/{id}/promote、/ticket/{accept,start,submit,verify,reassign,cancel}GET /tickets?role=assigned|requested|all&status=GET|POST /items/{id}/links、DELETE /items/{id}/links/{link_id}POST /tickets手工开单:不经过任务直接指定人,初始open,与升级单共用同一条 item- 指派前校验"在册成员"(
_ensure_member),否则任务会挂在一个没人看得到的名字上 GET /notifications、/unread-count、/read、/read-all- 抽出
app/serializers.py:任务与工单共用一份序列化,根除"工单响应缺 tags 导致前端白屏"
2.4 Web(3d)
- 工单页
/w/:wsId/tickets:派给我的 / 我发起的 / 全部,状态过滤,列表内联快操作 - 详情抽屉:按角色显示受理、开工、提交、验收、驳回、改派、取消;工单态顶部显示需求人/办理人/退回次数
- 任务详情底部"升级成工单"面板(从成员里选人,成功后提示"这条任务现在处于被阻塞")
- 导航加未读角标(30 秒对齐一次,操作后立刻刷新)
- 通知页
/w/:wsId/notifications:未读 / 全部两档,点开即标已读并跳到目标;深链?open=落地即展开那一条 - 工单页"新建"面板:手工开单(选人、优先级、说明),成功切到"我发起的"并展开
- 登录支持
?next=回跳,邀请链接不再"登录完掉回首页"
2.5 安卓(1.5d)
- 工单列表与动作 —— 仅按"我是需求人 / 办理人 / 管理员"显隐动作按钮,不再人人可见
- 通知页与手工开单:五个 tab、未读数 30 秒刷新、通知点开跳列表并清标记
- 本次未编译验证:本机 JDK 已升级到 25,Gradle 8.7 / AGP 8.7.3 不兼容,需装回 JDK 17 再
assembleDebug
2.6 测试与验收(0.7d)
backend/tests/test_tickets.py11 个用例:升级建单+原任务阻塞+通知、全流程验收回流、驳回、 自验 403、非法流转、重复升级、非成员指派、角色作用域、跨租户、关系挂载、通知已读、响应必带 tagsbackend/tests/test_notifications.py6 个用例:改状态只通知对方(自己不动自己)、指派必须是成员、 手工单全流程(开出→被指派人收到→受理/提交→验收,且没有来源任务)、越界指派 400/1001、 评论通知对方、未读数与全部已读- 全量
pytest -q→ 85 passed;ruff check/format --check全 0 web/e2e/tickets.mjs双浏览器上下文双用户 e2e,8 项断言全绿: 升级 → B 受理/开工/提交 → A 验收通过 → 原任务回流"待办" → 两端控制台零报错web/e2e/notifications.mjs12 项断言全绿:A 建工作区邀 B → B 自动加入 → A 手工开单 → 这张单没有来源任务 → B 收到"派给你的工单" → 通知列表有条目 → 点通知跳到工单并展开 → 未读数归零 → 两端控制台零报错web/e2e/smoke.mjs回归复跑(抽屉多了"选人"下拉后修正选择器)7 项全绿
2.7 通知真实投递
- 站内通知已有;微信/飞书推送推迟到 M6(M2 只要"在系统里能看到该我办了")
M2 完成定义(DOD)
- 两个人能跑完一次真实协助:升级 → 受理 → 办理 → 提交 → 验收 → 原任务自动解锁
- 办理人无法自验、跨租户看不到工单、通知只给当事人
- 真实用一周:所有"需要别人帮忙"的事都走工单,不再私聊催(需要真实团队)
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)
- 一件事办完 → 自动出现一份草稿 → 核对 → 发布,全程不用手动复制粘贴
- 每条知识都能追到来源(除手记),点开能看到来源事项
- 非管理员发布被拒;跨租户看不到别人的知识
- 知识是备忘录:标题、正文、标签、三级分类、搜索;新建时就能把这些一次填完(Web 与安卓一致)
- 点开知识默认看到的是排好版的正文,不是编辑器的原文;要改才点「编辑」
- 切片与语义检索跑通(
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 只改列类型与索引,应用层接口不变。
两条必须记住的约束:
- 内置 hash 向量不是语义模型,只是"零依赖、可复现"的基线;上线前换
EMBEDDING_PROVIDER=openai并整体重建(reindex_workspace),否则新旧向量混表、分数不可比。 - 检索只认
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)
- 色板:冷白
paper、靛蓝primary(#4F46E5)、陶土橙clay,语义色 sage/wheat/rust/sky(Webtailwind.config.js,安卓ui/Theme.kt同名同值)(2026-11-01 主色由墨绿改蓝系;2026-11-02 再由沉靛蓝改为高饱和靛蓝#4F46E5,中性色转 Slate 轴,语义色不变) - 字号阶:11/12/13/14/15/18/22/26/36,正文 14、辅助 11/12;细描边 + 极轻投影 + 14dp 圆角
- 组件:卡片 / 按钮(primary、ghost、subtle、clay、danger)/ 状态 pill / 输入 / 空态 / 骨架屏 / 抽屉
- 图标:Web 用自绘线性图标集(
components/icons.tsx),安卓用 Material Icons + 少量自绘,线宽一致
4.2 Web
AppShell:左侧固定侧栏(工作区切换、五项导航、快捷键提示、用户),移动端转顶部横条 + 横向导航- 六个页面全部重做:登录(品牌面板 + 表单)、首页(问候 + 工作区卡片)、任务(视图 tab + 快速录入 + 圆勾选 + 骨架屏)、看板(四列 + 拖拽高亮 + 空列提示)、工单、知识、成员、邀请落地
- 详情抽屉:
Sheet组件承载,工单态显示需求人/办理人/退回次数/验收结论 + 动作按钮 - 验证:
tsc -b0 错、vite build45 modules(241.82 kB │ gzip 76.70 kB)、三套 e2e(7 + 8 + 7 项)全绿 - 深色模式:未做(token 已留出语义位,等有用户提需求)
- 无障碍:只做了焦点环与
aria-label,未做完整键盘可达性与对比度审计
4.3 安卓
WorkspaceScaffold:顶部工作区 + 未读角标,底部四 tab(看板不占 tab,屏幕放不下)- 任务(列表/看板切换、快速录入、勾选、详情底部抽屉)、工单(角色 chip + 按状态动作 + 验收对话框)、知识(四 tab + 记一条 + 详情动作)、成员(邀请链接分享)
- 任务详情抽屉里可升级成工单、沉淀成知识(此前安卓缺这两个入口,是功能欠账)
- 编译链固化:
android/scripts/emulator.ps1一键构建 + 装机 + 起模拟器;assembleDebug→ APK 10.1 MB - 模拟器(API 35)实跑:登录 → 工作区 → 任务勾选 → 工单 → 知识 → 成员,截图存
2.Design/screens/android-*.png - [~] 看板不做拖拽:手机屏幕上一列已占 76% 宽,拖拽命中率太差;改为点卡片选目标列,功能等价
- 离线缓存(Room)、图片附件、消息推送:均未做,M5 再议
5. 富文本与附件(M2.5)
5.1 决策:正文存 Markdown,编辑用成熟组件 Vditor
- 任务备注、工单说明与验收结论、知识正文、评论,统一
*_md字段存 Markdown - 渲染用
markdown-it+dompurify(先渲染再消毒),链接协议白名单http(s)/mailto// - 编辑器不自研(最初那版 textarea + 正则工具栏已删除):改用 Vditor 4.0.1(MIT,中文优先,
ir模式所见即所得),落盘仍是 Markdown - 选型依据:Vditor 11.4k★ / 依赖仅
diff-match-patch/ 中文界面与文档齐全;TipTap 是 headless 且存 JSON 要另做序列化;@toast-ui/react-editor停更在 2023 且 peer 依赖锁 React 17 - 运行时资源自托管(
web/scripts/vendor-vditor.mjs→public/vditor,由predev/prebuild自动生成),不走 unpkg CDN——内网/离线环境下编辑器会一直卡在加载 - 表格:Vditor 里能插能编;只读渲染(评论、动态、预览)走
markdown-it的 GFM 表,display:block + overflow-x:auto让宽表在抽屉里横向滚动、不撑破布局(e2e 断言表格宽度 ≤ 900px,实测 561px) - HTML 片段:仍按文本显示(不开放 raw HTML 是有意的,避免把消毒责任交给调用方)
5.2 附件
- 后端:
attachment表 + RLS + 迁移 0005;POST/GET/DELETE与download/raw两个读取口 - 本地磁盘存储(
backend/app/services/storage.py),key 由内容 sha256 决定;换对象存储只改这一个文件 - 上限
MAX_UPLOAD_MB=20,声明大小与流式读取双重拦截,超限8002 - 列表带
attachment_count,详情带attachments,避免一屏 N 次请求 - 下载走带
Authorization的 fetch 转 blob;<img>直链走短期签名raw?exp=&sig=(HMAC 密钥由JWT_SECRET域分离派生,默认 30 分钟,跨工作区拒绝) - 图片直链改成签名 URL 后:正文里只存不带凭据的相对路径
/raw,凭据每次渲染现签,不再把会过期的令牌写进库 - 缩略图、图片压缩、病毒扫描:未做
5.3 编辑器
RichTextEditor= Vditor 封装:mode: "ir"、工具栏(标题/粗体/斜体/删除线/链接/列表/任务列表/引用/代码/行内代码/图片/表格/撤销/重做/全屏)、关掉cache(草稿由我们自己的保存按钮负责)- 粘贴与拖拽上传图片:
upload.handler里逐张落到附件接口,再insertMD插入)。Vditor 不会替我们插入 handler 的返回值(它只把字符串当提示语tip.show),这点踩过坑 - 令牌只在浏览器内存里存在:落库前
stripAttachmentTokens,渲染/装载前补回;<img>带不了 Authorization 头,所以raw?token=是必要折中 AttachmentPanel:上传/下载/删除(删除二次确认,对应后端?confirm=true)- 安卓:
RichTextEditorScreen(Kotlin 手写 Markdown 工具栏:B/I/标题/列表/引用/代码/链接/图片,选区包裹与行首前缀;预览用 Markwon 渲染,图片用 Coil 加载签名直链),从任务详情与知识点打开,保存按body_md回写 - 安卓编辑器保存后回到任务列表而不是原详情抽屉(体验缺口:用户要重新点开那条任务)
5.4 验证
- 后端
pytest→ 74 passed(附件 8 个用例:上传下载、超限、跨租户、删除确认与权限、raw 签名校验、签名篡改与过期、跨工作区签名) web/e2e/richtext.mjs→ 15 项全绿(Vditor 与 lute 就绪、**粗体**/`代码`/- 列表就地渲染、落库正文不含凭据、贴图后/raw带签名且返回 200、附件重开仍在、表格渲染且不撑破抽屉、控制台无报错)- 既有三套 e2e 回归通过(smoke 7 / tickets 8 / knowledge 7)
tsc -b0 错;vite build主包 299.42 kB │ gzip 74.96 kB,Vditor 独立 chunk 385.24 kB │ gzip 136.05 kB(按需加载,只进详情抽屉)- 安卓
assembleDebug成功(APK 10.1 MB),模拟器上走通「详情 → 编辑正文 → 预览 → 保存」,截图2.Design/screens/android-editor{,-preview}.png
6. 安卓分发:应用内升级(OTA,2026-10-31)
要解决的是"改完安卓端怎么送到手机上":不发商店,也不能每次让人手动传 APK。
6.1 后端
backend/app/api/app_release.py:GET /app/release/latest?version_code=读latest.json清单,GET /app/release/download/{file}只认清单里登记的文件名(含..%2F一律 9001)- 两个接口都免登录:还没装上新包的客户端未必带着有效令牌,检查更新不该被登录挡住
download_url是相对路径,客户端用自己配置的 API 地址拼接——同一份清单要在内网/公网/模拟器之间共用has_update只比对version_code,不看版本名字符串(0.10.0与0.9.0的字典序是坑)backend/scripts/publish_apk.py:复制成dadaotask-<版本>.apk并写latest.json(sha256/size/published_at);backend/static/apk/已在.gitignorebackend/tests/test_app_release.py5 个用例:未发布 9001、清单字段与 download_url、has_update判据、下载字节与 content-type、路径穿越
6.2 安卓
data/AppUpdater.kt:查清单 → 下到Android/data/<pkg>/files/updates/(先写.part再改名)→ 校验 sha256 → 经FileProvider交给系统安装器ui/UpdateDialog.kt:当前/新版本号、更新说明、下载进度、失败重试;「允许安装未知应用」只跳系统设置,不代点- 权限回来那一跳要接住:用户在系统设置里开完开关回到 App,再点同一个按钮时先复查权限,开了就直接装——否则按钮点了没反应
- 启动时自动查一次;首页底部有「检查更新」手动入口,结果用一句话回给用户
- 图标换成与 Web 的
<Logo/>同一套图形(靛蓝圆角底 + 三条白色圆头线 + 淡紫点),生成mipmap-*/ic_launcher{,_round}.png与自适应图标mipmap-anydpi-v26/ic_launcher{,_round}.xml,512px 预览存2.Design/screens/android-icon-512.png - 版本
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 验证
- 后端
pytest→ 103 passed;ruff check/ruff format --check全绿 assembleRelease成功:app-release.apk7.4 MB,apksigner verify --print-certs显示证书CN=Dadao Task- 本地
GET /api/v1/app/release/latest?version_code=1→has_update=true、download_url可下(200,7 762 912 字节),version_code=2→false - 生产(
http://192.168.8.188:8200)同上,安装包已随部署上传 - 模拟器上走通全流程:装 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 两个问题(用户提的)
- 首页是"居中窄栏",点进工作区却变成全屏——同一套产品两种壳,进出像换了个网站。
- 用户相关功能缺失:没有基本信息、没有改密码、没有一个统一的"退出登录"入口。
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.py10 项);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,等产品定