Commit Graph
18 Commits
Author SHA1 Message Date
Steven 8436c0edbf feat(integrations): 微信要先扫码配对,改成 iLink 扫码登录 + 长轮询
CI / backend (push) Canceled after 0s
上一版按"webhook + URL 里带令牌"自己编了一套,用户当场指出微信必须先扫码配对。
现在照腾讯 iLink Bot 协议自己做客户端:取二维码 → 手机扫码确认 → 存加密登录态 →
后端长轮询收消息 → 用 context_token 回话。独立实现,不依赖 DSH 的运行环境。

后端
- wechat_ilink.py:get_bot_qrcode / get_qrcode_status / getupdates / sendmessage,
  bot_token 用 Fernet(密钥由 JWT_SECRET 派生)加密入库,日志与接口都不回明文
- wechat_intake.py:一句话 → 任务或知识草稿,前缀识别、#标签、首行标题
- wechat_bot.py:每条通道一个轮询任务,启动时按 status='active' 恢复;
  message_id 去重;errcode -14 转 expired 让用户重新扫
- api/integrations.py 重写成扫码三接口 + 停用/断开/最近消息
- 迁移 0013(登录态列)、0014(轮询入口按 app.bot_channel 主键的 RLS 口子)

Web
- 接入页改成扫码流程:页面里画出二维码、每 2 秒问一次状态、连上显示微信用户与最近消息

验证
- pytest 149 passed(新增 test_wechat_bot 9 例)
- ruff check / ruff format --check 干净;tsc 0;vite build ✓
- web/e2e/integrations.mjs 7 项 ALL PASS(拿的是微信真二维码)

教训:接入第三方协议先去已有实现里找协议,别从直觉倒推。
2026-11-05 23:03:18 +08:00
Steven 90b99f8253 feat(integrations): 微信里发一句话,变成一条任务或知识
CI / backend (push) Canceled after 0s
一条通道一张令牌,机器人把消息 POST 到 /integrations/clawbot/{token},
正文按前缀落到任务或知识草稿:没有前缀就是任务,「知识:」「笔记:」记成知识,
「帮助」只回用法。首行当标题、其余当正文,`#标签` 摘出来挂到事项上。
同一个 msg_id 只处理一次——微信会重推,重推不能变成两条任务。

为什么不塞进 system_setting 的 JSON:一个工作区可能挂多个通道,还得能单独停用。
令牌库里只存 sha256,只在创建/重新生成时显示一次;收消息时还不知道是哪个工作区,
所以 bot_channel 的 RLS 额外放行 token_hash = current_setting('app.bot_token_hash'),
读到通道再 bind_tenant 进上下文,按通道创建者记账。

- 后端:models_integrations.py(bot_channel / bot_message)、迁移 0012(RLS)、
  api/integrations.py(收消息 + 管理接口)、错误码 9002/9003
- Web:/w/:wsId/integrations「接入」页——建通道、复制回调地址与令牌、最近消息、
  停用/换令牌/删除,只有 owner/admin 能改
- 测试:test_clawbot.py 10 例;web/e2e/integrations.mjs 7 项(冒充 clawbot 真推一条)

验证:pytest 150 passed,ruff check/format 全绿,tsc 0,vite build 通过,e2e 7 项全绿。
2026-11-05 22:04:34 +08:00
Steven b5170ad1ce feat(auth): 邮箱验证码注册,生产重新有了自助入口
CI / backend (push) Canceled after 0s
短信登录关掉后,生产没有任何注册入口——这一版补上最普通的那条路:
填邮箱、收验证码、设密码,注册完直接进首页。

