Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28eab4ae5f | ||
|
|
dcd77c6b35 | ||
|
|
4e3f56af79 | ||
|
|
377dabcc9e | ||
|
|
3e29ef5ee2 | ||
|
|
690779c5fb | ||
|
|
626f83fb9c | ||
|
|
f6e7616756 | ||
|
|
2170a0664d | ||
|
|
07b01db7cc | ||
|
|
1e8cd01b3d | ||
|
|
8e3dc65cc6 | ||
|
|
8436c0edbf | ||
|
|
d21394a5ac | ||
|
|
c194609193 | ||
|
|
90b99f8253 | ||
|
|
33a3fc03e0 | ||
|
|
2a9d72a974 | ||
|
|
29805bd61e | ||
|
|
2c338490e5 | ||
|
|
02ef47e753 | ||
|
|
da31083df2 | ||
|
|
35fdea7260 | ||
|
|
d34a6ac69e | ||
|
|
127303b7aa | ||
|
|
b5170ad1ce | ||
|
|
c0c5230fd1 | ||
|
|
742af4dd02 | ||
|
|
7558637567 | ||
|
|
23687d6e37 | ||
|
|
0660e5cbda | ||
|
|
2032e9d981 | ||
|
|
0b4e829d8b | ||
|
|
71982f9fa4 | ||
|
|
d7c9670880 | ||
|
|
288e27e465 | ||
|
|
684b9b2604 | ||
|
|
4796b695a3 | ||
|
|
b3cd115bd9 | ||
|
|
694b643683 | ||
|
|
b662429842 | ||
|
|
d24be46d8c | ||
|
|
483b554c3c | ||
|
|
370d8923c8 | ||
|
|
429905892c | ||
|
|
e3f1310cff | ||
|
|
096acb00e7 | ||
|
|
a5510cdb79 | ||
|
|
29aae12224 | ||
|
|
7668799040 | ||
|
|
35fe4d92cb | ||
|
|
50227c2114 | ||
|
|
b29286778f | ||
|
|
441b1b93e1 | ||
|
|
bbd30ec85e | ||
|
|
039e5b903c | ||
|
|
8b6594bf10 | ||
|
|
2ff1f6e9cd |
@@ -27,6 +27,9 @@ android/.gradle/
|
||||
android/build/
|
||||
android/app/build/
|
||||
android/local.properties
|
||||
# 发布签名证书与口令(只在发布者本机;仓库里没有它就用调试签名)
|
||||
android/keystore.properties
|
||||
*.jks
|
||||
|
||||
# 编辑器 / 系统
|
||||
.DS_Store
|
||||
@@ -47,6 +50,15 @@ android/.kotlin/
|
||||
# 本地上传文件(附件落盘目录,绝不入库)
|
||||
backend/var/
|
||||
|
||||
# 发布的安卓安装包由 publish_apk.py 放在这里,APK 不进版本库
|
||||
backend/static/apk/
|
||||
|
||||
# pytest 的临时目录(在 backend/ 下跑测试时生成)
|
||||
backend/pytest-of-*/
|
||||
|
||||
# Vditor 运行时资源(lute/i18n/icons,约 3.8 MB)
|
||||
# 由 web/scripts/vendor-vditor.mjs 从 node_modules 生成,不进版本库
|
||||
web/public/vditor/
|
||||
|
||||
# 本机部署/调试的临时脚本与缓存
|
||||
.tmp/
|
||||
|
||||
@@ -29,6 +29,16 @@
|
||||
| 2010 | 429 | 验证码请求太频繁 | 倒计时 60 秒 |
|
||||
| 2011 | 400 | 验证码错误/已过期 | 重发 |
|
||||
| 2012 | 403 | 账号被禁用 | 联系管理员 |
|
||||
| 2013 | 400 | 密码错误,或这个号还没设过密码 | 提示核对账号与密码 |
|
||||
| 2014 | 400 | 该账号没有密码(保留码) | 先设密码 |
|
||||
| 2015 | 422 | 密码太弱(少于 8 位 / 缺字母或数字 / 与旧密码相同) | 就地提示规则 |
|
||||
| 2016 | 429 | 密码连错太多次(15 分钟内 10 次) | 提示稍后再试 |
|
||||
| 2017 | 403 | 这条登录方式没开(默认只开密码) | 换密码登录 |
|
||||
| 2018 | 422 | 邮箱格式不对 | 就地提示 |
|
||||
| 2019 | 409 | 邮箱已被别的账号占用 | 换一个 |
|
||||
| 2020 | 502 | 发信失败(SMTP 认证/收件人/网络) | 去「系统设置」核对授权码 |
|
||||
| 2021 | 400 | 邮箱验证码不对 / 已过期 / 试错次数用尽 | 重新获取验证码 |
|
||||
| 2022 | 429 | 验证码发送过于频繁 | 等 60 秒再发 |
|
||||
| 3001 | 404 | 工作区不存在 | — |
|
||||
| 3003 | 403 | 不是该工作区成员 | **不要**提示"工作区不存在" |
|
||||
| 3004 | 403 | 角色权限不足 | 隐藏对应按钮 |
|
||||
@@ -46,6 +56,10 @@
|
||||
`Authorization: Bearer <access_token>`。`access` 30 分钟,`refresh` 14 天。
|
||||
`refresh_token` 只能用于 `/auth/refresh`(服务端校验 `typ`)。
|
||||
|
||||
**客户端要自己续期,别等用户被踢出去**:access 剩不到 90 秒就先换一对,
|
||||
撞上 `401/2002` 也先用 refresh 换一对、把原请求重放一次,换不动才清令牌回登录页。
|
||||
每次 `/auth/refresh` 都会返回新的 refresh,所以只要 14 天内用过一次,登录态就一直往后顺延。
|
||||
|
||||
### 0.4 幂等
|
||||
|
||||
所有 `POST/PATCH/DELETE` 都可带 `Idempotency-Key: <uuid>`。
|
||||
@@ -61,6 +75,53 @@
|
||||
|
||||
## 1. 认证 `/auth`
|
||||
|
||||
登录方式有四种,**默认只开「密码」和「邮箱注册」**。服务端把"现在开着哪些"告诉前端,
|
||||
前端据此渲染,免得用户点进一条关着的路:
|
||||
|
||||
### `GET /auth/methods`
|
||||
|
||||
```json
|
||||
{ "password": true, "email_password": true, "sms_code": false, "email_register": true }
|
||||
```
|
||||
|
||||
`SMS_LOGIN_ENABLED=false`(默认)时,`POST /auth/sms/send` 与 `POST /auth/login` 都回 `2017`
|
||||
「短信验证码登录暂未开放」。理由:dev 环境的固定码等于"知道手机号就能进任何账号",
|
||||
这条路必须显式打开,生产打开时还会强制要求真短信通道(见 `deploy` 文档的启动校验)。
|
||||
|
||||
### `POST /auth/email/code`
|
||||
|
||||
```json
|
||||
{ "email": "someone@example.com" }
|
||||
```
|
||||
|
||||
发一封注册验证码(6 位数字,`EMAIL_CODE_TTL_SECONDS` 默认 600 秒有效)。
|
||||
邮箱统一按小写处理;格式不对 → `2018`;**这个邮箱已经注册过 → `2019`**(让用户在填完密码前就知道);
|
||||
60 秒内重复请求 → `2022`;发信配置没填或发不出去 → `2020`(不会假装发出去);
|
||||
`EMAIL_REGISTER_ENABLED=false` → `2017`。
|
||||
|
||||
响应 `data: { sent: true, expires_in: 600 }`。**真发信时不回显验证码**;
|
||||
只有本机联调(`EMAIL_CODE_DEV_ECHO=true`)时才带 `dev_code` 且不发信——这个开关在生产会直接拒绝启动。
|
||||
|
||||
### `POST /auth/register`
|
||||
|
||||
```json
|
||||
{ "email": "someone@example.com", "code": "123456", "password": "dadao2026", "nickname": "大道" }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "user": { "id": "…", "phone": "", "email": "someone@example.com", "email_verified": true, … },
|
||||
"tokens": { "access_token": "…", "refresh_token": "…", "token_type": "bearer", "expires_in": 1800 } }
|
||||
```
|
||||
|
||||
注册成功即登录态(直接返回 `tokens`)。规则:
|
||||
|
||||
- 验证码不对 / 过期 / 试错超过 `EMAIL_CODE_MAX_ATTEMPTS`(默认 5)次 → `2021`;用完即作废,不能重放
|
||||
- 密码强度不够 → `2015`;邮箱已被占用 → `2019`;`EMAIL_REGISTER_ENABLED=false` → `2017`
|
||||
- 昵称不填就取邮箱 `@` 前面那段
|
||||
- 建出来的账号**没有手机号**(`app_user.phone` 可空),所以 `user.phone` 回空串,不是星号串
|
||||
|
||||
邮箱是这类账号的唯一账号名与联系方式,所以注册必须真验一次码——"随手填一个"会让账号变成没人能找回的孤岛。
|
||||
|
||||
### `POST /auth/sms/send`
|
||||
|
||||
```json
|
||||
@@ -68,7 +129,7 @@
|
||||
```
|
||||
|
||||
响应 `data: { dev_code?: "123456" }`。`ENV=dev` 时才回显验证码,生产没有这个字段。
|
||||
60 秒内重复请求 → `2010`。
|
||||
60 秒内重复请求 → `2010`。短信登录关着时 → `2017`。
|
||||
|
||||
### `POST /auth/login`
|
||||
|
||||
@@ -89,11 +150,87 @@
|
||||
{ "refresh_token": "…" }
|
||||
```
|
||||
|
||||
返回同形状的 `tokens`。无状态 JWT,**服务端不维护黑名单**——token 泄露只能等过期,这是 M0 已知取舍。
|
||||
返回同形状的 `tokens`(**扁平**,没有 `tokens` 信封)。无状态 JWT,**服务端不维护黑名单**——
|
||||
token 泄露只能等过期,这是 M0 已知取舍;换来的是刷新不需要查库。
|
||||
|
||||
换的时候会看一眼账号状态:账号被停用 → `2012`(403),停用的人不能靠 refresh 一直续下去。
|
||||
|
||||
### `POST /auth/login/password`
|
||||
|
||||
手机号和邮箱都走这一个入口,`phone` 与 `email` **二选一**(都给或都不给 → 422):
|
||||
|
||||
```json
|
||||
{ "phone": "13800000000", "password": "dadao2026" }
|
||||
{ "email": "shufen@126.com", "password": "dadao2026" }
|
||||
```
|
||||
|
||||
返回与验证码登录同形状的 `{ user, tokens }`。**没设过密码的账号一律回 `2013`**,与"密码错"同一个码
|
||||
(不区分账号是否存在,少一个探测面;手机号与邮箱两种账号也不会互相暴露)。15 分钟内连错 10 次 → `2016`,
|
||||
期间正确密码也挡住,计数键是"手机号或邮箱"本身。
|
||||
|
||||
邮箱按 `lower(email)` 比较,绑定与登录都做 `strip` + 小写归一,所以 `Shufen@126.com` 与
|
||||
`shufen@126.com` 是同一个账号。
|
||||
|
||||
密码校验是 scrypt(`n=2^14, r=8, p=1`,标准库实现,不引 passlib),库里存
|
||||
`scrypt$n$r$p$salt$hash`。`password_hash` 这一列**永不出现在任何响应里**。
|
||||
|
||||
### `POST /auth/password`
|
||||
|
||||
```json
|
||||
{ "password": "dadao2026", "old_password": "old-one-1" }
|
||||
```
|
||||
|
||||
设密码 / 改密码共用。已经有密码时 `old_password` 必填且必须正确;新旧密码相同 → `2015`。
|
||||
强度:8–64 位,至少一个字母 + 一个数字。返回 `{ "has_password": true }`。
|
||||
|
||||
### `PATCH /auth/me`
|
||||
|
||||
```json
|
||||
{ "nickname": "大道", "email": "shufen@126.com" }
|
||||
```
|
||||
|
||||
字段都可选,只改传上来的。邮箱留空串表示**解绑**(之后不能再用邮箱登录)。
|
||||
格式不对 → `2018`,已被别的账号占用 → `2019`。返回 `{ "user": { … } }`。
|
||||
空昵称(strip 后为空)→ `1002`。手机号暂不支持自助换号。
|
||||
|
||||
### `GET /auth/me`
|
||||
|
||||
返回 `{ user, workspaces[] }`,`workspaces[].role` 是当前用户在该工作区的角色。
|
||||
`user` 里带 `has_password`(前端据此显示"设置密码"还是"修改密码")、`email`、
|
||||
`email_verified`(这个邮箱是不是真验过)、`platform_admin`(能不能进「系统设置」)。
|
||||
|
||||
---
|
||||
|
||||
## 1.5 系统设置 `/settings`
|
||||
|
||||
平台级配置,**全站一份、不挂租户**,只有 `PLATFORM_ADMIN_PHONES` 里的人能读写(否则 `3004`)。
|
||||
目前只有发信邮箱一项——它承载找回密码与以后的邮件通知,属于"改一次全站受影响"的东西,
|
||||
放后台可改而不用登服务器改 env。
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/settings/smtp` | 当前配置(**不含授权码**) |
|
||||
| PUT | `/settings/smtp` | 保存,立即生效,不重启 |
|
||||
| POST | `/settings/smtp/test` | 发一封测试邮件,确认通道通不通 |
|
||||
|
||||
```json
|
||||
// PUT /settings/smtp
|
||||
{ "host": "smtp.126.com", "port": 465, "username": "shufenhf@126.com",
|
||||
"password": "客户端授权码", "from_name": "达道Task", "use_ssl": true }
|
||||
```
|
||||
|
||||
`password` 传 `null` 或空串表示**沿用旧值**(改别的字段时不用重填授权码)。
|
||||
|
||||
```json
|
||||
// GET /settings/smtp
|
||||
{ "host": "smtp.126.com", "port": 465, "username": "shufenhf@126.com",
|
||||
"password_set": true, "from_name": "达道Task", "use_ssl": true,
|
||||
"configured": true, "source": "db", "updated_at": "2026-11-02T12:00:00Z" }
|
||||
```
|
||||
|
||||
`source` 说明当前值来自数据库(`db`)、服务器环境变量兜底(`env`),还是根本没配(`none`)。
|
||||
发信失败统一 `2020`(`502`),消息里写清是认证失败还是收件人被拒——授权码要用邮箱服务商的
|
||||
**客户端授权码**,不是登录密码。
|
||||
|
||||
---
|
||||
|
||||
@@ -376,7 +513,30 @@ Web 端未读数由 `AppShell` 每 30 秒轮询一次;在通知页标已读后
|
||||
|
||||
### 5.2 `GET /knowledge/{item_id}`
|
||||
|
||||
详情,附最近 50 条 `activities`。不存在或跨租户统一 `7001`。
|
||||
详情,附最近 50 条 `activities`。不存在、跨租户、**已经删掉**的统一 `7001`
|
||||
(删除是软删,但对读的人来说就是"没了",否则收藏的链接还会打开一篇已删除的知识)。
|
||||
|
||||
### 5.2.1 `GET /knowledge/tags` — 侧栏那串标签
|
||||
|
||||
```
|
||||
GET /knowledge/tags?status=published&status=in_review
|
||||
```
|
||||
|
||||
```json
|
||||
{"items": [{"name": "发布", "count": 12}, {"name": "回滚", "count": 3}]}
|
||||
```
|
||||
|
||||
- `count` 是**有多少条知识用了它**(`count(distinct item_id)`),不是引用次数。
|
||||
- 只统计未删除的知识;带了 `status` 就只数这些状态,和列表的 tab 对得上。
|
||||
- 按条数从多到少排,同数按名字。标签是分类之外的第二根轴:分类回答"属于哪一块",标签回答"跟什么有关"。
|
||||
|
||||
### 5.2.2 `DELETE /knowledge/{item_id}?confirm=true` — 删除
|
||||
|
||||
- 必须显式带 `confirm=true`,否则 `1002`「删除不能撤销,带 confirm=true 再来一次」(防手滑)。
|
||||
- 只有**作者本人**或 `owner`/`admin` 能删,否则 `3004`。
|
||||
- 软删:`item.deleted_at` 落时间戳,列表、详情、检索、标签统计里都不再出现;
|
||||
**检索切片真删**(`chunk` 表),否则"删掉的知识还能被搜出来"比删不掉更吓人。
|
||||
- 重复删不回错误,回 `message: "已经删过了"`,让重试的客户端不必区分。
|
||||
|
||||
### 5.3 `POST /knowledge` — 手记或指定来源
|
||||
|
||||
@@ -396,7 +556,11 @@ Web 端未读数由 `AppShell` 每 30 秒轮询一次;在通知页标已读后
|
||||
|
||||
必须带 `version`(乐观锁,冲突 `4009`)。可改 `title`/`body_md`/`summary`/`doc_type`/`tag_names`/`category_id`;
|
||||
传 `clear_category: true` 表示移到未分类。
|
||||
**已发布的不许直接改**(`7003`,先撤回审议或归档),避免"发布后被悄悄改写"。
|
||||
|
||||
**已发布的可以直接改**:改的是"团队现在看到的这一版",改完立刻重建检索切片(`chunk_count` 当场更新),
|
||||
避免"正文改了、搜出来还是旧句子"。版本号照旧 `+1`,历史仍能靠审计追。
|
||||
**已归档的不许直接改**(`7003`,`allowed_actions: ["restore"]`):先恢复成草稿再改,
|
||||
免得改完还挂着"现役"的样子。
|
||||
|
||||
### 5.5 知识分类 `/knowledge/categories`
|
||||
|
||||
@@ -404,7 +568,7 @@ Web 端未读数由 `AppShell` 每 30 秒轮询一次;在通知页标已读后
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/knowledge/categories` | 扁平列表(按 depth/sort_order 排好),每条带 `doc_count`、`path`、`full_path`;另有 `max_depth=3` |
|
||||
| GET | `/knowledge/categories` | 扁平列表,但按**树的先序**排好(父分类后紧跟它的子分类,兄弟按 `sort_order`、`name`),前端照 `depth` 缩进即可;每条带 `doc_count`、`path`、`full_path`;另有 `max_depth=3` |
|
||||
| POST | `/knowledge/categories` | `{name, parent_id?, sort_order?}` |
|
||||
| PATCH | `/knowledge/categories/{id}` | `{name?, parent_id?, move?, sort_order?, version}` |
|
||||
| DELETE | `/knowledge/categories/{id}?force=false` | 有子分类不让删;有知识要 `force=true`,删后知识变未分类 |
|
||||
@@ -540,8 +704,6 @@ Query:
|
||||
|
||||
### 6.3 附件错误码
|
||||
|
||||
### 6.3 附件错误码
|
||||
|
||||
| 码 | HTTP | 含义 |
|
||||
|---|---|---|
|
||||
| 8001 | 404 | 附件不存在,或不属于这个工作区 |
|
||||
@@ -558,7 +720,155 @@ Query:
|
||||
|
||||
---
|
||||
|
||||
## 7. 健康与版本
|
||||
## 6.5 外部接入(微信 clawbot,页面叫「机器人」)
|
||||
|
||||
微信里那个机器人连上本服务之后,发一句话就变成任务或知识草稿。
|
||||
走的是腾讯 iLink Bot 协议:**扫码登录 → 后端长轮询收消息 → 用 context_token 回复**。
|
||||
所以这一组里只有「扫码」是免登录的(机器人自己不带我们的令牌),其余都要工作区 admin 以上。
|
||||
|
||||
### 6.5.1 扫码与通道(工作区 admin 以上)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/workspaces/{ws}/integrations` | 列出通道(含 `status`、`connected`、`polling`、`last_seen_at`、`last_error`) |
|
||||
| POST | `/workspaces/{ws}/integrations/wechat/qr` | 取一张二维码,返回 `{channel_id, qrcode_id, payload, image, expires_in}` |
|
||||
| GET | `/workspaces/{ws}/integrations/wechat/qr/{qrcode_id}` | 问扫码结果:`waiting`/`scaned`/`confirmed`/`expired` |
|
||||
| PATCH | `/workspaces/{ws}/integrations/{id}` | `{"status":"active"|"disabled"}`,停用就停轮询 |
|
||||
| DELETE | `/workspaces/{ws}/integrations/{id}?confirm=true` | 断开:清掉登录态与游标,回到 `pending` |
|
||||
| GET | `/workspaces/{ws}/integrations/{id}/messages?limit=5` | 最近收到的消息与它变成了什么 |
|
||||
|
||||
取二维码的响应(`image` 是可以直接塞进 `<img>` 的 data URL,本地渲染,不是图片地址):
|
||||
|
||||
```json
|
||||
{
|
||||
"channel_id": "4f0a…",
|
||||
"qrcode_id": "30c97db83291e779217cbe20c5373e91",
|
||||
"payload": "https://liteapp.weixin.qq.com/q/7GiQu1?qrcode=30c97…&bot_type=3",
|
||||
"image": "data:image/svg+xml;base64,PD94bWw…",
|
||||
"expires_in": 300
|
||||
}
|
||||
```
|
||||
|
||||
扫码成功那一次(`status == "confirmed"`)除了回 `status`,还带 `channel`:登录态已加密存进
|
||||
`bot_channel.bot_token_enc`(Fernet,密钥由 `JWT_SECRET` 派生),**不会回给任何接口**,轮询任务同时被拉起。
|
||||
|
||||
`status` 的四个值:`pending`(还没连)、`active`(连上了、正在收)、`disabled`(管理员停用)、
|
||||
`expired`(登录态失效,需要重新扫码)。`polling` 说明后台任务此刻是否在跑。
|
||||
|
||||
### 6.5.2 消息怎么变成事项(不分接口,轮询里做)
|
||||
|
||||
给机器人发的话这样理解:
|
||||
|
||||
| 开头 | 落成什么 |
|
||||
|---|---|
|
||||
| `任务:`/`待办:`/`todo:` 或不带前缀 | 任务(`status = "inbox"`) |
|
||||
| `知识:`/`笔记:`/`note:`/`kb:` | 知识草稿(`status = "draft"`,`doc_type = "note"`) |
|
||||
| `帮助`/`?`/`help` | 只回一段用法,不建任何东西 |
|
||||
|
||||
正文第一行当标题(超过 180 字截断),其余当正文;`#标签` 会被摘出来加到事项上(纯数字的不算标签)。
|
||||
建完之后会用 `context_token` 在微信里回一句「已保存任务:标题」/「已保存知识草稿:标题」。
|
||||
**回话里不带链接**:站点地址对手机不一定打得开(内网 IP、端口、还没上 HTTPS 的域名),
|
||||
发过去只会让人以为崩了。
|
||||
同一条消息(按 `message_id`)重复推送只会落一次,重推直接跳过。
|
||||
|
||||
图片/文件/视频目前只在 `bot_message.raw` 里留痕,正文不搬;语音如果服务端给了转写就用转写文本。
|
||||
|
||||
### 6.5.2.1 发一条链接:抓正文存成知识
|
||||
|
||||
整个消息就是一条 `http://` / `https://` 链接时(前后带标点不算),不当任务,而是去把文章抓下来:
|
||||
|
||||
| 环节 | 行为 |
|
||||
|---|---|
|
||||
| 判断 | `extract_url()`:去掉首尾标点后只剩一个链接;带了别的话("看下 https://… 这个")仍按任务处理 |
|
||||
| 抓取 | `httpx` 取页面(25 秒超时、8 MB 上限),`trafilatura` 抽正文;标题取 `og:title`/`<h1>`/`<title>` |
|
||||
| 公众号(旧页) | `mp.weixin.qq.com` 且页面有 `#js_content`:标题 `#activity-name`、作者 `#js_name`、正文 `#js_content`,图片取 `data-src`(通用抽取器会把公众号的图丢光) |
|
||||
| 公众号(新版页) | 没有 `#js_content` 时(`appmsg_type = '9'` / `'10002'`)整篇在 `window.cgiDataNew` 里:从 `content_noencode` / `desc` 的 JS 字符串字面量读正文,按 `\xNN`、`\uNNNN` 反转义,话题标签先 `html.unescape` 再走 DOM;配图字段(`cdn_url`/`img_url`/`pic_url`)排在文字之后,前后各找 2500 字符合图;作者兜底取 `nick_name` |
|
||||
| 清洗 | 导航/页脚按行边界裁掉(`_trim_chrome`);`trafilatura` 结果太短(<120 字)就退到容器打分兜底 |
|
||||
| 配图 | 正文里的外链图下载进我们自己的存储,最多 12 张、单张 8 MB,正文改写成 `/attachments/{id}/raw?签名`——公众号一换图,外链就是一排红叉 |
|
||||
| 落库 | 知识草稿(`status="draft"`,`doc_type="note"`),正文首行是 `> 原文:<url>` |
|
||||
| 回话 | `已存成知识草稿:标题`(存了图再加「,连图 N 张」) |
|
||||
| 失败 | `这个链接没抓下来(原因)。想直接记成任务的话,加「任务:」前缀再发一次。` |
|
||||
|
||||
安全:抓取入口对任何能给机器人发消息的人开放,所以**只允许公网地址**——`127.0.0.1`、内网段、
|
||||
链路本地、保留地址、解析不出来的域名一律拒绝(`ArticleError`),否则就是个 SSRF 跳板。
|
||||
反爬/需登录的页面(微信"环境异常"验证页、Cloudflare 302、SPA 空壳)会回上面那句失败提示,不静默吞掉。
|
||||
|
||||
### 6.5.3 错误码
|
||||
|
||||
| 场景 | code | HTTP |
|
||||
|---|---|---|
|
||||
| 取二维码 / 问状态时微信返回错误 | `9002` | 404 |
|
||||
| 通道不存在、或不属于这个工作区 | `9002` | 404 |
|
||||
| 登录态失效(微信 errcode `-14`) | —(通道转 `expired`,接口正常返回) | 200 |
|
||||
|
||||
### 6.5.4 表与隔离
|
||||
|
||||
`bot_channel`(通道、登录态、游标)与 `bot_message`(收到的消息留痕)都挂 `workspace_id`,
|
||||
走标准 RLS。轮询任务从 `channel_id` 起步、此刻还没有工作区上下文,所以迁移 `0014`
|
||||
额外给了一条 `app.bot_channel` 的按主键口子(permissive 策略,与原隔离策略 OR)。
|
||||
|
||||
|
||||
## 7. 安卓发布与升级 `/app`
|
||||
|
||||
安装包不进版本库:`backend/static/apk/` 由 `publish_apk.py` 写入,里面有 APK 与一份
|
||||
`latest.json` 清单。接口只读这份清单,**没有清单就等于没发布过**(9001)。
|
||||
|
||||
### 7.1 `GET /app/release/latest`
|
||||
|
||||
| 参数 | 说明 |
|
||||
|---|---|
|
||||
| `version_code` | 可选,客户端当前版本号;给了才会算 `has_update` |
|
||||
|
||||
免登录:老版本客户端可能连"登录"这一步都还没走到,检查更新不该需要令牌。
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"platform": "android",
|
||||
"version_code": 2,
|
||||
"version_name": "0.2.0",
|
||||
"notes": "新增应用内升级;图标换成和 Web 一样",
|
||||
"file": "dadaotask-0.2.0.apk",
|
||||
"size": 8123456,
|
||||
"sha256": "…",
|
||||
"min_sdk": 26,
|
||||
"published_at": "2026-10-31T03:20:00Z",
|
||||
"download_url": "/api/v1/app/release/download/dadaotask-0.2.0.apk",
|
||||
"has_update": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`download_url` 是**相对路径**:同一份清单要被内网、公网、模拟器三个入口共用,
|
||||
写死域名只会让其中一个坏掉;客户端用自己配置的 API 地址拼。
|
||||
|
||||
`has_update` 的判据只有 `version_code`,不看版本名字符串——字符串比较会把
|
||||
`0.10.0` 判成比 `0.9.0` 旧。
|
||||
|
||||
### 7.2 `GET /app/release/download/{file}`
|
||||
|
||||
只认清单里登记过的文件名,其它一律 9001(含 `..%2F` 这类路径穿越尝试)。
|
||||
返回 `application/vnd.android.package-archive`。同样免登录:下载地址本身就是
|
||||
清单里给出的,能拿到清单就说明已经过了一道。
|
||||
|
||||
### 7.3 发布包的约定
|
||||
|
||||
- APK 必须用**同一把证书**签名,否则新包装不上去(系统只会报"应用未安装")。
|
||||
证书放在发布者本机(`android/keystore.properties` 指向它),不进仓库。
|
||||
- 上传清单带 `sha256`,客户端下完先校验再交给系统安装器:内网代理缓存旧包时,
|
||||
这是唯一能挡住"装了个旧版本"的地方。
|
||||
- 「允许安装未知应用」是系统开关,App 只能把用户送到设置页,不能代点。
|
||||
|
||||
### 7.4 错误码
|
||||
|
||||
| 码 | HTTP | 含义 |
|
||||
|---|---|---|
|
||||
| 9001 | 404 | 还没发布过安装包,或下载的文件不在清单里 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 健康与版本
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
|
||||
@@ -57,6 +57,11 @@ vim ~/.config/dadaotask/env.production # 替换全部 __CHANGE_ME__
|
||||
|
||||
必填项:`DATABASE_URL` / `MIGRATION_DATABASE_URL`(2.1 的口令)、`JWT_SECRET`(≥32 位随机串)、`REDIS_URL`、`WEB_DIST_DIR`、`UPLOAD_DIR`。
|
||||
|
||||
两个踩过的坑:
|
||||
|
||||
- **`CORS_ORIGINS` 必须写成 JSON 数组**(`'["http://...","http://..."]'`)。它是 list 字段,逗号分隔的字符串会让 pydantic-settings 当 JSON 解析并抛 `SettingsError`,alembic 这一步就挂。
|
||||
- **Redis 有口令**:fnsvr-01 的 redis 开了 `requirepass`(口令看 `docker inspect redis`),写进 URL 时要百分号编码(`#` → `%23`),否则连不上、发码/登录直接 500。
|
||||
|
||||
**当前状态是内测模式**:短信通道还没接(非 dev provider 直接 501),所以 `SMS_PROVIDER=dev` + `INTERNAL_TEST_LOGIN=true`,固定验证码 `123456`。启动日志会打 WARNING。**只允许内网访问**;短信通道接好后删掉 `INTERNAL_TEST_LOGIN` 并把 `SMS_PROVIDER` 换成真实供应商。
|
||||
|
||||
### 2.4 装服务单元
|
||||
@@ -95,7 +100,87 @@ curl -sI http://192.168.8.188:8200/ | head -1 # 200,text/html(前端产
|
||||
|
||||
浏览器验收:打开 `http://192.168.8.188:8200` → 手机号任意 + 验证码 `123456` 登录 → 任务/工单/知识各点一遍。
|
||||
|
||||
## 5. 运维
|
||||
## 5. 安卓安装包发布(OTA)
|
||||
|
||||
APK 不进版本库,也不走 `deploy.sh`:代码发布和"发一个安装包"是两件事,
|
||||
前者每次上线都做,后者只在安卓端有改动时做。
|
||||
|
||||
### 5.1 出包(本机)
|
||||
|
||||
```bash
|
||||
cd android
|
||||
export JAVA_HOME=<JDK 17> # AGP 8.7 不认 JDK 25
|
||||
export GRADLE_USER_HOME=<可写的 gradle 目录>
|
||||
gradle -Dorg.gradle.jvmargs="-Djava.io.tmpdir=<可写 tmp>" assembleRelease
|
||||
# 产物:android/app/build/outputs/apk/release/app-release.apk
|
||||
```
|
||||
|
||||
- 版本号在 `android/app/build.gradle.kts`:`versionCode` 是 OTA 的唯一判据,**每次发版必须 +1**,
|
||||
否则客户端查不到更新;`versionName` 只是给人看的。
|
||||
- 签名证书在 `android/keystore.properties`(本机文件,`.gitignore` 已排除)指向的 `.jks`。
|
||||
**换证书等于换应用**:装了旧包的手机再也装不上新包,只能卸载重装。
|
||||
- `dadao.apiBase`(`android/local.properties`)决定包里写死的后端地址。正式包指向
|
||||
`http://192.168.8.188:8200`,模拟器调试包指向 `http://10.0.2.2:8100`。
|
||||
|
||||
### 5.2 发布清单
|
||||
|
||||
```bash
|
||||
python backend/scripts/publish_apk.py android/app/build/outputs/apk/release/app-release.apk \
|
||||
--version-code 2 --version-name 0.2.0 --notes "新增应用内升级;图标与 Web 一致"
|
||||
```
|
||||
|
||||
脚本把 APK 复制成 `backend/static/apk/dadaotask-<版本>.apk`,并写 `latest.json`
|
||||
(含 `sha256`、`size`、`published_at`)。接口读的就是这份清单。
|
||||
|
||||
### 5.3 传到服务器
|
||||
|
||||
```bash
|
||||
scp backend/static/apk/* openclaw-01:~/app/dadaotask/backend/static/apk/
|
||||
```
|
||||
|
||||
`deploy.sh` 的 `git reset --hard` 不会删未跟踪文件,所以已发布的 APK 在服务器上能留住;
|
||||
但换了机器或清了目录就要重新传一次。**不需要重启服务**:接口每次请求都现读清单与文件。
|
||||
|
||||
### 5.4 验证
|
||||
|
||||
```bash
|
||||
curl -s "http://192.168.8.188:8200/api/v1/app/release/latest?version_code=1"
|
||||
# data.version_code 为新版号、has_update=true、download_url 可下载
|
||||
curl -sI http://192.168.8.188:8200/api/v1/app/release/download/dadaotask-0.2.0.apk | head -1 # 200
|
||||
```
|
||||
|
||||
手机端:装上新包 → 下次启动自动查一次;也可以在首页底部点「检查更新」。
|
||||
装完再启动,`version_code` 已是最新,接口返回 `has_update=false`,不会再弹。
|
||||
|
||||
## 6. Web 装到桌面(PWA)
|
||||
|
||||
前端已经打成 PWA:`/manifest.webmanifest` 与 `/sw.js` 由后端静态层直接返回(`_SPAStaticFiles` 挂在 `/`,
|
||||
不需要额外配置),`node_modules/.bin` 之外没有额外依赖。发布流程不变。
|
||||
|
||||
**装的条件是安全上下文**——浏览器只允许 `https` 或 `localhost` 注册 Service Worker,也就是说:
|
||||
|
||||
| 入口 | 能不能装 |
|
||||
|---|---|
|
||||
| `https://<域名>/` | 能,手机和电脑都会出现安装入口 |
|
||||
| `http://127.0.0.1:8200/`(本机打开) | 能,仅限这台机器 |
|
||||
| `http://192.168.8.188:8200/` | **不能**,非安全上下文:不弹安装、SW 也不注册 |
|
||||
|
||||
内网 IP 目前就是这种情况,所以真要在手机上装到桌面,得给入口套一层 HTTPS:
|
||||
在能装 nginx 的机器上(或 fnsvr-01 的 1Panel OpenResty)加一个 `443 → 127.0.0.1:8200` 的反代就好,
|
||||
应用本身不用改;示例见 `deploy/prod/nginx.dadaotask.conf.example`。也可以用 Cloudflare Tunnel 之类的
|
||||
隧道拿一个 https 域名,同样不用碰应用代码。
|
||||
|
||||
装好之后的两个行为:
|
||||
|
||||
- **图标**:来自 `web/public/icons/`(any + maskable 两套,iOS 吃 `apple-touch-icon.png`),
|
||||
与 Web 页头、安卓桌面图标是同一枚。
|
||||
- **发版提示**:SW 采用 `registerType: "prompt"`,检测到新版本只在页面底部提示「有新版了」,
|
||||
用户点「刷新」才切换。**故意不自动刷新**:Vditor、图表是懒加载分片,后台把 chunk 换掉之后,
|
||||
用户下一次点开面板就是 404(编辑器白屏)。
|
||||
- **离线**:只预缓存页面骨架(构建产物 + 图标,约 0.8MB),`/api/` 一律 NetworkOnly——
|
||||
宁可报错,也不拿旧数据糊弄人。
|
||||
|
||||
## 7. 运维
|
||||
|
||||
```bash
|
||||
systemctl --user status dadaotask-api # 状态
|
||||
@@ -107,11 +192,11 @@ journalctl --user -u dadaotask-api -f # 日志(gunicorn 输出到 stdout
|
||||
- 回滚:`cd ~/app/dadaotask && git reset --hard <旧 commit> && bash deploy/prod/deploy.sh`;**数据库迁移不回滚**,回滚代码前确认旧代码兼容新表结构
|
||||
- 端口:`8200`(systemd 单元与 `deploy.sh` 的 `PORT` 默认值,两处要一致)
|
||||
|
||||
## 6. 已知限制(当前阶段)
|
||||
## 8. 已知限制(当前阶段)
|
||||
|
||||
| 限制 | 影响 | 解除方式 |
|
||||
|---|---|---|
|
||||
| 没有 HTTPS / 域名 | 只能内网 IP 访问,令牌明文传输 | 域名 + nginx + 证书(需 root) |
|
||||
| 没有 HTTPS / 域名 | 只能内网 IP 访问,令牌明文传输;**PWA 装不上**(非安全上下文) | 域名 + nginx + 证书(需 root) |
|
||||
| 内测固定验证码 | 谁都能用任意手机号登录 | 接短信通道,关 `INTERNAL_TEST_LOGIN` |
|
||||
| 无 pgvector | 语义检索走 `real[]` 全表扫描,数据量大了会慢 | 上独立 pgvector 实例(`deploy/docker-compose.vector.yml`) |
|
||||
| 上传目录在本机磁盘 | 机器坏了附件就没了 | 备份 `backend/var/uploads`,或将来到对象存储 |
|
||||
|
||||
@@ -58,7 +58,8 @@
|
||||
- [x] 手机号 + 验证码注册/登录(dev 固定码 `123456` 并回显;`ENV=prod` 禁止 `SMS_PROVIDER=dev`)
|
||||
- [x] JWT:**access 30min / refresh 14d**(计划里写的 15min/30d,实际取宽了一点,理由是移动端弱网刷新成本高)
|
||||
- [x] 发码节流(`SMS_THROTTLE_SECONDS`,默认 60s,测试可置 0)
|
||||
- [ ] 邮箱 + 密码(argon2)(**未做**,M1 有空再说;手机号登录先行)
|
||||
- [x] 手机号 + **密码**登录(可选,第二把钥匙:设过密码就能用密码进,没设过的一律回 `2013`;scrypt 哈希,15 分钟连错 10 次 → `2016`)
|
||||
- [ ] 邮箱 + 密码(**未做**,M1 有空再说;手机号登录先行)
|
||||
- [ ] refresh 令牌落库可撤销(**未做**,当前是无状态 JWT;被移出团队的成员靠"每次请求查 member 表"兜底,refresh 本身不可撤销)
|
||||
- **验收**:✅ 注册 → 登录 → 刷新 → `/auth/me` 全链路通;错误码 2010/2011/2012 语义清晰
|
||||
|
||||
@@ -291,15 +292,18 @@
|
||||
| 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` |
|
||||
| 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 仍待独立实例)
|
||||
- [ ] 真实用两周:新同事靠知识库自己解决过一次问题(需要真实团队)
|
||||
|
||||
@@ -355,7 +359,7 @@
|
||||
重做不是换配色,而是**先立设计系统、再让页面去用它**,否则下次还是东拼西凑。
|
||||
|
||||
### 4.1 设计系统(两端共用一套 token)
|
||||
- [x] 色板:纸白 `paper`、墨绿 `primary`、陶土橙 `clay`,语义色 sage/wheat/rust/sky(Web `tailwind.config.js`,安卓 `ui/Theme.kt` 同名同值)
|
||||
- [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 + 少量自绘,线宽一致
|
||||
@@ -414,3 +418,489 @@
|
||||
- [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 能扫码与断开,其他成员只读。
|
||||
|
||||

|
||||
|
||||
### 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`,
|
||||
否则收藏的链接还能打开一篇"已经删掉的知识"。
|
||||
|
||||

|
||||
|
||||
### 13.10 还没做
|
||||
|
||||
- 只做了"进"(收消息建事项),没做"出"(任务有进展回一条微信通知)。
|
||||
- 消息不认人:按通道创建者记账;要认人得先做微信身份绑定。
|
||||
- 语音只在服务端给了转写时使用,图片/文件/视频只留痕不搬。
|
||||
|
||||
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 58 KiB |
@@ -28,7 +28,7 @@
|
||||
| 安卓端任务 / 看板 / 工单 / 知识 / 成员五个页面 | ✅ 编译成 APK(10.1 MB),模拟器上逐页跑通,截图见 [`2.Design/screens/`](2.Design/screens) |
|
||||
| 工单:升级不搬数据、受理→办理→提交→验收、驳回、改派、站内通知 | ✅ 后端 + Web + 安卓已通,双人 e2e 8 项全绿 |
|
||||
| 知识:工单验收自动生成草稿、核对后发布、来源可追、手记 | ✅ 后端 + Web + 安卓已通,e2e 7 项全绿 |
|
||||
| 界面:两端统一设计系统(纸白 / 墨绿 / 陶土橙,语义色、字号阶、组件库) | ✅ 已重做,见 [`2.Design/ROADMAP-M0-M1.md`](2.Design/ROADMAP-M0-M1.md) 第 4 节 |
|
||||
| 界面:两端统一设计系统(冷白 / 靛蓝 #4F46E5 / 陶土橙,语义色、字号阶、组件库) | ✅ 已重做,见 [`2.Design/ROADMAP-M0-M1.md`](2.Design/ROADMAP-M0-M1.md) 第 4 节 |
|
||||
| 富文本:备注 / 工单说明 / 知识正文支持图文混排与附件 | ✅ Web 用 Vditor 编辑、安卓用 Markwon 预览编辑(e2e 15 项全绿);接口见 [`2.Design/API.md`](2.Design/API.md) 第 6 节 |
|
||||
| 知识切片与语义检索(混合打分、只搜已发布、带来源) | ✅ 已跑通:`real[]` + SQL 余弦(pgvector 待独立实例),`pytest` 5 个用例 + `e2e/retrieval.mjs` 6 项 |
|
||||
| 后端测试 | ✅ 79 passed,ruff 干净 |
|
||||
|
||||
@@ -79,7 +79,9 @@ release 仍然只允许 `network_security_config.xml` 里列出的域,别把
|
||||
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
首次打开 → 手机号登录(开发环境验证码在接口响应里回显,形如 `开发环境验证码:123456`)。
|
||||
首次打开 → **手机号或邮箱 + 密码**登录(短信验证码登录默认关闭;开发环境需要验证码登录时,
|
||||
后端设 `INTERNAL_TEST_LOGIN=true`,固定验证码 `123456`,登录页会自动出现「验证码登录」切换)。
|
||||
没有账号就点「用邮箱注册」:收邮箱验证码 → 设密码 → 直接进工作区。
|
||||
|
||||
## 结构
|
||||
|
||||
@@ -90,11 +92,11 @@ app/src/main/java/com/dadao/task/
|
||||
│ ├── Api.kt OkHttp + kotlinx.serialization;信封解包、业务码抛 ApiException
|
||||
│ └── TokenStore.kt 令牌本地存储(TODO:换 EncryptedSharedPreferences)
|
||||
└── ui/
|
||||
├── Theme.kt 与 Web 一致的墨青 + 陶土色板
|
||||
├── LoginScreen.kt 手机号 + 验证码
|
||||
├── Theme.kt 与 Web 一致的靛蓝 #4F46E5 + 陶土色板
|
||||
├── LoginScreen.kt 手机号/邮箱 + 密码(短信登录只在后端开启时出现)、邮箱验证码注册
|
||||
├── HomeScreen.kt 工作区列表 / 新建 / 生成邀请码
|
||||
├── TaskListScreen.kt 任务视图切换、快速新建、勾选完成、详情与评论
|
||||
└── KnowledgeScreen.kt 知识四态 tab、手记、按状态给动作(发布需 owner/admin)
|
||||
└── KnowledgeScreen.kt 知识四态 tab、手记、按状态给动作(发布需 owner/admin)、分类管理与配色
|
||||
```
|
||||
|
||||
## 模拟器(本机搭建记录,别重复踩)
|
||||
|
||||
@@ -26,8 +26,9 @@ android {
|
||||
applicationId = "com.dadao.task"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
// 版本号是 OTA 的唯一判据:后端只比对 version_code,改这里才算"有新版本"
|
||||
versionCode = 3
|
||||
versionName = "0.3.0"
|
||||
buildConfigField("String", "API_BASE", "\"$apiBase\"")
|
||||
}
|
||||
|
||||
@@ -51,6 +52,37 @@ android {
|
||||
keyAlias = "androiddebugkey"
|
||||
keyPassword = "android"
|
||||
}
|
||||
/**
|
||||
* 发布签名从 android/keystore.properties 读,证书本身不进仓库。
|
||||
*
|
||||
* 没有这个文件(别人 clone 下来、CI 没注入)时回落到调试签名:
|
||||
* assembleRelease 依然能跑,只是装不上已经装了正式版的机器——
|
||||
* 而这正是应该发生的:发布包的签名只能由发布者本人持有。
|
||||
*/
|
||||
create("release") {
|
||||
val file = rootProject.file("keystore.properties")
|
||||
if (file.exists()) {
|
||||
val props = Properties()
|
||||
file.inputStream().use { props.load(it) }
|
||||
storeFile = rootProject.file(props.getProperty("storeFile"))
|
||||
storePassword = props.getProperty("storePassword")
|
||||
keyAlias = props.getProperty("keyAlias")
|
||||
keyPassword = props.getProperty("keyPassword")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// 没有正式证书时回落调试签名,保证任何机器都能 assembleRelease
|
||||
signingConfig = if (rootProject.file("keystore.properties").exists()) {
|
||||
signingConfigs.getByName("release")
|
||||
} else {
|
||||
signingConfigs.getByName("debug")
|
||||
}
|
||||
isMinifyEnabled = false
|
||||
isShrinkResources = false
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
@@ -69,12 +101,17 @@ dependencies {
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.activity:activity-compose:1.9.3")
|
||||
// FileProvider(应用内升级把安装包交给系统安装器)就在 core 里,显式声明不靠传递依赖
|
||||
implementation("androidx.core:core-ktx:1.13.1")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
// Markdown 预览用成熟库渲染,不自己写解析器
|
||||
implementation("io.noties.markwon:core:4.6.2")
|
||||
// 表格与删除线:Web 端正文支持,安卓不能把 | 管道符原样吐给读者
|
||||
implementation("io.noties.markwon:ext-tables:4.6.2")
|
||||
implementation("io.noties.markwon:ext-strikethrough:4.6.2")
|
||||
implementation("io.coil-kt:coil-compose:2.7.0")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
}
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- 应用内升级:下好的 APK 交给系统安装器 -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="达道Task"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.DadaoTask">
|
||||
|
||||
@@ -26,5 +30,16 @@
|
||||
<data android:scheme="https" android:host="dadaotask.com" android:pathPrefix="/join/" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- 安装包只从应用自己的 updates 目录出,不外扩到其它路径 -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -17,6 +17,8 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.dadao.task.data.Api
|
||||
import com.dadao.task.data.AppRelease
|
||||
import com.dadao.task.data.AppUpdater
|
||||
import com.dadao.task.data.TokenStore
|
||||
import com.dadao.task.data.Workspace
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -31,6 +33,7 @@ import com.dadao.task.ui.NotificationsScreen
|
||||
import com.dadao.task.ui.RichTextEditorScreen
|
||||
import com.dadao.task.ui.TaskListScreen
|
||||
import com.dadao.task.ui.TicketsScreen
|
||||
import com.dadao.task.ui.UpdateDialog
|
||||
import com.dadao.task.ui.WorkspaceScaffold
|
||||
import com.dadao.task.ui.WorkspaceTab
|
||||
|
||||
@@ -80,6 +83,9 @@ private fun AppRoot(joinUri: String?) {
|
||||
var reopenItem by remember { mutableStateOf<String?>(null) }
|
||||
// 上面那条是任务还是知识:决定回哪个页面去重开
|
||||
var reopenKnowledge by remember { mutableStateOf(false) }
|
||||
// 应用内升级:启动后查一次,查到了就把对话框摆出来
|
||||
val updater = remember { AppUpdater(context, api) }
|
||||
var update by remember { mutableStateOf<AppRelease?>(null) }
|
||||
|
||||
// 有本地令牌就先静默校验一次:令牌过期/被吊销要退回登录页,而不是进了主页再崩。
|
||||
LaunchedEffect(screen) {
|
||||
@@ -114,11 +120,33 @@ private fun AppRoot(joinUri: String?) {
|
||||
unread = api.unreadCount(ws.id)
|
||||
}
|
||||
|
||||
// 版本检查:登录成功后查一次就够,查不到(网络、没发布过包)就当没有新版
|
||||
LaunchedEffect(screen) {
|
||||
if (screen != Screen.Home && screen != Screen.Workspace) return@LaunchedEffect
|
||||
if (update != null) return@LaunchedEffect
|
||||
update = runCatching { updater.check() }.getOrNull()
|
||||
}
|
||||
|
||||
/** 手动检查:结果用一句话回给首页显示,查到新版顺便把对话框摆出来。 */
|
||||
suspend fun checkUpdate(): String = runCatching { updater.checkStrict() }.fold(
|
||||
onSuccess = { release ->
|
||||
if (release == null) {
|
||||
"已经是最新版 ${updater.currentVersionName}"
|
||||
} else {
|
||||
update = release
|
||||
"发现新版本 ${release.versionName}"
|
||||
}
|
||||
},
|
||||
onFailure = { "检查更新失败:${it.message ?: "网络不通"}" },
|
||||
)
|
||||
|
||||
when (screen) {
|
||||
Screen.Splash -> Unit
|
||||
Screen.Login -> LoginScreen(api = api, onLoggedIn = { screen = Screen.Home })
|
||||
Screen.Home -> HomeScreen(
|
||||
api = api,
|
||||
versionName = updater.currentVersionName,
|
||||
onCheckUpdate = { checkUpdate() },
|
||||
onLogout = {
|
||||
tokens.clear()
|
||||
screen = Screen.Login
|
||||
@@ -219,4 +247,14 @@ private fun AppRoot(joinUri: String?) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新对话框是独立窗口,摆在哪一层都能盖住当前界面
|
||||
update?.let { release ->
|
||||
UpdateDialog(
|
||||
release = release,
|
||||
currentVersionName = updater.currentVersionName,
|
||||
updater = updater,
|
||||
onLater = { update = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.dadao.task.data
|
||||
|
||||
import com.dadao.task.BuildConfig
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.SerialName
|
||||
@@ -42,10 +44,34 @@ private data class Envelope<T>(
|
||||
@Serializable
|
||||
data class User(
|
||||
val id: String,
|
||||
val phone: String,
|
||||
/**
|
||||
* 邮箱注册的账号没有手机号;有手机号的也只给脱敏值(138****8000),
|
||||
* 与后端 `_user_out` 一致。所以这里必须可空——否则邮箱账号一登录就解析失败。
|
||||
*/
|
||||
val phone: String? = null,
|
||||
val email: String? = null,
|
||||
@SerialName("email_verified") val emailVerified: Boolean = false,
|
||||
@SerialName("has_password") val hasPassword: Boolean = false,
|
||||
val nickname: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
* 这台服务现在开放哪些登录入口(后端 `/auth/methods`)。
|
||||
*
|
||||
* 生产早就把短信登录关了,客户端不能"照着界面上有就点"——
|
||||
* 点进去才发现这条路关着最气人,所以登录页先问一次再渲染。
|
||||
*/
|
||||
@Serializable
|
||||
data class AuthMethods(
|
||||
val password: Boolean = true,
|
||||
@SerialName("email_password") val emailPassword: Boolean = true,
|
||||
@SerialName("sms_code") val smsCode: Boolean = false,
|
||||
@SerialName("email_register") val emailRegister: Boolean = false,
|
||||
) {
|
||||
/** 一条路都没有时至少让密码登录留在界面上,免得白屏。 */
|
||||
val usable: Boolean get() = password || emailPassword || smsCode
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Tokens(
|
||||
@SerialName("access_token") val accessToken: String,
|
||||
@@ -236,12 +262,30 @@ data class SourceRef(
|
||||
val status: String = "",
|
||||
)
|
||||
|
||||
/** 服务端发布的安卓安装包(OTA)。`hasUpdate` 由服务端按 version_code 比较后给。 */
|
||||
@Serializable
|
||||
data class AppRelease(
|
||||
val platform: String = "android",
|
||||
@SerialName("version_code") val versionCode: Int = 0,
|
||||
@SerialName("version_name") val versionName: String = "",
|
||||
val notes: String = "",
|
||||
val file: String = "",
|
||||
val size: Long = 0,
|
||||
val sha256: String = "",
|
||||
@SerialName("min_sdk") val minSdk: Int = 26,
|
||||
@SerialName("published_at") val publishedAt: String? = null,
|
||||
@SerialName("download_url") val downloadUrl: String = "",
|
||||
@SerialName("has_update") val hasUpdate: Boolean = false,
|
||||
)
|
||||
|
||||
/** 知识分类:工作区级的三级小树,fullPath 形如「研发 / 部署 / 回滚」。 */
|
||||
@Serializable
|
||||
data class KnowledgeCategory(
|
||||
val id: String,
|
||||
val name: String = "",
|
||||
@SerialName("parent_id") val parentId: String? = null,
|
||||
/** 分类色(#RRGGBB),卡片上边线用它;没设时前端按 id 稳定挑一个默认色。 */
|
||||
val color: String? = null,
|
||||
val depth: Int = 1,
|
||||
@SerialName("sort_order") val sortOrder: Int = 0,
|
||||
val version: Int = 1,
|
||||
@@ -307,6 +351,25 @@ private data class SmsRequest(val phone: String)
|
||||
@Serializable
|
||||
private data class LoginRequest(val phone: String, val code: String)
|
||||
|
||||
/** 手机号或邮箱二选一:只发一个字段,另一个为 null 时不会被序列化出去。 */
|
||||
@Serializable
|
||||
private data class PasswordLoginRequest(
|
||||
val phone: String? = null,
|
||||
val email: String? = null,
|
||||
val password: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class EmailCodeRequest(val email: String)
|
||||
|
||||
@Serializable
|
||||
private data class RegisterRequest(
|
||||
val email: String,
|
||||
val code: String,
|
||||
val password: String,
|
||||
val nickname: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class RefreshRequest(@SerialName("refresh_token") val refreshToken: String)
|
||||
|
||||
@@ -353,6 +416,27 @@ private data class KnowledgeCreateRequest(
|
||||
@Serializable
|
||||
private data class KnowledgeActionRequest(val action: String)
|
||||
|
||||
@Serializable
|
||||
private data class CategoryCreateRequest(
|
||||
val name: String,
|
||||
@SerialName("parent_id") val parentId: String? = null,
|
||||
@SerialName("sort_order") val sortOrder: Int = 0,
|
||||
val color: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* 分类改名 / 改色。`version` 是乐观锁,必填。
|
||||
*
|
||||
* color 传空串表示"取消上色",不传(null)表示"别动颜色"——
|
||||
* 靠 `explicitNulls = false` 把 null 字段整个省掉来区分这两件事。
|
||||
*/
|
||||
@Serializable
|
||||
private data class CategoryPatchRequest(
|
||||
val version: Int,
|
||||
val name: String? = null,
|
||||
val color: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class TicketCreateRequest(
|
||||
val title: String,
|
||||
@@ -458,6 +542,58 @@ class Api(private val tokens: TokenStore) {
|
||||
|
||||
suspend fun me(): MeResult = call(path = "/auth/me", auth = true)
|
||||
|
||||
/** 这台服务开放哪些登录入口;登录页拿到之后才决定渲染哪几个 tab。 */
|
||||
suspend fun authMethods(): AuthMethods = call(path = "/auth/methods")
|
||||
|
||||
/**
|
||||
* 手机号 / 邮箱 + 密码登录。
|
||||
*
|
||||
* 账号里有没有 `@` 决定发哪个字段:后端两个都收,但带着一个空字符串 phone
|
||||
* 会被 pattern 校验挡掉,所以这里只发一个。
|
||||
*/
|
||||
suspend fun loginPassword(account: String, password: String): LoginResult {
|
||||
val who = account.trim()
|
||||
val body = if (who.contains('@')) {
|
||||
PasswordLoginRequest(email = who, password = password)
|
||||
} else {
|
||||
PasswordLoginRequest(phone = who, password = password)
|
||||
}
|
||||
val result: LoginResult = call(
|
||||
path = "/auth/login/password",
|
||||
body = json.encodeToString(body),
|
||||
)
|
||||
tokens.save(result.tokens.accessToken, result.tokens.refreshToken)
|
||||
return result
|
||||
}
|
||||
|
||||
/** 邮箱注册验证码。生产真发信;`dev_code` 只在开了 EMAIL_CODE_DEV_ECHO 的本机才回显。 */
|
||||
suspend fun sendEmailCode(email: String): JsonElement? = call(
|
||||
path = "/auth/email/code",
|
||||
body = json.encodeToString(EmailCodeRequest(email.trim())),
|
||||
)
|
||||
|
||||
/** 邮箱 + 验证码 + 密码一步建号,注册完就是登录态(服务端直接给令牌)。 */
|
||||
suspend fun register(
|
||||
email: String,
|
||||
code: String,
|
||||
password: String,
|
||||
nickname: String? = null,
|
||||
): LoginResult {
|
||||
val result: LoginResult = call(
|
||||
path = "/auth/register",
|
||||
body = json.encodeToString(
|
||||
RegisterRequest(
|
||||
email = email.trim(),
|
||||
code = code.trim(),
|
||||
password = password,
|
||||
nickname = nickname?.trim()?.ifBlank { null },
|
||||
),
|
||||
),
|
||||
)
|
||||
tokens.save(result.tokens.accessToken, result.tokens.refreshToken)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表类接口服务端返回的是 `{"items": [...]}` 而不是裸数组
|
||||
* (`backend/app/api/workspaces.py` 的 list_workspaces),
|
||||
@@ -472,6 +608,13 @@ class Api(private val tokens: TokenStore) {
|
||||
auth = true,
|
||||
)
|
||||
|
||||
/**
|
||||
* 查最新安装包。不带 auth:还没登录就可能在检查更新,
|
||||
* 传当前 versionCode 让服务端直接算好 `hasUpdate`。
|
||||
*/
|
||||
suspend fun latestAppRelease(versionCode: Int): AppRelease =
|
||||
call(path = "/app/release/latest?version_code=$versionCode")
|
||||
|
||||
suspend fun members(workspaceId: String): List<Member> =
|
||||
call<MemberPage>(path = "/workspaces/$workspaceId/members", auth = true).items
|
||||
|
||||
@@ -712,6 +855,50 @@ class Api(private val tokens: TokenStore) {
|
||||
suspend fun knowledgeCategories(workspaceId: String): KnowledgeCategoryPage =
|
||||
call(path = "/workspaces/$workspaceId/knowledge/categories", auth = true)
|
||||
|
||||
/** 建分类;同层重名会被服务端拒(4006)。color 不传就是不上色。 */
|
||||
suspend fun createKnowledgeCategory(
|
||||
workspaceId: String,
|
||||
name: String,
|
||||
parentId: String? = null,
|
||||
color: String? = null,
|
||||
): KnowledgeCategory = call(
|
||||
path = "/workspaces/$workspaceId/knowledge/categories",
|
||||
body = json.encodeToString(
|
||||
CategoryCreateRequest(name = name.trim(), parentId = parentId, color = color),
|
||||
),
|
||||
auth = true,
|
||||
)
|
||||
|
||||
/**
|
||||
* 改名 / 改色,必须带 version。
|
||||
*
|
||||
* color 传 `""` 表示取消上色,传 null 表示别动颜色——这个区别由
|
||||
* `explicitNulls = false`(null 字段直接不序列化)兜住。
|
||||
*/
|
||||
suspend fun patchKnowledgeCategory(
|
||||
workspaceId: String,
|
||||
categoryId: String,
|
||||
version: Int,
|
||||
name: String? = null,
|
||||
color: String? = null,
|
||||
): KnowledgeCategory = call(
|
||||
path = "/workspaces/$workspaceId/knowledge/categories/$categoryId",
|
||||
body = json.encodeToString(CategoryPatchRequest(version, name?.trim(), color)),
|
||||
auth = true,
|
||||
method = "PATCH",
|
||||
)
|
||||
|
||||
/** 删分类。force=true 才能删有内容的分类,里面的知识会退成未分类。 */
|
||||
suspend fun deleteKnowledgeCategory(
|
||||
workspaceId: String,
|
||||
categoryId: String,
|
||||
force: Boolean = false,
|
||||
): JsonElement = call(
|
||||
path = "/workspaces/$workspaceId/knowledge/categories/$categoryId?force=$force",
|
||||
auth = true,
|
||||
method = "DELETE",
|
||||
)
|
||||
|
||||
/** 手记:没有来源的知识,得自己写清楚。 */
|
||||
suspend fun createKnowledge(
|
||||
workspaceId: String,
|
||||
@@ -767,11 +954,55 @@ class Api(private val tokens: TokenStore) {
|
||||
method = "PATCH",
|
||||
)
|
||||
|
||||
/**
|
||||
* 访问令牌只有 30 分钟,refresh 有 14 天。
|
||||
*
|
||||
* 撞上 2002(登录已过期)时先用 refresh 换一对新令牌,把这次请求原样重放一次;
|
||||
* 换不动才清令牌让界面回登录页。以前是直接清,用户用一会儿就被踢出去。
|
||||
* 并发请求共用一把锁:刷新只发一次,其余等它换完直接用新令牌。
|
||||
*/
|
||||
private suspend inline fun <reified T> call(
|
||||
path: String,
|
||||
body: String? = null,
|
||||
auth: Boolean = false,
|
||||
method: String? = null,
|
||||
): T {
|
||||
val stale = tokens.accessToken
|
||||
return try {
|
||||
callOnce<T>(path, body, auth, method)
|
||||
} catch (e: ApiException) {
|
||||
if (!auth || e.code != 2002 || !refreshTokens(stale)) throw e
|
||||
callOnce<T>(path, body, auth, method)
|
||||
}
|
||||
}
|
||||
|
||||
private val refreshLock = Mutex()
|
||||
|
||||
private suspend fun refreshTokens(stale: String?): Boolean {
|
||||
val refresh = tokens.refreshToken ?: return false
|
||||
return refreshLock.withLock {
|
||||
// 等锁期间别人可能已经换完了:本地令牌已经不是失败的那一个,直接用新的
|
||||
val current = tokens.accessToken
|
||||
if (current != null && current != stale) return@withLock true
|
||||
try {
|
||||
val fresh: Tokens = call(
|
||||
path = "/auth/refresh",
|
||||
body = json.encodeToString(RefreshRequest(refresh)),
|
||||
)
|
||||
tokens.save(fresh.accessToken, fresh.refreshToken)
|
||||
true
|
||||
} catch (e: ApiException) {
|
||||
tokens.clear()
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend inline fun <reified T> callOnce(
|
||||
path: String,
|
||||
body: String? = null,
|
||||
auth: Boolean = false,
|
||||
method: String? = null,
|
||||
): T = withContext(Dispatchers.IO) {
|
||||
val builder = Request.Builder().url(base + path)
|
||||
val verb = method ?: if (body == null) "GET" else "POST"
|
||||
@@ -795,7 +1026,6 @@ class Api(private val tokens: TokenStore) {
|
||||
throw ApiException(1500, "响应格式异常(HTTP ${response.code})")
|
||||
}
|
||||
if (envelope.code != 0) {
|
||||
if (envelope.code == 2002 && auth) tokens.clear()
|
||||
throw ApiException(envelope.code, envelope.message, envelope.requestId)
|
||||
}
|
||||
envelope.data ?: throw ApiException(1500, "响应缺少 data")
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.dadao.task.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.core.content.FileProvider
|
||||
import com.dadao.task.BuildConfig
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* 应用内升级(OTA)。
|
||||
*
|
||||
* 流程刻意做得很短:查清单 → 下到应用自己的目录 → 交给系统安装器。
|
||||
* 不申请存储权限、不自己实现安装界面:APK 走 [FileProvider] 授权给系统,
|
||||
* 「允许安装未知应用」这类系统开关也必须由用户在系统设置里点,不能被代劳。
|
||||
*/
|
||||
class AppUpdater(private val context: Context, private val api: Api) {
|
||||
|
||||
private val http = OkHttpClient.Builder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
val currentVersionName: String = BuildConfig.VERSION_NAME
|
||||
|
||||
/**
|
||||
* 有新版就返回它,否则 null。
|
||||
*
|
||||
* 网络失败、后端还没发布过安装包都当成"没有更新":启动时不打扰用户,
|
||||
* 手动检查那次再把异常抛给界面去提示。
|
||||
*/
|
||||
suspend fun check(): AppRelease? = runCatching {
|
||||
api.latestAppRelease(BuildConfig.VERSION_CODE)
|
||||
}.getOrNull()?.takeIf { it.hasUpdate && it.downloadUrl.isNotBlank() }
|
||||
|
||||
/** 同 [check],但失败会抛异常——给「检查更新」按钮用。 */
|
||||
suspend fun checkStrict(): AppRelease? {
|
||||
val release = api.latestAppRelease(BuildConfig.VERSION_CODE)
|
||||
return release.takeIf { it.hasUpdate && it.downloadUrl.isNotBlank() }
|
||||
}
|
||||
|
||||
/**
|
||||
* 把安装包下到 `Android/data/<pkg>/files/updates/` 下。
|
||||
*
|
||||
* 先写 .part 再改名:下到一半断网不会留下一个"看起来能用"的坏 APK。
|
||||
* 清单带了 sha256 就校验,防止内网代理/缓存给出旧包。
|
||||
*/
|
||||
suspend fun download(
|
||||
release: AppRelease,
|
||||
onProgress: (downloaded: Long, total: Long) -> Unit = { _, _ -> },
|
||||
): File = withContext(Dispatchers.IO) {
|
||||
val dir = File(context.getExternalFilesDir(null) ?: context.filesDir, "updates")
|
||||
dir.mkdirs()
|
||||
val safeName = release.file.ifBlank { "dadaotask-${release.versionName}.apk" }
|
||||
val target = File(dir, safeName)
|
||||
val partial = File(dir, "$safeName.part")
|
||||
|
||||
val url = api.currentOrigin() + release.downloadUrl
|
||||
val response = try {
|
||||
http.newCall(Request.Builder().url(url).build()).execute()
|
||||
} catch (e: IOException) {
|
||||
throw ApiException(1, "下载失败:连不上 $url")
|
||||
}
|
||||
response.use { res ->
|
||||
if (!res.isSuccessful) throw ApiException(1, "下载失败:HTTP ${res.code}")
|
||||
val body = res.body ?: throw ApiException(1, "下载失败:响应为空")
|
||||
val total = if (release.size > 0) release.size else body.contentLength()
|
||||
partial.outputStream().use { out ->
|
||||
body.byteStream().use { input ->
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
var downloaded = 0L
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read <= 0) break
|
||||
out.write(buffer, 0, read)
|
||||
downloaded += read
|
||||
onProgress(downloaded, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (release.sha256.isNotBlank() && sha256Of(partial) != release.sha256.lowercase()) {
|
||||
partial.delete()
|
||||
throw ApiException(1, "安装包校验不通过(sha256 不一致),请稍后重试")
|
||||
}
|
||||
if (target.exists()) target.delete()
|
||||
if (!partial.renameTo(target)) throw ApiException(1, "安装包保存失败,检查存储空间")
|
||||
target
|
||||
}
|
||||
|
||||
/** 应用没有「安装未知应用」权限时,先把用户送到系统设置页。 */
|
||||
fun canInstall(): Boolean =
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.O || context.packageManager.canRequestPackageInstalls()
|
||||
|
||||
fun openInstallPermission(): Boolean {
|
||||
if (canInstall()) return false
|
||||
val intent = Intent(
|
||||
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
||||
Uri.parse("package:${context.packageName}"),
|
||||
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
runCatching { context.startActivity(intent) }
|
||||
return true
|
||||
}
|
||||
|
||||
/** 交给系统安装器。返回 false 表示还没给"安装未知应用"权限。 */
|
||||
fun install(file: File): Boolean {
|
||||
if (!canInstall()) return false
|
||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun sha256Of(file: File): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
file.inputStream().use { input ->
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read <= 0) break
|
||||
digest.update(buffer, 0, read)
|
||||
}
|
||||
}
|
||||
return digest.digest().joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,13 @@ private fun roleLabel(role: String?): String = when (role) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HomeScreen(api: Api, onLogout: () -> Unit, onOpenWorkspace: (Workspace) -> Unit) {
|
||||
fun HomeScreen(
|
||||
api: Api,
|
||||
versionName: String,
|
||||
onCheckUpdate: suspend () -> String,
|
||||
onLogout: () -> Unit,
|
||||
onOpenWorkspace: (Workspace) -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var user by remember { mutableStateOf<User?>(null) }
|
||||
var workspaces by remember { mutableStateOf<List<Workspace>>(emptyList()) }
|
||||
@@ -71,6 +77,9 @@ fun HomeScreen(api: Api, onLogout: () -> Unit, onOpenWorkspace: (Workspace) -> U
|
||||
var name by remember { mutableStateOf("") }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
// 手动检查更新的回话:查到/没查到/失败都用一句话说清楚
|
||||
var updateNotice by remember { mutableStateOf<String?>(null) }
|
||||
var checking by remember { mutableStateOf(false) }
|
||||
|
||||
suspend fun reload() {
|
||||
runCatching { api.me() }
|
||||
@@ -234,6 +243,38 @@ fun HomeScreen(api: Api, onLogout: () -> Unit, onOpenWorkspace: (Workspace) -> U
|
||||
item { SoftPill(notice, Rust, RustSoft, Modifier.fillMaxWidth()) }
|
||||
}
|
||||
|
||||
item {
|
||||
Column(Modifier.padding(top = 6.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"达道Task v$versionName",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = InkMuted,
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
if (checking) "检查中…" else "检查更新",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Primary,
|
||||
modifier = Modifier
|
||||
.clickable(enabled = !checking) {
|
||||
checking = true
|
||||
scope.launch {
|
||||
updateNotice = onCheckUpdate()
|
||||
checking = false
|
||||
}
|
||||
}
|
||||
.padding(vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
val note = updateNotice
|
||||
if (note != null) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(note, style = MaterialTheme.typography.labelSmall, color = InkMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item { Spacer(Modifier.height(40.dp)) }
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,13 @@ import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -18,15 +21,19 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
@@ -39,10 +46,14 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.dadao.task.data.Api
|
||||
@@ -51,15 +62,20 @@ import com.dadao.task.data.Attachment
|
||||
import com.dadao.task.data.KnowledgeCategory
|
||||
import com.dadao.task.data.KnowledgeDoc
|
||||
import com.dadao.task.data.Workspace
|
||||
import io.noties.markwon.Markwon
|
||||
import io.noties.markwon.ext.strikethrough.StrikethroughPlugin
|
||||
import io.noties.markwon.ext.tables.TablePlugin
|
||||
import io.noties.markwon.ext.tables.TableTheme
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
// 与 Web 一致:「全部」在第一位并且是默认——先看有什么,再去看待办
|
||||
private val knowledgeTabs = listOf(
|
||||
"all" to "全部",
|
||||
"draft" to "待核对",
|
||||
"published" to "已发布",
|
||||
"archived" to "已归档",
|
||||
"all" to "全部",
|
||||
)
|
||||
|
||||
private val docTypes = listOf(
|
||||
@@ -83,6 +99,76 @@ private fun parseTags(raw: String): List<String> =
|
||||
private fun catLabel(cat: KnowledgeCategory): String =
|
||||
"·".repeat((cat.depth - 1).coerceAtLeast(0)) + cat.name
|
||||
|
||||
/**
|
||||
* 与 Web 端同一套调色板。
|
||||
*
|
||||
* 分类没设颜色时按 id 稳定挑一个:老分类不会全灰,也不会每次刷新换个颜色
|
||||
* (用户按颜色记分类,颜色一变等于记忆作废)。
|
||||
*/
|
||||
private val categoryPalette = listOf(
|
||||
"#4F46E5", "#0EA5E9", "#10B981", "#F59E0B", "#EF4444",
|
||||
"#EC4899", "#8B5CF6", "#14B8A6", "#64748B", "#84CC16",
|
||||
)
|
||||
|
||||
private val hexColorRe = Regex("^#[0-9A-Fa-f]{6}$")
|
||||
|
||||
/** 分类色 → Compose Color。没设色就按 id 稳定挑调色板里的一个。 */
|
||||
private fun categoryColorOf(id: String?, color: String?): Color? {
|
||||
val raw = color?.takeIf { hexColorRe.matches(it) } ?: id?.let { key ->
|
||||
var hash = 0
|
||||
key.forEach { hash = (hash * 31 + it.code) % 9973 }
|
||||
categoryPalette[hash % categoryPalette.size]
|
||||
} ?: return null
|
||||
return runCatching { Color(android.graphics.Color.parseColor(raw)) }.getOrNull()
|
||||
}
|
||||
|
||||
private fun categoryColorOf(cat: KnowledgeCategory?): Color? =
|
||||
cat?.let { categoryColorOf(it.id, it.color) }
|
||||
|
||||
/** 带色点的分类 chip:颜色是"这个分类是哪个"最快的识别方式。 */
|
||||
@Composable
|
||||
private fun CategoryChip(
|
||||
label: String,
|
||||
color: Color?,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.clickable { onClick() },
|
||||
color = if (selected) PrimarySoft else Color.White,
|
||||
contentColor = if (selected) Primary else InkSoft,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
border = BorderStroke(
|
||||
if (selected) 1.5.dp else 1.dp,
|
||||
if (selected) Primary else Line,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (color != null) {
|
||||
CategoryDot(color, 6.dp)
|
||||
Spacer(Modifier.width(5.dp))
|
||||
}
|
||||
Text(label, style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingInline(text: String = "载入中…") {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(12.dp),
|
||||
strokeWidth = 1.5.dp,
|
||||
color = Primary,
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(text, style = MaterialTheme.typography.labelSmall, color = Primary)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun KnowledgeScreen(
|
||||
api: Api,
|
||||
@@ -93,7 +179,7 @@ fun KnowledgeScreen(
|
||||
onReopened: () -> Unit = {},
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var tab by remember { mutableStateOf("draft") }
|
||||
var tab by remember { mutableStateOf("all") }
|
||||
var docs by remember { mutableStateOf<List<KnowledgeDoc>>(emptyList()) }
|
||||
var categories by remember { mutableStateOf<List<KnowledgeCategory>>(emptyList()) }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
@@ -102,6 +188,7 @@ fun KnowledgeScreen(
|
||||
var adding by remember { mutableStateOf(false) }
|
||||
// null = 全部;"none" = 未分类;其它是分类 id(服务端含子分类)
|
||||
var catFilter by remember { mutableStateOf<String?>(null) }
|
||||
var managing by remember { mutableStateOf(false) }
|
||||
|
||||
suspend fun loadCategories() {
|
||||
runCatching { api.knowledgeCategories(workspace.id) }
|
||||
@@ -155,7 +242,15 @@ fun KnowledgeScreen(
|
||||
) {
|
||||
item {
|
||||
Column(Modifier.padding(top = 4.dp)) {
|
||||
Text("知识", style = MaterialTheme.typography.headlineSmall)
|
||||
// 加载指示放在标题这一行:分类 chip 是横向滚动的,写在 chip 行末尾时
|
||||
// 大概率被挤出屏幕,反而让人以为"点了没反应"。
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("知识", style = MaterialTheme.typography.headlineSmall)
|
||||
if (loading) {
|
||||
Spacer(Modifier.width(10.dp))
|
||||
LoadingInline()
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"工单验收通过会自动整理一份草稿;核对无误再发布,发布后才会进入团队检索。",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -172,27 +267,22 @@ fun KnowledgeScreen(
|
||||
Row(
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
SurfaceChip(
|
||||
text = "全部分类",
|
||||
selected = catFilter == null,
|
||||
onClick = { catFilter = null },
|
||||
)
|
||||
SurfaceChip(
|
||||
text = "未分类",
|
||||
selected = catFilter == "none",
|
||||
onClick = { catFilter = "none" },
|
||||
)
|
||||
CategoryChip("全部分类", null, catFilter == null) { catFilter = null }
|
||||
CategoryChip("未分类", null, catFilter == "none") { catFilter = "none" }
|
||||
categories.forEach { cat ->
|
||||
SurfaceChip(
|
||||
text = catLabel(cat),
|
||||
selected = catFilter == cat.id,
|
||||
onClick = { catFilter = cat.id },
|
||||
)
|
||||
CategoryChip(catLabel(cat), categoryColorOf(cat), catFilter == cat.id) {
|
||||
catFilter = cat.id
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
GhostButton("记一条", onClick = { adding = true })
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
GhostButton("记一条", onClick = { adding = true })
|
||||
Spacer(Modifier.width(8.dp))
|
||||
GhostButton("管理分类", onClick = { managing = true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,14 +294,25 @@ fun KnowledgeScreen(
|
||||
if (!loading && docs.isEmpty()) {
|
||||
item {
|
||||
EmptyState(
|
||||
if (catFilter == null) "还没有待核对的知识" else "这个分类下还没有知识",
|
||||
if (catFilter != null) {
|
||||
"这个分类下还没有知识"
|
||||
} else if (tab == "all") {
|
||||
"还没有知识"
|
||||
} else {
|
||||
"这一类里还没有知识"
|
||||
},
|
||||
"把工单验收通过,或在任务详情里点「沉淀成知识」",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
items(docs, key = { it.id }) { doc ->
|
||||
SoftCard(padding = 12.dp, onClick = { open = doc }) {
|
||||
SoftCard(
|
||||
padding = 12.dp,
|
||||
accent = categoryColorOf(doc.category),
|
||||
modifier = Modifier.alpha(if (loading) 0.45f else 1f),
|
||||
onClick = { open = doc },
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
SoftPill(docTypeLabel(doc.knowledge.docType))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
@@ -225,16 +326,27 @@ fun KnowledgeScreen(
|
||||
Text(
|
||||
doc.title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
val meta = buildList {
|
||||
doc.category?.let { add(it.fullPath.ifBlank { it.name }) }
|
||||
doc.tags.forEach { add("#${it.name}") }
|
||||
// 分类用同色圆点带出来:卡片上边线看颜色,这里看名字,两处一致
|
||||
doc.category?.let { cat ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
categoryColorOf(cat)?.let {
|
||||
CategoryDot(it, 6.dp)
|
||||
Spacer(Modifier.width(5.dp))
|
||||
}
|
||||
Text(
|
||||
cat.fullPath.ifBlank { cat.name },
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = InkSoft,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
if (meta.isNotEmpty()) {
|
||||
if (doc.tags.isNotEmpty()) {
|
||||
Text(
|
||||
meta.joinToString(" "),
|
||||
doc.tags.joinToString(" ") { "#${it.name}" },
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Primary,
|
||||
)
|
||||
@@ -297,6 +409,423 @@ fun KnowledgeScreen(
|
||||
onFail = { fail(it) },
|
||||
)
|
||||
}
|
||||
|
||||
if (managing) {
|
||||
CategoryManagerDialog(
|
||||
api = api,
|
||||
workspaceId = workspace.id,
|
||||
categories = categories,
|
||||
onDismiss = { managing = false },
|
||||
onChanged = {
|
||||
scope.launch {
|
||||
loadCategories()
|
||||
reload()
|
||||
}
|
||||
},
|
||||
onFail = { fail(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 分类编辑中的草稿:id 为空表示"还没建出来的新分类"。 */
|
||||
private data class CategoryDraft(
|
||||
val id: String?,
|
||||
val parentId: String?,
|
||||
val name: String,
|
||||
val color: String?,
|
||||
val version: Int,
|
||||
val depth: Int,
|
||||
)
|
||||
|
||||
private fun hexToColor(hex: String): Color =
|
||||
runCatching { Color(android.graphics.Color.parseColor(hex)) }.getOrDefault(InkMuted)
|
||||
|
||||
/**
|
||||
* 分类管理:改名 / 改色 / 建子分类 / 删除。
|
||||
*
|
||||
* 手机上不放浮层菜单——点分类行直接进编辑,所有动作都在同一张卡片里,
|
||||
* 免得"改名在哪、删在哪"到处找。
|
||||
*/
|
||||
@Composable
|
||||
private fun CategoryManagerDialog(
|
||||
api: Api,
|
||||
workspaceId: String,
|
||||
categories: List<KnowledgeCategory>,
|
||||
onDismiss: () -> Unit,
|
||||
onChanged: () -> Unit,
|
||||
onFail: (Throwable) -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var items by remember { mutableStateOf(categories) }
|
||||
var draft by remember { mutableStateOf<CategoryDraft?>(null) }
|
||||
var pendingDelete by remember { mutableStateOf<KnowledgeCategory?>(null) }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var notice by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
suspend fun refresh() {
|
||||
runCatching { api.knowledgeCategories(workspaceId) }
|
||||
.onSuccess {
|
||||
items = it.items
|
||||
onChanged()
|
||||
}
|
||||
.onFailure { onFail(it) }
|
||||
}
|
||||
|
||||
fun fail(e: Throwable) {
|
||||
val code = (e as? ApiException)?.code ?: 0
|
||||
notice = verifyStatusCode(code).ifBlank { e.message ?: "操作没成功" }
|
||||
}
|
||||
|
||||
fun save() {
|
||||
val d = draft ?: return
|
||||
val name = d.name.trim()
|
||||
if (name.isEmpty()) {
|
||||
notice = "先给分类起个名字"
|
||||
return
|
||||
}
|
||||
if (d.color != null && !hexColorRe.matches(d.color)) {
|
||||
notice = "颜色要用 #RRGGBB,例如 #4F46E5"
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
busy = true
|
||||
// 没设色时传空串,表示"这个分类不上色";有颜色就传它
|
||||
val color = d.color ?: ""
|
||||
runCatching {
|
||||
if (d.id == null) {
|
||||
api.createKnowledgeCategory(workspaceId, name, d.parentId, color.ifBlank { null })
|
||||
} else {
|
||||
api.patchKnowledgeCategory(workspaceId, d.id, d.version, name, color)
|
||||
}
|
||||
}
|
||||
.onSuccess {
|
||||
notice = if (d.id == null) "分类已建好" else "已保存"
|
||||
draft = null
|
||||
refresh()
|
||||
}
|
||||
.onFailure { fail(it) }
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth(0.94f)
|
||||
.fillMaxHeight(0.9f)
|
||||
.background(Paper, RoundedCornerShape(16.dp))
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"分类管理",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
GhostButton("关闭", onClick = onDismiss)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
GhostButton(
|
||||
"新建一级分类",
|
||||
enabled = !busy,
|
||||
onClick = {
|
||||
notice = null
|
||||
draft = CategoryDraft(null, null, "", null, 1, 1)
|
||||
},
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
"分类颜色决定卡片上边线,最多 3 层",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = InkMuted,
|
||||
)
|
||||
}
|
||||
|
||||
val hint = notice
|
||||
if (hint != null) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
SoftPill(hint, Primary, PrimarySoft, Modifier.fillMaxWidth())
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) {
|
||||
val editing = draft
|
||||
if (editing != null) {
|
||||
CategoryEditorCard(
|
||||
draft = editing,
|
||||
busy = busy,
|
||||
onChange = { draft = it },
|
||||
onSave = { save() },
|
||||
onAddChild = {
|
||||
draft = CategoryDraft(
|
||||
// 新分类还没有 id,挂到当前行的父级下;已有分类就挂在它自己下
|
||||
id = null,
|
||||
parentId = editing.id,
|
||||
name = "",
|
||||
color = editing.color,
|
||||
version = 1,
|
||||
depth = editing.depth + 1,
|
||||
)
|
||||
},
|
||||
onDelete = {
|
||||
val id = editing.id
|
||||
val cat = items.firstOrNull { it.id == id }
|
||||
if (cat != null) pendingDelete = cat
|
||||
},
|
||||
onCancel = { draft = null },
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
|
||||
if (items.isEmpty()) {
|
||||
Text(
|
||||
"还没有分类。建一个吧——比如「研发」「客户」「流程」。",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = InkMuted,
|
||||
modifier = Modifier.padding(vertical = 10.dp),
|
||||
)
|
||||
}
|
||||
items.forEach { cat ->
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
notice = null
|
||||
draft = CategoryDraft(
|
||||
cat.id,
|
||||
cat.parentId,
|
||||
cat.name,
|
||||
cat.color,
|
||||
cat.version,
|
||||
cat.depth,
|
||||
)
|
||||
}
|
||||
.padding(start = ((cat.depth - 1) * 14).dp, top = 9.dp, bottom = 9.dp, end = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CategoryDot(categoryColorOf(cat) ?: LineStrong, 9.dp)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
cat.name,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
"${cat.docCount} 条",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = InkMuted,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
"编辑",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Primary,
|
||||
)
|
||||
}
|
||||
DividerLine()
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val victim = pendingDelete
|
||||
if (victim != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { pendingDelete = null },
|
||||
title = { Text("删除「${victim.name}」?") },
|
||||
text = {
|
||||
Text(
|
||||
if (victim.docCount > 0) {
|
||||
"这个分类下还有 ${victim.docCount} 条知识,它们会回到「未分类」,知识本身不会被删。"
|
||||
} else {
|
||||
"这个分类下没有知识,可以放心删。"
|
||||
},
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
val target = victim
|
||||
pendingDelete = null
|
||||
scope.launch {
|
||||
busy = true
|
||||
runCatching {
|
||||
api.deleteKnowledgeCategory(workspaceId, target.id, force = true)
|
||||
}
|
||||
.onSuccess {
|
||||
notice = "已删除「${target.name}」"
|
||||
draft = null
|
||||
refresh()
|
||||
}
|
||||
.onFailure { fail(it) }
|
||||
busy = false
|
||||
}
|
||||
}) { Text("删除") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { pendingDelete = null }) { Text("取消") }
|
||||
},
|
||||
containerColor = Paper,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 一个分类的编辑卡:名字、颜色(色板 + 手填 hex)、建子分类、删除。 */
|
||||
@Composable
|
||||
private fun CategoryEditorCard(
|
||||
draft: CategoryDraft,
|
||||
busy: Boolean,
|
||||
onChange: (CategoryDraft) -> Unit,
|
||||
onSave: () -> Unit,
|
||||
onAddChild: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
SoftCard(padding = 12.dp, accent = categoryColorOf(draft.id, draft.color)) {
|
||||
Text(
|
||||
if (draft.id == null) "新建分类" else "编辑「${draft.name.ifBlank { "未命名" }}」",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = draft.name,
|
||||
onValueChange = { onChange(draft.copy(name = it.take(40))) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text("分类名,同层不能重名", color = InkMuted) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White,
|
||||
unfocusedContainerColor = Color.White,
|
||||
focusedIndicatorColor = Primary,
|
||||
unfocusedIndicatorColor = Line,
|
||||
),
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
SectionLabel("颜色(卡片上边线用它)")
|
||||
Row(
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
categoryPalette.forEach { hex ->
|
||||
val picked = draft.color?.equals(hex, ignoreCase = true) == true
|
||||
Box(
|
||||
Modifier
|
||||
.size(26.dp)
|
||||
.clip(CircleShape)
|
||||
.background(hexToColor(hex))
|
||||
.border(
|
||||
if (picked) 2.5.dp else 1.dp,
|
||||
if (picked) Ink else Line,
|
||||
CircleShape,
|
||||
)
|
||||
.clickable { onChange(draft.copy(color = hex)) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (picked) Text("✓", style = MaterialTheme.typography.labelSmall, color = Color.White)
|
||||
}
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier.clickable { onChange(draft.copy(color = null)) },
|
||||
color = if (draft.color == null) PrimarySoft else Color.White,
|
||||
contentColor = if (draft.color == null) Primary else InkSoft,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
border = BorderStroke(1.dp, if (draft.color == null) Primary else Line),
|
||||
) {
|
||||
Text(
|
||||
"不上色",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 5.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = draft.color.orEmpty(),
|
||||
onValueChange = { onChange(draft.copy(color = it.trim().take(7).ifBlank { null })) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text("也可以手填 #RRGGBB", color = InkMuted) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White,
|
||||
unfocusedContainerColor = Color.White,
|
||||
focusedIndicatorColor = Primary,
|
||||
unfocusedIndicatorColor = Line,
|
||||
),
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
PrimaryButton("保存", enabled = !busy, onClick = onSave)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
GhostButton("取消", enabled = !busy, onClick = onCancel)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
if (draft.id != null && draft.depth < 3) {
|
||||
GhostButton("加子分类", enabled = !busy, onClick = onAddChild)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
if (draft.id != null) {
|
||||
TextButton(enabled = !busy, onClick = onDelete) {
|
||||
Text("删除", color = Rust)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 正文的「看」形态:Markwon 把 Markdown 排成排版好的正文。
|
||||
*
|
||||
* 编辑走 RichTextEditorScreen,所以这里永远是只读——点开一条知识先看到内容本身,
|
||||
* 而不是一堆 `##`、`-` 原样摆着。
|
||||
*/
|
||||
@Composable
|
||||
private fun KnowledgeBody(text: String) {
|
||||
if (text.isBlank()) {
|
||||
Text("(还没有正文,点「编辑正文」补齐)", fontSize = 13.sp, color = InkMuted)
|
||||
return
|
||||
}
|
||||
val context = LocalContext.current
|
||||
// 表格/删除线插件:Web 正文支持表格,安卓照抄同一份 markdown 时要能画出来,
|
||||
// 否则 | 管道符会原样铺在正文里,像没解析。
|
||||
val markwon = remember {
|
||||
// 表格主题按 Web 的纸面来:1dp 浅线、表头浅底、偶数行淡一档。
|
||||
// 用默认主题的话边框是 1px 纯黑,一格一格的,像 Excel 截图。
|
||||
val density = context.resources.displayMetrics.density
|
||||
val tableTheme = TableTheme.Builder()
|
||||
.tableBorderWidth((1 * density).toInt())
|
||||
.tableBorderColor(android.graphics.Color.parseColor("#E3E7EF"))
|
||||
.tableCellPadding((8 * density).toInt())
|
||||
.tableHeaderRowBackgroundColor(android.graphics.Color.parseColor("#F4F5F9"))
|
||||
.tableOddRowBackgroundColor(android.graphics.Color.WHITE)
|
||||
.tableEvenRowBackgroundColor(android.graphics.Color.parseColor("#FAFAFC"))
|
||||
.build()
|
||||
Markwon.builder(context)
|
||||
.usePlugin(TablePlugin.create(tableTheme))
|
||||
.usePlugin(StrikethroughPlugin.create())
|
||||
.build()
|
||||
}
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
android.widget.TextView(ctx).apply {
|
||||
setTextIsSelectable(true)
|
||||
// 与 Web 阅读页对齐:正文 16sp、行高 1.7 —— 手机上读长文,14sp 太挤
|
||||
textSize = 16f
|
||||
setLineSpacing(0f, 1.7f)
|
||||
setTextColor(android.graphics.Color.parseColor("#1E293B"))
|
||||
markwon.setMarkdown(this, text)
|
||||
}
|
||||
},
|
||||
update = { view -> markwon.setMarkdown(view, text) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 80.dp),
|
||||
)
|
||||
}
|
||||
|
||||
private fun sourceLine(doc: KnowledgeDoc): String {
|
||||
@@ -533,15 +1062,13 @@ private fun KnowledgeDetailDialog(
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
|
||||
// 阅读区给足高度:手机上一屏能看二十来行,不用一直往里滚
|
||||
Modifier.heightIn(min = 320.dp, max = 560.dp).verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Text(sourceLine(doc), style = MaterialTheme.typography.labelSmall, color = InkMuted)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
doc.bodyMd.ifBlank { "(还没有正文,点「编辑正文」补齐)" },
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = InkSoft,
|
||||
)
|
||||
// 点开就是「看」:正文用 Markwon 排好版再显示,要看原文/改内容点「编辑正文」
|
||||
KnowledgeBody(doc.bodyMd)
|
||||
|
||||
// 附件:手册、日志、导出表格都能挂在这条知识下,点一下用浏览器打开
|
||||
Spacer(Modifier.height(14.dp))
|
||||
@@ -587,8 +1114,8 @@ private fun KnowledgeDetailDialog(
|
||||
}
|
||||
}
|
||||
|
||||
// 已发布的内容不能直接改,分类/标签也跟着锁住,避免"发布后又悄悄换归属"
|
||||
if (action != "published") {
|
||||
// 已发布的也能改(后端改完会重建检索切片);归档的先恢复成草稿再动,免得"收起来的"被悄悄改写
|
||||
if (action != "archived") {
|
||||
Spacer(Modifier.height(14.dp))
|
||||
SectionLabel("分类")
|
||||
Row(
|
||||
@@ -644,14 +1171,14 @@ private fun KnowledgeDetailDialog(
|
||||
title = doc.title,
|
||||
body = doc.bodyMd,
|
||||
version = version,
|
||||
titleEnabled = action == "draft",
|
||||
titleEnabled = action != "archived",
|
||||
knowledge = true,
|
||||
),
|
||||
)
|
||||
onClose()
|
||||
},
|
||||
) { Text("编辑正文") }
|
||||
if (action != "published") {
|
||||
if (action != "archived") {
|
||||
TextButton(
|
||||
enabled = !savingMeta,
|
||||
onClick = {
|
||||
|
||||
@@ -6,11 +6,12 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -19,6 +20,7 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -32,23 +34,45 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.dadao.task.data.Api
|
||||
import com.dadao.task.data.ApiException
|
||||
import com.dadao.task.data.AuthMethods
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
/** 手机号 11 位、邮箱只要有 @ 就放行——最终格式由服务端判,前端别提前拦。 */
|
||||
private fun looksLikeEmail(input: String): Boolean = input.contains('@')
|
||||
|
||||
private fun accountReady(account: String): Boolean {
|
||||
val text = account.trim()
|
||||
return if (looksLikeEmail(text)) text.length >= 6 else text.length == 11
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// 登录入口以服务端为准:生产早就关了短信登录,界面不能还摆着一条走不通的路。
|
||||
var methods by remember { mutableStateOf<AuthMethods?>(null) }
|
||||
var mode by remember { mutableStateOf("password") }
|
||||
|
||||
var account by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
|
||||
var phone by remember { mutableStateOf("") }
|
||||
var code by remember { mutableStateOf("") }
|
||||
var seconds by remember { mutableStateOf(0) }
|
||||
var devCode by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var registerOpen by remember { mutableStateOf(false) }
|
||||
|
||||
// 后端地址可改:包里默认是模拟器地址 10.0.2.2,真机必须换成电脑的局域网地址。
|
||||
var serverOpen by remember { mutableStateOf(false) }
|
||||
@@ -86,6 +110,15 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
runCatching { api.authMethods() }
|
||||
.onSuccess {
|
||||
methods = it
|
||||
// 只有短信这一条路时,默认切到它,别让人对着密码框发呆
|
||||
if (!it.password && !it.emailPassword && it.smsCode) mode = "code"
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(seconds) {
|
||||
if (seconds > 0) {
|
||||
delay(1000)
|
||||
@@ -93,6 +126,25 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
fun report(e: Throwable, fallback: String) {
|
||||
val c = (e as? ApiException)?.code ?: 0
|
||||
if (c == 1) serverOpen = true
|
||||
error = verifyStatusCode(c).ifBlank { e.message ?: fallback }
|
||||
}
|
||||
|
||||
fun submitPassword() {
|
||||
scope.launch {
|
||||
busy = true
|
||||
runCatching { api.loginPassword(account, password) }
|
||||
.onSuccess {
|
||||
error = null
|
||||
onLoggedIn()
|
||||
}
|
||||
.onFailure { report(it, "登录没成功") }
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
fun send() {
|
||||
scope.launch {
|
||||
busy = true
|
||||
@@ -102,16 +154,12 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
|
||||
devCode = data?.jsonObject?.get("dev_code")?.jsonPrimitive?.content
|
||||
error = null
|
||||
}
|
||||
.onFailure {
|
||||
val c = (it as? ApiException)?.code ?: 0
|
||||
if (c == 1) serverOpen = true
|
||||
error = verifyStatusCode(c).ifBlank { it.message ?: "验证码没发出去" }
|
||||
}
|
||||
.onFailure { report(it, "验证码没发出去") }
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
fun submit() {
|
||||
fun submitCode() {
|
||||
scope.launch {
|
||||
busy = true
|
||||
runCatching { api.login(phone.trim(), code.trim()) }
|
||||
@@ -119,15 +167,14 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
|
||||
error = null
|
||||
onLoggedIn()
|
||||
}
|
||||
.onFailure {
|
||||
val c = (it as? ApiException)?.code ?: 0
|
||||
if (c == 1) serverOpen = true
|
||||
error = verifyStatusCode(c).ifBlank { it.message ?: "登录没成功" }
|
||||
}
|
||||
.onFailure { report(it, "登录没成功") }
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
val smsAvailable = methods?.smsCode == true
|
||||
val registerAvailable = methods?.emailRegister == true
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
@@ -160,39 +207,45 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
|
||||
Text("登录", style = MaterialTheme.typography.headlineMedium)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
"手机号 + 验证码,不用记密码。第一次登录会自动建好账号。",
|
||||
if (smsAvailable) {
|
||||
"手机号或邮箱 + 密码,也可以收验证码登录。"
|
||||
} else {
|
||||
"手机号或邮箱 + 密码。第一次来先注册一个账号。"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = InkMuted,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(22.dp))
|
||||
OutlinedTextField(
|
||||
value = phone,
|
||||
onValueChange = { phone = it.filter { c -> c.isDigit() }.take(11) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("手机号") },
|
||||
placeholder = { Text("11 位手机号", color = InkMuted) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White,
|
||||
unfocusedContainerColor = Color.White,
|
||||
focusedIndicatorColor = Primary,
|
||||
unfocusedIndicatorColor = Line,
|
||||
),
|
||||
)
|
||||
// 两条路都可以走时才给切换 tab,只有一条就别摆出来当摆设
|
||||
if (smsAvailable) {
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SurfaceChip(
|
||||
text = "密码登录",
|
||||
selected = mode == "password",
|
||||
onClick = { mode = "password"; error = null },
|
||||
)
|
||||
SurfaceChip(
|
||||
text = "验证码登录",
|
||||
selected = mode == "code",
|
||||
onClick = { mode = "code"; error = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Spacer(Modifier.height(18.dp))
|
||||
if (mode == "password") {
|
||||
OutlinedTextField(
|
||||
value = code,
|
||||
onValueChange = { code = it.filter { c -> c.isDigit() }.take(6) },
|
||||
modifier = Modifier.weight(1f),
|
||||
label = { Text("验证码") },
|
||||
value = account,
|
||||
onValueChange = { account = it.take(80) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("手机号或邮箱") },
|
||||
placeholder = { Text("13800138000 或 you@example.com", color = InkMuted) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = if (looksLikeEmail(account)) KeyboardType.Email else KeyboardType.Text,
|
||||
),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White,
|
||||
unfocusedContainerColor = Color.White,
|
||||
@@ -200,23 +253,76 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
|
||||
unfocusedIndicatorColor = Line,
|
||||
),
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
GhostButton(
|
||||
text = if (seconds > 0) "${seconds}s" else "获取验证码",
|
||||
enabled = phone.length == 11 && seconds == 0 && !busy,
|
||||
onClick = { send() },
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it.take(64) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("密码") },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White,
|
||||
unfocusedContainerColor = Color.White,
|
||||
focusedIndicatorColor = Primary,
|
||||
unfocusedIndicatorColor = Line,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
OutlinedTextField(
|
||||
value = phone,
|
||||
onValueChange = { phone = it.filter { c -> c.isDigit() }.take(11) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("手机号") },
|
||||
placeholder = { Text("11 位手机号", color = InkMuted) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White,
|
||||
unfocusedContainerColor = Color.White,
|
||||
focusedIndicatorColor = Primary,
|
||||
unfocusedIndicatorColor = Line,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val hint = devCode
|
||||
if (hint != null) {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
SoftPill(
|
||||
"开发环境验证码:$hint",
|
||||
Primary,
|
||||
PrimarySoft,
|
||||
Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
value = code,
|
||||
onValueChange = { code = it.filter { c -> c.isDigit() }.take(6) },
|
||||
modifier = Modifier.weight(1f),
|
||||
label = { Text("验证码") },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White,
|
||||
unfocusedContainerColor = Color.White,
|
||||
focusedIndicatorColor = Primary,
|
||||
unfocusedIndicatorColor = Line,
|
||||
),
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
GhostButton(
|
||||
text = if (seconds > 0) "${seconds}s" else "获取验证码",
|
||||
enabled = phone.length == 11 && seconds == 0 && !busy,
|
||||
onClick = { send() },
|
||||
)
|
||||
}
|
||||
|
||||
val hint = devCode
|
||||
if (hint != null) {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
SoftPill(
|
||||
"开发环境验证码:$hint",
|
||||
Primary,
|
||||
PrimarySoft,
|
||||
Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val notice = error
|
||||
@@ -226,12 +332,36 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(22.dp))
|
||||
PrimaryButton(
|
||||
text = if (busy) "请稍候…" else "登录",
|
||||
enabled = phone.length == 11 && code.length >= 4 && !busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = { submit() },
|
||||
)
|
||||
if (mode == "password") {
|
||||
PrimaryButton(
|
||||
text = if (busy) "请稍候…" else "登录",
|
||||
enabled = accountReady(account) && password.isNotEmpty() && !busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = { submitPassword() },
|
||||
)
|
||||
} else {
|
||||
PrimaryButton(
|
||||
text = if (busy) "请稍候…" else "登录",
|
||||
enabled = phone.length == 11 && code.length >= 4 && !busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = { submitCode() },
|
||||
)
|
||||
}
|
||||
|
||||
if (registerAvailable) {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"还没有账号?",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = InkMuted,
|
||||
)
|
||||
TextButton(
|
||||
enabled = !busy,
|
||||
onClick = { error = null; registerOpen = true },
|
||||
) { Text("用邮箱注册") }
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
@@ -338,4 +468,190 @@ fun LoginScreen(api: Api, onLoggedIn: () -> Unit) {
|
||||
}
|
||||
Spacer(Modifier.height(40.dp))
|
||||
}
|
||||
|
||||
if (registerOpen) {
|
||||
RegisterDialog(
|
||||
api = api,
|
||||
onDismiss = { registerOpen = false },
|
||||
onRegistered = {
|
||||
registerOpen = false
|
||||
onLoggedIn()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 邮箱注册:验证码 + 密码,一步建号。
|
||||
*
|
||||
* 注册完服务端直接回令牌,所以这里不再跳回登录页——落地即登录,少一次输入。
|
||||
*/
|
||||
@Composable
|
||||
private fun RegisterDialog(api: Api, onDismiss: () -> Unit, onRegistered: () -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var email by remember { mutableStateOf("") }
|
||||
var code by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var nickname by remember { mutableStateOf("") }
|
||||
var seconds by remember { mutableStateOf(0) }
|
||||
var devCode by remember { mutableStateOf<String?>(null) }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(seconds) {
|
||||
if (seconds > 0) {
|
||||
delay(1000)
|
||||
seconds -= 1
|
||||
}
|
||||
}
|
||||
|
||||
fun fail(e: Throwable, fallback: String) {
|
||||
val c = (e as? ApiException)?.code ?: 0
|
||||
error = verifyStatusCode(c).ifBlank { e.message ?: fallback }
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth(0.94f)
|
||||
.fillMaxHeight(0.9f)
|
||||
.background(Paper, RoundedCornerShape(16.dp))
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"注册",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
GhostButton("取消", onClick = onDismiss)
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) {
|
||||
Text(
|
||||
"用邮箱收一个验证码,设好密码就是账号。邮箱也是以后找回密码的唯一途径,别填错。",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = InkMuted,
|
||||
)
|
||||
Spacer(Modifier.height(14.dp))
|
||||
OutlinedTextField(
|
||||
value = email,
|
||||
onValueChange = { email = it.take(80) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("邮箱") },
|
||||
placeholder = { Text("you@example.com", color = InkMuted) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White,
|
||||
unfocusedContainerColor = Color.White,
|
||||
focusedIndicatorColor = Primary,
|
||||
unfocusedIndicatorColor = Line,
|
||||
),
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
value = code,
|
||||
onValueChange = { code = it.filter { c -> c.isDigit() }.take(6) },
|
||||
modifier = Modifier.weight(1f),
|
||||
label = { Text("验证码") },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White,
|
||||
unfocusedContainerColor = Color.White,
|
||||
focusedIndicatorColor = Primary,
|
||||
unfocusedIndicatorColor = Line,
|
||||
),
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
GhostButton(
|
||||
text = if (seconds > 0) "${seconds}s" else "发验证码",
|
||||
enabled = email.contains('@') && seconds == 0 && !busy,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
busy = true
|
||||
runCatching { api.sendEmailCode(email) }
|
||||
.onSuccess {
|
||||
seconds = 60
|
||||
devCode = it?.jsonObject
|
||||
?.get("dev_code")
|
||||
?.jsonPrimitive
|
||||
?.content
|
||||
error = null
|
||||
}
|
||||
.onFailure { fail(it, "验证码没发出去") }
|
||||
busy = false
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
val hint = devCode
|
||||
if (hint != null) {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
SoftPill("开发环境验证码:$hint", Primary, PrimarySoft, Modifier.fillMaxWidth())
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it.take(64) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("密码") },
|
||||
placeholder = { Text("至少 8 位,别用生日", color = InkMuted) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White,
|
||||
unfocusedContainerColor = Color.White,
|
||||
focusedIndicatorColor = Primary,
|
||||
unfocusedIndicatorColor = Line,
|
||||
),
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
OutlinedTextField(
|
||||
value = nickname,
|
||||
onValueChange = { nickname = it.take(32) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("称呼(可不填)") },
|
||||
placeholder = { Text("别人在成员列表里看到的名字", color = InkMuted) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.White,
|
||||
unfocusedContainerColor = Color.White,
|
||||
focusedIndicatorColor = Primary,
|
||||
unfocusedIndicatorColor = Line,
|
||||
),
|
||||
)
|
||||
val notice = error
|
||||
if (notice != null) {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
SoftPill(notice, Rust, RustSoft, Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
PrimaryButton(
|
||||
text = if (busy) "请稍候…" else "注册并登录",
|
||||
enabled = email.contains('@') && code.length >= 4 && password.isNotEmpty() && !busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = {
|
||||
scope.launch {
|
||||
busy = true
|
||||
runCatching { api.register(email, code, password, nickname) }
|
||||
.onSuccess { onRegistered() }
|
||||
.onFailure { fail(it, "注册没成功") }
|
||||
busy = false
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,7 +350,7 @@ private fun PreviewBody(
|
||||
textSize = 15f
|
||||
setPadding(0, 0, 0, 0)
|
||||
setLineSpacing(0f, 1.5f)
|
||||
setTextColor(android.graphics.Color.parseColor("#4A5854"))
|
||||
setTextColor(android.graphics.Color.parseColor("#475569"))
|
||||
markwon.setMarkdown(this, text)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -42,12 +42,13 @@ import com.dadao.task.data.Item
|
||||
import com.dadao.task.data.Workspace
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// 「全部」放第一个,与 Web 任务页保持一致;默认选中的视图在下面单独定,不靠顺序。
|
||||
private val views = listOf(
|
||||
"all" to "全部",
|
||||
"today" to "今天",
|
||||
"active" to "进行中",
|
||||
"inbox" to "收件箱",
|
||||
"done" to "已完成",
|
||||
"all" to "全部",
|
||||
)
|
||||
|
||||
@Composable
|
||||
@@ -299,7 +300,7 @@ private fun TaskRow(item: Item, onOpen: () -> Unit, onToggle: () -> Unit) {
|
||||
Text(
|
||||
item.title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = if (item.isDone) InkMuted else Ink,
|
||||
textDecoration = if (item.isDone) TextDecoration.LineThrough else TextDecoration.None,
|
||||
)
|
||||
|
||||
@@ -13,22 +13,22 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* 与 Web 端 tailwind.config.js 同一套色板:暖白纸面 + 墨松绿主色 + 陶土橙强调。
|
||||
* 与 Web 端 tailwind.config.js 同一套色板:冷白纸面 + 靛蓝主色(#4F46E5)+ 陶土橙强调。
|
||||
* 两端看同一张截图时,颜色应该能对上,所以这里的值不要单独调。
|
||||
*/
|
||||
val Paper = Color(0xFFFBF9F6)
|
||||
val PaperSunken = Color(0xFFF3F0EA)
|
||||
val Line = Color(0xFFE4E0D8)
|
||||
val LineStrong = Color(0xFFD3CEC2)
|
||||
val Paper = Color(0xFFFAFAFC)
|
||||
val PaperSunken = Color(0xFFF4F5F9)
|
||||
val Line = Color(0xFFE3E7EF)
|
||||
val LineStrong = Color(0xFFD5DAE4)
|
||||
|
||||
val Ink = Color(0xFF1F2A28)
|
||||
val InkSoft = Color(0xFF4A5854)
|
||||
val InkMuted = Color(0xFF7C8985)
|
||||
val Ink = Color(0xFF1E293B)
|
||||
val InkSoft = Color(0xFF475569)
|
||||
val InkMuted = Color(0xFF64748B)
|
||||
|
||||
val Primary = Color(0xFF2A5A50)
|
||||
val PrimaryDeep = Color(0xFF1E443C)
|
||||
val PrimarySoft = Color(0xFFE3EDE8)
|
||||
val PrimaryTint = Color(0xFFD2E3DB)
|
||||
val Primary = Color(0xFF4F46E5)
|
||||
val PrimaryDeep = Color(0xFF4338CA)
|
||||
val PrimarySoft = Color(0xFFE0E7FF)
|
||||
val PrimaryTint = Color(0xFFC7D2FE)
|
||||
|
||||
val Clay = Color(0xFFC2703D)
|
||||
val ClaySoft = Color(0xFFFBEADF)
|
||||
@@ -39,8 +39,8 @@ val Wheat = Color(0xFFA97C25)
|
||||
val WheatSoft = Color(0xFFF8EED7)
|
||||
val Rust = Color(0xFFB04A32)
|
||||
val RustSoft = Color(0xFFFBE5DF)
|
||||
val Sky = Color(0xFF3E6F91)
|
||||
val SkySoft = Color(0xFFE4EEF5)
|
||||
val Sky = Color(0xFF16697C)
|
||||
val SkySoft = Color(0xFFE6F4F6)
|
||||
|
||||
private val colors = lightColorScheme(
|
||||
primary = Primary,
|
||||
|
||||
@@ -164,7 +164,7 @@ fun TicketsScreen(
|
||||
Text(
|
||||
ticket.title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package com.dadao.task.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.dadao.task.data.AppRelease
|
||||
import com.dadao.task.data.AppUpdater
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
/** 更新对话框当前停在哪一步。 */
|
||||
private enum class UpdatePhase { Offer, Downloading, Downloaded, Denied, Failed }
|
||||
|
||||
/**
|
||||
* 应用内升级的对话窗口。
|
||||
*
|
||||
* 只有一条主按钮,因为这一步没得选:点了就下载、下完就交给系统安装器。
|
||||
* 「允许安装未知应用」是系统开关,界面只能把用户送到设置页,不能替他点。
|
||||
*/
|
||||
@Composable
|
||||
fun UpdateDialog(
|
||||
release: AppRelease,
|
||||
currentVersionName: String,
|
||||
updater: AppUpdater,
|
||||
onLater: () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var phase by remember { mutableStateOf(UpdatePhase.Offer) }
|
||||
var progress by remember { mutableStateOf(0f) }
|
||||
var detail by remember { mutableStateOf("") }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var file by remember { mutableStateOf<File?>(null) }
|
||||
|
||||
fun handToInstaller(target: File) {
|
||||
if (updater.install(target)) onLater() else phase = UpdatePhase.Denied
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
// 下载过程中不给关:半截的包留在磁盘上比重新下一个更麻烦
|
||||
onDismissRequest = { if (phase != UpdatePhase.Downloading) onLater() },
|
||||
title = { Text("有新版 ${release.versionName}", style = MaterialTheme.typography.titleMedium) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(
|
||||
"当前 ${currentVersionName} · 新包 ${formatSize(release.size)}",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = InkMuted,
|
||||
)
|
||||
if (release.notes.isNotBlank()) {
|
||||
Text(release.notes, style = MaterialTheme.typography.bodySmall, color = InkSoft)
|
||||
}
|
||||
when (phase) {
|
||||
UpdatePhase.Downloading -> {
|
||||
LinearProgressIndicator(
|
||||
progress = { progress },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Text(
|
||||
"下载中 ${(progress * 100).toInt()}% $detail",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = InkMuted,
|
||||
)
|
||||
}
|
||||
|
||||
UpdatePhase.Denied -> Text(
|
||||
"安装包已经下好了,但系统还关着「允许安装未知应用」。去打开这个开关再回来点安装。",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Clay,
|
||||
)
|
||||
|
||||
UpdatePhase.Failed -> Text(
|
||||
error ?: "更新失败了,稍后再试",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Clay,
|
||||
)
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
when (phase) {
|
||||
UpdatePhase.Denied -> PrimaryButton(
|
||||
text = "去开启安装权限",
|
||||
onClick = {
|
||||
// 用户可能是刚从系统设置回来:权限开了就直接装,没开才把人送过去
|
||||
val downloaded = file
|
||||
if (downloaded != null && updater.canInstall()) {
|
||||
handToInstaller(downloaded)
|
||||
} else {
|
||||
updater.openInstallPermission()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
UpdatePhase.Downloaded -> PrimaryButton(
|
||||
text = "安装",
|
||||
onClick = { file?.let { handToInstaller(it) } },
|
||||
)
|
||||
|
||||
UpdatePhase.Downloading -> PrimaryButton("下载中…", onClick = {}, enabled = false)
|
||||
|
||||
else -> PrimaryButton(
|
||||
text = if (phase == UpdatePhase.Failed) "重新下载" else "下载并安装",
|
||||
onClick = {
|
||||
scope.launch {
|
||||
phase = UpdatePhase.Downloading
|
||||
progress = 0f
|
||||
runCatching {
|
||||
val downloaded = updater.download(release) { done, total ->
|
||||
progress = if (total > 0) (done.toFloat() / total).coerceIn(0f, 1f) else 0f
|
||||
detail = "${formatSize(done)} / ${formatSize(total)}"
|
||||
}
|
||||
file = downloaded
|
||||
phase = UpdatePhase.Downloaded
|
||||
handToInstaller(downloaded)
|
||||
}.onFailure {
|
||||
error = it.message ?: "更新失败了,稍后再试"
|
||||
phase = UpdatePhase.Failed
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { if (phase != UpdatePhase.Downloading) onLater() }) {
|
||||
Text("以后再说", color = InkMuted)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** 把字节数说成人话:文件小就 KB,大就 MB。 */
|
||||
private fun formatSize(bytes: Long): String {
|
||||
if (bytes <= 0) return "未知大小"
|
||||
if (bytes < 1024 * 1024) return "${(bytes / 1024)} KB"
|
||||
return String.format(Locale.US, "%.1f MB", bytes / 1024.0 / 1024.0)
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@@ -96,27 +97,46 @@ fun SoftPill(text: String, fg: Color = InkSoft, bg: Color = PaperSunken, modifie
|
||||
}
|
||||
}
|
||||
|
||||
/** 卡片:白底、细描边、几乎不可见的投影。整个 App 用它承载所有内容。 */
|
||||
/**
|
||||
* 卡片:白底、细描边、几乎不可见的投影。整个 App 用它承载所有内容。
|
||||
*
|
||||
* [accent] 是卡片顶部的一条 3dp 色带——知识卡片用它显示所属分类的颜色,
|
||||
* 扫一眼列表就能按颜色找回"这堆是哪个分类的"。用 clip 保证色带两端跟着圆角走。
|
||||
*/
|
||||
@Composable
|
||||
fun SoftCard(
|
||||
modifier: Modifier = Modifier,
|
||||
padding: Dp = 14.dp,
|
||||
accent: Color? = null,
|
||||
onClick: (() -> Unit)? = null,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
val shape = RoundedCornerShape(14.dp)
|
||||
val base = modifier
|
||||
.fillMaxWidth()
|
||||
.border(1.dp, Line, RoundedCornerShape(14.dp))
|
||||
.background(Color.White, RoundedCornerShape(14.dp))
|
||||
.clip(shape)
|
||||
.border(1.dp, Line, shape)
|
||||
.background(Color.White, shape)
|
||||
Surface(
|
||||
modifier = if (onClick != null) base.clickable { onClick() } else base,
|
||||
color = Color.White,
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
shape = shape,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(padding), content = content)
|
||||
Column {
|
||||
if (accent != null) {
|
||||
Box(Modifier.fillMaxWidth().height(3.dp).background(accent))
|
||||
}
|
||||
Column(modifier = Modifier.padding(padding), content = content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 分类色的小圆点:侧栏、筛选 chip、卡片 meta 里到处都用它。 */
|
||||
@Composable
|
||||
fun CategoryDot(color: Color, size: Dp = 8.dp, modifier: Modifier = Modifier) {
|
||||
Box(modifier.size(size).background(color, CircleShape))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SectionLabel(text: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- 自适应图标前景:与 Web Logo 同一套比例(108 画布,安全区 66 居中;靛蓝底上的白线与淡紫点) -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:pathData="M36.3,42.6 L71.7,42.6"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="5.06"
|
||||
android:strokeLineCap="round" />
|
||||
<path
|
||||
android:pathData="M36.3,54 L59.6,54"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="5.06"
|
||||
android:strokeLineCap="round" />
|
||||
<path
|
||||
android:pathData="M36.3,65.4 L50.8,65.4"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="5.06"
|
||||
android:strokeLineCap="round" />
|
||||
<path
|
||||
android:fillColor="#C7D2FE"
|
||||
android:pathData="M71.7,65.4 m-7.6,0 a7.6,7.6 0 1,0 15.2,0 a7.6,7.6 0 1,0 -15.2,0" />
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- 与 Web 端 Logo(web/src/components/icons.tsx 的 <Logo/>)同一个靛蓝 -->
|
||||
<color name="ic_launcher_background">#4F46E5</color>
|
||||
</resources>
|
||||
@@ -2,7 +2,7 @@
|
||||
<resources>
|
||||
<!-- Compose 自己管配色,这里只提供无 ActionBar 的容器主题 -->
|
||||
<style name="Theme.DadaoTask" parent="android:Theme.Material.Light.NoActionBar">
|
||||
<item name="android:statusBarColor">#3F5A68</item>
|
||||
<item name="android:windowBackground">#F7F8F8</item>
|
||||
<item name="android:statusBarColor">#4F46E5</item>
|
||||
<item name="android:windowBackground">#FAFAFC</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- FileProvider 的可见范围:只暴露应用自己的 files/updates,安装包就在那儿 -->
|
||||
<paths>
|
||||
<external-files-path name="updates" path="updates/" />
|
||||
<files-path name="internal_updates" path="updates/" />
|
||||
</paths>
|
||||
@@ -1,11 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
只对本机开发地址放行明文 HTTP。生产域名必须走 HTTPS,不放行任何 cleartext。
|
||||
只给内网/本机地址放行明文 HTTP:10.0.2.2 是模拟器里的宿主机,
|
||||
192.168.8.188 是当前的内网生产入口(还没上 HTTPS)。
|
||||
正式域名必须走 HTTPS——上线时把 192.168.8.188 这条删掉。
|
||||
-->
|
||||
<network-security-config>
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="false">10.0.2.2</domain>
|
||||
<domain includeSubdomains="false">127.0.0.1</domain>
|
||||
<domain includeSubdomains="false">localhost</domain>
|
||||
<domain includeSubdomains="false">192.168.8.188</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""登录密码(M1 账号功能)
|
||||
|
||||
Revision ID: 0008
|
||||
Revises: 0007
|
||||
Create Date: 2026-10-31
|
||||
|
||||
M0 只有手机号 + 验证码登录。验证码登录在换手机、短信通道欠费、
|
||||
或者只想快一点的时候都不够用,所以给账号加一条**可选**的密码登录路径。
|
||||
|
||||
两个取舍:
|
||||
|
||||
1. **密码可空**。老用户不去设置就一直走验证码,不会被强制改流程;
|
||||
登录页两种方式并存,谁设过谁自己知道。
|
||||
2. **只存 scrypt 串**(见 `app/security.py`),不存明文也不存 md5。
|
||||
密码本身永远不回给客户端,接口只返回 `has_password` 这个布尔。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0008"
|
||||
down_revision: str = "0007"
|
||||
branch_labels: Sequence[str] | None = None
|
||||
depends_on: Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE app_user
|
||||
ADD COLUMN IF NOT EXISTS password_hash VARCHAR(255)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE app_user DROP COLUMN IF EXISTS password_hash")
|
||||
@@ -0,0 +1,48 @@
|
||||
"""知识分类配色(M3 补充)
|
||||
|
||||
Revision ID: 0009
|
||||
Revises: 0008
|
||||
Create Date: 2026-11-02
|
||||
|
||||
分类从"只能分级"变成"还能上色"。一列而已,但解决了卡片墙上的一个真问题:
|
||||
三十条知识平铺在一起,光看标题要逐条读才知道哪条属于部署、哪条属于业务。
|
||||
分类色让"归属"在扫视时就成立,不用读字。
|
||||
|
||||
三个取舍:
|
||||
|
||||
1. **颜色存分类上,不存知识上**。同一类的颜色必须一致,否则卡片墙会花成
|
||||
一盘散沙;存在分类上,改一次全类生效。
|
||||
2. **允许为空**。老数据(0007 建的分类)没有颜色,强行补一个默认值等于替用户
|
||||
做决定;为空时前端按分类 id 稳定地挑一个调色板色,看起来仍是"每类一色"。
|
||||
3. **CHECK 约束卡格式**。只接受 `#RRGGBB`,大写。前端传 `rgb(...)` 或带 alpha
|
||||
的值会被挡在数据库外——渲染层不会因为一个脏值悄悄丢样式。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0009"
|
||||
down_revision: str = "0008"
|
||||
branch_labels: Sequence[str] | None = None
|
||||
depends_on: Sequence[str] | None = None
|
||||
|
||||
UPGRADE_SQL = (
|
||||
"ALTER TABLE knowledge_category ADD COLUMN IF NOT EXISTS color varchar(7)",
|
||||
"ALTER TABLE knowledge_category DROP CONSTRAINT IF EXISTS ck_kcat_color",
|
||||
"ALTER TABLE knowledge_category ADD CONSTRAINT ck_kcat_color "
|
||||
"CHECK (color IS NULL OR color ~ '^#[0-9A-F]{6}$')",
|
||||
"COMMENT ON COLUMN knowledge_category.color IS '分类色 #RRGGBB,卡片上边线用它'",
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for statement in UPGRADE_SQL:
|
||||
op.execute(statement)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE knowledge_category DROP CONSTRAINT IF EXISTS ck_kcat_color")
|
||||
op.execute("ALTER TABLE knowledge_category DROP COLUMN IF EXISTS color")
|
||||
@@ -0,0 +1,67 @@
|
||||
"""邮箱登录 + 平台设置(M1 账号功能)
|
||||
|
||||
Revision ID: 0010
|
||||
Revises: 0009
|
||||
Create Date: 2026-11-02
|
||||
|
||||
两件事:
|
||||
|
||||
1. `app_user.email`:邮箱当账号名,配合密码登录。唯一性用 `lower(email)` 的部分
|
||||
唯一索引——邮箱大小写不敏感,`Steven@x.com` 和 `steven@x.com` 是同一个人;
|
||||
允许为空(老用户先不绑)。
|
||||
|
||||
2. `system_setting`:平台级键值表,第一项就是 SMTP。**这张表刻意不挂 workspace_id、
|
||||
也不开 RLS**:发信账号是整台服务共用的,属于平台而不是某个租户,开 RLS 反而要
|
||||
编一个"当前租户"出来。写权限在 API 层用平台管理员白名单把关(deps.platform_admin),
|
||||
应用角色只有表的读写权,没有建表/改结构的权。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
from app.roles import APP_ROLE
|
||||
|
||||
revision: str = "0010"
|
||||
down_revision: str = "0009"
|
||||
branch_labels: Sequence[str] | None = None
|
||||
depends_on: Sequence[str] | None = None
|
||||
|
||||
|
||||
CREATE_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS system_setting (
|
||||
key VARCHAR(64) PRIMARY KEY,
|
||||
value JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
updated_by UUID REFERENCES app_user(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
version INTEGER NOT NULL DEFAULT 1
|
||||
)
|
||||
"""
|
||||
|
||||
INDEX_STATEMENTS = (
|
||||
"ALTER TABLE app_user ADD COLUMN IF NOT EXISTS email VARCHAR(255)",
|
||||
"CREATE UNIQUE INDEX uq_app_user_email ON app_user (lower(email)) WHERE email IS NOT NULL",
|
||||
"COMMENT ON TABLE system_setting IS '平台级设置(SMTP 等),不挂租户、无 RLS'",
|
||||
)
|
||||
|
||||
GRANT_SQL = (
|
||||
f"GRANT SELECT, INSERT, UPDATE, DELETE ON system_setting TO {APP_ROLE}",
|
||||
f"GRANT SELECT, UPDATE ON app_user TO {APP_ROLE}",
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(CREATE_SQL)
|
||||
for statement in INDEX_STATEMENTS:
|
||||
op.execute(statement)
|
||||
for statement in GRANT_SQL:
|
||||
op.execute(statement)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS uq_app_user_email")
|
||||
op.execute("ALTER TABLE app_user DROP COLUMN IF EXISTS email")
|
||||
op.execute("DROP TABLE IF EXISTS system_setting")
|
||||
@@ -0,0 +1,41 @@
|
||||
"""app_user 支持邮箱注册:放开 phone 非空、记录邮箱已验证时间
|
||||
|
||||
手机验证码登录关掉以后,新用户是「邮箱 + 验证码」注册进来的,此时还没有手机号;
|
||||
`app_user.phone` 原来 NOT NULL,不放开就没法插这类账号。
|
||||
|
||||
`email_verified_at` 用来区分"填了个邮箱字符串"和"这个地址确实验证过":
|
||||
注册流程发码成功后写入;账号页手工绑邮箱时同一套流程。
|
||||
|
||||
Revision ID: 0011
|
||||
Revises: 0010
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
|
||||
from app.roles import APP_ROLE
|
||||
|
||||
revision = "0011"
|
||||
down_revision = "0010"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 邮箱注册的账号没有手机号:老账号都有值,放开非空不影响它们
|
||||
op.execute("ALTER TABLE app_user ALTER COLUMN phone DROP NOT NULL")
|
||||
op.execute("ALTER TABLE app_user ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ")
|
||||
op.execute(
|
||||
"COMMENT ON COLUMN app_user.email_verified_at IS "
|
||||
"'邮箱验证通过的时间;为空表示还没验证过这个地址'"
|
||||
)
|
||||
# 本表全局(不挂租户),只补列不新增表,权限照旧:应用角色要能读写这些列
|
||||
op.execute(f"GRANT SELECT, UPDATE ON app_user TO {APP_ROLE}")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 回滚前先把没有手机号的账号补上占位值,否则加回 NOT NULL 会失败
|
||||
op.execute("UPDATE app_user SET phone = 'redeem-' || replace(id::text, '-', '') WHERE phone IS NULL")
|
||||
op.execute("ALTER TABLE app_user DROP COLUMN IF EXISTS email_verified_at")
|
||||
op.execute("ALTER TABLE app_user ALTER COLUMN phone SET NOT NULL")
|
||||
@@ -0,0 +1,108 @@
|
||||
"""微信(clawbot)接入通道
|
||||
|
||||
Revision ID: 0012
|
||||
Revises: 0011
|
||||
Create Date: 2026-11-05
|
||||
|
||||
两张表:
|
||||
|
||||
- `bot_channel`:一个工作区可以挂多个机器人通道,令牌只存 sha256。
|
||||
- `bot_message`:进来的每条消息留痕,附带它变成了哪条任务/知识。
|
||||
|
||||
`bot_channel` 的 RLS 多放了一条口子:`token_hash = app_bot_token_hash()`。
|
||||
原因很实在——**收到 webhook 时我们还不知道它是哪个工作区的**,而候选令牌
|
||||
必须读过表才知道。没有这条口子,就只能要么关掉 RLS(不行),要么把令牌
|
||||
放一张不挂租户的表里(等于把钥匙串放在门外)。现在的做法是:
|
||||
请求先把 `app.bot_token_hash` 设成本次令牌的哈希,策略只允许命中那一行,
|
||||
其余行照旧看不见。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
from app.roles import APP_ROLE
|
||||
|
||||
revision: str = "0012"
|
||||
down_revision: str = "0011"
|
||||
branch_labels: Sequence[str] | None = None
|
||||
depends_on: Sequence[str] | None = None
|
||||
|
||||
CREATE_STATEMENTS = (
|
||||
"""
|
||||
CREATE TABLE bot_channel (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
|
||||
kind varchar(32) NOT NULL DEFAULT 'wechat_clawbot',
|
||||
name varchar(64) NOT NULL DEFAULT '',
|
||||
token_hash varchar(64) NOT NULL,
|
||||
status varchar(16) NOT NULL DEFAULT 'active',
|
||||
created_by uuid REFERENCES app_user(id) ON DELETE SET NULL,
|
||||
last_seen_at timestamptz,
|
||||
last_error text,
|
||||
version integer NOT NULL DEFAULT 1,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE bot_message (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id uuid NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
|
||||
channel_id uuid NOT NULL REFERENCES bot_channel(id) ON DELETE CASCADE,
|
||||
external_id varchar(128),
|
||||
sender varchar(128),
|
||||
text text NOT NULL DEFAULT '',
|
||||
action varchar(24) NOT NULL DEFAULT 'ignored',
|
||||
item_id uuid REFERENCES item(id) ON DELETE SET NULL,
|
||||
reply text NOT NULL DEFAULT '',
|
||||
raw jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
version integer NOT NULL DEFAULT 1,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
)
|
||||
""",
|
||||
)
|
||||
|
||||
INDEX_STATEMENTS = (
|
||||
"CREATE INDEX ix_bot_channel_ws ON bot_channel (workspace_id)",
|
||||
"CREATE UNIQUE INDEX uq_bot_channel_token ON bot_channel (token_hash)",
|
||||
"CREATE INDEX ix_bot_message_channel ON bot_message (channel_id, created_at)",
|
||||
"CREATE UNIQUE INDEX uq_bot_message_external ON bot_message (channel_id, external_id) "
|
||||
"WHERE external_id IS NOT NULL",
|
||||
)
|
||||
|
||||
POLICY_SQL = (
|
||||
"ALTER TABLE bot_channel ENABLE ROW LEVEL SECURITY",
|
||||
"CREATE POLICY bot_channel_isolation ON bot_channel "
|
||||
"USING (workspace_id = app_ws_id() OR token_hash = current_setting('app.bot_token_hash', true)) "
|
||||
"WITH CHECK (workspace_id = app_ws_id())",
|
||||
"ALTER TABLE bot_message ENABLE ROW LEVEL SECURITY",
|
||||
"CREATE POLICY bot_message_isolation ON bot_message "
|
||||
"USING (workspace_id = app_ws_id()) WITH CHECK (workspace_id = app_ws_id())",
|
||||
f"GRANT SELECT, INSERT, UPDATE, DELETE ON bot_channel TO {APP_ROLE}",
|
||||
f"GRANT SELECT, INSERT, UPDATE, DELETE ON bot_message TO {APP_ROLE}",
|
||||
)
|
||||
|
||||
COMMENT_SQL = (
|
||||
"COMMENT ON TABLE bot_channel IS '外部接入通道(微信 clawbot 等),令牌只存 sha256'",
|
||||
"COMMENT ON TABLE bot_message IS '外部接入收到并处理过的消息,用于支线排查与去重'",
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for statement in CREATE_STATEMENTS:
|
||||
op.execute(statement)
|
||||
for statement in INDEX_STATEMENTS:
|
||||
op.execute(statement)
|
||||
for statement in POLICY_SQL:
|
||||
op.execute(statement)
|
||||
for statement in COMMENT_SQL:
|
||||
op.execute(statement)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TABLE IF EXISTS bot_message")
|
||||
op.execute("DROP TABLE IF EXISTS bot_channel")
|
||||
@@ -0,0 +1,63 @@
|
||||
"""微信 clawbot 改成"扫码登录 + 长轮询"
|
||||
|
||||
Revision ID: 0013
|
||||
Revises: 0012
|
||||
Create Date: 2026-11-05
|
||||
|
||||
上一版(0012)做成了"clawbot 往我们这 POST webhook"。真实协议是腾讯 iLink Bot:
|
||||
我们先扫码登录拿 `bot_token`,再由我们长轮询 `getupdates` 拉消息(见
|
||||
`app/services/wechat_ilink.py` 文件头的出处说明)。所以这张表要补登录态与游标:
|
||||
|
||||
- `bot_token_enc`:加密后的登录态(密钥由 JWT_SECRET 派生),换 secret 就要重新扫码;
|
||||
- `ilink_user_id`:扫码的微信号,界面上显示"谁扫的";
|
||||
- `base_url`:服务端可能下发新基址;
|
||||
- `sync_buf`:长轮询游标,丢了会重复收消息;
|
||||
- `context_token`:回复必须带对方上一条消息的 context_token。
|
||||
|
||||
`bot_channel.status` 增加 `pending`(等扫码)与 `expired`(登录态失效)两个取值;
|
||||
`token_hash` 是上一版遗留列,改成可空、不再使用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
from app.roles import APP_ROLE
|
||||
|
||||
revision: str = "0013"
|
||||
down_revision: str = "0012"
|
||||
branch_labels: Sequence[str] | None = None
|
||||
depends_on: Sequence[str] | None = None
|
||||
|
||||
STATEMENTS = (
|
||||
"ALTER TABLE bot_channel ADD COLUMN IF NOT EXISTS bot_token_enc text",
|
||||
"ALTER TABLE bot_channel ADD COLUMN IF NOT EXISTS ilink_user_id varchar(64)",
|
||||
"ALTER TABLE bot_channel ADD COLUMN IF NOT EXISTS base_url text",
|
||||
"ALTER TABLE bot_channel ADD COLUMN IF NOT EXISTS sync_buf text",
|
||||
"ALTER TABLE bot_channel ADD COLUMN IF NOT EXISTS context_token text",
|
||||
"ALTER TABLE bot_channel ALTER COLUMN token_hash DROP NOT NULL",
|
||||
"ALTER TABLE bot_channel ALTER COLUMN status SET DEFAULT 'pending'",
|
||||
"COMMENT ON COLUMN bot_channel.bot_token_enc IS 'iLink 登录态,Fernet 加密(密钥由 JWT_SECRET 派生)'",
|
||||
"COMMENT ON COLUMN bot_channel.sync_buf IS 'getupdates 游标,丢了会重复收消息'",
|
||||
f"GRANT SELECT, INSERT, UPDATE, DELETE ON bot_channel TO {APP_ROLE}",
|
||||
)
|
||||
|
||||
DOWN_STATEMENTS = (
|
||||
"ALTER TABLE bot_channel DROP COLUMN IF EXISTS context_token",
|
||||
"ALTER TABLE bot_channel DROP COLUMN IF EXISTS sync_buf",
|
||||
"ALTER TABLE bot_channel DROP COLUMN IF EXISTS base_url",
|
||||
"ALTER TABLE bot_channel DROP COLUMN IF EXISTS ilink_user_id",
|
||||
"ALTER TABLE bot_channel DROP COLUMN IF EXISTS bot_token_enc",
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for statement in STATEMENTS:
|
||||
op.execute(statement)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for statement in DOWN_STATEMENTS:
|
||||
op.execute(statement)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""轮询任务读通道需要一条 RLS 口子
|
||||
|
||||
Revision ID: 0014
|
||||
Revises: 0013
|
||||
Create Date: 2026-11-05
|
||||
|
||||
后台轮询是"从 channel_id 开始"的:它还没有工作区上下文,而 `bot_channel` 的策略要求
|
||||
`workspace_id = app_ws_id()`,直接 SELECT 会一行都看不到(先有鸡还是先有蛋)。
|
||||
|
||||
和 0012 那条 `token_hash = app_bot_token_hash()` 同理,这里再放一条**只按主键命中自己**
|
||||
的口子:任务在读任何东西之前先把 `app.bot_channel` 设成本次要处理的通道 id,
|
||||
策略允许它看见那一行,其余行照旧不可见。读到 workspace_id 之后立刻 `bind_tenant`,
|
||||
后面所有查询都回到正常的租户上下文里。
|
||||
|
||||
两条策略是 permissive 关系(PG 会 OR 起来),所以原来的隔离策略不用改。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
from app.roles import APP_ROLE
|
||||
|
||||
revision: str = "0014"
|
||||
down_revision: str = "0013"
|
||||
branch_labels: Sequence[str] | None = None
|
||||
depends_on: Sequence[str] | None = None
|
||||
|
||||
CREATE_POLICY = """
|
||||
CREATE POLICY bot_channel_worker ON bot_channel
|
||||
FOR ALL
|
||||
USING (id = current_setting('app.bot_channel', true)::uuid)
|
||||
WITH CHECK (id = current_setting('app.bot_channel', true)::uuid)
|
||||
"""
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("DROP POLICY IF EXISTS bot_channel_worker ON bot_channel")
|
||||
op.execute(CREATE_POLICY)
|
||||
op.execute(f"GRANT SELECT, INSERT, UPDATE, DELETE ON bot_channel TO {APP_ROLE}")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP POLICY IF EXISTS bot_channel_worker ON bot_channel")
|
||||
@@ -0,0 +1,56 @@
|
||||
"""bot_channel 的轮询口子要容忍"空字符串"的 GUC
|
||||
|
||||
`set_config(..., true)` 提交后会把变量还原成上一次的值;连接池里这条连接如果本来没有
|
||||
会话级值,还原出来的是空串 `''` 而不是 NULL。`0014` 的策略直接
|
||||
`current_setting('app.bot_channel', true)::uuid`,空串一 cast 就
|
||||
`InvalidTextRepresentationError: invalid input syntax for type uuid: ""`,
|
||||
于是同一连接上下一个请求(比如扫码确认)500。
|
||||
|
||||
这里把策略重建成和 `app_ws_id()` 一样的写法:先 `nullif(..., '')` 再 cast。
|
||||
|
||||
Revision ID: 0015
|
||||
Revises: 0014
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
|
||||
from app.roles import APP_ROLE
|
||||
|
||||
revision = "0015"
|
||||
down_revision = "0014"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
STATEMENTS: tuple[str, ...] = (
|
||||
"DROP POLICY IF EXISTS bot_channel_worker ON bot_channel",
|
||||
"""
|
||||
CREATE POLICY bot_channel_worker ON bot_channel
|
||||
FOR ALL
|
||||
USING (
|
||||
id = nullif(current_setting('app.bot_channel', true), '')::uuid
|
||||
)
|
||||
WITH CHECK (
|
||||
id = nullif(current_setting('app.bot_channel', true), '')::uuid
|
||||
)
|
||||
""",
|
||||
f"GRANT SELECT, INSERT, UPDATE, DELETE ON bot_channel TO {APP_ROLE}",
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for statement in STATEMENTS:
|
||||
op.execute(statement)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP POLICY IF EXISTS bot_channel_worker ON bot_channel")
|
||||
op.execute(
|
||||
"""
|
||||
CREATE POLICY bot_channel_worker ON bot_channel
|
||||
FOR ALL
|
||||
USING (id = current_setting('app.bot_channel', true)::uuid)
|
||||
WITH CHECK (id = current_setting('app.bot_channel', true)::uuid)
|
||||
"""
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
"""启动时列出要接管的通道,也需要一条 RLS 口子
|
||||
|
||||
Revision ID: 0016
|
||||
Revises: 0015
|
||||
Create Date: 2026-11-05
|
||||
|
||||
`BotManager.start_all()` 要在进程启动时问一句"现在有哪些通道已登录、该接着收消息",
|
||||
但 `bot_channel` 的隔离策略要求 `workspace_id = app_ws_id()`,启动那一刻**没有工作区**,
|
||||
结果一行都查不到——表现是"重启之后机器人静默不收消息,页面上还显示已连接"。
|
||||
|
||||
0014 那条口子是"知道 id、只看自己那一行",这里补的是"允许列出还没开始干的活":
|
||||
只有当次事务显式设了 `app.bot_list = 'on'` 时才放行,而且只开 **SELECT**,
|
||||
防止有人顺手拿它改别人的通道。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
from app.roles import APP_ROLE
|
||||
|
||||
revision: str = "0016"
|
||||
down_revision: str = "0015"
|
||||
branch_labels: Sequence[str] | None = None
|
||||
depends_on: Sequence[str] | None = None
|
||||
|
||||
CREATE_POLICY = """
|
||||
CREATE POLICY bot_channel_startup ON bot_channel
|
||||
FOR SELECT
|
||||
USING (current_setting('app.bot_list', true) = 'on')
|
||||
"""
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("DROP POLICY IF EXISTS bot_channel_startup ON bot_channel")
|
||||
op.execute(CREATE_POLICY)
|
||||
op.execute(f"GRANT SELECT ON bot_channel TO {APP_ROLE}")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP POLICY IF EXISTS bot_channel_startup ON bot_channel")
|
||||
@@ -0,0 +1,93 @@
|
||||
"""安卓 OTA:最新版本清单 + APK 下载。
|
||||
|
||||
为什么不用一张表:一个安装包就是一条"发布记录",而 APK 是构建产物、不能进仓库。
|
||||
所以发布目录里放「latest.json + 同名 apk」,换版本只换文件,运维不需要动数据库。
|
||||
清单由 `backend/scripts/publish_apk.py` 生成(算好大小与 sha256),本模块只读。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from ..config import get_settings
|
||||
from ..errors import AppError, ErrorCode
|
||||
from ..logging_setup import get_logger
|
||||
from ..schemas import ok
|
||||
|
||||
router = APIRouter(prefix="/app", tags=["版本升级"])
|
||||
settings = get_settings()
|
||||
logger = get_logger("app.release")
|
||||
|
||||
MANIFEST_NAME = "latest.json"
|
||||
|
||||
|
||||
def apk_dir() -> Path:
|
||||
"""发布目录:优先用配置里的 APK_DIR,默认在 backend/static/apk(部署时保留、不入库)。"""
|
||||
if settings.apk_dir:
|
||||
return Path(settings.apk_dir)
|
||||
return Path(__file__).resolve().parents[2] / "static" / "apk"
|
||||
|
||||
|
||||
def _manifest() -> dict[str, Any]:
|
||||
path = apk_dir() / MANIFEST_NAME
|
||||
if not path.is_file():
|
||||
raise AppError(ErrorCode.APP_RELEASE_NOT_FOUND, "还没有发布过安装包")
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc: # noqa: BLE001 - 清单坏了也要说人话
|
||||
logger.exception("读取 OTA 清单失败")
|
||||
raise AppError(ErrorCode.APP_RELEASE_NOT_FOUND, "安装包清单损坏,请重新发布") from exc
|
||||
if not isinstance(data, dict) or not data.get("file"):
|
||||
raise AppError(ErrorCode.APP_RELEASE_NOT_FOUND, "安装包清单缺少文件名")
|
||||
return data
|
||||
|
||||
|
||||
def _payload(data: dict[str, Any], base: str, current: int | None) -> dict[str, Any]:
|
||||
version_code = int(data.get("version_code") or 0)
|
||||
return {
|
||||
"platform": data.get("platform", "android"),
|
||||
"version_code": version_code,
|
||||
"version_name": data.get("version_name", ""),
|
||||
"notes": data.get("notes", ""),
|
||||
"file": data["file"],
|
||||
"size": int(data.get("size") or 0),
|
||||
"sha256": data.get("sha256", ""),
|
||||
"min_sdk": int(data.get("min_sdk") or 26),
|
||||
"published_at": data.get("published_at"),
|
||||
# 客户端拿相对路径拼自己的 API 前缀,换域名不用改包
|
||||
"download_url": f"{base}/app/release/download/{data['file']}",
|
||||
# 带了当前版本号就顺手算好要不要升级,客户端不用再比一次
|
||||
"has_update": current is not None and version_code > current,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/release/latest", summary="安卓最新版本")
|
||||
async def latest_release(version_code: int | None = None) -> dict[str, Any]:
|
||||
"""公开接口:装完 App 之前就要能查,不该要登录。
|
||||
|
||||
`version_code` 传客户端当前版本(`BuildConfig.VERSION_CODE`),返回里的
|
||||
`has_update` 直接告诉它要不要提示升级。
|
||||
"""
|
||||
data = _manifest()
|
||||
return ok(_payload(data, settings.api_prefix, version_code))
|
||||
|
||||
|
||||
@router.api_route("/release/download/{name}", methods=["GET", "HEAD"], summary="下载安装包")
|
||||
async def download_release(name: str) -> FileResponse:
|
||||
# 只认清单里那一个文件:不做目录列举,也不接受 ../ 之类
|
||||
data = _manifest()
|
||||
if name != data["file"]:
|
||||
raise AppError(ErrorCode.APP_RELEASE_NOT_FOUND, "没有这个安装包")
|
||||
path = apk_dir() / name
|
||||
if not path.is_file():
|
||||
raise AppError(ErrorCode.APP_RELEASE_NOT_FOUND, "安装包文件不见了,请重新发布")
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type="application/vnd.android.package-archive",
|
||||
filename=name,
|
||||
)
|
||||
@@ -1,32 +1,98 @@
|
||||
"""认证:手机号 + 验证码。M0 不做密码、不做第三方登录。"""
|
||||
"""认证:手机号 / 邮箱 + 密码,邮箱验证码注册,手机验证码可选。
|
||||
|
||||
当前**默认关掉短信验证码**(`SMS_LOGIN_ENABLED=false`):内测阶段的固定验证码
|
||||
等于"知道手机号就能进任意账号"。关掉后 `/auth/sms/send` 与 `/auth/login` 直接
|
||||
回 2017,登录走「手机号 或 邮箱 + 密码」。登录页先问 `/auth/methods`,只显示
|
||||
服务端真开着的登录方式,免得点进去才报错。
|
||||
|
||||
新用户走「邮箱 + 验证码」注册(`POST /auth/email/code` → `POST /auth/register`):
|
||||
短信一关,就没有别的注册入口了。注册出来的账号没有手机号,`app_user.phone` 因此可空。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import re
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..config import get_settings
|
||||
from ..deps import CurrentUser, SessionDep
|
||||
from ..errors import AppError, ErrorCode
|
||||
from ..models import AppUser, Member, Workspace
|
||||
from ..schemas import ok
|
||||
from ..security import create_token_pair, decode_token
|
||||
from ..stores import get_store, sms_code_key, sms_throttle_key
|
||||
from ..security import (
|
||||
create_token_pair,
|
||||
decode_token,
|
||||
hash_password,
|
||||
password_problem,
|
||||
verify_password,
|
||||
)
|
||||
from ..services import mail
|
||||
from ..stores import (
|
||||
email_code_key,
|
||||
email_throttle_key,
|
||||
email_tries_key,
|
||||
get_store,
|
||||
sms_code_key,
|
||||
sms_throttle_key,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["认证"])
|
||||
settings = get_settings()
|
||||
|
||||
PHONE_PATTERN = r"^1[3-9]\d{9}$"
|
||||
# 邮箱只做形状校验:真正的可达性靠发信验证,正则再严也证明不了地址存在
|
||||
EMAIL_RE = re.compile(r"^[^@\s]{1,64}@[^@\s]{1,190}\.[A-Za-z]{2,}$")
|
||||
|
||||
# 密码登录连错这么多次就先锁一会儿:暴力猜密码的成本必须比猜验证码高
|
||||
PASSWORD_FAIL_LIMIT = 10
|
||||
PASSWORD_FAIL_TTL = 900
|
||||
|
||||
|
||||
def mask_phone(phone: str) -> str:
|
||||
def mask_phone(phone: str | None) -> str:
|
||||
if not phone:
|
||||
return ""
|
||||
return f"{phone[:3]}****{phone[-4:]}" if len(phone) == 11 else "***"
|
||||
|
||||
|
||||
def normalize_email(email: str) -> str:
|
||||
"""邮箱大小写不敏感:统一小写存,登录时也按小写比。"""
|
||||
return email.strip().lower()
|
||||
|
||||
|
||||
def _user_out(user: AppUser) -> dict:
|
||||
"""对外只给脱敏手机号;密码本身一个字节都不出去,只告诉客户端「设没设」。
|
||||
|
||||
邮箱是用户自己的账号名,给全值(账号页要显示、要能改)。
|
||||
"""
|
||||
return {
|
||||
"id": str(user.id),
|
||||
"phone": mask_phone(user.phone),
|
||||
"email": user.email,
|
||||
"email_verified": user.email_verified_at is not None,
|
||||
"nickname": user.nickname,
|
||||
"avatar_url": user.avatar_url,
|
||||
"has_password": bool(user.password_hash),
|
||||
"platform_admin": bool(user.phone) and user.phone in settings.platform_admin_phones,
|
||||
"created_at": user.created_at,
|
||||
}
|
||||
|
||||
|
||||
def _tokens_out(tokens) -> dict:
|
||||
return {
|
||||
"access_token": tokens.access_token,
|
||||
"refresh_token": tokens.refresh_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": tokens.expires_in,
|
||||
}
|
||||
|
||||
|
||||
class SendCodeIn(BaseModel):
|
||||
phone: str = Field(pattern=PHONE_PATTERN, examples=["13800138000"])
|
||||
|
||||
@@ -36,12 +102,57 @@ class LoginIn(BaseModel):
|
||||
code: str = Field(min_length=4, max_length=8)
|
||||
|
||||
|
||||
class PasswordLoginIn(BaseModel):
|
||||
"""手机号或邮箱 + 密码。两个都给也行,手机号优先(老客户端只发 phone)。"""
|
||||
|
||||
phone: str | None = Field(default=None, pattern=PHONE_PATTERN)
|
||||
email: str | None = Field(default=None, max_length=255)
|
||||
password: str = Field(min_length=1, max_length=64)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _one_account(self) -> PasswordLoginIn:
|
||||
if not self.phone and not (self.email and self.email.strip()):
|
||||
raise ValueError("手机号或邮箱填一个")
|
||||
return self
|
||||
|
||||
|
||||
class ProfilePatch(BaseModel):
|
||||
nickname: str | None = Field(default=None, min_length=1, max_length=32)
|
||||
# 传空串表示解绑;不传表示不动
|
||||
email: str | None = Field(default=None, max_length=255)
|
||||
|
||||
|
||||
class PasswordIn(BaseModel):
|
||||
"""old_password 只在「已经设过密码」时必填;第一次设密码不需要。"""
|
||||
|
||||
password: str = Field(min_length=1, max_length=64)
|
||||
old_password: str | None = Field(default=None, max_length=64)
|
||||
|
||||
|
||||
class RefreshIn(BaseModel):
|
||||
refresh_token: str = Field(min_length=10)
|
||||
|
||||
|
||||
@router.get("/methods", summary="这台服务现在开放哪些登录方式")
|
||||
async def methods() -> dict:
|
||||
"""登录页据此渲染,别让用户点进去才发现这条路关着。"""
|
||||
return ok(
|
||||
{
|
||||
"password": True,
|
||||
"email_password": True,
|
||||
"sms_code": settings.sms_login_enabled,
|
||||
"email_register": settings.email_register_enabled,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sms/send", summary="发送登录验证码")
|
||||
async def send_code(body: SendCodeIn) -> dict:
|
||||
if not settings.sms_login_enabled:
|
||||
raise AppError(
|
||||
ErrorCode.LOGIN_METHOD_DISABLED,
|
||||
"短信验证码登录暂未开放,请用手机号或邮箱 + 密码登录",
|
||||
)
|
||||
store = get_store()
|
||||
if settings.sms_throttle_seconds > 0:
|
||||
hits = await store.incr(sms_throttle_key(body.phone), settings.sms_throttle_seconds)
|
||||
@@ -64,6 +175,11 @@ async def send_code(body: SendCodeIn) -> dict:
|
||||
|
||||
@router.post("/login", summary="验证码登录(不存在则自动注册)")
|
||||
async def login(body: LoginIn, session: SessionDep) -> dict:
|
||||
if not settings.sms_login_enabled:
|
||||
raise AppError(
|
||||
ErrorCode.LOGIN_METHOD_DISABLED,
|
||||
"短信验证码登录暂未开放,请用手机号或邮箱 + 密码登录",
|
||||
)
|
||||
store = get_store()
|
||||
stored = await store.get(sms_code_key(body.phone))
|
||||
if stored is None:
|
||||
@@ -81,40 +197,68 @@ async def login(body: LoginIn, session: SessionDep) -> dict:
|
||||
await session.flush()
|
||||
|
||||
tokens = create_token_pair(user.id)
|
||||
return ok(
|
||||
{
|
||||
"user": {
|
||||
"id": str(user.id),
|
||||
"phone": mask_phone(user.phone),
|
||||
"nickname": user.nickname,
|
||||
"created_at": user.created_at,
|
||||
},
|
||||
"tokens": {
|
||||
"access_token": tokens.access_token,
|
||||
"refresh_token": tokens.refresh_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": tokens.expires_in,
|
||||
},
|
||||
}
|
||||
)
|
||||
return ok({"user": _user_out(user), "tokens": _tokens_out(tokens)})
|
||||
|
||||
|
||||
@router.post("/login/password", summary="手机号 / 邮箱 + 密码登录")
|
||||
async def login_password(body: PasswordLoginIn, session: SessionDep) -> dict:
|
||||
"""手机号或邮箱 + 密码登录。
|
||||
|
||||
三个细节:
|
||||
- **不区分"没这个人"和"密码错"**,同一个错误码,别让登录接口变成账号探测工具。
|
||||
- 连错 PASSWORD_FAIL_LIMIT 次锁 PASSWORD_FAIL_TTL 秒;成功即清零,正常用户不会感知。
|
||||
失败计数按"填的那个账号"记,换手机号/邮箱轮着试也照样累计。
|
||||
- 邮箱大小写不敏感(存的时候已经统一小写,这里再 lower 一次兜住历史数据)。
|
||||
"""
|
||||
store = get_store()
|
||||
account_key = body.phone or normalize_email(body.email or "")
|
||||
fail_key = f"pwd_fail:{account_key}"
|
||||
if int(await store.get(fail_key) or 0) >= PASSWORD_FAIL_LIMIT:
|
||||
raise AppError(
|
||||
ErrorCode.LOGIN_TOO_MANY,
|
||||
f"密码错误次数过多,请 {PASSWORD_FAIL_TTL // 60} 分钟后再试",
|
||||
)
|
||||
|
||||
if body.phone:
|
||||
stmt = select(AppUser).where(AppUser.phone == body.phone)
|
||||
hint = "手机号或密码不对"
|
||||
else:
|
||||
stmt = select(AppUser).where(func.lower(AppUser.email) == account_key)
|
||||
hint = "邮箱或密码不对"
|
||||
|
||||
user = (await session.execute(stmt)).scalar_one_or_none()
|
||||
if user is None or not verify_password(body.password, user.password_hash):
|
||||
await store.incr(fail_key, PASSWORD_FAIL_TTL)
|
||||
raise AppError(ErrorCode.PASSWORD_INVALID, hint)
|
||||
|
||||
if user.status != "active":
|
||||
raise AppError(ErrorCode.ACCOUNT_DISABLED, "账号已停用")
|
||||
|
||||
await store.delete(fail_key)
|
||||
tokens = create_token_pair(user.id)
|
||||
return ok({"user": _user_out(user), "tokens": _tokens_out(tokens)})
|
||||
|
||||
|
||||
@router.post("/refresh", summary="刷新访问令牌")
|
||||
async def refresh(body: RefreshIn) -> dict:
|
||||
async def refresh(body: RefreshIn, session: SessionDep) -> dict:
|
||||
user_id = decode_token(body.refresh_token, "refresh")
|
||||
# 换令牌时也看一眼账号状态:停用的账号不该靠 refresh 一直续下去
|
||||
user = await session.get(AppUser, user_id)
|
||||
if user is None:
|
||||
raise AppError(ErrorCode.UNAUTHORIZED, "账号不存在")
|
||||
if user.status != "active":
|
||||
raise AppError(ErrorCode.ACCOUNT_DISABLED, "账号已停用,请联系管理员")
|
||||
tokens = create_token_pair(user_id)
|
||||
return ok(
|
||||
{
|
||||
"access_token": tokens.access_token,
|
||||
"refresh_token": tokens.refresh_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": tokens.expires_in,
|
||||
}
|
||||
)
|
||||
# 这里的形状保持扁平(不带 tokens 信封),老客户端与安卓端都按这个取
|
||||
return ok(_tokens_out(tokens))
|
||||
|
||||
|
||||
@router.get("/me", summary="当前用户与所属工作区")
|
||||
async def me(user: CurrentUser, session: SessionDep) -> dict:
|
||||
row = await session.get(AppUser, user.id)
|
||||
if row is None:
|
||||
raise AppError(ErrorCode.UNAUTHORIZED, "账号不存在")
|
||||
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(Workspace, Member.role)
|
||||
@@ -126,11 +270,7 @@ async def me(user: CurrentUser, session: SessionDep) -> dict:
|
||||
|
||||
return ok(
|
||||
{
|
||||
"user": {
|
||||
"id": str(user.id),
|
||||
"phone": mask_phone(user.phone),
|
||||
"nickname": user.nickname,
|
||||
},
|
||||
"user": _user_out(row),
|
||||
"workspaces": [
|
||||
{
|
||||
"id": str(ws.id),
|
||||
@@ -144,3 +284,186 @@ async def me(user: CurrentUser, session: SessionDep) -> dict:
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/me", summary="改自己的昵称与邮箱")
|
||||
async def update_me(body: ProfilePatch, user: CurrentUser, session: SessionDep) -> dict:
|
||||
row = await session.get(AppUser, user.id)
|
||||
if row is None:
|
||||
raise AppError(ErrorCode.UNAUTHORIZED, "账号不存在")
|
||||
|
||||
if body.nickname is not None:
|
||||
nickname = body.nickname.strip()
|
||||
if not nickname:
|
||||
raise AppError(ErrorCode.VALIDATION_FAILED, "昵称不能为空")
|
||||
row.nickname = nickname
|
||||
|
||||
if body.email is not None:
|
||||
raw = body.email.strip()
|
||||
if not raw:
|
||||
# 解绑:邮箱只是"多一条登录方式",不是必填项
|
||||
row.email = None
|
||||
else:
|
||||
email = normalize_email(raw)
|
||||
if not EMAIL_RE.match(email):
|
||||
raise AppError(ErrorCode.EMAIL_INVALID, "邮箱格式不对")
|
||||
taken = (
|
||||
await session.execute(
|
||||
select(AppUser.id).where(
|
||||
func.lower(AppUser.email) == email, AppUser.id != user.id
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if taken is not None:
|
||||
raise AppError(ErrorCode.EMAIL_TAKEN, "这个邮箱已经绑在别的账号上了")
|
||||
row.email = email
|
||||
|
||||
await session.flush()
|
||||
return ok({"user": _user_out(row)})
|
||||
|
||||
|
||||
@router.post("/password", summary="设置或修改登录密码")
|
||||
async def set_password(body: PasswordIn, user: CurrentUser, session: SessionDep) -> dict:
|
||||
"""已经设过密码的,必须先给旧密码——否则拿到一个没锁屏的浏览器就能把密码换掉。"""
|
||||
row = await session.get(AppUser, user.id)
|
||||
if row is None:
|
||||
raise AppError(ErrorCode.UNAUTHORIZED, "账号不存在")
|
||||
|
||||
if row.password_hash:
|
||||
if not body.old_password:
|
||||
raise AppError(ErrorCode.PASSWORD_INVALID, "请输入当前密码")
|
||||
if not verify_password(body.old_password, row.password_hash):
|
||||
raise AppError(ErrorCode.PASSWORD_INVALID, "当前密码不对")
|
||||
if hmac.compare_digest(body.old_password, body.password):
|
||||
raise AppError(ErrorCode.PASSWORD_WEAK, "新密码不能和当前密码一样")
|
||||
|
||||
problem = password_problem(body.password)
|
||||
if problem:
|
||||
raise AppError(ErrorCode.PASSWORD_WEAK, problem)
|
||||
|
||||
row.password_hash = hash_password(body.password)
|
||||
await session.flush()
|
||||
return ok({"has_password": True})
|
||||
|
||||
|
||||
# ── 邮箱注册 ────────────────────────────────────────────
|
||||
class EmailCodeIn(BaseModel):
|
||||
email: str = Field(max_length=255, examples=["someone@example.com"])
|
||||
|
||||
|
||||
class RegisterIn(BaseModel):
|
||||
"""邮箱 + 验证码 + 密码,一步把账号建出来。
|
||||
|
||||
昵称不填就取邮箱 @ 前面那段,别让新用户第一眼看到一个空名字。
|
||||
"""
|
||||
|
||||
email: str = Field(max_length=255)
|
||||
code: str = Field(min_length=4, max_length=8)
|
||||
password: str = Field(min_length=1, max_length=64)
|
||||
nickname: str | None = Field(default=None, max_length=32)
|
||||
|
||||
|
||||
async def _email_taken(session: AsyncSession, email: str) -> bool:
|
||||
stmt = select(AppUser.id).where(func.lower(AppUser.email) == email)
|
||||
return (await session.execute(stmt)).first() is not None
|
||||
|
||||
|
||||
@router.post("/email/code", summary="发送邮箱注册验证码")
|
||||
async def send_email_code(body: EmailCodeIn, session: SessionDep) -> dict:
|
||||
"""发码前先查邮箱是否已被占用:让用户填完密码才发现"这个邮箱注册过了"最气人。"""
|
||||
if not settings.email_register_enabled:
|
||||
raise AppError(
|
||||
ErrorCode.LOGIN_METHOD_DISABLED,
|
||||
"邮箱注册暂未开放,请联系管理员开通账号",
|
||||
)
|
||||
|
||||
email = normalize_email(body.email)
|
||||
if not EMAIL_RE.match(email):
|
||||
raise AppError(ErrorCode.EMAIL_INVALID, "邮箱格式不对")
|
||||
if await _email_taken(session, email):
|
||||
raise AppError(ErrorCode.EMAIL_TAKEN, "这个邮箱已经注册过了,直接登录即可")
|
||||
|
||||
store = get_store()
|
||||
if settings.email_code_throttle_seconds > 0:
|
||||
hits = await store.incr(email_throttle_key(email), settings.email_code_throttle_seconds)
|
||||
if hits > 1:
|
||||
raise AppError(
|
||||
ErrorCode.EMAIL_CODE_TOO_FREQUENT,
|
||||
f"发送过于频繁,请 {settings.email_code_throttle_seconds} 秒后重试",
|
||||
)
|
||||
|
||||
code = f"{secrets.randbelow(1_000_000):06d}"
|
||||
await store.set(email_code_key(email), code, settings.email_code_ttl_seconds)
|
||||
await store.delete(email_tries_key(email))
|
||||
|
||||
minutes = max(1, settings.email_code_ttl_seconds // 60)
|
||||
if settings.email_code_dev_echo:
|
||||
# 本机联调:不发真信,直接把码回给调用方(生产打开会被 validate_runtime 拒掉)
|
||||
return ok(
|
||||
{
|
||||
"sent": True,
|
||||
"expires_in": settings.email_code_ttl_seconds,
|
||||
"dev_code": code,
|
||||
}
|
||||
)
|
||||
|
||||
await mail.send_verify_code(session, to=email, code=code, minutes=minutes)
|
||||
return ok({"sent": True, "expires_in": settings.email_code_ttl_seconds})
|
||||
|
||||
|
||||
@router.post("/register", summary="邮箱注册(验证码 + 密码)")
|
||||
async def register(body: RegisterIn, session: SessionDep) -> dict:
|
||||
"""邮箱注册:验码 → 建账号 → 直接发令牌,注册完就是登录状态。
|
||||
|
||||
账号没有手机号(`phone` 为空),所以邮件是唯一联系方式——这也是为什么注册必须
|
||||
真验一次码,而不是"填个邮箱就算"。
|
||||
"""
|
||||
if not settings.email_register_enabled:
|
||||
raise AppError(
|
||||
ErrorCode.LOGIN_METHOD_DISABLED,
|
||||
"邮箱注册暂未开放,请联系管理员开通账号",
|
||||
)
|
||||
|
||||
email = normalize_email(body.email)
|
||||
if not EMAIL_RE.match(email):
|
||||
raise AppError(ErrorCode.EMAIL_INVALID, "邮箱格式不对")
|
||||
|
||||
store = get_store()
|
||||
stored = await store.get(email_code_key(email))
|
||||
if not stored:
|
||||
raise AppError(ErrorCode.EMAIL_CODE_INVALID, "验证码已过期,请重新获取")
|
||||
|
||||
tries = await store.incr(email_tries_key(email), settings.email_code_ttl_seconds)
|
||||
if tries > settings.email_code_max_attempts:
|
||||
await store.delete(email_code_key(email))
|
||||
raise AppError(ErrorCode.EMAIL_CODE_INVALID, "验证码错太多次,请重新获取")
|
||||
|
||||
if not hmac.compare_digest(stored, body.code.strip()):
|
||||
raise AppError(ErrorCode.EMAIL_CODE_INVALID, "验证码不对")
|
||||
|
||||
problem = password_problem(body.password)
|
||||
if problem:
|
||||
raise AppError(ErrorCode.PASSWORD_WEAK, problem)
|
||||
|
||||
# 拿到验证码之后这段时间里,别人可能已经用同一个邮箱注册完了
|
||||
if await _email_taken(session, email):
|
||||
raise AppError(ErrorCode.EMAIL_TAKEN, "这个邮箱已经注册过了,直接登录即可")
|
||||
|
||||
nickname = (body.nickname or "").strip() or email.split("@", 1)[0][:32]
|
||||
user = AppUser(
|
||||
email=email,
|
||||
phone=None,
|
||||
nickname=nickname,
|
||||
password_hash=hash_password(body.password),
|
||||
email_verified_at=datetime.now(timezone.utc),
|
||||
status="active",
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
|
||||
await store.delete(email_code_key(email))
|
||||
await store.delete(email_tries_key(email))
|
||||
await store.delete(email_throttle_key(email))
|
||||
|
||||
tokens = create_token_pair(user.id)
|
||||
return ok({"user": _user_out(user), "tokens": _tokens_out(tokens)})
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
"""外部接入:微信 clawbot 的扫码登录与状态。
|
||||
|
||||
真实协议是腾讯 iLink Bot(出处见 `app/services/wechat_ilink.py` 文件头):
|
||||
**我们**去取二维码、等人扫、拿到登录态后长轮询拉消息。所以这里的接口是三件事:
|
||||
|
||||
1. `POST /workspaces/{ws}/integrations/wechat/qr` —— 取一张二维码,页面显示给人扫;
|
||||
2. `GET /workspaces/{ws}/integrations/wechat/qr/{id}` —— 问"扫了吗",扫成功就存登录态并开始轮询;
|
||||
3. `DELETE /workspaces/{ws}/integrations/{channel_id}` —— 断开:登录态作废、轮询停止。
|
||||
|
||||
二维码 id 放 Redis 而不是内存:问状态的那一下可能落到另一个进程,
|
||||
存内存就会"扫完了但没人知道"。key 带工作区,两个工作区互不踩。
|
||||
|
||||
上一版这里做的是 webhook 接收端(`POST /integrations/clawbot/{token}`),
|
||||
对 clawbot 的理解是错的,接口与令牌一并删掉了(迁移 0013 的注释记了原因)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import qrcode
|
||||
import qrcode.image.svg
|
||||
from fastapi import APIRouter, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
|
||||
from ..db import bind_tenant
|
||||
from ..deps import AdminDep, SessionDep
|
||||
from ..errors import AppError, ErrorCode
|
||||
from ..models_integrations import BotChannel, BotMessage
|
||||
from ..schemas import ok
|
||||
from ..services import wechat_ilink
|
||||
from ..services.wechat_bot import lease_held, manager
|
||||
from ..stores import get_store
|
||||
|
||||
admin_router = APIRouter(prefix="/workspaces", tags=["外部接入"])
|
||||
|
||||
# iLink 的二维码大约 3 分钟过期,我们按 5 分钟缓存 id,过期就重新取。
|
||||
QR_TTL_SECONDS = 300
|
||||
|
||||
|
||||
class ChannelPatch(BaseModel):
|
||||
status: str
|
||||
|
||||
|
||||
def _qr_key(ws_id: uuid.UUID) -> str:
|
||||
return f"wechat:qr:{ws_id}"
|
||||
|
||||
|
||||
def render_qr_svg(payload: str) -> str:
|
||||
"""把二维码内容画成 data URL:服务端画,前端不用再引一个二维码库。"""
|
||||
image = qrcode.make(payload, image_factory=qrcode.image.svg.SvgPathImage, box_size=10)
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer)
|
||||
encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
|
||||
return f"data:image/svg+xml;base64,{encoded}"
|
||||
|
||||
|
||||
async def _channel_out(channel: BotChannel) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(channel.id),
|
||||
"kind": channel.kind,
|
||||
"name": channel.name,
|
||||
"status": channel.status,
|
||||
"connected": bool(channel.bot_token_enc) and channel.status == "active",
|
||||
# 数据库级租约:谁在真收消息所有进程都认,页面不会跟着进程抖动。
|
||||
"polling": await lease_held(channel.id),
|
||||
"ilink_user_id": channel.ilink_user_id,
|
||||
"last_seen_at": channel.last_seen_at,
|
||||
"last_error": channel.last_error,
|
||||
"created_at": channel.created_at,
|
||||
}
|
||||
|
||||
|
||||
async def _wechat_channel(session: SessionDep, ws_id: uuid.UUID) -> BotChannel | None:
|
||||
rows = await session.execute(
|
||||
select(BotChannel)
|
||||
.where(BotChannel.workspace_id == ws_id, BotChannel.kind == "wechat_clawbot")
|
||||
.order_by(BotChannel.created_at.desc())
|
||||
)
|
||||
return rows.scalars().first()
|
||||
|
||||
|
||||
@admin_router.get("/{ws_id}/integrations")
|
||||
async def list_integrations(ws_id: uuid.UUID, ctx: AdminDep, session: SessionDep):
|
||||
rows = await session.execute(
|
||||
select(BotChannel)
|
||||
.where(BotChannel.workspace_id == ws_id)
|
||||
.order_by(BotChannel.created_at.desc())
|
||||
)
|
||||
return ok({"items": [await _channel_out(c) for c in rows.scalars().all()]})
|
||||
|
||||
|
||||
@admin_router.post("/{ws_id}/integrations/wechat/qr")
|
||||
async def begin_wechat_login(ws_id: uuid.UUID, ctx: AdminDep, session: SessionDep):
|
||||
"""取一张二维码。已经登录过的通道会被重置成 pending(换号 = 重新扫)。"""
|
||||
try:
|
||||
code = await wechat_ilink.get_qrcode()
|
||||
except wechat_ilink.WechatError as exc:
|
||||
raise AppError(ErrorCode.BOT_TOKEN_INVALID, f"取二维码失败:{exc}") from exc
|
||||
|
||||
channel = await _wechat_channel(session, ws_id)
|
||||
if channel is None:
|
||||
channel = BotChannel(
|
||||
workspace_id=ws_id,
|
||||
kind="wechat_clawbot",
|
||||
name="微信",
|
||||
status="pending",
|
||||
created_by=ctx.user.id,
|
||||
)
|
||||
session.add(channel)
|
||||
if channel.bot_token_enc:
|
||||
await manager.stop(channel.id)
|
||||
channel.bot_token_enc = None
|
||||
channel.sync_buf = None
|
||||
channel.context_token = None
|
||||
channel.status = "pending"
|
||||
channel.last_error = None
|
||||
await session.flush()
|
||||
|
||||
await get_store().set(_qr_key(ws_id), code.qrcode_id, QR_TTL_SECONDS)
|
||||
await session.commit()
|
||||
|
||||
return ok(
|
||||
{
|
||||
"channel_id": str(channel.id),
|
||||
"qrcode_id": code.qrcode_id,
|
||||
"payload": code.payload,
|
||||
"image": render_qr_svg(code.payload),
|
||||
"expires_in": QR_TTL_SECONDS,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@admin_router.get("/{ws_id}/integrations/wechat/qr/{qrcode_id}")
|
||||
async def poll_wechat_login(ws_id: uuid.UUID, qrcode_id: str, ctx: AdminDep, session: SessionDep):
|
||||
"""问扫码结果。只有 confirmed 才落登录态并开轮询,其余状态原样回给页面。"""
|
||||
cached = await get_store().get(_qr_key(ws_id))
|
||||
if cached != qrcode_id:
|
||||
# 页面拿着旧二维码在问:直接说"过期",让它重新取一张。
|
||||
return ok({"status": "expired"})
|
||||
|
||||
try:
|
||||
status = await wechat_ilink.qrcode_status(qrcode_id)
|
||||
except wechat_ilink.WechatError as exc:
|
||||
return ok({"status": "error", "message": str(exc)})
|
||||
|
||||
if status.status != "confirmed" or not status.bot_token:
|
||||
return ok({"status": status.status})
|
||||
|
||||
channel = await _wechat_channel(session, ws_id)
|
||||
if channel is None:
|
||||
raise AppError(ErrorCode.BOT_TOKEN_INVALID, "通道不见了,请重新取二维码")
|
||||
|
||||
await bind_tenant(session, ws_id, ctx.user.id)
|
||||
channel.bot_token_enc = wechat_ilink.encrypt_token(status.bot_token)
|
||||
channel.ilink_user_id = status.ilink_user_id
|
||||
channel.base_url = status.base_url
|
||||
channel.status = "active"
|
||||
channel.sync_buf = None
|
||||
channel.context_token = None
|
||||
channel.last_error = None
|
||||
channel.created_by = channel.created_by or ctx.user.id
|
||||
await session.commit()
|
||||
await get_store().delete(_qr_key(ws_id))
|
||||
|
||||
await manager.start(channel.id)
|
||||
return ok({"status": "confirmed", "channel": await _channel_out(channel)})
|
||||
|
||||
|
||||
@admin_router.patch("/{ws_id}/integrations/{channel_id}")
|
||||
async def update_integration(
|
||||
ws_id: uuid.UUID,
|
||||
channel_id: uuid.UUID,
|
||||
ctx: AdminDep,
|
||||
session: SessionDep,
|
||||
body: ChannelPatch,
|
||||
):
|
||||
channel = await session.get(BotChannel, channel_id)
|
||||
if channel is None or channel.workspace_id != ws_id:
|
||||
raise AppError(ErrorCode.BOT_TOKEN_INVALID, "接入通道不存在")
|
||||
wanted = body.status.strip()
|
||||
if wanted not in ("active", "disabled"):
|
||||
raise AppError(ErrorCode.VALIDATION_FAILED, "status 只能是 active 或 disabled")
|
||||
if wanted == "active" and not channel.bot_token_enc:
|
||||
raise AppError(ErrorCode.BOT_TOKEN_INVALID, "这个通道还没扫码登录")
|
||||
channel.status = wanted
|
||||
await session.commit()
|
||||
if wanted == "active":
|
||||
await manager.start(channel.id)
|
||||
else:
|
||||
await manager.stop(channel.id)
|
||||
return ok({"channel": await _channel_out(channel)})
|
||||
|
||||
|
||||
@admin_router.delete("/{ws_id}/integrations/{channel_id}")
|
||||
async def disconnect_integration(
|
||||
ws_id: uuid.UUID, channel_id: uuid.UUID, ctx: AdminDep, session: SessionDep
|
||||
):
|
||||
"""断开:停轮询、清登录态。留痕(bot_message)不删,翻旧账用得上。"""
|
||||
channel = await session.get(BotChannel, channel_id)
|
||||
if channel is None or channel.workspace_id != ws_id:
|
||||
raise AppError(ErrorCode.BOT_TOKEN_INVALID, "接入通道不存在")
|
||||
await manager.stop(channel.id)
|
||||
channel.bot_token_enc = None
|
||||
channel.sync_buf = None
|
||||
channel.context_token = None
|
||||
channel.status = "pending"
|
||||
channel.last_error = None
|
||||
await session.commit()
|
||||
return ok({"channel": await _channel_out(channel)})
|
||||
|
||||
|
||||
@admin_router.get("/{ws_id}/integrations/{channel_id}/messages")
|
||||
async def list_messages(
|
||||
ws_id: uuid.UUID,
|
||||
channel_id: uuid.UUID,
|
||||
ctx: AdminDep,
|
||||
session: SessionDep,
|
||||
limit: int = Query(default=5, ge=1, le=100),
|
||||
):
|
||||
rows = await session.execute(
|
||||
select(BotMessage)
|
||||
.where(BotMessage.channel_id == channel_id, BotMessage.workspace_id == ws_id)
|
||||
.order_by(BotMessage.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return ok(
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": str(m.id),
|
||||
"action": m.action,
|
||||
"sender": m.sender,
|
||||
"text": m.text,
|
||||
"reply": m.reply,
|
||||
"item_id": str(m.item_id) if m.item_id else None,
|
||||
"created_at": m.created_at,
|
||||
}
|
||||
for m in rows.scalars().all()
|
||||
]
|
||||
}
|
||||
)
|
||||
@@ -15,27 +15,32 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import Select, and_, func, or_, select, update
|
||||
from sqlalchemy import Select, and_, delete, func, insert, or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..audit import record as audit_record
|
||||
from ..db import bind_tenant
|
||||
from ..deps import MemberDep, SessionDep
|
||||
from ..domain.knowledge import can_publish_role, sidebar_fields, target_status
|
||||
from ..errors import AppError, ErrorCode
|
||||
from ..models import Member, Workspace
|
||||
from ..models_attachment import Attachment
|
||||
from ..models_knowledge import (
|
||||
DOC_TYPES,
|
||||
KNOWLEDGE_STATUSES,
|
||||
MAX_CATEGORY_DEPTH,
|
||||
Chunk,
|
||||
KnowledgeCategory,
|
||||
KnowledgeDoc,
|
||||
)
|
||||
from ..models_task import Activity, Item, ItemTag, Tag
|
||||
from ..models_task import Activity, Comment, Item, ItemTag, Tag
|
||||
from ..schemas import clamp_limit, decode_cursor, encode_cursor, ok
|
||||
from ..serializers import item_out, tags_of
|
||||
from ..services.knowledge import create_draft_from_source, find_doc_by_source
|
||||
@@ -73,6 +78,8 @@ class CategoryIn(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=40)
|
||||
parent_id: uuid.UUID | None = None
|
||||
sort_order: int = 0
|
||||
# 分类色,卡片上边线用它。不传就不上色(前端按 id 稳定地挑一个默认色)。
|
||||
color: str | None = Field(default=None, max_length=7)
|
||||
|
||||
|
||||
class CategoryPatch(BaseModel):
|
||||
@@ -80,6 +87,8 @@ class CategoryPatch(BaseModel):
|
||||
parent_id: uuid.UUID | None = None
|
||||
move: bool = False
|
||||
sort_order: int | None = None
|
||||
# 不传 = 不改;传空串 = 清成默认色(分类色是“可选项”,得能取消)。
|
||||
color: str | None = Field(default=None, max_length=7)
|
||||
version: int
|
||||
|
||||
|
||||
@@ -89,13 +98,33 @@ class StatusActionIn(BaseModel):
|
||||
note: str = ""
|
||||
|
||||
|
||||
class BulkMoveIn(BaseModel):
|
||||
item_ids: list[uuid.UUID] = Field(min_length=1, max_length=100)
|
||||
# 不传 = 留在当前工作区;传了就必须是自己是成员的工作区
|
||||
target_workspace_id: uuid.UUID | None = None
|
||||
# 不传 = 移动到"未分类"
|
||||
category_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
# ── 内部工具 ────────────────────────────────────────────────────────────
|
||||
async def _load_doc(
|
||||
session: AsyncSession, ws_id: uuid.UUID, item_id: uuid.UUID
|
||||
session: AsyncSession, ws_id: uuid.UUID, item_id: uuid.UUID, *, include_deleted: bool = False
|
||||
) -> tuple[Item, KnowledgeDoc]:
|
||||
"""取一条知识。
|
||||
|
||||
删除是软删(`deleted_at`),所以默认把已删除的当成"不存在":
|
||||
否则删完还能从收藏的链接、浏览器历史里点开,用户会以为删除没生效。
|
||||
只有删除接口自己需要 `include_deleted=True`,好让重复删回一句"已经删过了"。
|
||||
"""
|
||||
item = await session.get(Item, item_id)
|
||||
doc = await session.get(KnowledgeDoc, item_id)
|
||||
if item is None or doc is None or item.workspace_id != ws_id or item.kind != "knowledge":
|
||||
if (
|
||||
item is None
|
||||
or doc is None
|
||||
or item.workspace_id != ws_id
|
||||
or item.kind != "knowledge"
|
||||
or (item.deleted_at is not None and not include_deleted)
|
||||
):
|
||||
raise AppError(ErrorCode.KNOWLEDGE_NOT_FOUND, "这份知识不存在")
|
||||
return item, doc
|
||||
|
||||
@@ -146,7 +175,35 @@ def _doc_out(doc: KnowledgeDoc) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _row_values(row: Any) -> dict[str, Any]:
|
||||
"""把一行 ORM 对象摊平成可原样 INSERT 的字典(跨租户搬家时用)。"""
|
||||
return {column.name: getattr(row, column.name) for column in row.__table__.columns}
|
||||
|
||||
|
||||
# ── 分类 ────────────────────────────────────────────────────────────────
|
||||
_COLOR_RE = re.compile(r"^#[0-9A-Fa-f]{6}$")
|
||||
|
||||
|
||||
def _norm_color(value: str | None) -> str | None:
|
||||
"""分类色统一成 ``#RRGGBB`` 大写;空串表示“不要颜色”。
|
||||
|
||||
不在这里做“看得见就好”的宽松处理:颜色是直接拼进 CSS 的,宁可当场报错,
|
||||
也不要让一个 ``red`` 或带 alpha 的值进了库、在前端静默变成透明边框。
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
raw = value.strip()
|
||||
if raw == "":
|
||||
return None
|
||||
if not _COLOR_RE.match(raw):
|
||||
raise AppError(
|
||||
ErrorCode.VALIDATION_FAILED,
|
||||
"分类颜色要用 #RRGGBB 格式,例如 #4F46E5",
|
||||
details={"value": value},
|
||||
)
|
||||
return raw.upper()
|
||||
|
||||
|
||||
async def _categories(
|
||||
session: AsyncSession, ws_id: uuid.UUID
|
||||
) -> dict[uuid.UUID, KnowledgeCategory]:
|
||||
@@ -175,12 +232,38 @@ def _category_payload(
|
||||
"name": cat.name,
|
||||
"depth": cat.depth,
|
||||
"sort_order": cat.sort_order,
|
||||
"color": cat.color,
|
||||
"version": cat.version,
|
||||
"path": names,
|
||||
"full_path": " / ".join(names),
|
||||
}
|
||||
|
||||
|
||||
def _ordered_categories(
|
||||
by_id: dict[uuid.UUID, KnowledgeCategory],
|
||||
) -> list[KnowledgeCategory]:
|
||||
"""深度优先:父分类在前,它的子分类紧跟其后。
|
||||
|
||||
早先按 `(depth, sort_order, name)` 排,二级分类会全部堆在一级分类后面,
|
||||
前端照着 depth 缩进就成了一棵"假树"——「技术」和它的上级「我的备忘」隔着十几行。
|
||||
"""
|
||||
children: dict[uuid.UUID | None, list[KnowledgeCategory]] = {}
|
||||
for cat in by_id.values():
|
||||
children.setdefault(cat.parent_id, []).append(cat)
|
||||
for group in children.values():
|
||||
group.sort(key=lambda c: (c.sort_order, c.name))
|
||||
|
||||
ordered: list[KnowledgeCategory] = []
|
||||
|
||||
def walk(parent_id: uuid.UUID | None) -> None:
|
||||
for cat in children.get(parent_id, []):
|
||||
ordered.append(cat)
|
||||
walk(cat.id)
|
||||
|
||||
walk(None)
|
||||
return ordered
|
||||
|
||||
|
||||
def _subtree_ids(root_id: uuid.UUID, by_id: dict[uuid.UUID, KnowledgeCategory]) -> list[uuid.UUID]:
|
||||
children: dict[uuid.UUID, list[uuid.UUID]] = {}
|
||||
for cat in by_id.values():
|
||||
@@ -303,7 +386,7 @@ async def list_knowledge(
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
Item.updated_at < cur.created_at,
|
||||
and_(Item.updated_at == cur.created_at, Item.id < cur.item_id),
|
||||
and_(Item.updated_at == cur.created_at, Item.id < cur.id),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -358,7 +441,7 @@ async def list_categories(ctx: MemberDep, session: SessionDep) -> dict:
|
||||
).all()
|
||||
)
|
||||
items = []
|
||||
for cat in sorted(by_id.values(), key=lambda c: (c.depth, c.sort_order, c.name)):
|
||||
for cat in _ordered_categories(by_id):
|
||||
data = _category_payload(cat, by_id)
|
||||
data["doc_count"] = counts.get(cat.id, 0)
|
||||
items.append(data)
|
||||
@@ -387,6 +470,7 @@ async def create_category(body: CategoryIn, ctx: MemberDep, session: SessionDep)
|
||||
name=name,
|
||||
depth=depth,
|
||||
sort_order=body.sort_order,
|
||||
color=_norm_color(body.color),
|
||||
)
|
||||
session.add(cat)
|
||||
await session.flush()
|
||||
@@ -447,6 +531,9 @@ async def patch_category(
|
||||
values.update(parent_id=new_parent_id, depth=new_depth)
|
||||
if body.sort_order is not None:
|
||||
values["sort_order"] = body.sort_order
|
||||
# 只传了 color 才算改色;没传保持原样,传空串等于取消上色。
|
||||
if body.color is not None:
|
||||
values["color"] = _norm_color(body.color)
|
||||
|
||||
result = await session.execute(
|
||||
update(KnowledgeCategory)
|
||||
@@ -537,8 +624,39 @@ async def delete_category(
|
||||
return ok({"deleted": str(category_id), "uncategorized_docs": docs})
|
||||
|
||||
|
||||
# 注意路由顺序:`/knowledge/search` 必须排在 `/knowledge/{item_id}` 之前,
|
||||
# 否则 "search" 会被当成 uuid 解析,返回 422。
|
||||
# 注意路由顺序:`/knowledge/search` 与 `/knowledge/tags` 必须排在
|
||||
# `/knowledge/{item_id}` 之前,否则会被当成 uuid 解析,返回 422。
|
||||
@router.get("/knowledge/tags", summary="知识用到的标签(带条数)")
|
||||
async def list_knowledge_tags(
|
||||
ctx: MemberDep,
|
||||
session: SessionDep,
|
||||
status: Annotated[list[str] | None, Query()] = None,
|
||||
) -> dict:
|
||||
"""只统计"还在用"的标签,给右侧那串标签用,点一下按标签筛。
|
||||
|
||||
条数是**知识条数**,不是引用次数——同一个标签在一条知识里挂两次也只算一条。
|
||||
"""
|
||||
stmt: Select = (
|
||||
select(Tag.name, func.count(func.distinct(Item.id)).label("docs"))
|
||||
.join(ItemTag, ItemTag.tag_id == Tag.id)
|
||||
.join(Item, Item.id == ItemTag.item_id)
|
||||
.join(KnowledgeDoc, KnowledgeDoc.item_id == Item.id)
|
||||
.where(
|
||||
Item.workspace_id == ctx.ws_id,
|
||||
Item.kind == "knowledge",
|
||||
Item.deleted_at.is_(None),
|
||||
Tag.workspace_id == ctx.ws_id,
|
||||
)
|
||||
.group_by(Tag.name)
|
||||
.order_by(func.count(func.distinct(Item.id)).desc(), Tag.name)
|
||||
)
|
||||
wanted = [s for s in (status or []) if s in KNOWLEDGE_STATUSES]
|
||||
if wanted:
|
||||
stmt = stmt.where(KnowledgeDoc.status_hint.in_(wanted))
|
||||
rows = (await session.execute(stmt)).all()
|
||||
return ok({"items": [{"name": name, "count": count} for name, count in rows]})
|
||||
|
||||
|
||||
@router.get("/knowledge/search", summary="检索已发布知识")
|
||||
async def search(
|
||||
ctx: MemberDep,
|
||||
@@ -551,6 +669,294 @@ async def search(
|
||||
return ok({"query": q, "mode": mode, "items": hits, "has_more": False})
|
||||
|
||||
|
||||
@router.post("/knowledge/bulk-move", summary="批量移动知识到目录 / 其它工作区")
|
||||
async def bulk_move_knowledge(body: BulkMoveIn, ctx: MemberDep, session: SessionDep) -> dict:
|
||||
"""把多条知识一起搬到另一个目录,或搬到另一个工作区的某个目录。
|
||||
|
||||
同一工作区只是改 `knowledge_doc.category_id`;跨工作区是"真的搬家":
|
||||
RLS 下一条 UPDATE 没法既让源租户看得见旧行、又让目标租户放行新行,
|
||||
所以在同一个事务里先读完整快照、按 id 删掉源条目(外键级联清子表),
|
||||
再切到目标租户按同样的 id 写回。失败整体回滚,不会出现"搬了一半"。
|
||||
"""
|
||||
ws_id = ctx.ws_id
|
||||
target_ws = body.target_workspace_id or ws_id
|
||||
item_ids = list(dict.fromkeys(body.item_ids))
|
||||
cross = target_ws != ws_id
|
||||
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(Item, KnowledgeDoc)
|
||||
.join(KnowledgeDoc, KnowledgeDoc.item_id == Item.id)
|
||||
.where(
|
||||
Item.id.in_(item_ids),
|
||||
Item.workspace_id == ws_id,
|
||||
Item.kind == "knowledge",
|
||||
Item.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
found = {item.id for item, _doc in rows}
|
||||
missing = [str(i) for i in item_ids if i not in found]
|
||||
if missing:
|
||||
raise AppError(
|
||||
ErrorCode.KNOWLEDGE_NOT_FOUND,
|
||||
"有知识不存在或已被删除",
|
||||
details={"missing": missing},
|
||||
)
|
||||
|
||||
if cross:
|
||||
# 移出工作区等于把它从团队手里拿走,和删除同一条权限线
|
||||
for item, _doc in rows:
|
||||
if item.creator_id != ctx.user.id and ctx.role not in ("owner", "admin"):
|
||||
raise AppError(
|
||||
ErrorCode.INSUFFICIENT_ROLE,
|
||||
f"「{item.title}」只有作者本人或管理员能移出工作区",
|
||||
)
|
||||
# member 表的策略允许看到"自己的成员记录",所以不用切租户就能确认目标身份
|
||||
target_member = (
|
||||
await session.execute(
|
||||
select(Member).where(
|
||||
Member.workspace_id == target_ws,
|
||||
Member.user_id == ctx.user.id,
|
||||
Member.status == "active",
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if target_member is None or target_member.role not in ("owner", "admin", "member"):
|
||||
raise AppError(ErrorCode.INSUFFICIENT_ROLE, "你不是目标工作区的成员,不能移入")
|
||||
# 目标租户的工作区和分类要切过去才看得见(RLS 只放行当前租户)
|
||||
await bind_tenant(session, target_ws, ctx.user.id)
|
||||
try:
|
||||
target_ws_row = await session.get(Workspace, target_ws)
|
||||
if target_ws_row is None or target_ws_row.deleted_at is not None:
|
||||
raise AppError(ErrorCode.WORKSPACE_NOT_FOUND, "目标工作区不存在")
|
||||
category = await _resolve_category(session, target_ws, body.category_id)
|
||||
finally:
|
||||
await bind_tenant(session, ws_id, ctx.user.id)
|
||||
else:
|
||||
category = await _resolve_category(session, ws_id, body.category_id)
|
||||
|
||||
category_id = category.id if category else None
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
if not cross:
|
||||
await session.execute(
|
||||
update(KnowledgeDoc)
|
||||
.where(KnowledgeDoc.workspace_id == ws_id, KnowledgeDoc.item_id.in_(item_ids))
|
||||
.values(category_id=category_id)
|
||||
)
|
||||
# 分类也算这条知识的"样子"变了,推进 version,免得别处拿着旧版本号撞 4009
|
||||
await session.execute(
|
||||
update(Item)
|
||||
.where(Item.workspace_id == ws_id, Item.id.in_(item_ids))
|
||||
.values(version=Item.version + 1, updated_at=now)
|
||||
)
|
||||
for item, _doc in rows:
|
||||
await _log(
|
||||
session,
|
||||
item,
|
||||
ctx.user.id,
|
||||
"knowledge_moved",
|
||||
{"category_id": str(category_id) if category_id else None},
|
||||
)
|
||||
await audit_record(
|
||||
session,
|
||||
workspace_id=ws_id,
|
||||
actor_user_id=ctx.user.id,
|
||||
action="knowledge.moved",
|
||||
target_type="knowledge_bulk",
|
||||
target_id=str(len(item_ids)),
|
||||
payload={
|
||||
"item_ids": [str(i) for i in item_ids],
|
||||
"category_id": str(category_id) if category_id else None,
|
||||
},
|
||||
)
|
||||
await session.flush()
|
||||
return ok(
|
||||
{
|
||||
"moved": [str(i) for i in item_ids],
|
||||
"workspace_id": str(ws_id),
|
||||
"category_id": str(category_id) if category_id else None,
|
||||
}
|
||||
)
|
||||
|
||||
# ── 跨工作区:先快照,再删,再在目标租户写回 ──────────────────────────
|
||||
item_values = [_row_values(item) for item, _doc in rows]
|
||||
doc_values = [_row_values(doc) for _item, doc in rows]
|
||||
moved = {item.id for item, _doc in rows}
|
||||
|
||||
tag_pairs = (
|
||||
await session.execute(
|
||||
select(ItemTag.item_id, Tag.name, Tag.color)
|
||||
.join(Tag, Tag.id == ItemTag.tag_id)
|
||||
.where(ItemTag.workspace_id == ws_id, ItemTag.item_id.in_(moved))
|
||||
)
|
||||
).all()
|
||||
tags_by_item: dict[uuid.UUID, list[str]] = {}
|
||||
tag_colors: dict[str, str] = {}
|
||||
for item_id, name, color in tag_pairs:
|
||||
tags_by_item.setdefault(item_id, []).append(name)
|
||||
tag_colors.setdefault(name, color or "")
|
||||
|
||||
comment_values = [
|
||||
_row_values(row)
|
||||
for row in (
|
||||
await session.execute(
|
||||
select(Comment).where(Comment.workspace_id == ws_id, Comment.item_id.in_(moved))
|
||||
)
|
||||
).scalars()
|
||||
]
|
||||
activity_values = [
|
||||
_row_values(row)
|
||||
for row in (
|
||||
await session.execute(
|
||||
select(Activity).where(Activity.workspace_id == ws_id, Activity.item_id.in_(moved))
|
||||
)
|
||||
).scalars()
|
||||
]
|
||||
attachment_values = [
|
||||
_row_values(row)
|
||||
for row in (
|
||||
await session.execute(
|
||||
select(Attachment).where(
|
||||
Attachment.workspace_id == ws_id, Attachment.item_id.in_(moved)
|
||||
)
|
||||
)
|
||||
).scalars()
|
||||
]
|
||||
chunk_values = [
|
||||
_row_values(row)
|
||||
for row in (
|
||||
await session.execute(
|
||||
select(Chunk).where(Chunk.workspace_id == ws_id, Chunk.item_id.in_(moved))
|
||||
)
|
||||
).scalars()
|
||||
]
|
||||
|
||||
# 被搬走的知识如果还是本工作区别的知识的"来源",先把引用改成手动来源:
|
||||
# 否则删除源条目时 FK SET NULL 会撞上 source_kind 的 CHECK 约束。
|
||||
await session.execute(
|
||||
update(KnowledgeDoc)
|
||||
.where(
|
||||
KnowledgeDoc.workspace_id == ws_id,
|
||||
KnowledgeDoc.source_item_id.in_(moved),
|
||||
KnowledgeDoc.item_id.not_in(moved),
|
||||
)
|
||||
.values(source_item_id=None, source_kind="manual")
|
||||
)
|
||||
await session.execute(delete(Item).where(Item.workspace_id == ws_id, Item.id.in_(moved)))
|
||||
session.expunge_all()
|
||||
|
||||
await audit_record(
|
||||
session,
|
||||
workspace_id=ws_id,
|
||||
actor_user_id=ctx.user.id,
|
||||
action="knowledge.moved_out",
|
||||
target_type="knowledge_bulk",
|
||||
target_id=str(len(item_ids)),
|
||||
payload={
|
||||
"item_ids": [str(i) for i in item_ids],
|
||||
"target_workspace_id": str(target_ws),
|
||||
"category_id": str(category_id) if category_id else None,
|
||||
},
|
||||
)
|
||||
|
||||
await bind_tenant(session, target_ws, ctx.user.id)
|
||||
existing_tags = {
|
||||
tag.name: tag
|
||||
for tag in (
|
||||
await session.execute(select(Tag).where(Tag.workspace_id == target_ws))
|
||||
).scalars()
|
||||
}
|
||||
created_tags = [
|
||||
Tag(workspace_id=target_ws, name=name, color=color)
|
||||
for name, color in tag_colors.items()
|
||||
if name not in existing_tags
|
||||
]
|
||||
if created_tags:
|
||||
session.add_all(created_tags)
|
||||
await session.flush()
|
||||
tag_ids = {name: tag.id for name, tag in existing_tags.items()}
|
||||
tag_ids.update({tag.name: tag.id for tag in created_tags})
|
||||
|
||||
for values in item_values:
|
||||
values["workspace_id"] = target_ws
|
||||
for values in doc_values:
|
||||
values["workspace_id"] = target_ws
|
||||
values["category_id"] = category_id
|
||||
source_item_id = values.get("source_item_id")
|
||||
if source_item_id is not None and source_item_id not in moved:
|
||||
# 来源留在原工作区,目标工作区里不该留一条跨租户的外键
|
||||
values["source_item_id"] = None
|
||||
values["source_kind"] = "manual"
|
||||
|
||||
await session.execute(insert(Item), item_values)
|
||||
await session.execute(insert(KnowledgeDoc), doc_values)
|
||||
|
||||
item_tag_values = [
|
||||
{
|
||||
"id": uuid.uuid4(),
|
||||
"workspace_id": target_ws,
|
||||
"item_id": item_id,
|
||||
"tag_id": tag_ids[name],
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"version": 1,
|
||||
}
|
||||
for item_id, names in tags_by_item.items()
|
||||
for name in names
|
||||
]
|
||||
# 子表全部跟着换租户:RLS 的 WITH CHECK 只看新行,漏一个就整单回滚
|
||||
for child_values in (comment_values, activity_values, attachment_values, chunk_values):
|
||||
for row in child_values:
|
||||
row["workspace_id"] = target_ws
|
||||
for table, values in (
|
||||
(ItemTag, item_tag_values),
|
||||
(Comment, comment_values),
|
||||
(Activity, activity_values),
|
||||
(Attachment, attachment_values),
|
||||
(Chunk, chunk_values),
|
||||
):
|
||||
if values:
|
||||
await session.execute(insert(table), values)
|
||||
|
||||
for item_id in item_ids:
|
||||
session.add(
|
||||
Activity(
|
||||
workspace_id=target_ws,
|
||||
item_id=item_id,
|
||||
actor_id=ctx.user.id,
|
||||
action="knowledge_moved",
|
||||
payload={
|
||||
"from_workspace_id": str(ws_id),
|
||||
"category_id": str(category_id) if category_id else None,
|
||||
},
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
await audit_record(
|
||||
session,
|
||||
workspace_id=target_ws,
|
||||
actor_user_id=ctx.user.id,
|
||||
action="knowledge.moved_in",
|
||||
target_type="knowledge_bulk",
|
||||
target_id=str(len(item_ids)),
|
||||
payload={
|
||||
"item_ids": [str(i) for i in item_ids],
|
||||
"from_workspace_id": str(ws_id),
|
||||
"category_id": str(category_id) if category_id else None,
|
||||
},
|
||||
)
|
||||
await bind_tenant(session, ws_id, ctx.user.id)
|
||||
return ok(
|
||||
{
|
||||
"moved": [str(i) for i in item_ids],
|
||||
"workspace_id": str(target_ws),
|
||||
"category_id": str(category_id) if category_id else None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/knowledge/{item_id}", summary="知识详情")
|
||||
async def get_knowledge(item_id: uuid.UUID, ctx: MemberDep, session: SessionDep) -> dict:
|
||||
item, doc = await _load_doc(session, ctx.ws_id, item_id)
|
||||
@@ -636,11 +1042,12 @@ async def patch_knowledge(
|
||||
) -> dict:
|
||||
ws_id = ctx.ws_id
|
||||
item, doc = await _load_doc(session, ws_id, item_id)
|
||||
if item.status == "published":
|
||||
if item.status == "archived":
|
||||
# 已归档是先收起来,不是不能改:要改先恢复成草稿,免得改完"发布着"的样子被人当成现役
|
||||
raise AppError(
|
||||
ErrorCode.KNOWLEDGE_STATUS_INVALID,
|
||||
"已发布的知识不能直接改内容,先撤回成草稿",
|
||||
details={"from": item.status, "allowed_actions": ["archive"]},
|
||||
"已归档的知识不能直接改内容,先恢复成草稿",
|
||||
details={"from": item.status, "allowed_actions": ["restore"]},
|
||||
)
|
||||
if body.doc_type is not None and body.doc_type not in DOC_TYPES:
|
||||
raise AppError(ErrorCode.VALIDATION_ERROR, f"未知的知识类型:{body.doc_type}")
|
||||
@@ -689,9 +1096,54 @@ async def patch_knowledge(
|
||||
if body.tag_names is not None:
|
||||
await _set_tags_for(session, ws_id, item, body.tag_names)
|
||||
await session.flush()
|
||||
if (body.title is not None or body.body_md is not None) and item.status == "published":
|
||||
# 已发布的知识改完立刻重建切片:检索只认 published,
|
||||
# 不重建就会出现"正文改了、搜出来还是旧句子"。
|
||||
await reindex_document(session, ws_id, item.id)
|
||||
await session.flush()
|
||||
return ok(await _knowledge_out(session, ws_id, item, doc))
|
||||
|
||||
|
||||
@router.delete("/knowledge/{item_id}", summary="删除知识(软删,连带删掉检索切片)")
|
||||
async def delete_knowledge(
|
||||
item_id: uuid.UUID,
|
||||
ctx: MemberDep,
|
||||
session: SessionDep,
|
||||
confirm: Annotated[bool, Query(description="必须显式带 true,防手滑")] = False,
|
||||
) -> dict:
|
||||
"""删除是软删:条目留在库里(活动记录、来源关系都还在),只是不再出现在列表与检索里。
|
||||
|
||||
切片必须真删:检索只查 `chunk` 表,留着的话"已经删掉的知识"还能被搜出来,
|
||||
那比删不掉更吓人。
|
||||
"""
|
||||
ws_id = ctx.ws_id
|
||||
if not confirm:
|
||||
raise AppError(ErrorCode.VALIDATION_FAILED, "删除不能撤销,带 confirm=true 再来一次")
|
||||
item, doc = await _load_doc(session, ws_id, item_id, include_deleted=True)
|
||||
if item.deleted_at is not None:
|
||||
return ok({"id": str(item.id)}, message="已经删过了")
|
||||
if item.creator_id != ctx.user.id and ctx.role not in ("owner", "admin"):
|
||||
raise AppError(ErrorCode.INSUFFICIENT_ROLE, "只有作者本人或管理员能删这条知识")
|
||||
|
||||
item.deleted_at = datetime.now(timezone.utc)
|
||||
item.version = (item.version or 1) + 1
|
||||
await session.execute(
|
||||
delete(Chunk).where(Chunk.workspace_id == ws_id, Chunk.item_id == item.id)
|
||||
)
|
||||
doc.chunk_count = 0
|
||||
await session.flush()
|
||||
await audit_record(
|
||||
session,
|
||||
workspace_id=ws_id,
|
||||
actor_user_id=ctx.user.id,
|
||||
action="knowledge.deleted",
|
||||
target_type="item",
|
||||
target_id=str(item.id),
|
||||
payload={"title": item.title, "status": doc.status_hint},
|
||||
)
|
||||
return ok({"id": str(item.id)}, message="已删除")
|
||||
|
||||
|
||||
# ── 状态动作 ────────────────────────────────────────────────────────────
|
||||
@router.post("/knowledge/{item_id}/status", summary="提交审议 / 发布 / 归档 / 恢复")
|
||||
async def change_status(
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""系统设置:平台级的运维参数(目前只有 SMTP)。
|
||||
|
||||
为什么单独一个路由而不是塞进 auth:这些设置**不属于任何工作区**,
|
||||
权限也不是租户角色,而是「平台管理员」白名单(`deps.platform_admin`)。
|
||||
把它和租户接口分开,读代码的人一眼就知道这里改的是整台服务的配置。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..deps import PlatformAdminDep, SessionDep
|
||||
from ..errors import AppError, ErrorCode
|
||||
from ..models import AppUser
|
||||
from ..schemas import ok
|
||||
from ..services import mail
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["系统设置"])
|
||||
|
||||
|
||||
class SmtpIn(BaseModel):
|
||||
"""授权码留空表示"沿用原来的",不传等于没改——避免每次保存都要重新粘贴一遍。"""
|
||||
|
||||
host: str = Field(min_length=1, max_length=255)
|
||||
port: int = Field(default=465, ge=1, le=65535)
|
||||
username: str = Field(min_length=1, max_length=255)
|
||||
password: str | None = Field(default=None, max_length=255)
|
||||
from_name: str = Field(default="达道Task", max_length=64)
|
||||
use_ssl: bool = True
|
||||
|
||||
|
||||
class TestMailIn(BaseModel):
|
||||
# 不填就发给自己(账号里绑的邮箱),方便先确认通道通不通
|
||||
to: str | None = Field(default=None, max_length=255)
|
||||
|
||||
|
||||
@router.get("/smtp", summary="读发信配置(授权码只回是否已设)")
|
||||
async def get_smtp(admin: PlatformAdminDep, session: SessionDep) -> dict:
|
||||
return ok(await mail.config_payload(session))
|
||||
|
||||
|
||||
@router.put("/smtp", summary="改发信配置,存库即时生效")
|
||||
async def put_smtp(body: SmtpIn, admin: PlatformAdminDep, session: SessionDep) -> dict:
|
||||
await mail.save_config(
|
||||
session,
|
||||
values={
|
||||
"host": body.host.strip(),
|
||||
"port": body.port,
|
||||
"username": body.username.strip(),
|
||||
"password": (body.password or "").strip() or None,
|
||||
"from_name": body.from_name.strip() or "达道Task",
|
||||
"use_ssl": body.use_ssl,
|
||||
},
|
||||
updated_by=admin.id,
|
||||
)
|
||||
return ok(await mail.config_payload(session))
|
||||
|
||||
|
||||
@router.post("/smtp/test", summary="发一封测试邮件")
|
||||
async def test_smtp(body: TestMailIn, admin: PlatformAdminDep, session: SessionDep) -> dict:
|
||||
"""真发一封。配错授权码、端口不通这类问题,只有真连一次才知道。"""
|
||||
to = (body.to or "").strip()
|
||||
if not to:
|
||||
row = await session.get(AppUser, admin.id)
|
||||
to = (row.email if row else None) or ""
|
||||
if not to:
|
||||
raise AppError(ErrorCode.VALIDATION_FAILED, "填一个收件邮箱,或先在自己的账号里绑一个")
|
||||
|
||||
cfg = await mail.load_config(session)
|
||||
if cfg is None:
|
||||
raise AppError(ErrorCode.MAIL_SEND_FAILED, "还没有配置发信邮箱,请先填上面的 SMTP")
|
||||
|
||||
await mail.send_mail(
|
||||
cfg,
|
||||
to=to,
|
||||
subject="达道Task 测试邮件",
|
||||
text=(
|
||||
"这封信是从达道Task的「系统设置」里发出来的。\n\n"
|
||||
"收到它说明 SMTP 配置可用:以后的找回密码、任务提醒都会走这条通道。\n"
|
||||
),
|
||||
)
|
||||
return ok({"sent": True, "to": to})
|
||||
@@ -132,7 +132,8 @@ async def list_members(
|
||||
"role": m.role,
|
||||
"status": m.status,
|
||||
"nickname": u.nickname,
|
||||
"phone": f"{u.phone[:3]}****{u.phone[-4:]}",
|
||||
# 邮箱注册的账号没有手机号:留空比显示一串星号诚实
|
||||
"phone": f"{u.phone[:3]}****{u.phone[-4:]}" if u.phone else "",
|
||||
"joined_at": m.joined_at,
|
||||
}
|
||||
for m, u in rows
|
||||
|
||||
@@ -4,10 +4,10 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
||||
|
||||
DEV_JWT_SECRET = "dev-only-change-me" # noqa: S105 - 仅开发默认值,生产启动时会被校验拦下
|
||||
|
||||
@@ -60,6 +60,44 @@ class Settings(BaseSettings):
|
||||
# 内测开关:生产默认禁止固定验证码。确实要在内网试用时显式打开,
|
||||
# 启动会打 WARNING(见 main.py),其余生产校验照旧——绝不能配合公网暴露。
|
||||
internal_test_login: bool = False
|
||||
# 登录方式开关:手机验证码这条**默认关掉**。内测期的固定验证码等于"知道手机号就能进任意账号",
|
||||
# 关掉后 /auth/sms/send 与 /auth/login 统一回 2017,登录走「手机号 / 邮箱 + 密码」。
|
||||
# 将来接上真短信通道把它打开即可,登录页会按 /auth/methods 自动列出可用的方式。
|
||||
sms_login_enabled: bool = False
|
||||
|
||||
# 平台管理员:能改系统设置(SMTP 等)。按手机号白名单,不挂工作区角色——
|
||||
# 系统设置是**跨租户**的,某个工作区的 owner 也不该看到平台的发信配置。
|
||||
# `NoDecode` + 下面的 before-validator:这个值环境变量里是逗号分隔的一串,
|
||||
# 不让 pydantic-settings 先按 JSON 解析(那样 `1360...,1390...` 会直接启动失败)。
|
||||
platform_admin_phones: Annotated[list[str], NoDecode] = Field(
|
||||
default_factory=lambda: ["13605513683"]
|
||||
)
|
||||
|
||||
# ── 邮件(SMTP)────────────────────────────────────────
|
||||
# 以「系统设置」页里存的值为准(DB),这里只是首次启动前的兜底。
|
||||
# 改 SMTP 不该要人登服务器改 env 再重启——所以真配置在 DB 里(见 services/mail.py)。
|
||||
smtp_host: str | None = None
|
||||
smtp_port: int = 465
|
||||
smtp_username: str | None = None
|
||||
smtp_password: str | None = None
|
||||
smtp_from_name: str = "达道Task"
|
||||
# 465 走 SSL,587 走 STARTTLS;126/163 这类用 465 + SSL
|
||||
smtp_use_ssl: bool = True
|
||||
smtp_timeout_seconds: int = 10
|
||||
|
||||
# ── 邮箱注册 ──────────────────────────────────────────
|
||||
# 手机验证码关掉以后,新用户靠「邮箱 + 验证码」自己注册进来。
|
||||
# 注册必须真的发出验证码(发信配置在「系统设置」里),所以这个开关跟 SMTP 是否配好绑定:
|
||||
# 没配 SMTP 时发码接口会回 2020,而不是假装发出去。
|
||||
email_register_enabled: bool = True
|
||||
email_code_ttl_seconds: int = 600
|
||||
# 同一邮箱两次发码的最小间隔;测试里设为 0 可关闭节流
|
||||
email_code_throttle_seconds: int = 60
|
||||
# 一个邮箱最多试错几次验证码,超了就作废(防止把 6 位数当彩票刮)
|
||||
email_code_max_attempts: int = 5
|
||||
# 开发/联调用:把验证码直接回给调用方,**不发真信**。
|
||||
# 生产打开等于任何人都能注册任意邮箱,所以 validate_runtime() 会直接拒绝启动。
|
||||
email_code_dev_echo: bool = False
|
||||
|
||||
# ── 幂等 ──────────────────────────────────────────────
|
||||
idempotency_enabled: bool = True
|
||||
@@ -107,15 +145,23 @@ class Settings(BaseSettings):
|
||||
# 生产没有 nginx 时由后端直接托管前端构建产物(同源,/api 无跨域)。
|
||||
# 指向 web/dist 的绝对路径;留空则只提供 API。
|
||||
web_dist_dir: str | None = None
|
||||
# 回复微信消息时用的站点地址(形如 http://192.168.8.188:8200,不带 /api/v1)。
|
||||
# 留空则退回 cors_origins 的第一个,本机开发够用,生产务必显式配。
|
||||
public_base_url: str | None = None
|
||||
|
||||
# ── 安卓安装包(OTA)──────────────────────────────────
|
||||
# 放 latest.json + 对应 apk 的目录;留空则用 backend/static/apk。
|
||||
# 发布目录是构建产物,不入库,部署脚本也不清理(git reset 不动未跟踪文件)。
|
||||
apk_dir: str | None = None
|
||||
|
||||
# ── 构建标记(部署校验用)──────────────────────────────
|
||||
build_commit: str = "unknown"
|
||||
build_time: str = "unknown"
|
||||
|
||||
@field_validator("cors_origins", mode="before")
|
||||
@field_validator("cors_origins", "platform_admin_phones", mode="before")
|
||||
@classmethod
|
||||
def _split_origins(cls, value: object) -> object:
|
||||
"""支持 CORS_ORIGINS="a,b" 这种写法。"""
|
||||
"""支持 CORS_ORIGINS="a,b" 这种写法(平台管理员白名单同理)。"""
|
||||
if isinstance(value, str) and not value.startswith("["):
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
return value
|
||||
@@ -129,10 +175,15 @@ class Settings(BaseSettings):
|
||||
problems.append("JWT_SECRET 仍是开发默认值或长度不足 32")
|
||||
if "dadao_dev_pw" in self.database_url:
|
||||
problems.append("DATABASE_URL 仍在用开发默认口令")
|
||||
if self.sms_provider == "dev" and not self.internal_test_login:
|
||||
if self.sms_login_enabled and self.sms_provider == "dev":
|
||||
problems.append(
|
||||
"生产环境不允许 SMS_PROVIDER=dev(任意验证码可登录);"
|
||||
"内网试用确需固定验证码时显式设置 INTERNAL_TEST_LOGIN=true"
|
||||
"短信验证码登录开着(SMS_LOGIN_ENABLED=true),但 SMS_PROVIDER=dev 只会发固定验证码"
|
||||
"(等于知道手机号就能进);接真短信通道之前请把 SMS_LOGIN_ENABLED 关掉"
|
||||
)
|
||||
if self.email_code_dev_echo:
|
||||
problems.append(
|
||||
"EMAIL_CODE_DEV_ECHO=true 会把注册验证码直接回给调用方,"
|
||||
"等于任何人都能注册任意邮箱;生产必须关掉(只在本地联调时打开)"
|
||||
)
|
||||
if problems:
|
||||
raise RuntimeError("配置校验失败:" + ";".join(problems))
|
||||
|
||||
@@ -14,6 +14,7 @@ from fastapi import Depends, Header, Path
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from .config import get_settings
|
||||
from .db import bind_tenant, bind_user, get_session
|
||||
from .errors import AppError, ErrorCode
|
||||
from .logging_setup import user_id_ctx, workspace_id_ctx
|
||||
@@ -26,7 +27,9 @@ ROLE_RANK: dict[str, int] = {"owner": 4, "admin": 3, "member": 2, "guest": 1}
|
||||
@dataclass(frozen=True)
|
||||
class AuthUser:
|
||||
id: uuid.UUID
|
||||
phone: str
|
||||
# 邮箱注册的账号还没有手机号,所以可空;用手机号的地方(平台管理员白名单)按
|
||||
# "空就不匹配" 处理,别拿 None 去比字符串。
|
||||
phone: str | None
|
||||
nickname: str
|
||||
|
||||
|
||||
@@ -112,3 +115,18 @@ def require_role(minimum: str):
|
||||
|
||||
OwnerDep = Annotated[TenantCtx, Depends(require_role("owner"))]
|
||||
AdminDep = Annotated[TenantCtx, Depends(require_role("admin"))]
|
||||
|
||||
|
||||
async def platform_admin(user: CurrentUser) -> AuthUser:
|
||||
"""平台管理员:能改 SMTP 这类**跨租户**的系统设置。
|
||||
|
||||
刻意不复用工作区角色:某个工作区的 owner 只说明他管得了自己的工作区,
|
||||
不代表他能看到整台服务的发信账号(那里面存着别人的授权码)。
|
||||
名单来自配置 `PLATFORM_ADMIN_PHONES`,换人不用发版。
|
||||
"""
|
||||
if user.phone not in get_settings().platform_admin_phones:
|
||||
raise AppError(ErrorCode.INSUFFICIENT_ROLE, "只有平台管理员能改系统设置")
|
||||
return user
|
||||
|
||||
|
||||
PlatformAdminDep = Annotated[AuthUser, Depends(platform_admin)]
|
||||
|
||||
@@ -25,6 +25,16 @@ class ErrorCode(IntEnum):
|
||||
SMS_CODE_INVALID = 2010
|
||||
SMS_TOO_FREQUENT = 2011
|
||||
ACCOUNT_DISABLED = 2012
|
||||
PASSWORD_INVALID = 2013
|
||||
PASSWORD_NOT_SET = 2014
|
||||
PASSWORD_WEAK = 2015
|
||||
LOGIN_TOO_MANY = 2016
|
||||
LOGIN_METHOD_DISABLED = 2017
|
||||
EMAIL_INVALID = 2018
|
||||
EMAIL_TAKEN = 2019
|
||||
MAIL_SEND_FAILED = 2020
|
||||
EMAIL_CODE_INVALID = 2021
|
||||
EMAIL_CODE_TOO_FREQUENT = 2022
|
||||
|
||||
# 3xxx 租户 / 权限
|
||||
WORKSPACE_NOT_FOUND = 3001
|
||||
@@ -66,6 +76,13 @@ class ErrorCode(IntEnum):
|
||||
ATTACHMENT_FORBIDDEN = 8003
|
||||
CONFIRM_REQUIRED = 8004
|
||||
|
||||
# 9xxx 客户端发布(OTA)
|
||||
APP_RELEASE_NOT_FOUND = 9001
|
||||
|
||||
# 9xxx 外部接入(微信 clawbot 等)
|
||||
BOT_TOKEN_INVALID = 9002
|
||||
BOT_DISABLED = 9003
|
||||
|
||||
|
||||
# 默认 HTTP 状态:业务码自己带语义,HTTP 只是传输层
|
||||
_HTTP_STATUS: dict[ErrorCode, int] = {
|
||||
@@ -83,6 +100,16 @@ _HTTP_STATUS: dict[ErrorCode, int] = {
|
||||
ErrorCode.SMS_CODE_INVALID: 400,
|
||||
ErrorCode.SMS_TOO_FREQUENT: 429,
|
||||
ErrorCode.ACCOUNT_DISABLED: 403,
|
||||
ErrorCode.PASSWORD_INVALID: 400,
|
||||
ErrorCode.PASSWORD_NOT_SET: 400,
|
||||
ErrorCode.PASSWORD_WEAK: 422,
|
||||
ErrorCode.LOGIN_TOO_MANY: 429,
|
||||
ErrorCode.LOGIN_METHOD_DISABLED: 403,
|
||||
ErrorCode.EMAIL_INVALID: 422,
|
||||
ErrorCode.EMAIL_TAKEN: 409,
|
||||
ErrorCode.MAIL_SEND_FAILED: 502,
|
||||
ErrorCode.EMAIL_CODE_INVALID: 400,
|
||||
ErrorCode.EMAIL_CODE_TOO_FREQUENT: 429,
|
||||
ErrorCode.WORKSPACE_NOT_FOUND: 404,
|
||||
ErrorCode.NOT_A_MEMBER: 403,
|
||||
ErrorCode.INSUFFICIENT_ROLE: 403,
|
||||
@@ -111,6 +138,9 @@ _HTTP_STATUS: dict[ErrorCode, int] = {
|
||||
ErrorCode.ATTACHMENT_TOO_LARGE: 413,
|
||||
ErrorCode.ATTACHMENT_FORBIDDEN: 403,
|
||||
ErrorCode.CONFIRM_REQUIRED: 409,
|
||||
ErrorCode.APP_RELEASE_NOT_FOUND: 404,
|
||||
ErrorCode.BOT_TOKEN_INVALID: 404,
|
||||
ErrorCode.BOT_DISABLED: 403,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -16,12 +16,23 @@ from starlette.responses import Response
|
||||
from starlette.staticfiles import StaticFiles
|
||||
from starlette.types import Scope
|
||||
|
||||
from .api import attachments, auth, items, knowledge, tickets, workspaces
|
||||
from .api import (
|
||||
app_release,
|
||||
attachments,
|
||||
auth,
|
||||
integrations,
|
||||
items,
|
||||
knowledge,
|
||||
tickets,
|
||||
workspaces,
|
||||
)
|
||||
from .api import settings as settings_api
|
||||
from .config import get_settings
|
||||
from .db import SessionFactory, current_app_role, engine
|
||||
from .errors import AppError, ErrorCode, http_status_for
|
||||
from .logging_setup import get_logger, request_id_ctx, setup_logging
|
||||
from .middleware import IdempotencyMiddleware, RequestContextMiddleware, error_body
|
||||
from .services.wechat_bot import manager as wechat_manager
|
||||
|
||||
settings = get_settings()
|
||||
setup_logging("DEBUG" if settings.env == "dev" else "INFO")
|
||||
@@ -31,16 +42,19 @@ logger = get_logger("app")
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
settings.validate_runtime()
|
||||
if settings.env == "prod" and settings.internal_test_login:
|
||||
if settings.env == "prod" and settings.sms_login_enabled:
|
||||
logger.warning(
|
||||
"内测登录已开启:任意手机号可用固定验证码登录,只能在内网访问,切勿暴露公网",
|
||||
"短信验证码登录开着,只能配真通道;用 dev 固定码等于知道手机号就能进任何账号",
|
||||
extra={"extra_fields": {"sms_provider": settings.sms_provider}},
|
||||
)
|
||||
logger.info(
|
||||
"服务启动",
|
||||
extra={"extra_fields": {"env": settings.env, "commit": settings.build_commit}},
|
||||
)
|
||||
# 已登录的微信通道在进程启动时接上,不然重启之后消息就静默收不到了。
|
||||
await wechat_manager.start_all()
|
||||
yield
|
||||
await wechat_manager.shutdown()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@@ -115,6 +129,9 @@ app.include_router(tickets.router, prefix=settings.api_prefix)
|
||||
app.include_router(tickets.notify_router, prefix=settings.api_prefix)
|
||||
app.include_router(knowledge.router, prefix=settings.api_prefix)
|
||||
app.include_router(attachments.router, prefix=settings.api_prefix)
|
||||
app.include_router(app_release.router, prefix=settings.api_prefix)
|
||||
app.include_router(settings_api.router, prefix=settings.api_prefix)
|
||||
app.include_router(integrations.admin_router, prefix=settings.api_prefix)
|
||||
|
||||
|
||||
@app.get("/health", tags=["运维"], summary="存活探针")
|
||||
|
||||
@@ -41,10 +41,36 @@ class AppUser(Base, Timestamps):
|
||||
__tablename__ = "app_user"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid)
|
||||
phone: Mapped[str] = mapped_column(String(32), nullable=False, unique=True)
|
||||
# 手机号可空:短信登录关掉以后,新账号是「邮箱 + 验证码」注册的,没有手机号。
|
||||
# 老账号照旧有;将来绑手机号(换绑/找回)再补。
|
||||
phone: Mapped[str | None] = mapped_column(String(32), unique=True)
|
||||
nickname: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
||||
avatar_url: Mapped[str | None] = mapped_column(String(512))
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
|
||||
# 登录密码(scrypt 串,见 security.hash_password)。为空表示只用验证码登录。
|
||||
password_hash: Mapped[str | None] = mapped_column(String(255))
|
||||
# 邮箱:既是「邮箱 + 密码」登录的账号名,也是以后找回密码/发通知的去处。
|
||||
# 可空(老用户还没有),唯一性由迁移里的 lower(email) 部分唯一索引保证。
|
||||
email: Mapped[str | None] = mapped_column(String(255))
|
||||
# 邮箱验过的时间:注册时发码验证过就有值;账号页手工绑邮箱时也走同一个验证流程。
|
||||
# 用"有没有值"区分"填了个字符串"和"这个地址真是他的"。
|
||||
email_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class SystemSetting(Base, Timestamps):
|
||||
"""平台级键值设置(SMTP 等),**不挂租户、没有 RLS**。
|
||||
|
||||
刻意不做成租户表:发信账号是整台服务共用的,某个工作区的 owner 不该看到
|
||||
平台的 SMTP 授权码。写权限由 API 层的「平台管理员」依赖把关(见 deps.py)。
|
||||
"""
|
||||
|
||||
__tablename__ = "system_setting"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
value: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
updated_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="SET NULL")
|
||||
)
|
||||
|
||||
|
||||
class Workspace(Base, Timestamps):
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""外部接入:微信 clawbot 的机器人账号。
|
||||
|
||||
为什么单开一张表而不是塞进 `system_setting`:
|
||||
|
||||
1. 一个工作区可能挂过好几个机器人(自己的号、公司的号),也随时可能换;
|
||||
塞进 setting 的 JSON 里就没法"停用这一个、保留那一个"。
|
||||
2. 通道是**租户数据**:谁能往里发消息,取决于它属于哪个工作区。
|
||||
租户表就得有 `workspace_id NOT NULL` + RLS(AGENTS.md 第 2.5 条)。
|
||||
|
||||
登录态(`bot_token_enc`)是加密存的:它等于"这个微信账号的登录态",
|
||||
明文落库等于把号交出去。密钥由 `JWT_SECRET` 派生,见 `services/wechat_ilink.py`。
|
||||
|
||||
`token_hash` 是上一版 webhook 方案留下的列:那一版以为 clawbot 会往我们这 POST,
|
||||
后来发现真实协议是"我们扫码登录 + 长轮询"(见 `services/wechat_ilink.py` 文件头),
|
||||
列先留着不为空但不再使用,等下一版清理。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, String, Text, text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.dialects.postgresql import UUID as PgUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .models import Base, Timestamps, new_uuid
|
||||
|
||||
BOT_KINDS = ("wechat_clawbot",)
|
||||
# pending:二维码还没扫;active:登录态可用、正在轮询;
|
||||
# disabled:人手动停用;expired:登录态失效,需要重新扫码。
|
||||
BOT_STATUSES = ("pending", "active", "disabled", "expired")
|
||||
|
||||
INTEGRATION_TENANT_TABLES = ("bot_channel", "bot_message")
|
||||
|
||||
|
||||
class BotChannel(Base, Timestamps):
|
||||
"""一条接入通道:微信里那个机器人 ⇄ 本工作区。"""
|
||||
|
||||
__tablename__ = "bot_channel"
|
||||
__table_args__ = (
|
||||
Index("ix_bot_channel_ws", "workspace_id"),
|
||||
Index("uq_bot_channel_token", "token_hash", unique=True),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid)
|
||||
workspace_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("workspace.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
kind: Mapped[str] = mapped_column(String(32), nullable=False, default="wechat_clawbot")
|
||||
name: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
||||
# 上一版 webhook 方案的遗留列,现在不用了(见模块说明)。
|
||||
token_hash: Mapped[str | None] = mapped_column(String(64))
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending")
|
||||
# 加密后的 iLink 登录态;解不出来就说明 JWT_SECRET 换过,要重新扫码。
|
||||
bot_token_enc: Mapped[str | None] = mapped_column(Text)
|
||||
# 扫码的那个微信号(iLink 的 ilink_user_id),用来在界面上显示"谁扫的"。
|
||||
ilink_user_id: Mapped[str | None] = mapped_column(String(64))
|
||||
# 服务端可能下发新的基址,跟着它走。
|
||||
base_url: Mapped[str | None] = mapped_column(Text)
|
||||
# 长轮询游标:丢了就会重复收消息。
|
||||
sync_buf: Mapped[str | None] = mapped_column(Text)
|
||||
# 最近一条消息的 context_token:回复必须带它。
|
||||
context_token: Mapped[str | None] = mapped_column(Text)
|
||||
# 消息落在谁名下:用创建这条通道的人;人没了就退回工作区 owner。
|
||||
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("app_user.id", ondelete="SET NULL")
|
||||
)
|
||||
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
last_error: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
|
||||
class BotMessage(Base, Timestamps):
|
||||
"""收到的每条消息都留痕:出问题时能回答"那条任务到底有没有进去"。"""
|
||||
|
||||
__tablename__ = "bot_message"
|
||||
__table_args__ = (
|
||||
Index("ix_bot_message_channel", "channel_id", "created_at"),
|
||||
# 同一个外部消息 id 只处理一次:微信会重推,重推不能变成两条任务。
|
||||
Index(
|
||||
"uq_bot_message_external",
|
||||
"channel_id",
|
||||
"external_id",
|
||||
unique=True,
|
||||
postgresql_where=text("external_id IS NOT NULL"),
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(PgUUID(as_uuid=True), primary_key=True, default=new_uuid)
|
||||
workspace_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("workspace.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
channel_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("bot_channel.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
external_id: Mapped[str | None] = mapped_column(String(128))
|
||||
sender: Mapped[str | None] = mapped_column(String(128))
|
||||
text: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
# task / knowledge / help / ignored / error
|
||||
action: Mapped[str] = mapped_column(String(24), nullable=False, default="ignored")
|
||||
item_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PgUUID(as_uuid=True), ForeignKey("item.id", ondelete="SET NULL")
|
||||
)
|
||||
reply: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
raw: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
@@ -48,6 +48,10 @@ class KnowledgeCategory(Base, Timestamps):
|
||||
|
||||
`depth` 是冗余列,但值得:每次筛选、移动子树都要判断层级,存下来就不用
|
||||
递归数祖先。写入时由 API 计算,读到不一致的行属于数据事故。
|
||||
|
||||
`color` 是给人看的分类色(`#RRGGBB`)。它不参与任何判断,只负责让
|
||||
"这条知识属于哪一类"在卡片墙上一眼分得出来——所以允许为空(前端给默认色),
|
||||
但一旦有值就必须是合法十六进制,否则渲染会静默丢样式。
|
||||
"""
|
||||
|
||||
__tablename__ = "knowledge_category"
|
||||
@@ -55,6 +59,7 @@ class KnowledgeCategory(Base, Timestamps):
|
||||
Index("ix_kcat_ws_parent", "workspace_id", "parent_id"),
|
||||
CheckConstraint("depth BETWEEN 1 AND 3", name="ck_kcat_depth"),
|
||||
CheckConstraint("length(btrim(name)) > 0", name="ck_kcat_name"),
|
||||
CheckConstraint("color IS NULL OR color ~ '^#[0-9A-F]{6}$'", name="ck_kcat_color"),
|
||||
{"comment": "知识分类树,深不过三"},
|
||||
)
|
||||
|
||||
@@ -68,6 +73,7 @@ class KnowledgeCategory(Base, Timestamps):
|
||||
name: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
depth: Mapped[int] = mapped_column(nullable=False, default=1)
|
||||
sort_order: Mapped[int] = mapped_column(nullable=False, default=0)
|
||||
color: Mapped[str | None] = mapped_column(String(7))
|
||||
|
||||
|
||||
class KnowledgeDoc(Base, Timestamps):
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
import time
|
||||
@@ -78,3 +80,64 @@ def new_invite_code() -> str:
|
||||
|
||||
def invite_code_matches(provided: str, expected: str) -> bool:
|
||||
return hmac.compare_digest(provided.strip().upper(), expected)
|
||||
|
||||
|
||||
# —— 登录密码 ——
|
||||
# 用标准库 scrypt,不引入 passlib/bcrypt:少一个依赖,也就少一处装不上的风险。
|
||||
# 存储格式 `scrypt$n$r$p$salt_b64$hash_b64`,参数写进串里,将来调强度不用改代码。
|
||||
_SCRYPT_N = 2**14
|
||||
_SCRYPT_R = 8
|
||||
_SCRYPT_P = 1
|
||||
_PASSWORD_MIN = 8
|
||||
_PASSWORD_MAX = 64
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
salt = secrets.token_bytes(16)
|
||||
digest = hashlib.scrypt(
|
||||
password.encode("utf-8"), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=32
|
||||
)
|
||||
return "$".join(
|
||||
[
|
||||
"scrypt",
|
||||
str(_SCRYPT_N),
|
||||
str(_SCRYPT_R),
|
||||
str(_SCRYPT_P),
|
||||
base64.b64encode(salt).decode(),
|
||||
base64.b64encode(digest).decode(),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def verify_password(password: str, stored: str | None) -> bool:
|
||||
"""stored 为空(没设过密码)一律返回 False,别把"没密码"当成"密码对"。"""
|
||||
if not stored:
|
||||
return False
|
||||
try:
|
||||
scheme, n, r, p, salt_b64, hash_b64 = stored.split("$")
|
||||
if scheme != "scrypt":
|
||||
return False
|
||||
digest = hashlib.scrypt(
|
||||
password.encode("utf-8"),
|
||||
salt=base64.b64decode(salt_b64),
|
||||
n=int(n),
|
||||
r=int(r),
|
||||
p=int(p),
|
||||
dklen=len(base64.b64decode(hash_b64)),
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
return hmac.compare_digest(digest, base64.b64decode(hash_b64))
|
||||
|
||||
|
||||
def password_problem(password: str) -> str | None:
|
||||
"""返回不合格的原因;合格返回 None。规则写在一处,设置与修改都用它。"""
|
||||
if len(password) < _PASSWORD_MIN:
|
||||
return f"密码至少 {_PASSWORD_MIN} 位"
|
||||
if len(password) > _PASSWORD_MAX:
|
||||
return f"密码最多 {_PASSWORD_MAX} 位"
|
||||
has_letter = any(c.isalpha() for c in password)
|
||||
has_digit = any(c.isdigit() for c in password)
|
||||
if not (has_letter and has_digit):
|
||||
return "密码要同时有字母和数字"
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,636 @@
|
||||
"""把一条链接抓成一篇知识。
|
||||
|
||||
微信里发一条链接,多半就是想"把这篇文章存下来"。这里负责三件事:
|
||||
|
||||
1. **取正文**:用 trafilatura 的正文抽取(开源社区里在中文站上表现最稳的一个),
|
||||
微信公众号单独走一条路——它的正文在 `#js_content` 里、图片放在 `data-src`,
|
||||
通用抽取器会把图丢掉。
|
||||
2. **留来源**:正文开头记一行原文地址与作者,日后要核对时不用猜。
|
||||
3. **图片转存**:文章里的图片不是躺在我们的存储里,公众号一改就变红叉。
|
||||
所以抓下来的图逐张存成附件,正文里的地址换成我们自己的签名直链。
|
||||
|
||||
抓不下来不硬扛:调用方拿到 `ArticleError` 就把原链接当普通文本存成任务,
|
||||
用户至少不会丢东西。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html as html_mod
|
||||
import ipaddress
|
||||
import re
|
||||
import socket
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
# 先让 app.models 加载完:它在文件末尾反向导入各域的租户表清单,
|
||||
# 直接 import models_attachment 会撞上"部分初始化"的循环导入。
|
||||
from .. import models as _models # noqa: F401
|
||||
from ..config import get_settings
|
||||
from ..models_attachment import Attachment
|
||||
from . import signing, storage
|
||||
|
||||
# 一条消息里只认第一个链接;带别的字就当普通任务("看下 https://… 这个" 不该被吞成收藏)
|
||||
URL_RE = re.compile(r"https?://[^\s<>\"'))】。,;]+", re.I)
|
||||
|
||||
MAX_ARTICLES_BYTES = 8 * 1024 * 1024
|
||||
MAX_IMAGES = 12
|
||||
MAX_IMAGE_BYTES = 8 * 1024 * 1024
|
||||
MAX_BODY_CHARS = 200_000
|
||||
FETCH_TIMEOUT = 25.0
|
||||
|
||||
DESKTOP_UA = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||
" (KHTML, like Gecko) Chrome/126.0 Safari/537.36"
|
||||
)
|
||||
# 公众号对"非微信浏览器"会压缩内容、图片懒加载,用微信 UA 拿到的才是完整一篇。
|
||||
WECHAT_UA = (
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15"
|
||||
" (KHTML, like Gecko) Mobile/15E148 MicroMessenger/8.0.44(0x18002c2e)"
|
||||
" NetType/WIFI Language/zh_CN"
|
||||
)
|
||||
|
||||
IMAGE_EXTS = {"jpeg": "jpg", "jpg": "jpg", "png": "png", "gif": "gif", "webp": "webp"}
|
||||
|
||||
# 公众号新版页面(2024 之后)不再把正文放进 `#js_content`,而是把整篇塞进
|
||||
# `window.cgiDataNew` 这个 JS 对象里,由前端渲染。所以还得会读 JS 字符串字面量:
|
||||
# 换行写成 `\x0a`,话题链接是 `\x26lt;a …\x26gt;`(先转义成实体、再转义成 JS)。
|
||||
_WECHAT_FIELD_RE = re.compile(r"(?:content_noencode|desc)\s*:\s*'")
|
||||
_WECHAT_IMAGE_RE = re.compile(
|
||||
r"""(?:cdn_url|img_url|pic_url)\s*:\s*['"]((?:https?:)?//[^'"]*mmbiz\.qpic\.cn[^'"]*)['"]"""
|
||||
)
|
||||
_JS_ESCAPE_RE = re.compile(r"\\(x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}|.)", re.S)
|
||||
|
||||
|
||||
class ArticleError(RuntimeError):
|
||||
"""抓取/解析没成功。调用方据此决定降级方式。"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Article:
|
||||
url: str
|
||||
title: str
|
||||
body_md: str
|
||||
site: str = ""
|
||||
author: str = ""
|
||||
images: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def extract_url(text: str) -> str | None:
|
||||
"""这一整句话就是个链接吗?
|
||||
|
||||
允许链接前后有点标点、空格("看下:https://x" 不算);带了别的内容就当普通文本,
|
||||
免得用户随口提一句网址,心情是"记条任务",结果被存成一篇文章。
|
||||
"""
|
||||
stripped = text.strip()
|
||||
match = URL_RE.search(stripped)
|
||||
if not match:
|
||||
return None
|
||||
rest = (stripped[: match.start()] + stripped[match.end() :]).strip()
|
||||
rest = rest.strip(" \t\r\n::,。;;、!!??()()【】[]<>「」")
|
||||
if rest:
|
||||
return None
|
||||
return match.group(0)
|
||||
|
||||
|
||||
def is_wechat_article(url: str) -> bool:
|
||||
host = (urlparse(url).hostname or "").lower()
|
||||
return host == "mp.weixin.qq.com"
|
||||
|
||||
|
||||
async def _assert_public(url: str) -> None:
|
||||
"""只抓公网地址。
|
||||
|
||||
这个入口能由工作区以外的人(微信里的任何人)触发,放任它就是一台 SSRF 跳板:
|
||||
`http://127.0.0.1:8200/version`、`http://169.254.169.254/` 之类都能被打。
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise ArticleError("只支持 http/https 链接")
|
||||
host = parsed.hostname or ""
|
||||
if not host:
|
||||
raise ArticleError("链接里没有主机名")
|
||||
try:
|
||||
infos = await _resolve(host)
|
||||
except OSError as exc: # 解析不出来就别猜
|
||||
raise ArticleError(f"解析不了这个域名:{exc}") from exc
|
||||
for _family, _type, _proto, _canon, sockaddr in infos:
|
||||
ip = ipaddress.ip_address(sockaddr[0])
|
||||
if (
|
||||
ip.is_private
|
||||
or ip.is_loopback
|
||||
or ip.is_link_local
|
||||
or ip.is_reserved
|
||||
or ip.is_multicast
|
||||
or ip.is_unspecified
|
||||
):
|
||||
raise ArticleError("这个地址在内网,不抓")
|
||||
|
||||
|
||||
async def _resolve(host: str):
|
||||
import asyncio
|
||||
|
||||
def lookup():
|
||||
return socket.getaddrinfo(host, None)
|
||||
|
||||
return await asyncio.to_thread(lookup)
|
||||
|
||||
|
||||
def _pick_ua(url: str) -> str:
|
||||
return WECHAT_UA if is_wechat_article(url) else DESKTOP_UA
|
||||
|
||||
|
||||
def _looks_blocked(html: str) -> bool:
|
||||
probes = (
|
||||
"环境异常",
|
||||
"完成验证后即可继续访问",
|
||||
"请在微信客户端打开链接",
|
||||
"该内容已被发布者删除",
|
||||
"此内容因违规无法查看",
|
||||
)
|
||||
return any(p in html for p in probes)
|
||||
|
||||
|
||||
def _clean_text(value: str) -> str:
|
||||
return re.sub(r"[ \t\u00a0]+", " ", (value or "").replace("\r", "")).strip()
|
||||
|
||||
|
||||
def _wechat_meta(html: str) -> tuple[str, str, list[tuple[str, str]]]:
|
||||
"""从公众号页面里取 (标题, 作者/公众号名, [(图片地址, alt)]) 与正文节点。"""
|
||||
from lxml import html as lxml_html
|
||||
|
||||
tree = lxml_html.fromstring(html)
|
||||
title = ""
|
||||
for xpath in (
|
||||
'//*[@id="activity-name"]//text()',
|
||||
'//meta[@property="og:title"]/@content',
|
||||
"//title/text()",
|
||||
):
|
||||
parts = [_clean_text(t) for t in tree.xpath(xpath)]
|
||||
title = " ".join(p for p in parts if p)
|
||||
if title:
|
||||
break
|
||||
author = ""
|
||||
for xpath in (
|
||||
'//*[@id="js_name"]//text()',
|
||||
'//meta[@name="author"]/@content',
|
||||
'//*[@id="meta_content"]//*[@class="rich_media_meta_text"]//text()',
|
||||
):
|
||||
parts = [_clean_text(t) for t in tree.xpath(xpath)]
|
||||
author = " ".join(p for p in parts if p)
|
||||
if author:
|
||||
break
|
||||
if not author:
|
||||
# 新版页面没有 #js_name,公众号名在 cgiDataNew 的 nick_name 字段里
|
||||
found = re.search(r"nick_name\s*:\s*'([^']{1,80})'", html)
|
||||
author = _clean_text(found.group(1)) if found else ""
|
||||
return title, author, tree
|
||||
|
||||
|
||||
def _wechat_body(html: str) -> str:
|
||||
"""`#js_content` → markdown。
|
||||
|
||||
公众号正文是一堆嵌套 `section` 拼版,段落文字常被拆成多个 `span`;
|
||||
这里只取"没有块级子节点"的元素,避免同一个句子被输出好几遍。
|
||||
"""
|
||||
from lxml import html as lxml_html
|
||||
|
||||
tree = lxml_html.fromstring(html)
|
||||
nodes = tree.xpath('//*[@id="js_content"]')
|
||||
if not nodes:
|
||||
return ""
|
||||
content = nodes[0]
|
||||
|
||||
block_tags = {
|
||||
"p",
|
||||
"section",
|
||||
"div",
|
||||
"blockquote",
|
||||
"li",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"pre",
|
||||
}
|
||||
lines: list[str] = []
|
||||
for node in content.iter():
|
||||
tag = node.tag if isinstance(node.tag, str) else ""
|
||||
if tag == "img":
|
||||
src = node.get("data-src") or node.get("src") or ""
|
||||
if src.startswith("http"):
|
||||
lines.append(f"")
|
||||
continue
|
||||
if tag == "br":
|
||||
lines.append("")
|
||||
continue
|
||||
if tag not in block_tags:
|
||||
continue
|
||||
# 还有块级子孙的容器交给子节点输出,自己不出文字,否则整段重复
|
||||
if any(
|
||||
(child.tag if isinstance(child.tag, str) else "") in block_tags
|
||||
for child in node.iterdescendants()
|
||||
):
|
||||
continue
|
||||
text = _clean_text("".join(node.itertext()))
|
||||
if not text:
|
||||
continue
|
||||
if tag.startswith("h") and len(tag) == 2:
|
||||
text = f"## {text}"
|
||||
elif tag == "li":
|
||||
text = f"- {text}"
|
||||
elif tag == "blockquote":
|
||||
text = f"> {text}"
|
||||
lines.append(text)
|
||||
|
||||
# 相邻空行压成一个,段间留空行
|
||||
out: list[str] = []
|
||||
for line in lines:
|
||||
if not line and (not out or not out[-1]):
|
||||
continue
|
||||
out.append(line)
|
||||
return "\n\n".join(s.strip() for s in "\n".join(out).split("\n\n") if s.strip())
|
||||
|
||||
|
||||
def _js_literal(text: str, quote_pos: int) -> str:
|
||||
"""读一个单引号 JS 字符串的原始内容(`quote_pos` 指向那个开引号)。"""
|
||||
out: list[str] = []
|
||||
i = quote_pos + 1
|
||||
while i < len(text):
|
||||
ch = text[i]
|
||||
if ch == "\\":
|
||||
out.append(text[i : i + 2])
|
||||
i += 2
|
||||
continue
|
||||
if ch == "'":
|
||||
break
|
||||
out.append(ch)
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _unescape_js(raw: str) -> str:
|
||||
def repl(match: re.Match[str]) -> str:
|
||||
seq = match.group(1)
|
||||
if seq[0] in "xu" and len(seq) > 1:
|
||||
return chr(int(seq[1:], 16))
|
||||
return {"n": "\n", "t": "\t", "r": "\r", "'": "'", '"': '"', "\\": "\\"}.get(seq, seq)
|
||||
|
||||
return _JS_ESCAPE_RE.sub(repl, raw)
|
||||
|
||||
|
||||
def _wechat_data_body(html_text: str) -> str:
|
||||
"""新版公众号:从 `window.cgiDataNew` 的 JS 字段里把正文和配图捞出来。
|
||||
|
||||
一篇文章是若干「图片 + 一段说明文字」拼起来的,JS 里是
|
||||
`cdn_url: '…'` 后面紧跟 `desc` / `content_noencode`,所以按出现顺序取文字,
|
||||
再往回找它自己那张图,配图就跟着段落走。
|
||||
"""
|
||||
blocks: list[str] = []
|
||||
seen_text: set[str] = set()
|
||||
used_images: set[str] = set()
|
||||
for match in _WECHAT_FIELD_RE.finditer(html_text):
|
||||
piece = _fragment_to_markdown(_unescape_js(_js_literal(html_text, match.end() - 1)))
|
||||
if len(piece) < 30 or piece in seen_text:
|
||||
continue
|
||||
seen_text.add(piece)
|
||||
image = _nearby_image(html_text, match.start(), used_images)
|
||||
if image and "
|
||||
piece = f"{piece}\n\n"
|
||||
blocks.append(piece)
|
||||
# 图文型文章("小绿书")的图不在文字附近,靠上面的就近配对会漏掉;
|
||||
# 剩下的正文图统一补在末尾,宁可顺序差点也别丢图。
|
||||
leftover: list[str] = []
|
||||
for match in _WECHAT_IMAGE_RE.finditer(html_text):
|
||||
image = match.group(1)
|
||||
image = image if image.startswith("http") else f"https:{image}"
|
||||
if image in used_images:
|
||||
continue
|
||||
used_images.add(image)
|
||||
leftover.append(f"")
|
||||
if len(leftover) >= MAX_IMAGES:
|
||||
break
|
||||
if leftover:
|
||||
blocks.append("\n\n".join(leftover))
|
||||
return "\n\n".join(blocks).strip()
|
||||
|
||||
|
||||
def _fragment_to_markdown(fragment: str) -> str:
|
||||
"""把「实体转义 + 夹着标签」的正文片段变成 markdown 文本。
|
||||
|
||||
公众号把话题标签写成 `<a class="wx_topic_link" …>#调料</a>`,
|
||||
先 unescape 再当 HTML 处理,标签会被去掉、只留文字;`<img>` 转成 markdown。
|
||||
"""
|
||||
from lxml import html as lxml_html
|
||||
|
||||
fragment = html_mod.unescape(fragment or "")
|
||||
fragment = re.sub(
|
||||
r"""<img[^>]*?(?:data-src|src)\s*=\s*["']([^"']+)["'][^>]*>""",
|
||||
lambda m: f"\n\n})\n\n",
|
||||
fragment,
|
||||
flags=re.I,
|
||||
)
|
||||
fragment = re.sub(r"<br\s*/?>", "\n", fragment, flags=re.I)
|
||||
try:
|
||||
tree = lxml_html.fragment_fromstring(fragment, create_parent="div")
|
||||
text = tree.text_content()
|
||||
except Exception: # 片段结构烂到 lxml 都不认,直接剥标签
|
||||
text = re.sub(r"<[^>]+>", "", fragment)
|
||||
text = text.replace("\u00a0", " ")
|
||||
lines = [re.sub(r"[ \t\u3000]+", " ", line).strip() for line in text.splitlines()]
|
||||
out: list[str] = []
|
||||
for line in lines:
|
||||
if not line and (not out or not out[-1]):
|
||||
continue
|
||||
out.append(line)
|
||||
return "\n".join(out).strip()
|
||||
|
||||
|
||||
def _nearby_image(html_text: str, pos: int, used: set[str]) -> str | None:
|
||||
"""这条文字对应的配图:先往后找(新版页面图片字段排在文字之后),再往回找。"""
|
||||
for start, end in ((pos, pos + 2500), (max(0, pos - 2500), pos)):
|
||||
found = [
|
||||
m.group(1)
|
||||
for m in _WECHAT_IMAGE_RE.finditer(html_text, start, min(end, len(html_text)))
|
||||
if m.group(1) not in used
|
||||
]
|
||||
if found:
|
||||
image = found[0]
|
||||
return image if image.startswith("http") else f"https:{image}"
|
||||
return None
|
||||
|
||||
|
||||
def _block_markdown(html: str) -> str:
|
||||
"""兜底的正文提取:找出"装着最多段文字"的那个容器,再按段落拼成 markdown。
|
||||
|
||||
为什么还要这一手:trafilatura 在结构清楚的资讯站上很准,但遇到"整页一个 div、
|
||||
导航和正文混在一起"的老站点(政府站、企业站)时会连导航一起端出来。
|
||||
这里用最笨但稳的判据——哪个容器里的 <p> 文字最多,正文就在哪个容器里。
|
||||
"""
|
||||
from lxml import html as lxml_html
|
||||
|
||||
tree = lxml_html.fromstring(html)
|
||||
best = None
|
||||
best_len = 0
|
||||
for node in tree.iter("div", "article", "section", "main", "td"):
|
||||
total = sum(len("".join(p.itertext()).strip()) for p in node.iter("p"))
|
||||
if total > best_len:
|
||||
best, best_len = node, total
|
||||
if best is None or best_len < 200:
|
||||
return ""
|
||||
|
||||
parts: list[str] = []
|
||||
for node in best.iter():
|
||||
tag = node.tag if isinstance(node.tag, str) else ""
|
||||
if tag == "img":
|
||||
src = node.get("data-src") or node.get("src") or ""
|
||||
if src.startswith("http"):
|
||||
parts.append(f"")
|
||||
continue
|
||||
if tag not in {"p", "h1", "h2", "h3", "h4", "blockquote", "li"}:
|
||||
continue
|
||||
text = _clean_text("".join(node.itertext()))
|
||||
if not text:
|
||||
continue
|
||||
if tag.startswith("h") and len(tag) == 2:
|
||||
text = f"## {text}"
|
||||
elif tag == "li":
|
||||
text = f"- {text}"
|
||||
elif tag == "blockquote":
|
||||
text = f"> {text}"
|
||||
parts.append(text)
|
||||
return "\n\n".join(parts).strip()
|
||||
|
||||
|
||||
def _body_score(body: str) -> int:
|
||||
"""给一份抽取结果打分:有多少段"像人写的段落"。
|
||||
|
||||
只用来在两个候选之间挑一个,不需要绝对准确——比较的是同一篇文章的两种切法。
|
||||
"""
|
||||
lines = [line.strip() for line in body.splitlines() if line.strip()]
|
||||
return sum(1 for line in lines if len(line) >= 40)
|
||||
|
||||
|
||||
def _trim_chrome(body: str) -> str:
|
||||
"""去掉抽取器一并带出来的导航与页脚。
|
||||
|
||||
按行看,找出第一行和最后一行"像正文的"(够长、或者有句读),把两头那些短标签
|
||||
("Java教程"、"Sign In")切掉。按行而不是按段落,是因为导航往往是一行一个、
|
||||
中间不空行,按段落切会把它和正文粘成一整块,判不出边界。
|
||||
"""
|
||||
lines = body.splitlines()
|
||||
if not lines:
|
||||
return ""
|
||||
|
||||
def substantive(line: str) -> bool:
|
||||
text = line.strip()
|
||||
if not text or text.startswith(("![", "> ")):
|
||||
return False
|
||||
plain = text.lstrip("#-* ")
|
||||
return len(plain) >= 20 or bool(re.search(r"[。!?;]", plain))
|
||||
|
||||
first = next((i for i, line in enumerate(lines) if substantive(line)), None)
|
||||
if first is None:
|
||||
return body.strip()
|
||||
last = max(i for i, line in enumerate(lines) if substantive(line))
|
||||
# 图片通常紧贴在正文前后,边界外紧邻的图保留
|
||||
while first > 0 and lines[first - 1].strip().startswith("!["):
|
||||
first -= 1
|
||||
while last + 1 < len(lines) and lines[last + 1].strip().startswith("!["):
|
||||
last += 1
|
||||
return "\n".join(lines[first : last + 1]).strip()
|
||||
|
||||
|
||||
def _generic_article(html: str, url: str) -> Article:
|
||||
"""非公众号:交给 trafilatura,它在正文/噪声的判别上比手搓规则靠谱得多。
|
||||
|
||||
先按 `favor_precision` 抠最干净的一块;它经常因为"看着都不像正文"而返回空,
|
||||
再退回默认召回模式。两头再削一刀导航。
|
||||
"""
|
||||
import trafilatura
|
||||
|
||||
metadata = trafilatura.extract_metadata(html, default_url=url)
|
||||
common = {
|
||||
"url": url,
|
||||
"output_format": "markdown",
|
||||
"include_images": True,
|
||||
"include_links": True,
|
||||
"include_formatting": True,
|
||||
}
|
||||
body = trafilatura.extract(html, favor_precision=True, **common) or ""
|
||||
if len(body.strip()) < 120:
|
||||
body = trafilatura.extract(html, **common) or ""
|
||||
if not body.strip():
|
||||
# 再退一步:trafilatura 认不出结构时,退到纯文本
|
||||
body = trafilatura.extract(html, url=url, output_format="txt") or ""
|
||||
body = _trim_chrome(body)
|
||||
|
||||
# 老站点(政府站、企业站)常把导航和正文塞进同一个 div,trafilatura 会连导航一起端出来。
|
||||
# 用"哪个容器里段落文字最多"另取一份,两份按"像样段落的条数"挑更干净的那个。
|
||||
block = _trim_chrome(_block_markdown(html))
|
||||
if _body_score(block) > _body_score(body):
|
||||
body = block
|
||||
if not body.strip():
|
||||
raise ArticleError("这个页面里没找到正文(可能是动态渲染或需要登录)")
|
||||
title = _clean_text(getattr(metadata, "title", "") or "")
|
||||
return Article(
|
||||
url=url,
|
||||
title=title,
|
||||
body_md=body.strip(),
|
||||
site=_clean_text(getattr(metadata, "sitename", "") or ""),
|
||||
author=_clean_text(getattr(metadata, "author", "") or ""),
|
||||
)
|
||||
|
||||
|
||||
def parse_article(html: str, url: str) -> Article:
|
||||
if _looks_blocked(html):
|
||||
raise ArticleError("微信把这篇拦下来了(需要人工验证或链接已失效)")
|
||||
if is_wechat_article(url):
|
||||
title, author, _tree = _wechat_meta(html)
|
||||
body = _wechat_body(html) or _wechat_data_body(html)
|
||||
if not body.strip():
|
||||
# 公众号偶尔会换结构,退回通用抽取,宁可少图也别丢正文
|
||||
article = _generic_article(html, url)
|
||||
article.author = author or article.author
|
||||
return article
|
||||
return Article(
|
||||
url=url,
|
||||
title=title,
|
||||
body_md=body.strip(),
|
||||
site="微信公众号",
|
||||
author=author,
|
||||
)
|
||||
return _generic_article(html, url)
|
||||
|
||||
|
||||
async def fetch_article(url: str) -> Article:
|
||||
await _assert_public(url)
|
||||
headers = {
|
||||
"User-Agent": _pick_ua(url),
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.6",
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
follow_redirects=True, timeout=FETCH_TIMEOUT, headers=headers
|
||||
) as client:
|
||||
resp = await client.get(url)
|
||||
resp.raise_for_status()
|
||||
raw = resp.content
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise ArticleError(f"对方站点返回 {exc.response.status_code}") from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise ArticleError(f"取不到这个页面:{type(exc).__name__}") from exc
|
||||
if len(raw) > MAX_ARTICLES_BYTES:
|
||||
raise ArticleError("页面太大了,不像是篇文章")
|
||||
html = raw.decode(resp.encoding or "utf-8", errors="replace")
|
||||
article = parse_article(html, url)
|
||||
article.title = (article.title or urlparse(url).hostname or "未命名")[:180]
|
||||
return article
|
||||
|
||||
|
||||
async def _download_image(client: httpx.AsyncClient, url: str) -> tuple[bytes, str] | None:
|
||||
try:
|
||||
resp = await client.get(url)
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPError:
|
||||
return None
|
||||
if len(resp.content) > MAX_IMAGE_BYTES:
|
||||
return None
|
||||
ctype = (resp.headers.get("content-type") or "").split(";")[0].strip().lower()
|
||||
if not ctype.startswith("image/"):
|
||||
# 公众号图片地址带 wx_fmt 参数,content-type 偶尔是 octet-stream,按扩展名兜一下
|
||||
fmt = urlparse(url).query or ""
|
||||
m = re.search(r"wx_fmt=(\w+)", fmt)
|
||||
guess = IMAGE_EXTS.get((m.group(1) if m else "").lower(), "")
|
||||
if not guess:
|
||||
return None
|
||||
ctype = f"image/{'jpeg' if guess == 'jpg' else guess}"
|
||||
return resp.content, ctype
|
||||
|
||||
|
||||
async def store_images(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
ws_id: uuid.UUID,
|
||||
item_id: uuid.UUID,
|
||||
uploader_id: uuid.UUID,
|
||||
body_md: str,
|
||||
limit: int = MAX_IMAGES,
|
||||
) -> tuple[str, list[Attachment]]:
|
||||
"""把正文里引用的远程图片存成我们自己的附件,返回改写后的正文与附件行。
|
||||
|
||||
失败就当没这张图:一张打不开的配图不该让整篇收藏失败。
|
||||
"""
|
||||
from io import BytesIO
|
||||
|
||||
urls = re.findall(r"!\[[^\]]*\]\((https?://[^)\s]+)\)", body_md)
|
||||
unique: list[str] = []
|
||||
for url in urls:
|
||||
if url not in unique:
|
||||
unique.append(url)
|
||||
if len(unique) >= limit:
|
||||
break
|
||||
if not unique:
|
||||
return body_md, []
|
||||
|
||||
settings = get_settings()
|
||||
saved: list[Attachment] = []
|
||||
mapping: dict[str, str] = {}
|
||||
headers = {"User-Agent": DESKTOP_UA, "Referer": "https://mp.weixin.qq.com/"}
|
||||
async with httpx.AsyncClient(
|
||||
follow_redirects=True, timeout=FETCH_TIMEOUT, headers=headers
|
||||
) as client:
|
||||
for index, remote in enumerate(unique, start=1):
|
||||
got = await _download_image(client, remote)
|
||||
if got is None:
|
||||
continue
|
||||
payload, content_type = got
|
||||
ext = IMAGE_EXTS.get(content_type.split("/")[-1], "jpg")
|
||||
# 落盘是阻塞 IO:一篇公众号十几张图,别把事件循环按住
|
||||
stored = await run_in_threadpool(
|
||||
storage.save, BytesIO(payload), workspace_id=ws_id, item_id=item_id
|
||||
)
|
||||
if stored.size > settings.max_upload_mb * 1024 * 1024:
|
||||
storage.remove(stored.key)
|
||||
continue
|
||||
row = Attachment(
|
||||
workspace_id=ws_id,
|
||||
item_id=item_id,
|
||||
uploader_id=uploader_id,
|
||||
filename=f"图{index}.{ext}",
|
||||
content_type=content_type[:120],
|
||||
size_bytes=stored.size,
|
||||
sha256=stored.sha256,
|
||||
storage_key=stored.key,
|
||||
)
|
||||
session.add(row)
|
||||
await session.flush()
|
||||
mapping[remote] = (
|
||||
f"{settings.api_prefix}/workspaces/{ws_id}"
|
||||
f"/attachments/{row.id}/raw?{signing.sign(ws_id, row.id)}"
|
||||
)
|
||||
saved.append(row)
|
||||
|
||||
for remote, local in mapping.items():
|
||||
body_md = body_md.replace(remote, local)
|
||||
return body_md, saved
|
||||
|
||||
|
||||
def compose_body(article: Article) -> str:
|
||||
"""正文 = 来源行 + 原文。来源要留在最上面,日后核对不用翻聊天记录。"""
|
||||
bits = [f"> 原文:<{article.url}>"]
|
||||
if article.site or article.author:
|
||||
who = " · ".join(x for x in (article.site, article.author) if x)
|
||||
bits[0] += f"({who})"
|
||||
body = "\n".join(bits) + "\n\n" + article.body_md
|
||||
if len(body) > MAX_BODY_CHARS:
|
||||
body = body[:MAX_BODY_CHARS] + "\n\n…(原文太长,后面的内容没有保存)"
|
||||
return body
|
||||
@@ -0,0 +1,223 @@
|
||||
"""发信(SMTP)。
|
||||
|
||||
三个刻意的取舍:
|
||||
|
||||
1. **配置以数据库里的为准**。发信账号是平台级的运维参数,改它不该要人登服务器
|
||||
改 env 再重启;所以「系统设置」页写库,这里每次读库、读不到才回落到环境变量。
|
||||
2. **只用标准库 smtplib**。发信不是性能路径,引一个三方 SDK 换来的只是多一条升级债;
|
||||
SSL / STARTTLS 两种握手都覆盖了(465 走 SSL,587 走 STARTTLS)。
|
||||
3. **失败要能说清是哪种失败**。连不上、认证不过、被对方拒收,对使用者是三件不同的
|
||||
事;统一报"发信失败"会让人只能去翻服务器日志。所以异常分类后带进错误码 2020。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import smtplib
|
||||
import ssl
|
||||
from dataclasses import dataclass
|
||||
from email.header import Header
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formataddr, formatdate
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..config import get_settings
|
||||
from ..errors import AppError, ErrorCode
|
||||
from ..models import SystemSetting
|
||||
|
||||
SMTP_KEY = "smtp"
|
||||
# 存库的字段名与对外字段一致,避免来回翻译出现第三个名字
|
||||
SMTP_FIELDS = ("host", "port", "username", "password", "from_name", "use_ssl")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SmtpConfig:
|
||||
host: str
|
||||
port: int
|
||||
username: str
|
||||
password: str
|
||||
from_name: str = "达道Task"
|
||||
use_ssl: bool = True
|
||||
|
||||
@property
|
||||
def sender(self) -> str:
|
||||
return formataddr((str(Header(self.from_name, "utf-8")), self.username))
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(self.host and self.username and self.password)
|
||||
|
||||
|
||||
def _from_settings() -> SmtpConfig | None:
|
||||
s = get_settings()
|
||||
if not (s.smtp_host and s.smtp_username and s.smtp_password):
|
||||
return None
|
||||
return SmtpConfig(
|
||||
host=s.smtp_host,
|
||||
port=s.smtp_port,
|
||||
username=s.smtp_username,
|
||||
password=s.smtp_password,
|
||||
from_name=s.smtp_from_name,
|
||||
use_ssl=s.smtp_use_ssl,
|
||||
)
|
||||
|
||||
|
||||
async def load_config(session: AsyncSession) -> SmtpConfig | None:
|
||||
"""先看库里有没有人配过,没有再回落到环境变量。"""
|
||||
row = await session.get(SystemSetting, SMTP_KEY)
|
||||
data = dict(row.value or {}) if row else {}
|
||||
if not (data.get("host") and data.get("username") and data.get("password")):
|
||||
return _from_settings()
|
||||
|
||||
return SmtpConfig(
|
||||
host=str(data["host"]),
|
||||
port=int(data.get("port") or 465),
|
||||
username=str(data["username"]),
|
||||
# 授权码里可能有多余空格,粘贴时最常见的就是把尾巴带上
|
||||
password=str(data["password"]).strip(),
|
||||
from_name=str(data.get("from_name") or "达道Task"),
|
||||
use_ssl=bool(data.get("use_ssl", True)),
|
||||
)
|
||||
|
||||
|
||||
async def save_config(session: AsyncSession, *, values: dict, updated_by) -> SmtpConfig:
|
||||
"""写库。password 传空表示"沿用原来的",不传等于没改。"""
|
||||
row = await session.get(SystemSetting, SMTP_KEY)
|
||||
current = dict(row.value or {}) if row else {}
|
||||
merged = {**current, **{k: v for k, v in values.items() if v is not None}}
|
||||
if not values.get("password"):
|
||||
merged["password"] = current.get("password", "")
|
||||
|
||||
if not (merged.get("host") and merged.get("username") and merged.get("password")):
|
||||
raise AppError(ErrorCode.VALIDATION_FAILED, "SMTP 服务器、账号、授权码都要填")
|
||||
|
||||
if row is None:
|
||||
row = SystemSetting(key=SMTP_KEY, value=merged, updated_by=updated_by)
|
||||
session.add(row)
|
||||
else:
|
||||
row.value = merged
|
||||
row.updated_by = updated_by
|
||||
|
||||
await session.flush()
|
||||
cfg = SmtpConfig(
|
||||
host=str(merged["host"]),
|
||||
port=int(merged.get("port") or 465),
|
||||
username=str(merged["username"]),
|
||||
password=str(merged["password"]).strip(),
|
||||
from_name=str(merged.get("from_name") or "达道Task"),
|
||||
use_ssl=bool(merged.get("use_ssl", True)),
|
||||
)
|
||||
return cfg
|
||||
|
||||
|
||||
def _send_sync(cfg: SmtpConfig, to: str, subject: str, text: str, html: str | None) -> None:
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = str(Header(subject, "utf-8"))
|
||||
msg["From"] = cfg.sender
|
||||
msg["To"] = to
|
||||
msg["Date"] = formatdate(localtime=True)
|
||||
msg.set_content(text)
|
||||
if html:
|
||||
msg.add_alternative(html, subtype="html")
|
||||
|
||||
s = get_settings()
|
||||
timeout = s.smtp_timeout_seconds
|
||||
context = ssl.create_default_context()
|
||||
if cfg.use_ssl:
|
||||
server: smtplib.SMTP = smtplib.SMTP_SSL(
|
||||
cfg.host, cfg.port, timeout=timeout, context=context
|
||||
)
|
||||
else:
|
||||
server = smtplib.SMTP(cfg.host, cfg.port, timeout=timeout)
|
||||
server.starttls(context=context)
|
||||
try:
|
||||
server.login(cfg.username, cfg.password)
|
||||
server.send_message(msg)
|
||||
finally:
|
||||
# 信已经发出去了,QUIT 失败不该把成功回报成失败
|
||||
with contextlib.suppress(Exception):
|
||||
server.quit()
|
||||
|
||||
|
||||
async def send_mail(
|
||||
session_or_cfg: AsyncSession | SmtpConfig,
|
||||
*,
|
||||
to: str,
|
||||
subject: str,
|
||||
text: str,
|
||||
html: str | None = None,
|
||||
) -> None:
|
||||
"""发一封纯文本(可带 HTML 替代体)的信。失败一律抛 2020,并说清是哪一步。"""
|
||||
cfg = (
|
||||
await load_config(session_or_cfg)
|
||||
if isinstance(session_or_cfg, AsyncSession)
|
||||
else session_or_cfg
|
||||
)
|
||||
if cfg is None or not cfg.configured:
|
||||
raise AppError(ErrorCode.MAIL_SEND_FAILED, "还没有配置发信邮箱,请先在系统设置里填 SMTP")
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(_send_sync, cfg, to, subject, text, html)
|
||||
except smtplib.SMTPAuthenticationError as exc:
|
||||
raise AppError(
|
||||
ErrorCode.MAIL_SEND_FAILED,
|
||||
"SMTP 认证失败:账号或授权码不对(126/163 要用客户端授权码,不是登录密码)",
|
||||
) from exc
|
||||
except smtplib.SMTPRecipientsRefused as exc:
|
||||
raise AppError(ErrorCode.MAIL_SEND_FAILED, f"收件地址被拒绝:{to}") from exc
|
||||
except (smtplib.SMTPException, OSError) as exc:
|
||||
raise AppError(ErrorCode.MAIL_SEND_FAILED, f"发信失败:{exc}") from exc
|
||||
|
||||
|
||||
async def config_payload(session: AsyncSession) -> dict:
|
||||
"""给设置页看的形状:授权码只回"设没设",不回原值。"""
|
||||
row = await session.get(SystemSetting, SMTP_KEY)
|
||||
data = dict(row.value or {}) if row else {}
|
||||
cfg = await load_config(session)
|
||||
if cfg is None:
|
||||
return {
|
||||
"host": data.get("host") or "",
|
||||
"port": int(data.get("port") or 465),
|
||||
"username": data.get("username") or "",
|
||||
"password_set": bool(data.get("password")),
|
||||
"from_name": data.get("from_name") or "达道Task",
|
||||
"use_ssl": bool(data.get("use_ssl", True)),
|
||||
"configured": False,
|
||||
"source": "none",
|
||||
"updated_at": row.updated_at if row else None,
|
||||
}
|
||||
|
||||
return {
|
||||
"host": cfg.host,
|
||||
"port": cfg.port,
|
||||
"username": cfg.username,
|
||||
"password_set": True,
|
||||
"from_name": cfg.from_name,
|
||||
"use_ssl": cfg.use_ssl,
|
||||
"configured": cfg.configured,
|
||||
"source": "db" if row else "env",
|
||||
"updated_at": row.updated_at if row else None,
|
||||
}
|
||||
|
||||
|
||||
async def send_verify_code(session: AsyncSession, *, to: str, code: str, minutes: int) -> None:
|
||||
"""发注册/验证用的数字验证码。
|
||||
|
||||
模板写得朴素但说清楚了"谁在什么地方注册"和"不是你就算了"——收件箱里躺着一条
|
||||
没头没尾的六位数,用户只会当成钓鱼。
|
||||
"""
|
||||
text = (
|
||||
f"你的达道Task验证码是 {code}\n\n"
|
||||
f"{minutes} 分钟内有效。如果不是本人操作,忽略这封邮件即可,账号不会有任何变化。"
|
||||
)
|
||||
html = (
|
||||
'<div style="font:14px/1.7 -apple-system,Segoe UI,sans-serif;color:#1f2a28">'
|
||||
"<p>你的达道Task验证码是:</p>"
|
||||
f'<p style="font:600 28px/1.4 ui-monospace,Menlo,Consolas,monospace;'
|
||||
f'letter-spacing:6px;color:#2f6054">{code}</p>'
|
||||
f"<p>{minutes} 分钟内有效。如果不是本人操作,忽略这封邮件即可,账号不会有任何变化。</p>"
|
||||
"</div>"
|
||||
)
|
||||
await send_mail(session, to=to, subject="达道Task 验证码", text=text, html=html)
|
||||
@@ -0,0 +1,365 @@
|
||||
"""微信机器人的后台轮询:登录之后由它把消息捞进来。
|
||||
|
||||
为什么是长轮询而不是 webhook:iLink Bot 协议就是"登录后由我们主动去拉",
|
||||
服务端只在下一条消息到达时返回(最长等 75 秒)。所以每个通道一个常驻任务。
|
||||
|
||||
几条必须守住的规矩:
|
||||
|
||||
1. **游标要落库**:`get_updates_buf` 是下次从哪里接着读的凭据,丢了就会重复收、
|
||||
把同一条任务记两遍。
|
||||
2. **一条消息只处理一次**:`bot_message.external_id` 有唯一索引,重复投递时先撞唯一键。
|
||||
3. **token 失效要停**:服务端 errcode `-14` 表示登录态作废,这时停轮询、把通道标记成
|
||||
需要重新扫码,别拿死 token 一直撞墙。
|
||||
4. **回复失败不影响已经落库的事项**:任务记下了就是记下了,回不回得去另说。
|
||||
5. **一个通道只能有一个进程在收**:生产是 gunicorn `-w 2`,两个进程各自跑 lifespan,
|
||||
不加锁就会出现两条 getupdates 同时拉同一个会话——消息被其中一个消费、回复却从另一个发出,
|
||||
游标还会互相覆盖。用 Postgres 的 advisory lock 做租约:抢到的那个进程收,另一个等着接班。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import text as sa_text
|
||||
|
||||
from ..config import get_settings
|
||||
from ..db import SessionFactory, bind_tenant, engine
|
||||
from ..logging_setup import get_logger
|
||||
from ..models import Workspace
|
||||
from ..models_integrations import BotChannel, BotMessage
|
||||
from . import wechat_ilink
|
||||
from .wechat_intake import handle_text
|
||||
|
||||
logger = get_logger("wechat_bot")
|
||||
|
||||
# 长轮询一次最多等这么久(服务端 75 秒超时,客户端留点余量)。
|
||||
POLL_TIMEOUT_SECONDS = 80.0
|
||||
RETRY_DELAY_SECONDS = 5.0
|
||||
# 抢租约失败时隔多久再试:主进程挂了,备用进程要能在一两个呼吸之内接上。
|
||||
LEASE_RETRY_SECONDS = 10.0
|
||||
LEASE_NAMESPACE = "dadao.wechat_bot"
|
||||
|
||||
|
||||
def site_base() -> str:
|
||||
"""回复里的链接用哪个域名。生产必须配 PUBLIC_BASE_URL,否则只能退回 CORS 里的第一个。"""
|
||||
settings = get_settings()
|
||||
if settings.public_base_url:
|
||||
return settings.public_base_url.rstrip("/")
|
||||
if settings.cors_origins:
|
||||
return str(settings.cors_origins[0]).rstrip("/")
|
||||
return ""
|
||||
|
||||
|
||||
async def lease_held(channel_id: uuid.UUID) -> bool:
|
||||
"""这个通道现在有没有进程在收消息。
|
||||
|
||||
不能看 `manager.polling()`:生产是 gunicorn 多进程,每个进程只知道自己那把锁,
|
||||
请求落到哪个进程就回哪个答案,页面会在"接收中"和"已停止"之间闪。
|
||||
advisory lock 是数据库级的,`pg_locks` 里看得见——谁抢到了,所有进程都认。
|
||||
"""
|
||||
async with SessionFactory() as session:
|
||||
result = await session.execute(
|
||||
sa_text(
|
||||
"SELECT 1 FROM pg_locks"
|
||||
" WHERE locktype = 'advisory' AND granted AND objsubid = 2"
|
||||
" AND classid::bigint = (hashtext(:ns)::bigint & 4294967295)"
|
||||
" AND objid::bigint = (hashtext(:cid)::bigint & 4294967295)"
|
||||
" LIMIT 1"
|
||||
),
|
||||
{"ns": LEASE_NAMESPACE, "cid": str(channel_id)},
|
||||
)
|
||||
return result.first() is not None
|
||||
|
||||
|
||||
async def active_channel_ids() -> list[uuid.UUID]:
|
||||
"""列出"已扫码登录、该收消息"的通道。
|
||||
|
||||
启动时还没有工作区上下文,`bot_channel` 的 RLS 会把行全藏起来,所以先设
|
||||
`app.bot_list='on'` 走迁移 0016 那条只读口子——不设的话重启后机器人会静默失联。
|
||||
"""
|
||||
async with SessionFactory() as session, session.begin():
|
||||
await session.execute(sa_text("SELECT set_config('app.bot_list', 'on', true)"))
|
||||
rows = await session.execute(
|
||||
select(BotChannel.id).where(
|
||||
BotChannel.status == "active",
|
||||
BotChannel.bot_token_enc.is_not(None),
|
||||
)
|
||||
)
|
||||
return [row[0] for row in rows]
|
||||
|
||||
|
||||
async def acquire_lease(channel_id: uuid.UUID):
|
||||
"""抢"这个通道由我收消息"的租约;抢到返回连接(关掉就是释放),抢不到返回 None。
|
||||
|
||||
用会话级 advisory lock:锁挂在连接上,进程崩了连接断,锁自然释放,
|
||||
不会留下"死进程占着锁没人收消息"的局面。
|
||||
"""
|
||||
conn = await engine.connect()
|
||||
try:
|
||||
result = await conn.execute(
|
||||
sa_text("SELECT pg_try_advisory_lock(hashtext(:ns), hashtext(:cid))"),
|
||||
{"ns": LEASE_NAMESPACE, "cid": str(channel_id)},
|
||||
)
|
||||
if result.scalar():
|
||||
return conn
|
||||
except Exception:
|
||||
await conn.close()
|
||||
raise
|
||||
await conn.close()
|
||||
return None
|
||||
|
||||
|
||||
class BotManager:
|
||||
"""一个进程里的轮询任务表:一个通道一个任务。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._tasks: dict[uuid.UUID, asyncio.Task[None]] = {}
|
||||
# 抢到租约、真正在拉消息的通道;备用进程的任务在等租约,不在这里。
|
||||
self._polling: set[uuid.UUID] = set()
|
||||
|
||||
# ── 生命周期 ──────────────────────────────────────────
|
||||
|
||||
async def start(self, channel_id: uuid.UUID) -> None:
|
||||
task = self._tasks.get(channel_id)
|
||||
if task is not None and not task.done():
|
||||
return
|
||||
self._tasks[channel_id] = asyncio.create_task(
|
||||
self._run(channel_id), name=f"wechat-bot-{channel_id}"
|
||||
)
|
||||
|
||||
async def stop(self, channel_id: uuid.UUID) -> None:
|
||||
task = self._tasks.pop(channel_id, None)
|
||||
if task is None:
|
||||
return
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
async def start_all(self) -> None:
|
||||
"""进程启动时把已登录的通道都拉起来,不然重启就静默不收消息了。"""
|
||||
ids = await active_channel_ids()
|
||||
for channel_id in ids:
|
||||
await self.start(channel_id)
|
||||
if ids:
|
||||
logger.info("微信机器人已恢复", extra={"extra_fields": {"channels": len(ids)}})
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
for channel_id in list(self._tasks):
|
||||
await self.stop(channel_id)
|
||||
|
||||
def running(self, channel_id: uuid.UUID) -> bool:
|
||||
task = self._tasks.get(channel_id)
|
||||
return task is not None and not task.done()
|
||||
|
||||
def polling(self, channel_id: uuid.UUID) -> bool:
|
||||
"""只有真在拉消息的那个进程才算,"页面上的接收中"认这个。"""
|
||||
return channel_id in self._polling
|
||||
|
||||
# ── 轮询主体 ──────────────────────────────────────────
|
||||
|
||||
async def _load(self, channel_id: uuid.UUID) -> tuple[BotChannel, str] | None:
|
||||
"""读通道并解出 token。返回 None 表示这个通道不该再轮询了。"""
|
||||
async with SessionFactory() as session:
|
||||
channel = await self._get_channel(session, channel_id)
|
||||
if channel is None or channel.status != "active" or not channel.bot_token_enc:
|
||||
return None
|
||||
try:
|
||||
token = wechat_ilink.decrypt_token(channel.bot_token_enc)
|
||||
except wechat_ilink.WechatError:
|
||||
await self._mark_relogin(channel_id, "登录态解不开(JWT_SECRET 可能换过)")
|
||||
return None
|
||||
session.expunge(channel)
|
||||
return channel, token
|
||||
|
||||
async def _run(self, channel_id: uuid.UUID) -> None:
|
||||
lease = await self._wait_for_lease(channel_id)
|
||||
if lease is None:
|
||||
self._tasks.pop(channel_id, None)
|
||||
return
|
||||
try:
|
||||
await self._poll_forever(channel_id)
|
||||
finally:
|
||||
self._polling.discard(channel_id)
|
||||
self._tasks.pop(channel_id, None)
|
||||
await lease.close()
|
||||
|
||||
async def _wait_for_lease(self, channel_id: uuid.UUID):
|
||||
"""抢不到就等着——主进程一挂,最多 LEASE_RETRY_SECONDS 就由这里接上。"""
|
||||
while True:
|
||||
lease = await acquire_lease(channel_id)
|
||||
if lease is not None:
|
||||
self._polling.add(channel_id)
|
||||
return lease
|
||||
await asyncio.sleep(LEASE_RETRY_SECONDS)
|
||||
|
||||
async def _poll_forever(self, channel_id: uuid.UUID) -> None:
|
||||
loaded = await self._load(channel_id)
|
||||
if loaded is None:
|
||||
return
|
||||
channel, token = loaded
|
||||
cursor = channel.sync_buf or ""
|
||||
failures = 0
|
||||
while True:
|
||||
try:
|
||||
update = await wechat_ilink.get_updates(
|
||||
token, cursor, base_url=channel.base_url, timeout=POLL_TIMEOUT_SECONDS
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except wechat_ilink.WechatError as exc:
|
||||
if exc.stale_token:
|
||||
await self._mark_relogin(channel_id, str(exc))
|
||||
return
|
||||
failures += 1
|
||||
await self._note_error(channel_id, str(exc))
|
||||
await asyncio.sleep(RETRY_DELAY_SECONDS if failures < 10 else 60.0)
|
||||
continue
|
||||
except Exception as exc: # 轮询任务不能死,死了就永远收不到消息
|
||||
failures += 1
|
||||
logger.warning("微信长轮询异常:%s", exc)
|
||||
await asyncio.sleep(RETRY_DELAY_SECONDS)
|
||||
continue
|
||||
|
||||
failures = 0
|
||||
for message in update.messages:
|
||||
try:
|
||||
await self._handle(channel_id, message, token, channel.base_url)
|
||||
except Exception as exc: # 单条失败不拖垮整轮
|
||||
logger.warning("微信消息处理失败:%s", exc)
|
||||
|
||||
if update.cursor and update.cursor != cursor:
|
||||
cursor = update.cursor
|
||||
await self._save_cursor(channel_id, cursor)
|
||||
|
||||
# ── 单条消息 ──────────────────────────────────────────
|
||||
|
||||
async def _handle(
|
||||
self,
|
||||
channel_id: uuid.UUID,
|
||||
message: wechat_ilink.Inbound,
|
||||
token: str,
|
||||
base_url: str | None,
|
||||
) -> None:
|
||||
async with SessionFactory() as session:
|
||||
channel = await self._get_channel(session, channel_id)
|
||||
if channel is None or channel.status != "active":
|
||||
return
|
||||
# 读通道时只设了 app.bot_channel;workspace 表同样有 RLS,
|
||||
# 得先把通道所属工作区告诉数据库,才能读到那一行。
|
||||
await session.execute(
|
||||
sa_text("SELECT set_config('app.ws_id', :ws, true)"),
|
||||
{"ws": str(channel.workspace_id)},
|
||||
)
|
||||
workspace = await session.get(Workspace, channel.workspace_id)
|
||||
if workspace is None:
|
||||
return
|
||||
actor = channel.created_by or workspace.owner_id
|
||||
await bind_tenant(session, channel.workspace_id, actor)
|
||||
|
||||
existing = await session.execute(
|
||||
select(BotMessage).where(
|
||||
BotMessage.channel_id == channel_id,
|
||||
BotMessage.external_id == message.message_id,
|
||||
)
|
||||
)
|
||||
if existing.scalars().first() is not None:
|
||||
logger.info("微信消息重复投递,跳过:%s", message.message_id)
|
||||
return
|
||||
|
||||
result: dict[str, Any]
|
||||
if message.text:
|
||||
result = await handle_text(session, channel, message.text, base_url=site_base())
|
||||
elif message.kinds:
|
||||
result = {
|
||||
"action": "ignored",
|
||||
"item_id": None,
|
||||
"reply": "图片、文件、语音暂时还没搬进来,发文字最快。",
|
||||
}
|
||||
else:
|
||||
result = {"action": "ignored", "item_id": None, "reply": "没看懂这条消息。"}
|
||||
|
||||
session.add(
|
||||
BotMessage(
|
||||
workspace_id=channel.workspace_id,
|
||||
channel_id=channel_id,
|
||||
external_id=message.message_id,
|
||||
sender=message.sender[:128],
|
||||
text=message.text[:4000],
|
||||
action=str(result["action"]),
|
||||
item_id=result.get("item_id"),
|
||||
reply=str(result.get("reply") or "")[:2000],
|
||||
raw={"kinds": message.kinds, "message_id": message.message_id},
|
||||
)
|
||||
)
|
||||
channel.last_seen_at = _now()
|
||||
channel.context_token = message.context_token
|
||||
channel.last_error = None
|
||||
await session.commit()
|
||||
|
||||
reply = str(result.get("reply") or "")
|
||||
if reply:
|
||||
try:
|
||||
await wechat_ilink.send_text(
|
||||
token,
|
||||
message.sender,
|
||||
message.context_token,
|
||||
reply,
|
||||
base_url=base_url,
|
||||
)
|
||||
except wechat_ilink.WechatError as exc:
|
||||
await self._note_error(channel_id, f"回复失败:{exc}")
|
||||
|
||||
async def _get_channel(self, session, channel_id: uuid.UUID) -> BotChannel | None:
|
||||
"""按主键取通道。
|
||||
|
||||
轮询任务一开始没有工作区上下文,RLS 会把行藏起来;迁移 0014 给了一条
|
||||
`app.bot_channel` 的按主键口子,所以先设 GUC 再读。
|
||||
"""
|
||||
await session.execute(
|
||||
sa_text("SELECT set_config('app.bot_channel', :cid, true)"), {"cid": str(channel_id)}
|
||||
)
|
||||
return await session.get(BotChannel, channel_id)
|
||||
|
||||
# ── 落库小工具 ────────────────────────────────────────
|
||||
|
||||
async def _save_cursor(self, channel_id: uuid.UUID, cursor: str) -> None:
|
||||
async with SessionFactory() as session:
|
||||
channel = await self._get_channel(session, channel_id)
|
||||
if channel is None:
|
||||
return
|
||||
channel.sync_buf = cursor[:4000]
|
||||
await session.commit()
|
||||
|
||||
async def _note_error(self, channel_id: uuid.UUID, message: str) -> None:
|
||||
async with SessionFactory() as session:
|
||||
channel = await self._get_channel(session, channel_id)
|
||||
if channel is None:
|
||||
return
|
||||
channel.last_error = message[:500]
|
||||
await session.commit()
|
||||
|
||||
async def _mark_relogin(self, channel_id: uuid.UUID, message: str) -> None:
|
||||
"""登录态作废:停用这条通道并记下原因,界面上提示重新扫码。"""
|
||||
async with SessionFactory() as session:
|
||||
channel = await self._get_channel(session, channel_id)
|
||||
if channel is None:
|
||||
return
|
||||
channel.bot_token_enc = None
|
||||
channel.sync_buf = None
|
||||
channel.status = "expired"
|
||||
channel.context_token = None
|
||||
channel.last_error = message[:500]
|
||||
await session.commit()
|
||||
|
||||
|
||||
def _now():
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
manager = BotManager()
|
||||
@@ -0,0 +1,324 @@
|
||||
"""微信 clawbot 客户端(腾讯官方 iLink Bot 协议)。
|
||||
|
||||
协议不是我们自己猜的:OpenClaw 官方插件 `@tencent-weixin/openclaw-weixin` 用的就是它,
|
||||
本机参考实现见 `~/.dsh/.../@michengai/dsh-im-connect/src/channels/weixin.ts` 的
|
||||
`src/channels/weixin.ts`(文件头写着"扫码登录 → 长轮询收消息 → context_token 回复")。
|
||||
|
||||
流程:
|
||||
|
||||
1. `POST /ilink/bot/get_bot_qrcode?bot_type=3` 拿二维码 id 和内容,本地渲染成图给人扫;
|
||||
2. `POST /ilink/bot/get_qrcode_status?qrcode=<id>` 每 2 秒问一次,`confirmed` 时给 `bot_token`;
|
||||
3. 带 `Authorization: Bearer <bot_token>` 长轮询 `POST /ilink/bot/getupdates`,
|
||||
body 里带游标 `get_updates_buf`,回来的新游标要存下来,不然重启会重复收;
|
||||
4. 回复走 `POST /ilink/bot/sendmessage`,必须带对方上一条消息里的 `context_token`。
|
||||
|
||||
这里只封协议,不碰数据库;落库与建事项在 `services/wechat_bot.py`。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import secrets
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
from ..config import get_settings
|
||||
|
||||
BASE_URL = "https://ilinkai.weixin.qq.com"
|
||||
# 官方插件同款版本号;服务端会按它挑行为,别乱改。
|
||||
CHANNEL_VERSION = "1.0.0"
|
||||
# token 失效时服务端给的 errcode(官方插件叫 STALE_TOKEN_ERRCODE)。
|
||||
STALE_TOKEN_ERRCODE = -14
|
||||
|
||||
QR_TIMEOUT_SECONDS = 15
|
||||
STATUS_TIMEOUT_SECONDS = 40
|
||||
SEND_TIMEOUT_SECONDS = 15
|
||||
|
||||
|
||||
class WechatError(RuntimeError):
|
||||
"""协议层错误。`kind` 用来区分"该重新扫码"还是"过一会儿再试"。"""
|
||||
|
||||
def __init__(self, message: str, *, kind: str = "api") -> None:
|
||||
super().__init__(message)
|
||||
self.kind = kind
|
||||
|
||||
@property
|
||||
def stale_token(self) -> bool:
|
||||
return self.kind == "stale_token"
|
||||
|
||||
|
||||
@dataclass
|
||||
class QrCode:
|
||||
qrcode_id: str
|
||||
payload: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class QrStatus:
|
||||
status: str
|
||||
bot_token: str | None = None
|
||||
ilink_user_id: str | None = None
|
||||
base_url: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Inbound:
|
||||
"""一条收到的消息:只取用得上的部分。"""
|
||||
|
||||
message_id: str
|
||||
sender: str
|
||||
text: str
|
||||
context_token: str | None = None
|
||||
kinds: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Update:
|
||||
cursor: str
|
||||
messages: list[Inbound]
|
||||
|
||||
|
||||
# ── 令牌加密 ─────────────────────────────────────────────
|
||||
#
|
||||
# bot_token 等于"这个微信号的登录态",明文落库等于把号交出去。
|
||||
# 密钥由 JWT_SECRET 派生:换 JWT_SECRET 会让已存的令牌失效(重新扫码即可),这是可接受的代价。
|
||||
|
||||
|
||||
def _fernet() -> Fernet:
|
||||
digest = hashlib.sha256(get_settings().jwt_secret.encode("utf-8")).digest()
|
||||
return Fernet(base64.urlsafe_b64encode(digest))
|
||||
|
||||
|
||||
def encrypt_token(token: str) -> str:
|
||||
return _fernet().encrypt(token.encode("utf-8")).decode("ascii")
|
||||
|
||||
|
||||
def decrypt_token(stored: str) -> str:
|
||||
try:
|
||||
return _fernet().decrypt(stored.encode("ascii")).decode("utf-8")
|
||||
except InvalidToken as exc: # JWT_SECRET 换过,或者库里的值被人改过
|
||||
raise WechatError("微信登录态解不开,需要重新扫码", kind="stale_token") from exc
|
||||
|
||||
|
||||
# ── 协议 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _headers(token: str | None) -> dict[str, str]:
|
||||
uin = base64.b64encode(str(secrets.randbelow(0xFFFFFFFF)).encode("utf-8")).decode("ascii")
|
||||
headers = {
|
||||
"content-type": "application/json",
|
||||
"iLink-App-ClientVersion": "1",
|
||||
"X-WECHAT-UIN": uin,
|
||||
}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
headers["AuthorizationType"] = "ilink_bot_token"
|
||||
return headers
|
||||
|
||||
|
||||
async def _post(
|
||||
path: str,
|
||||
body: dict[str, Any],
|
||||
*,
|
||||
token: str | None = None,
|
||||
timeout: float = 15.0,
|
||||
tolerate_ret1: bool = False,
|
||||
base_url: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
url = f"{(base_url or BASE_URL).rstrip('/')}{path}"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
response = await client.post(url, json=body, headers=_headers(token))
|
||||
except httpx.HTTPError as exc:
|
||||
raise WechatError(f"微信接口连不上:{exc}") from exc
|
||||
if response.status_code >= 400:
|
||||
raise WechatError(f"微信接口 {path} 返回 HTTP {response.status_code}")
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError as exc:
|
||||
raise WechatError(f"微信接口 {path} 返回的不是 JSON") from exc
|
||||
if not isinstance(data, dict):
|
||||
raise WechatError(f"微信接口 {path} 返回的格式不对")
|
||||
|
||||
ret = int(data.get("ret") or 0)
|
||||
errcode = int(data.get("errcode") or 0)
|
||||
if tolerate_ret1 and ret == 1 and errcode == 0:
|
||||
return data
|
||||
if ret != 0 or errcode != 0:
|
||||
if errcode == STALE_TOKEN_ERRCODE:
|
||||
raise WechatError("微信登录态已失效,需要重新扫码", kind="stale_token")
|
||||
raise WechatError(
|
||||
f"微信接口 {path} 失败 ret={ret} errcode={errcode} {data.get('errmsg') or ''}".strip()
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
async def get_qrcode() -> QrCode:
|
||||
data = await _post("/ilink/bot/get_bot_qrcode?bot_type=3", {}, timeout=QR_TIMEOUT_SECONDS)
|
||||
qrcode_id = _text(data, "qrcode", "qrcode_id")
|
||||
payload = _text(data, "qrcode_img_content", "qrcode_url", "url")
|
||||
if not qrcode_id or not payload:
|
||||
raise WechatError("微信二维码字段不全,稍后再试")
|
||||
return QrCode(qrcode_id=qrcode_id, payload=payload)
|
||||
|
||||
|
||||
async def qrcode_status(qrcode_id: str) -> QrStatus:
|
||||
data = await _post(
|
||||
f"/ilink/bot/get_qrcode_status?qrcode={qrcode_id}",
|
||||
{},
|
||||
timeout=STATUS_TIMEOUT_SECONDS,
|
||||
tolerate_ret1=True,
|
||||
)
|
||||
status = str(data.get("status") or "waiting")
|
||||
# 上游用的是 "wait"(不是 "waiting"),扫过之后还有 "scaned"/"confirmed"/"expired";
|
||||
# 这里统一成一套说法,页面只认 waiting/scaned/confirmed/expired。
|
||||
status = {
|
||||
"wait": "waiting",
|
||||
"waiting": "waiting",
|
||||
"scaned": "scaned",
|
||||
"scanned": "scaned",
|
||||
"confirmed": "confirmed",
|
||||
"expired": "expired",
|
||||
}.get(status, status)
|
||||
if status != "confirmed":
|
||||
return QrStatus(status=status)
|
||||
token = _text(data, "bot_token")
|
||||
if not token:
|
||||
raise WechatError("微信确认了扫码,但没给登录态,请重新扫码")
|
||||
return QrStatus(
|
||||
status="confirmed",
|
||||
bot_token=token,
|
||||
ilink_user_id=_text(data, "ilink_user_id"),
|
||||
base_url=_text(data, "baseurl", "base_url"),
|
||||
)
|
||||
|
||||
|
||||
async def get_updates(
|
||||
token: str, cursor: str, *, base_url: str | None = None, timeout: float = 75.0
|
||||
) -> Update:
|
||||
data = await _post(
|
||||
"/ilink/bot/getupdates",
|
||||
{"get_updates_buf": cursor, "base_info": {"channel_version": CHANNEL_VERSION}},
|
||||
token=token,
|
||||
timeout=timeout,
|
||||
base_url=base_url,
|
||||
)
|
||||
next_cursor = _text(data, "get_updates_buf", "cursor", "sync_buf") or cursor
|
||||
raw = data.get("msgs") or data.get("messages") or data.get("updates") or []
|
||||
messages: list[Inbound] = []
|
||||
if isinstance(raw, list):
|
||||
for item in raw:
|
||||
parsed = parse_message(item)
|
||||
if parsed is not None:
|
||||
messages.append(parsed)
|
||||
return Update(cursor=next_cursor, messages=messages)
|
||||
|
||||
|
||||
async def send_text(
|
||||
token: str,
|
||||
to_user_id: str,
|
||||
context_token: str | None,
|
||||
text: str,
|
||||
*,
|
||||
base_url: str | None = None,
|
||||
) -> None:
|
||||
await _post(
|
||||
"/ilink/bot/sendmessage",
|
||||
{
|
||||
"msg": {
|
||||
"from_user_id": "",
|
||||
"to_user_id": to_user_id,
|
||||
"client_id": f"dadaotask:{secrets.token_hex(6)}",
|
||||
"message_type": 2,
|
||||
"message_state": 2,
|
||||
"context_token": context_token or "",
|
||||
"item_list": [{"type": 1, "text_item": {"text": text}}],
|
||||
},
|
||||
"base_info": {"channel_version": CHANNEL_VERSION},
|
||||
},
|
||||
token=token,
|
||||
timeout=SEND_TIMEOUT_SECONDS,
|
||||
base_url=base_url,
|
||||
)
|
||||
|
||||
|
||||
# ── 入站解析 ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def parse_message(raw: Any) -> Inbound | None:
|
||||
"""把一条原始消息拆成"谁说的、说了什么"。
|
||||
|
||||
iLink 的形状是 `{from_user_id, message_id, context_token, item_list:[{type, text_item}]}`,
|
||||
但偶尔会裹在 `message` 里,两种都认。只处理文本;语音有服务端转写就顺手用,
|
||||
图片/文件/视频只记下类型,这一版不下载。
|
||||
"""
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
message = dict(raw)
|
||||
nested = message.get("message")
|
||||
if isinstance(nested, dict):
|
||||
message.update(nested)
|
||||
if message.get("message_type") is not None and int(message.get("message_type") or 0) != 1:
|
||||
return None
|
||||
|
||||
sender = _text(message, "from_user_id", "from_user")
|
||||
if not sender:
|
||||
return None
|
||||
|
||||
parts: list[str] = []
|
||||
kinds: list[str] = []
|
||||
if isinstance(message.get("text"), str):
|
||||
parts.append(str(message["text"]))
|
||||
items = message.get("item_list")
|
||||
if isinstance(items, list):
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
kind = int(item.get("type") or 0)
|
||||
if kind == 1:
|
||||
text_item = item.get("text_item")
|
||||
if isinstance(text_item, dict):
|
||||
value = _text(text_item, "text")
|
||||
if value:
|
||||
parts.append(value)
|
||||
elif kind == 2:
|
||||
kinds.append("image")
|
||||
elif kind == 3:
|
||||
voice = item.get("voice_item")
|
||||
transcript = _text(voice, "text") if isinstance(voice, dict) else None
|
||||
if transcript:
|
||||
parts.append(f"[语音] {transcript}")
|
||||
else:
|
||||
kinds.append("voice")
|
||||
elif kind == 4:
|
||||
kinds.append("file")
|
||||
elif kind == 5:
|
||||
kinds.append("video")
|
||||
|
||||
message_id = (
|
||||
_text(message, "message_id", "msg_id")
|
||||
or f"{sender}:{_text(message, 'create_time_ms', 'create_time') or secrets.token_hex(4)}"
|
||||
)
|
||||
return Inbound(
|
||||
message_id=message_id,
|
||||
sender=sender,
|
||||
text="".join(parts).strip(),
|
||||
context_token=_text(message, "context_token"),
|
||||
kinds=kinds,
|
||||
)
|
||||
|
||||
|
||||
def _text(data: Any, *keys: str) -> str | None:
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
for key in keys:
|
||||
value = data.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
if isinstance(value, int | float) and not isinstance(value, bool):
|
||||
return str(value)
|
||||
return None
|
||||
@@ -0,0 +1,274 @@
|
||||
"""把一句话变成任务或知识草稿。
|
||||
|
||||
微信只负责把文本送进来,怎么落成事项是这里的事,接口和后台轮询共用同一份逻辑,
|
||||
免得"网页里发"和"机器人里发"落成两种东西。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..errors import AppError, ErrorCode
|
||||
from ..models import Member, Workspace
|
||||
from ..models_integrations import BotChannel
|
||||
from ..models_knowledge import KnowledgeDoc
|
||||
from ..models_task import Item
|
||||
from . import article as article_service
|
||||
|
||||
MAX_TITLE = 180
|
||||
MAX_BODY = 20000
|
||||
|
||||
KNOWLEDGE_PREFIXES = ("知识", "笔记", "note", "knowledge", "kb")
|
||||
TASK_PREFIXES = ("任务", "待办", "todo", "task")
|
||||
HELP_PREFIXES = ("?", "?", "帮助", "help", "用法")
|
||||
|
||||
HELP_TEXT = (
|
||||
"发什么都能用,写法:\n"
|
||||
"· 直接说 → 记成任务\n"
|
||||
"· 知识:内容 → 记成知识草稿(也可以写「笔记:」)\n"
|
||||
"· 任务:内容 → 明确记成任务\n"
|
||||
"· 一条链接 → 抓正文存成知识(公众号也行)\n"
|
||||
"首行当标题,后面几行当正文;标题里带 #标签 会自动加上。"
|
||||
)
|
||||
|
||||
SEPARATORS = (":", ":", " ", "\u3000")
|
||||
|
||||
|
||||
def split_command(text: str) -> tuple[str, str]:
|
||||
"""返回 (kind, 去掉前缀后的正文)。"""
|
||||
head, _, rest = text.partition("\n")
|
||||
probe = head.strip().lower()
|
||||
for prefix in HELP_PREFIXES:
|
||||
if probe == prefix or probe.startswith(f"{prefix}:") or probe.startswith(f"{prefix}:"):
|
||||
return "help", rest
|
||||
for prefix in KNOWLEDGE_PREFIXES:
|
||||
for sep in SEPARATORS:
|
||||
if probe.startswith(f"{prefix}{sep}"):
|
||||
offset = len(prefix) + len(sep)
|
||||
return "knowledge", (head.strip()[offset:] + ("\n" + rest if rest else "")).strip()
|
||||
for prefix in TASK_PREFIXES:
|
||||
for sep in SEPARATORS:
|
||||
if probe.startswith(f"{prefix}{sep}"):
|
||||
offset = len(prefix) + len(sep)
|
||||
return "task", (head.strip()[offset:] + ("\n" + rest if rest else "")).strip()
|
||||
return "task", text
|
||||
|
||||
|
||||
def split_tags(body: str) -> tuple[str, list[str]]:
|
||||
"""把 `#标签` 摘出来。只认行首和空白后的 #,免得把 "C#" 吃掉。"""
|
||||
tags: list[str] = []
|
||||
kept: list[str] = []
|
||||
for line in body.splitlines():
|
||||
rest: list[str] = []
|
||||
for word in line.split(" "):
|
||||
if word.startswith("#") and len(word) > 1 and not word[1:].isdigit():
|
||||
name = word[1:].strip(",,。.;;")
|
||||
if name and name.lower() not in {t.lower() for t in tags}:
|
||||
tags.append(name)
|
||||
continue
|
||||
rest.append(word)
|
||||
kept.append(" ".join(rest).rstrip())
|
||||
return "\n".join(kept).strip(), tags
|
||||
|
||||
|
||||
def split_title(body: str) -> tuple[str, str]:
|
||||
lines = body.splitlines()
|
||||
if not lines:
|
||||
return "", ""
|
||||
return lines[0].strip()[:MAX_TITLE], "\n".join(lines[1:]).strip()[:MAX_BODY]
|
||||
|
||||
|
||||
async def creator_id(session: AsyncSession, channel: BotChannel) -> uuid.UUID:
|
||||
"""消息记在谁名下:通道创建者;人没了就退回工作区 owner。"""
|
||||
if channel.created_by is not None:
|
||||
row = await session.execute(
|
||||
select(Member.id).where(
|
||||
Member.workspace_id == channel.workspace_id,
|
||||
Member.user_id == channel.created_by,
|
||||
Member.status == "active",
|
||||
)
|
||||
)
|
||||
if row.first() is not None:
|
||||
return channel.created_by
|
||||
workspace = await session.get(Workspace, channel.workspace_id)
|
||||
if workspace is None: # 理论上不可能:FK 是 CASCADE
|
||||
raise AppError(ErrorCode.BOT_TOKEN_INVALID, "接入通道不存在")
|
||||
return workspace.owner_id
|
||||
|
||||
|
||||
async def create_task(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
ws_id: uuid.UUID,
|
||||
creator: uuid.UUID,
|
||||
title: str,
|
||||
body: str,
|
||||
tags: list[str],
|
||||
) -> Item:
|
||||
item = Item(
|
||||
workspace_id=ws_id,
|
||||
kind="task",
|
||||
title=title,
|
||||
body_md=body,
|
||||
status="inbox",
|
||||
priority=0,
|
||||
creator_id=creator,
|
||||
)
|
||||
session.add(item)
|
||||
await session.flush()
|
||||
from ..api.items import _log, _set_tags # 延迟导入:services 不该在模块级依赖 api
|
||||
|
||||
if tags:
|
||||
await _set_tags(session, ws_id, item, tags)
|
||||
await _log(session, item, creator, "created", {"via": "wechat_clawbot"})
|
||||
return item
|
||||
|
||||
|
||||
async def create_knowledge(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
ws_id: uuid.UUID,
|
||||
creator: uuid.UUID,
|
||||
title: str,
|
||||
body: str,
|
||||
tags: list[str],
|
||||
) -> Item:
|
||||
item = Item(
|
||||
workspace_id=ws_id,
|
||||
kind="knowledge",
|
||||
title=title,
|
||||
body_md=body,
|
||||
status="draft",
|
||||
creator_id=creator,
|
||||
)
|
||||
session.add(item)
|
||||
await session.flush()
|
||||
session.add(
|
||||
KnowledgeDoc(
|
||||
item_id=item.id,
|
||||
workspace_id=ws_id,
|
||||
author_id=creator,
|
||||
source_item_id=None,
|
||||
source_kind="manual",
|
||||
doc_type="note",
|
||||
status_hint="draft",
|
||||
generated_by="human",
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
from ..api.items import _log, _set_tags # 延迟导入,理由同上
|
||||
|
||||
if tags:
|
||||
await _set_tags(session, ws_id, item, tags)
|
||||
await _log(session, item, creator, "knowledge_created", {"via": "wechat_clawbot"})
|
||||
return item
|
||||
|
||||
|
||||
def reply_text(kind: str, item: Item) -> str:
|
||||
"""回话只说"收下了",不带链接。
|
||||
|
||||
链接是站点地址,可"站点地址"对手机不一定打得开(内网 IP、端口、还没上 HTTPS 的域名),
|
||||
发过去反而像坏消息。要看详情回网页里看就行。
|
||||
"""
|
||||
if kind == "knowledge":
|
||||
return f"已保存知识草稿:{item.title}"
|
||||
return f"已保存任务:{item.title}"
|
||||
|
||||
|
||||
async def _collect_article(
|
||||
session: AsyncSession,
|
||||
channel: BotChannel,
|
||||
url: str,
|
||||
) -> dict[str, Any]:
|
||||
"""一条链接 → 一篇知识草稿(正文 + 转存后的图)。
|
||||
|
||||
抓不到就如实说,并把原链接留着:用户多半还会想手动点开看看,
|
||||
回一句"失败了"再让他重发一遍是最糟的体验。
|
||||
"""
|
||||
creator = await creator_id(session, channel)
|
||||
try:
|
||||
fetched = await article_service.fetch_article(url)
|
||||
except article_service.ArticleError as exc:
|
||||
return {
|
||||
"action": "ignored",
|
||||
"item_id": None,
|
||||
"reply": f"这个链接没抓下来({exc})。想直接记成任务的话,加「任务:」前缀再发一次。",
|
||||
}
|
||||
|
||||
item = await create_knowledge(
|
||||
session,
|
||||
ws_id=channel.workspace_id,
|
||||
creator=creator,
|
||||
title=fetched.title,
|
||||
body=article_service.compose_body(fetched),
|
||||
tags=[],
|
||||
)
|
||||
body_md, saved = await article_service.store_images(
|
||||
session,
|
||||
ws_id=channel.workspace_id,
|
||||
item_id=item.id,
|
||||
uploader_id=creator,
|
||||
body_md=item.body_md,
|
||||
)
|
||||
item.body_md = body_md
|
||||
await session.flush()
|
||||
|
||||
# 不写"已收藏",写清楚抓到了什么:有没有正文、有没有图,一眼能判断值不值得去补。
|
||||
tail = f",连图 {len(saved)} 张" if saved else ""
|
||||
return {
|
||||
"action": "knowledge",
|
||||
"item_id": item.id,
|
||||
"reply": f"已存成知识草稿:{item.title}{tail}",
|
||||
}
|
||||
|
||||
|
||||
async def handle_text(
|
||||
session: AsyncSession,
|
||||
channel: BotChannel,
|
||||
text: str,
|
||||
*,
|
||||
base_url: str,
|
||||
) -> dict[str, Any]:
|
||||
"""一段文本 → (action, item_id, reply)。调用方负责提交事务与留痕。"""
|
||||
kind, body = split_command(text.strip())
|
||||
if kind == "help":
|
||||
return {"action": "help", "item_id": None, "reply": HELP_TEXT}
|
||||
link = article_service.extract_url(body)
|
||||
if link:
|
||||
return await _collect_article(session, channel, link)
|
||||
body, tags = split_tags(body)
|
||||
title, rest = split_title(body)
|
||||
if not title:
|
||||
return {"action": "ignored", "item_id": None, "reply": "内容是空的,没记。"}
|
||||
creator = await creator_id(session, channel)
|
||||
if kind == "knowledge":
|
||||
item = await create_knowledge(
|
||||
session,
|
||||
ws_id=channel.workspace_id,
|
||||
creator=creator,
|
||||
title=title,
|
||||
body=rest,
|
||||
tags=tags,
|
||||
)
|
||||
return {
|
||||
"action": "knowledge",
|
||||
"item_id": item.id,
|
||||
"reply": reply_text("knowledge", item),
|
||||
}
|
||||
item = await create_task(
|
||||
session,
|
||||
ws_id=channel.workspace_id,
|
||||
creator=creator,
|
||||
title=title,
|
||||
body=rest,
|
||||
tags=tags,
|
||||
)
|
||||
return {
|
||||
"action": "task",
|
||||
"item_id": item.id,
|
||||
"reply": reply_text("task", item),
|
||||
}
|
||||
@@ -107,5 +107,19 @@ def sms_throttle_key(phone: str) -> str:
|
||||
return f"sms:throttle:{phone}"
|
||||
|
||||
|
||||
# ── 邮箱注册 ────────────────────────────────────────────
|
||||
def email_code_key(email: str) -> str:
|
||||
return f"email:code:{email}"
|
||||
|
||||
|
||||
def email_tries_key(email: str) -> str:
|
||||
"""试错计数:错一次算一次,超过上限就把验证码作废,别让人拿 6 位数当彩票刮。"""
|
||||
return f"email:tries:{email}"
|
||||
|
||||
|
||||
def email_throttle_key(email: str) -> str:
|
||||
return f"email:throttle:{email}"
|
||||
|
||||
|
||||
def idempotency_key(user_id: str, key: str) -> str:
|
||||
return f"idem:{user_id}:{key}"
|
||||
|
||||
@@ -18,4 +18,19 @@ tzdata==2026.5
|
||||
pytest==8.3.4
|
||||
pytest-asyncio==0.25.0
|
||||
httpx==0.28.1
|
||||
# 微信登录态加密(Fernet)与二维码渲染
|
||||
cryptography==50.0.2
|
||||
qrcode==8.2
|
||||
# 微信里发来链接时抓正文:trafilatura 的正文抽取在中文站上最稳,lxml 是它的解析底座;
|
||||
# lxml_html_clean 是 jusText(trafilatura 的回退抽取)还要的模块,lxml 6 起被拆成独立包。
|
||||
trafilatura==2.0.0
|
||||
lxml==6.1.3
|
||||
lxml_html_clean==0.4.6
|
||||
courlan==1.4.0
|
||||
htmldate==1.11.0
|
||||
jusText==3.0.2
|
||||
charset-normalizer==3.5.2
|
||||
urllib3==2.8.0
|
||||
tld==0.13.2
|
||||
dateparser==1.5.0
|
||||
ruff==0.8.4
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
"""把旧 memo_app(MySQL)里的备忘录导进达道Task的知识库。
|
||||
|
||||
一次性数据迁移,但保留成脚本:同一套数据以后可能还要再导一次(换环境、重导),
|
||||
手点 84 次不现实,也不该让"导进去的东西为什么缺一块"变成口头传说。
|
||||
|
||||
设计上的几个决定:
|
||||
|
||||
1. **走 HTTP API,不直连数据库**。直接 INSERT 会绕过版本、审计和检索切片,
|
||||
看起来更快,但导进去的知识搜不到——那等于把数据搬进了一个看不见的柜子。
|
||||
发布(publish)会顺带建切片索引,所以每条都走一次 publish。
|
||||
2. **分类按层级先建父后建子**,挪过来仍是最多三层(旧库只有两层)。
|
||||
已有同名分类就复用,重跑不会长出第二棵一模一样的树。
|
||||
3. **时间戳最后单独补**。API 不让人指定 created_at(本该如此),但备忘录的时间
|
||||
就是内容的一部分:一条 5 月写的配方不该显示成"今天写的"。所以建完之后用一条
|
||||
UPDATE 把 created_at/updated_at 改回原值;脚本只生成 SQL 文件,执行与否由人定。
|
||||
4. **图片不搬**。旧库的 uploads 在服务器上已经找不到(容器没了),正文里的
|
||||
`` 会让页面上一堆碎图图标,所以换成一行说明,把原始路径留在那里。
|
||||
5. **可以被中断、可以重跑**。按标题去重,导到一半断了再跑一次只会补没进去的。
|
||||
|
||||
用法:
|
||||
|
||||
backend/.venv/Scripts/python.exe scripts/import_memo_app.py --data .local/memo-export
|
||||
|
||||
然后按它打印的路径跑那条 SQL 补时间戳。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
BASE = "http://127.0.0.1:8100/api/v1"
|
||||
PHONE = "13900000001"
|
||||
CODE = "123456"
|
||||
WORKSPACE_NAME = "我的备忘"
|
||||
DOC_TYPE = "note"
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
IMAGE_RE = re.compile(r"!\[([^\]]*)\]\(([^)\s]+)\)")
|
||||
|
||||
|
||||
def _token_file() -> Path:
|
||||
"""令牌按「环境 + 手机号」分文件存:dev 和生产的令牌互不通用,
|
||||
混着写会出现"刚登录完下一个请求又 401"这种很难看的事。"""
|
||||
host = BASE.split("//", 1)[-1].split("/", 1)[0]
|
||||
return ROOT / ".local" / f"memo-token-{PHONE}-{host.replace(':', '-')}.txt"
|
||||
|
||||
|
||||
def _client() -> httpx.Client:
|
||||
return httpx.Client(base_url=BASE, timeout=120.0)
|
||||
|
||||
|
||||
def _data(resp: httpx.Response) -> Any:
|
||||
payload = resp.json()
|
||||
if payload.get("code") != 0:
|
||||
raise RuntimeError(f"{resp.request.method} {resp.request.url.path} → {payload}")
|
||||
return payload.get("data")
|
||||
|
||||
|
||||
def login(client: httpx.Client) -> dict[str, str]:
|
||||
"""拿令牌。dev 发验证码有 60 秒节流,重跑脚本很容易撞上,
|
||||
所以令牌缓存到文件里;缓存失效再走一次验证码。"""
|
||||
cache = _token_file()
|
||||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
if cache.exists():
|
||||
auth = {"Authorization": f"Bearer {cache.read_text(encoding='utf-8').strip()}"}
|
||||
if client.get("/auth/me", headers=auth).status_code == 200:
|
||||
return auth
|
||||
|
||||
client.post("/auth/sms/send", json={"phone": PHONE}).raise_for_status()
|
||||
payload = client.post("/auth/login", json={"phone": PHONE, "code": CODE}).json()
|
||||
if payload.get("code") != 0:
|
||||
raise SystemExit(
|
||||
f"登录失败:{payload.get('message')}(开发环境发码有 " f"{60} 秒节流,等一会儿再跑)"
|
||||
)
|
||||
token = payload["data"]["tokens"]["access_token"]
|
||||
cache.write_text(token, encoding="utf-8")
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def workspace(client: httpx.Client, auth: dict[str, str], name: str) -> tuple[str, set[str]]:
|
||||
"""找到(或建出)工作区,并带出里面已有的知识标题,用来跳过已导入的。"""
|
||||
rows = _data(client.get("/workspaces", headers=auth))["items"]
|
||||
ws_id = next((row["id"] for row in rows if row["name"] == name), None)
|
||||
if ws_id is None:
|
||||
created = _data(client.post("/workspaces", headers=auth, json={"name": name}))
|
||||
return created["id"], set()
|
||||
|
||||
titles: set[str] = set()
|
||||
cursor = None
|
||||
while True:
|
||||
params = {"limit": 100}
|
||||
if cursor:
|
||||
params["cursor"] = cursor
|
||||
page = _data(client.get(f"/workspaces/{ws_id}/knowledge", headers=auth, params=params))
|
||||
titles.update(row["title"] for row in page["items"])
|
||||
cursor = page.get("next_cursor")
|
||||
if not cursor:
|
||||
break
|
||||
return ws_id, titles
|
||||
|
||||
|
||||
def clean_body(body: str) -> str:
|
||||
"""把正文里的图片引用换成一行说明。
|
||||
|
||||
旧库的图片文件已经不在服务器上了(uploads 目录随容器一起没了),留着 markdown
|
||||
只会渲染成一排碎图。原始路径写进注释,将来真找回来了还能对得上。
|
||||
"""
|
||||
|
||||
def repl(match: re.Match[str]) -> str:
|
||||
target = match.group(2)
|
||||
name = target.rsplit("/", 1)[-1]
|
||||
return f"> 原图未迁移:{name}(旧路径 {target})"
|
||||
|
||||
return IMAGE_RE.sub(repl, body or "").strip()
|
||||
|
||||
|
||||
def _depth_of(row: dict[str, Any], by_id: dict[int, dict[str, Any]]) -> int:
|
||||
depth = 1
|
||||
parent = row["parent_id"]
|
||||
seen = set()
|
||||
while parent and parent in by_id and parent not in seen:
|
||||
seen.add(parent)
|
||||
depth += 1
|
||||
parent = by_id[parent]["parent_id"]
|
||||
return depth
|
||||
|
||||
|
||||
def create_categories(
|
||||
client: httpx.Client, auth: dict[str, str], ws_id: str, old: list[dict[str, Any]]
|
||||
) -> dict[int, str]:
|
||||
"""按层级从浅到深建;同名同父已存在就复用。返回 旧 id → 新 id。"""
|
||||
existing = _data(client.get(f"/workspaces/{ws_id}/knowledge/categories", headers=auth))
|
||||
known: dict[tuple[str | None, str], str] = {
|
||||
(row["parent_id"], row["name"].lower()): row["id"] for row in existing["items"]
|
||||
}
|
||||
|
||||
by_id = {row["id"]: row for row in old}
|
||||
mapping: dict[int, str] = {}
|
||||
for row in sorted(old, key=lambda r: (_depth_of(r, by_id), r["sort_order"], r["id"])):
|
||||
parent_new = mapping.get(row["parent_id"]) if row["parent_id"] else None
|
||||
key = (parent_new, row["name"].strip().lower())
|
||||
if key in known:
|
||||
mapping[row["id"]] = known[key]
|
||||
continue
|
||||
created = _data(
|
||||
client.post(
|
||||
f"/workspaces/{ws_id}/knowledge/categories",
|
||||
headers=auth,
|
||||
json={
|
||||
"name": row["name"].strip(),
|
||||
"parent_id": parent_new,
|
||||
"sort_order": row["sort_order"],
|
||||
},
|
||||
)
|
||||
)
|
||||
mapping[row["id"]] = created["id"]
|
||||
known[key] = created["id"]
|
||||
return mapping
|
||||
|
||||
|
||||
def main() -> int:
|
||||
global BASE, PHONE
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--data", default=".local/memo-export", help="导出的 JSON 目录")
|
||||
parser.add_argument("--sql-out", default=".local/memo-timestamps.sql")
|
||||
parser.add_argument("--limit", type=int, default=0, help="只导前 N 条(试跑用)")
|
||||
parser.add_argument("--api", default=BASE, help="目标环境的 API 根地址")
|
||||
parser.add_argument("--phone", default=PHONE, help="知识记到哪个手机号名下")
|
||||
parser.add_argument("--workspace", default=WORKSPACE_NAME, help="工作区名字,没有就新建")
|
||||
args = parser.parse_args()
|
||||
|
||||
BASE = args.api.rstrip("/")
|
||||
PHONE = args.phone
|
||||
workspace_name = args.workspace
|
||||
|
||||
root = Path(args.data)
|
||||
notes = json.loads((root / "notes.json").read_text(encoding="utf-8"))
|
||||
categories = json.loads((root / "categories.json").read_text(encoding="utf-8"))
|
||||
tags = json.loads((root / "tags.json").read_text(encoding="utf-8"))
|
||||
note_tags = json.loads((root / "note_tags.json").read_text(encoding="utf-8"))
|
||||
|
||||
tag_name = {row["id"]: row["name"] for row in tags}
|
||||
tags_of: dict[int, list[str]] = {}
|
||||
for row in note_tags:
|
||||
tags_of.setdefault(row["note_id"], []).append(tag_name[row["tag_id"]])
|
||||
|
||||
# 旧库有两个用户,只有 steven(1) 有笔记;别人的分类不搬,免得空分类挂在树上
|
||||
owners = {row["user_id"] for row in notes}
|
||||
categories = [row for row in categories if row["user_id"] in owners]
|
||||
|
||||
timestamps: list[str] = []
|
||||
failed: list[str] = []
|
||||
done = 0
|
||||
|
||||
with _client() as client:
|
||||
auth = login(client)
|
||||
ws_id, existing_titles = workspace(client, auth, workspace_name)
|
||||
print(f"工作区 {ws_id},已有 {len(existing_titles)} 条知识")
|
||||
|
||||
cat_map = create_categories(client, auth, ws_id, categories)
|
||||
print(f"分类 {len(cat_map)} 个")
|
||||
|
||||
ordered = sorted(notes, key=lambda r: r["id"])
|
||||
queue = [n for n in ordered if n["title"] not in existing_titles]
|
||||
if args.limit:
|
||||
queue = queue[: args.limit]
|
||||
print(f"待导入 {len(queue)} 条")
|
||||
|
||||
for index, note in enumerate(queue, start=1):
|
||||
title = (note["title"] or "").strip() or "无标题备忘"
|
||||
payload = {
|
||||
"title": title[:200],
|
||||
"body_md": clean_body(note["content"] or ""),
|
||||
"summary": (note["summary"] or "").strip(),
|
||||
"doc_type": DOC_TYPE,
|
||||
"tag_names": tags_of.get(note["id"], []),
|
||||
"category_id": cat_map.get(note["category_id"]),
|
||||
}
|
||||
try:
|
||||
doc = _data(
|
||||
client.post(f"/workspaces/{ws_id}/knowledge", headers=auth, json=payload)
|
||||
)
|
||||
action = "archive" if note["is_archived"] else "publish"
|
||||
_data(
|
||||
client.post(
|
||||
f"/workspaces/{ws_id}/knowledge/{doc['id']}/status",
|
||||
headers=auth,
|
||||
json={"action": action, "version": doc["version"]},
|
||||
)
|
||||
)
|
||||
except Exception as error: # 一条失败不该拖垮整批,记下来最后一起看
|
||||
failed.append(f"#{note['id']} {title}: {error}")
|
||||
continue
|
||||
|
||||
done += 1
|
||||
timestamps.append(
|
||||
f"('{doc['id']}', '{note['created_at']}+00', '{note['updated_at']}+00')"
|
||||
)
|
||||
if index % 10 == 0:
|
||||
print(f" … {index}/{len(queue)}")
|
||||
|
||||
sql = [
|
||||
"-- 把导入时的 now() 改回笔记原本的时间(dev 库执行,dbadmin 可绕过 RLS)",
|
||||
"BEGIN;",
|
||||
"CREATE TEMP TABLE memo_ts (item_id uuid PRIMARY KEY, created_at timestamptz,"
|
||||
" updated_at timestamptz) ON COMMIT DROP;",
|
||||
"INSERT INTO memo_ts (item_id, created_at, updated_at) VALUES",
|
||||
",\n".join(timestamps) + ";",
|
||||
"UPDATE item i SET created_at = t.created_at, updated_at = t.updated_at"
|
||||
" FROM memo_ts t WHERE i.id = t.item_id;",
|
||||
"UPDATE knowledge_doc k SET published_at = t.updated_at,"
|
||||
" archived_at = CASE WHEN k.status_hint = 'archived' THEN t.updated_at ELSE NULL END"
|
||||
" FROM memo_ts t WHERE k.item_id = t.item_id;",
|
||||
"COMMIT;",
|
||||
]
|
||||
Path(args.sql_out).write_text("\n".join(sql) + "\n", encoding="utf-8")
|
||||
|
||||
print(f"导入完成 {done} 条;时间戳 SQL → {args.sql_out}")
|
||||
if failed:
|
||||
print("这些没进去:")
|
||||
for line in failed:
|
||||
print(" -", line)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,86 @@
|
||||
"""把构建好的 APK 发布成 OTA 版本:算 sha256、写 latest.json。
|
||||
|
||||
用法(在仓库根目录):
|
||||
|
||||
python backend/scripts/publish_apk.py android/app/build/outputs/apk/release/app-release.apk \
|
||||
--version-code 2 --version-name 0.2.0 --notes "第一版安装包:知识库支持分类与附件"
|
||||
|
||||
发布目录默认 `backend/static/apk/`,可用 `--dir` 或环境变量 `APK_DIR` 改。
|
||||
这个目录是产物、不入库;部署到服务器时把 `latest.json` 和 apk 一起 scp 过去即可。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
DEFAULT_DIR = REPO / "backend" / "static" / "apk"
|
||||
|
||||
|
||||
def sha256_of(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="发布安卓安装包(OTA)")
|
||||
parser.add_argument("apk", type=Path, help="构建产物路径,如 app-release.apk")
|
||||
parser.add_argument(
|
||||
"--version-code", type=int, required=True, help="与 build.gradle 的 versionCode 一致"
|
||||
)
|
||||
parser.add_argument("--version-name", required=True, help="展示用版本号,如 0.2.0")
|
||||
parser.add_argument("--notes", default="", help="更新说明,App 里会原样显示")
|
||||
parser.add_argument("--min-sdk", type=int, default=26)
|
||||
parser.add_argument(
|
||||
"--dir",
|
||||
type=Path,
|
||||
default=Path(os.environ.get("APK_DIR") or DEFAULT_DIR),
|
||||
help="发布目录(默认 backend/static/apk)",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if not args.apk.is_file():
|
||||
print(f"APK 不存在:{args.apk}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
target_dir = args.dir
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
name = f"dadaotask-{args.version_name}.apk"
|
||||
target = target_dir / name
|
||||
shutil.copyfile(args.apk, target)
|
||||
|
||||
manifest = {
|
||||
"platform": "android",
|
||||
"version_code": args.version_code,
|
||||
"version_name": args.version_name,
|
||||
"notes": args.notes.strip(),
|
||||
"file": name,
|
||||
"size": target.stat().st_size,
|
||||
"sha256": sha256_of(target),
|
||||
"min_sdk": args.min_sdk,
|
||||
"published_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
}
|
||||
(target_dir / "latest.json").write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
print(f"已发布 {name}")
|
||||
print(f" versionCode={manifest['version_code']} versionName={manifest['version_name']}")
|
||||
print(f" size={manifest['size'] / 1024 / 1024:.1f}MB sha256={manifest['sha256'][:16]}…")
|
||||
print(f" 清单:{target_dir / 'latest.json'}")
|
||||
print("提示:服务器上把这两个文件放到 <repo>/backend/static/apk/ 即可,无需重启服务。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -13,6 +13,9 @@ os.environ["ENV"] = "test"
|
||||
os.environ.setdefault("SMS_DEV_CODE", "123456")
|
||||
# 测试里关掉发码节流,否则"同一手机号再登录一次"要等 60 秒
|
||||
os.environ.setdefault("SMS_THROTTLE_SECONDS", "0")
|
||||
# 线上默认关掉了短信验证码登录(固定码等于谁都能进),但测试夹具 `login()` 靠它批量造用户。
|
||||
# 这里显式打开;想验证"关掉之后回 2017"的用例自己 monkeypatch 一下 settings 即可。
|
||||
os.environ.setdefault("SMS_LOGIN_ENABLED", "true")
|
||||
# 测试库由 config 的 test_database_url / test_migration_database_url 决定(见 backend/.env),
|
||||
# 这里不再改 os.environ —— 配置对象自己会读 .env。
|
||||
|
||||
@@ -36,7 +39,9 @@ from app.stores import reset_store_for_tests
|
||||
settings = get_settings()
|
||||
DEV_CODE = settings.sms_dev_code
|
||||
|
||||
_TABLES = "audit_log, invitation, member, workspace, app_user"
|
||||
_TABLES = (
|
||||
"bot_message, bot_channel, system_setting, audit_log, invitation, member, workspace, app_user"
|
||||
)
|
||||
|
||||
|
||||
def _run_migrations() -> None:
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"""账号功能:昵称、密码设置/修改、密码登录。
|
||||
|
||||
这些都是"账号被占"的高风险入口,所以断言不只看 200,还要看:
|
||||
- 密码一字节都不回给客户端(只回 has_password)
|
||||
- 没设过密码的人不会被密码登录误放进去
|
||||
- 改完密码,旧密码立刻失效
|
||||
- 连错太多次要先锁一会儿,别让登录接口变成离线爆破的放大镜
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
from .conftest import auth, login, unwrap
|
||||
|
||||
|
||||
async def _me(client: AsyncClient, user: dict) -> dict:
|
||||
return unwrap(await client.get("/api/v1/auth/me", headers=user["headers"]))
|
||||
|
||||
|
||||
async def test_me_reports_profile_without_leaking_password(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
data = await _me(client, user)
|
||||
|
||||
assert data["user"]["has_password"] is False
|
||||
assert data["user"]["phone"].endswith(user["phone"][-4:])
|
||||
assert "****" in data["user"]["phone"]
|
||||
# 只回「设没设」,不回密码本身:字段白名单比黑名单可靠
|
||||
assert set(data["user"]) == {
|
||||
"id",
|
||||
"phone",
|
||||
"email",
|
||||
"email_verified",
|
||||
"nickname",
|
||||
"avatar_url",
|
||||
"has_password",
|
||||
"platform_admin",
|
||||
"created_at",
|
||||
}
|
||||
|
||||
|
||||
async def test_patch_nickname(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
data = unwrap(
|
||||
await client.patch(
|
||||
"/api/v1/auth/me", json={"nickname": " 老王 "}, headers=user["headers"]
|
||||
)
|
||||
)
|
||||
assert data["user"]["nickname"] == "老王"
|
||||
|
||||
# 空昵称要被拦住,不能变成"无名氏"
|
||||
resp = await client.patch("/api/v1/auth/me", json={"nickname": " "}, headers=user["headers"])
|
||||
assert resp.json()["code"] == 1002
|
||||
|
||||
|
||||
async def test_set_password_then_login_with_it(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
unwrap(
|
||||
await client.post(
|
||||
"/api/v1/auth/password",
|
||||
json={"password": "dadao2026"},
|
||||
headers=user["headers"],
|
||||
)
|
||||
)
|
||||
assert (await _me(client, user))["user"]["has_password"] is True
|
||||
|
||||
# 验证码登录照旧可用,两条路并存
|
||||
data = unwrap(
|
||||
await client.post(
|
||||
"/api/v1/auth/login/password",
|
||||
json={"phone": user["phone"], "password": "dadao2026"},
|
||||
)
|
||||
)
|
||||
assert data["tokens"]["access_token"]
|
||||
assert data["user"]["id"] == user["user_id"]
|
||||
|
||||
|
||||
async def test_password_login_rejects_wrong_password(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
unwrap(
|
||||
await client.post(
|
||||
"/api/v1/auth/password", json={"password": "dadao2026"}, headers=user["headers"]
|
||||
)
|
||||
)
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/login/password", json={"phone": user["phone"], "password": "wrong-pass1"}
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["code"] == 2013
|
||||
|
||||
|
||||
async def test_password_login_on_account_without_password(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/login/password", json={"phone": user["phone"], "password": "dadao2026"}
|
||||
)
|
||||
# 没设过密码 = 密码不对,不能因为「没设置」就报 2014 把信息漏出去
|
||||
assert resp.json()["code"] == 2013
|
||||
|
||||
|
||||
async def test_unknown_phone_looks_the_same_as_wrong_password(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
unwrap(
|
||||
await client.post(
|
||||
"/api/v1/auth/password", json={"password": "dadao2026"}, headers=user["headers"]
|
||||
)
|
||||
)
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/login/password", json={"phone": "13511112222", "password": "dadao2026"}
|
||||
)
|
||||
assert resp.json()["code"] == 2013
|
||||
|
||||
|
||||
async def test_change_password_requires_old_one_and_invalidates_it(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
unwrap(
|
||||
await client.post(
|
||||
"/api/v1/auth/password", json={"password": "dadao2026"}, headers=user["headers"]
|
||||
)
|
||||
)
|
||||
|
||||
# 不给旧密码 → 拒绝
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/password", json={"password": "dadao2027"}, headers=user["headers"]
|
||||
)
|
||||
assert resp.json()["code"] == 2013
|
||||
|
||||
# 旧密码给错 → 拒绝
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/password",
|
||||
json={"password": "dadao2027", "old_password": "nope-1234"},
|
||||
headers=user["headers"],
|
||||
)
|
||||
assert resp.json()["code"] == 2013
|
||||
|
||||
unwrap(
|
||||
await client.post(
|
||||
"/api/v1/auth/password",
|
||||
json={"password": "dadao2027", "old_password": "dadao2026"},
|
||||
headers=user["headers"],
|
||||
)
|
||||
)
|
||||
|
||||
# 新密码能登,旧密码不能
|
||||
unwrap(
|
||||
await client.post(
|
||||
"/api/v1/auth/login/password", json={"phone": user["phone"], "password": "dadao2027"}
|
||||
)
|
||||
)
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/login/password", json={"phone": user["phone"], "password": "dadao2026"}
|
||||
)
|
||||
assert resp.json()["code"] == 2013
|
||||
|
||||
|
||||
async def test_weak_password_rejected(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
for weak in ["short1", "12345678", "abcdefgh"]:
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/password", json={"password": weak}, headers=user["headers"]
|
||||
)
|
||||
assert resp.json()["code"] == 2015, weak
|
||||
|
||||
|
||||
async def test_password_endpoints_require_login(client: AsyncClient) -> None:
|
||||
resp = await client.post("/api/v1/auth/password", json={"password": "dadao2026"})
|
||||
assert resp.status_code == 401
|
||||
resp = await client.patch("/api/v1/auth/me", json={"nickname": "x"}, headers=auth("nope"))
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
async def test_repeated_failures_are_throttled(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
unwrap(
|
||||
await client.post(
|
||||
"/api/v1/auth/password", json={"password": "dadao2026"}, headers=user["headers"]
|
||||
)
|
||||
)
|
||||
|
||||
for _ in range(10):
|
||||
await client.post(
|
||||
"/api/v1/auth/login/password", json={"phone": user["phone"], "password": "bad-pass1"}
|
||||
)
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/login/password", json={"phone": user["phone"], "password": "dadao2026"}
|
||||
)
|
||||
# 次数用光之后,连正确密码也要先等一会儿——锁的是这段时间,不是这一次请求
|
||||
assert resp.json()["code"] == 2016
|
||||