先发码再注册,不是"注册时顺带发信"

  POST /auth/email/code 只做三件事:查重、限流、发码。
  填完密码才被告知"这个邮箱注册过了"最气人,所以占用(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() 拒绝启动。

前端

  新增 /register(邮箱、验证码、密码、确认、昵称;60 秒倒计时;
  服务端关着注册时整页只说"联系管理员开通"),登录页按 /auth/methods
  决定要不要显示「还没有账号?用邮箱注册」。

验证

  pytest 139 passed、ruff check / format 全绿
  tsc --noEmit 0、vite build 通过
  e2e register.mjs 7 项:登录页入口 → 拿码 → 注册落进首页 → 退出用邮箱+密码登回来
    → 同一邮箱再发码回 2019 → 控制台零报错
  e2e login-email-smtp.mjs 15 项回归通过(含真发一封测试信)
  e2e account.mjs 14 项、knowledge.mjs 全绿

顺带修的两处测试

  test_account 的字段白名单补 email_verified(接口确实多了这个字段);
  test_config_prod 用 Settings(_env_file=None, ...) 构造,别被本机 .env 里的
  EMAIL_CODE_DEV_ECHO=true 带偏——那个开关在生产是硬错误,本机却是跑 e2e 必需的。
2026-11-03 01:04:26 +08:00
Steven 7558637567 feat(auth): 短信登录默认关,换成手机号/邮箱 + 密码,发信邮箱进后台
CI / backend (push) Canceled after 0s
用户要求:内测的固定验证码 123456 等于"知道手机号就能进任何账号",先关掉;
登录改用账号密码,并多一种"邮箱当账号名"的方式;发信邮箱要能在后台改。

短信那条路(默认关):
- `SMS_LOGIN_ENABLED=false`,`/auth/sms/send` 与 `/auth/login` 统一回 2017,不是静默失败;
- 生产校验改成"开着短信登录就必须配真通道",不开时 provider 是 dev 也不拦;
- 新增 `GET /auth/methods`,登录页据此渲染,关着的路不出现(少一次"点进去才发现不能用")。

邮箱登录:
- `app_user.email` + `uq_app_user_email`(`lower(email)` 部分索引),登录与绑定都归一成小写;
- `POST /auth/login/password` 收 `phone` 或 `email`(二选一),失败统一 2013、连错 10 次锁 15 分钟;
- 绑定在 `PATCH /auth/me`:格式错 2018、被占用 2019、留空串表示解绑。

系统设置(跨租户,只有平台管理员能改):
- 新表 `system_setting`(不挂租户、无 RLS,写权限由 `deps.platform_admin` 把关);
- `GET/PUT /settings/smtp`、`POST /settings/smtp/test`,授权码只写不读,留空表示沿用旧值;
- `services/mail.py` 只用标准库 smtplib:465 走 SSL、587 走 STARTTLS,认证/拒收/网络失败都归 2020 但消息不同;
- Web 新增「系统设置」页(状态卡 + 测试发信),侧栏只对平台管理员显示入口;账号页补「邮箱」。

验证:pytest 130 passed、ruff check/format 全绿、tsc 通过、vite build 通过;
`web/e2e/login-email-smtp.mjs` 15 项全绿(含用 126 授权码真发一封测试邮件)。

顺带记下的两个坑:`platform_admin_phones` 是 list,pydantic-settings 会按 JSON 解析,
要用 `NoDecode` 交给 before-validator 拆逗号;迁移号写成 0009 会撞既有的 0009,一并改成 0010。
2026-11-03 00:00:11 +08:00
steven 71982f9fa4 feat(knowledge): 分类可改名/配色/删除,选中态与加载态更清楚
CI / backend (push) Canceled after 0s
分类之前只能新增,建错了只能干看着。补上三件事:

- knowledge_category 加 color 列(迁移 0009,CHECK 卡 #RRGGBB),
  PATCH 支持改名与改色,空串表示取消上色;知识列表带出分类色,前端不用再查。
- 分类行悬停出「⋯」:改名、选色板/手填色值、建子分类、删除(有知识时确认后 force)。
- 选中分类改成主色浅底 + 描边 + 色点,列表头显示当前筛选;切分类时列表淡出并转圈,
  不再一动不动等请求回来。

顺带给卡片画分类色上边线:扫一眼就能分出哪几条属于同一类。
2026-11-02 19:24:44 +08:00
Steven a5510cdb79 feat(auth): 手机号也能用密码登录,验证码之外多一条路
短信通道挂了人就进不来,所以给账号加第二把自助的钥匙:设过密码的用密码进,
没设过的照旧只用验证码,不强制。

- app_user.password_hash(迁移 0008 加列)+ scrypt 哈希(标准库实现,
  n=2^14/r=8/p=1,存 scrypt$n$r$p$salt$hash),不引 passlib
- POST /auth/login/password:没设过密码与密码错都回 2013,不区分账号是否存在;
  15 分钟内连错 10 次 → 2016,期间正确密码也挡住
- POST /auth/password:设/改共用,改要先验旧密码,新旧相同算弱密码 2015
- PATCH /auth/me 改昵称;用户输出走白名单 _user_out,密码哈希一个字节都不出去
- 登录页顶部「验证码 / 密码」两段切换

tests/test_account.py 10 项;全量 pytest 113 passed,ruff 全绿。
2026-10-31 04:04:55 +08:00
steven ea5d28304e fix(db): 0006/0007 迁移补上 APP_ROLE 导入 2026-10-30 21:53:42 +08:00
steven 2dbe4cf675 feat(db): 迁移里的数据库角色名改为环境变量注入 2026-10-30 18:49:30 +08:00
Steven ed31af3f46 feat(knowledge): 知识改成备忘录形态,一次写全 + 三级分类,抽屉加宽
CI / backend (push) Canceled after 0s
知识以前只能"填个标题就完成",正文、标签、归类都要点进去再补。现在新建
就是一张完整的备忘录:标题、类型、分类、标签、正文一次写完,存完直接打开
详情继续编辑;知识页左侧多了工作区三级分类树,可按分类子树 / 未分类 / 标签
筛选。凡是填信息的抽屉都加宽了,还能一键展开整屏。

后端
- 新增 `knowledge_category`(迁移 0007):`workspace_id NOT NULL` + 自引用
  `parent_id`、`depth` 1..3 检查约束、同层重名唯一索引(lower(name))、
  RLS 策略与 `dadao_app` 授权;`knowledge_doc.category_id` 外键 ON DELETE
  SET NULL。
- 新增 `/knowledge/categories` 增删改查:超三级 `7007`、同层重名 `7008`、
  跨租户或不存在 `7006`;`move=true` 才改父级,成环与移动后超过三级的子树
  一律拒绝,删除有知识的分类要 `force=true`(知识变未分类)。
- 知识列表支持 `category_id`(含子树)、`uncategorized`、`tag`;输出带
  `category`(含 `path` / `full_path`)。
- PATCH 只改分类或标签也会推进 `version`,避免"只动标签"之后下次保存撞
  乐观锁 `4009`。

Web
- `Sheet` 增 `size="md/lg/xl"` 与展开整屏按钮;任务、工单、知识抽屉统一
  加宽(`TaskDetail` / `Tickets` / `Knowledge`)。
- 知识页:左侧三级分类树(新建、按子树筛选、未分类)、`KnowledgeComposer`
  一次写全新的备忘录、列表卡片露出分类路径与标签、详情抽屉里可直接改分类
  与标签。
- 抽屉详情只拉一次:重复的 effect 或迟到的响应不再覆盖用户正在敲的内容。

安卓
- `Api.kt`:分类模型与接口、新建/更新带 `tag_names`/`category_id`、
  列表带分类与标签筛选。
- 知识页重做:新建改为近全屏大对话框(标题/类型/分类/标签/正文),列表
  顶部按分类筛选,详情里能改分类与标签后单独保存。

文档与测试
- `2.Design/API.md`:5.1/5.3/5.4 补分类与筛选参数,新增 5.5 分类接口章节,
  错误码补 `7006`/`7007`/`7008`。
- `2.Design/ROADMAP-M0-M1.md`:新增 3.11(三级分类)、3.12(备忘录形态)。
- 新增 `backend/tests/test_knowledge_categories.py`(8 例):备忘形态落库、
  三级到底、移动越界与成环、重名、按子树/未分类/标签/关键词筛选、删分类需
  `force`、跨租户隔离。
- 新增 `web/e2e/knowledge-notebook.mjs`(10 项,全绿):建两级分类 → 一次
  写全 → 卡片与筛选 → 抽屉改标签/分类 → 正文落库 → 重名 7008。

验证:`pytest` 93 passed、`ruff check` / `ruff format --check` 全过、
`tsc --noEmit` 通过、`vite build` 通过、`knowledge-notebook.mjs` 10 项全绿。
安卓端本机只有 JDK 25,Gradle 8.7 / AGP 8.7.3 不支持,本次未做编译验证。
2026-10-30 03:51:43 +08:00
dadao d079564d6c feat(knowledge): 语义检索打通——切片、混合打分、只搜已发布
CI / backend (push) Canceled after 0s
之前知识只能"存",不能"找"。这一版把链路补上:切片 → 向量 → 打分 → 带来源返回。

- chunk 表 + 迁移 0006:正文按标题归段切、代码块整块保留、超长按句切、
  相邻片重叠 80 字;RLS 与 GRANT 与其它租户表一致
- 向量提供方做成协议:内置 hash(字符二元组,零依赖可复现,作为基线)
  与 OpenAI 兼容端点两种,换实现不动业务代码
- 检索是混合打分:向量 0.6 + pg_trgm 0.4。专有编号、单号这类必须原样命中的词
  靠词法兜,换个说法表达的同一件事靠向量兜
- 三条纪律:只搜 published(过滤写在 SQL 里)、低于阈值宁可不返回、
  每条命中都带来源标题与 item_id
- 发布时自动建索引,改完正文用 POST /knowledge/{id}/reindex 重建
- Web 知识页加检索框与命中卡片;新增 e2e/retrieval.mjs 6 项

为什么不是 pgvector:本机 Docker 起不来、fnsvr-01 没有该扩展且明确不动。
先用 real[] + SQL 余弦把链路跑通,将来换列类型与索引即可,接口不变。

后端 79 passed,e2e smoke/knowledge/richtext/retrieval 全绿。
2026-10-29 04:28:09 +08:00
dadao a24a493403 feat(rich): 备注/工单说明/知识正文支持 Markdown 与图片附件
写一件事的时候,光有纯文本不够:要贴截图、要分段、要标重点。
这次把「正文」这条路补全,思路是**存 Markdown 而不是 HTML**——
能导出、能全文检索、能直接喂给 LLM,也不必每次渲染都重新清洗一遍 HTML。

后端
- attachment 表 + RLS + 迁移 0005;本地磁盘存储,key 由内容 sha256 决定,
  换对象存储只改 services/storage.py 一个文件
- 上传在**读流时**卡 20MB,不先落盘再判断;磁盘名由服务端生成,用户文件名只进库和响应头
- 列表带 attachment_count、详情带 attachments,避免一屏 N 次请求
- raw 直链给 <img src> 用(图片带不了 Authorization 头),只允许本工作区成员,
  非成员一律 8001 不区分「不存在」与「没权限」
- 5 个用例:上传下载、超限、跨租户、删除确认与权限、raw 鉴权

前端
- 自研零依赖 Markdown 渲染器:先转义再上标签,链接协议白名单
- RichTextEditor:工具栏 + Ctrl+B/I + 预览,粘贴或拖入图片自动上传并插入引用
- AttachmentPanel:上传/下载/删除,下载走带 Authorization 的 fetch 转 blob
- 任务备注、工单说明与验收结论、知识正文、评论全部接入

顺手修的
- TaskDetail.hydrate 漏了 setAttachments,导致附件面板永远是空的(e2e 抓到的)

71 passed;web tsc 0 错、构建 255.07 kB │ gzip 80.85 kB;
e2e:richtext 9 项 + smoke 7 + tickets 8 + knowledge 7,全绿。
2026-10-29 01:53:46 +08:00
dadao e9addeed25 feat(knowledge): 工单验收自动生成草稿,核对后发布,来源可追
- knowledge_doc 与 item 1:1,除 source_kind=manual 外必须有来源(库级约束)
- 状态机 draft/in_review/published/archived,发布要 owner/admin;非法动作回 allowed_from 与 allowed_actions
- 工单验收通过自动落 postmortem 草稿(generated_by=rule),同一来源只生成一次
- 接口:列表/详情/手记/改内容/状态动作/从事项沉淀;已发布禁改,改内容带乐观锁
- Web 知识页 + 详情抽屉;任务与工单详情里可「沉淀成知识」
- 8 个后端用例 + e2e 7 项断言(沉淀 → 待核对 → 发布)
2026-10-28 14:02:35 +08:00
dadao 30caaaf1d9 feat(ticket): 工单协同后端——升级不搬数据、受理到验收、站内通知
- item.kind='ticket' + ticket_ext 1:1,迁移 0003 同时重写 item 状态约束为任务∪工单并集
- 工单状态机与动作权限:受理/开工/提交限办理人,验收限需求人(办理人不能自验)
- 升级时原任务转 blocked,验收通过后自动回流 todo,事件流两侧都留痕
- item_link(父子/相关)与 notification(只发当事人),三张表均开 RLS
- 抽出 serializers.py 复用任务序列化,修复工单响应缺 tags 导致前端白屏
- 评论与 PATCH 放开给工单;conftest 清库前终止残留事务,避免 TRUNCATE 死等锁
- tests/test_tickets.py 11 个用例,全量 57 passed
2026-10-28 13:20:07 +08:00
Steven e25a1a4e31 feat(task): M1 任务域后端与 Web 列表(状态机/乐观锁/视图/搜索)
CI / backend (push) Canceled after 0s
- item/tag/item_tag/comment/activity 五表迁移,RLS 全覆盖,pg_trgm 子串索引
- 状态机 domain/task.py:非法流转回 5002 并给 allowed,done 写 completed_at
- /workspaces/{ws}/items 列表/详情/新建/改/状态/软删/评论/标签
- 乐观锁 UPDATE...WHERE version,冲突 4009 + current_version
- Web 任务页:视图 tab、快速新建、勾选完成、开工/阻塞、详情事件流、游标分页
- 测试 43 全绿,ruff 干净,Web 构建通过
- 文档:2.Design/API.md 新增,ROADMAP 按已验证口径重写
2026-10-28 03:48:48 +08:00
Steven d390b7b58a feat(db): 支持接团队共享库 fnsvr-01(PG 17.6),测试库与开发库分离
CI / backend (push) Canceled after 0s
fnsvr-01 的 PostgreSQL 在 8432 端口(不是 5432),实测 PostgreSQL 17.6 / Alpine。
已在该实例建好独立库与角色:
- dadaotask(开发)/ dadaotask_test(测试),owner 都是 dadao_owner
- dadao_owner 迁移用;dadao_app 应用用(NOSUPERUSER NOBYPASSRLS)
- 两个库都已 ALTER SCHEMA public OWNER TO dadao_owner + REVOKE CREATE FROM PUBLIC + GRANT 给 dadao_app

代码改动:
- config 增加 test_database_url / test_migration_database_url,并给出
  resolved_database_url() / resolved_migration_database_url():ENV=test 且有测试库时优先用测试库,
  否则退回开发库(CI 里直接给 DATABASE_URL 即可,行为不变)。
- db.py / alembic/env.py / tests/conftest.py 统一走这两个解析方法。
  原先 conftest 清表用的是 settings.migration_database_url,而应用连的是测试库,
  一旦测试库与开发库不同,TRUNCATE 就清到另一个库上——表现为"测试库脏数据越攒越多、
  列表类断言随机失败"。这是本次真踩到的坑,不是假想。
- tests/conftest.py 保留 E402 豁免(环境变量必须在 import app 模块前写好)。

验证:
- 远程 dadaotask_test 上 pytest 33 passed,跑完表内 0 行(确认清表打在同一个库)
- 远程 dadaotask 上 alembic upgrade head 成功,/health 与 /version 正常,db_role 为 dadao_app 且三项全 false
- 本机便携 PG 16.4 上同样 33 passed(两个 PG 大版本都过)
- ruff check / format 干净

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

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

验收:pytest 33 passed / ruff 干净 / alembic 升降级往返通过 / web build 通过。
2026-10-28 03:28:06 +08:00
Steven 2b493bd9f8 fix(m0): 迁移可执行 + 测试全绿(33 条)
CI / backend (push) Canceled after 0s
跑通迁移与测试后修掉的真问题:

- alembic 0001:asyncpg 走预备语句,一次 execute 只能一条 SQL。
  CREATE FUNCTION / CREATE POLICY / GRANT 全部拆成单语句逐条执行,
  否则报 "cannot insert multiple commands into a prepared statement"。
- 创建工作区:ORM flush 只按 relationship 排序,裸外键不算数,
  member 可能先于 workspace 落地撞 FK。改为先 flush workspace 再插 member。
- datetime.UTC 是 3.11+ 才有的,本机 3.10 直接 ImportError。改用 timezone.utc。
- 幂等中间件:原来会缓存 4xx。校验失败被缓存后,用户改对参数仍拿到同一个 422。
  改为只缓存 <400。
- 发码节流窗口改为可配置 SMS_THROTTLE_SECONDS(测试置 0),
  节流用例自己显式打开,避免"整库关掉节流"导致节流从未被验证。
- 租户隔离用例:pytest.raises(Exception) 改为精确的 ProgrammingError,
  否则任何异常都会让"应用角色不能建表"这条断言假通过。

验收:pytest 33 passed;ruff check / format 通过;真机跑 uvicorn,
/health、/version、登录、建工作区、列表、/auth/me 全通。
2026-10-28 03:22:59 +08:00
Steven 0c53aa7062 feat(m0): 后端骨架 + Web/安卓前端骨架 + 设计文档
- backend: FastAPI 应用工厂、统一错误信封、JWT、短信登录、工作区/成员/邀请、幂等中间件
- alembic 0001: workspace/member/invitation/audit_log/app_user + RLS 策略 + 双角色 GRANT
- tests: 认证/工作区/租户隔离/幂等 四组用例(尚未在本机执行)
- web: React18+Vite 骨架(登录、工作区、邀请落地页)
- android: Compose 骨架(登录、工作区列表)
- 2.Design: PRODUCT-PLAN / ROADMAP-M0-M1 / NAMING / DEVELOPMENT
2026-10-28 02:38:53 +08:00