Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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/build/
|
||||||
android/app/build/
|
android/app/build/
|
||||||
android/local.properties
|
android/local.properties
|
||||||
|
# 发布签名证书与口令(只在发布者本机;仓库里没有它就用调试签名)
|
||||||
|
android/keystore.properties
|
||||||
|
*.jks
|
||||||
|
|
||||||
# 编辑器 / 系统
|
# 编辑器 / 系统
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -47,6 +50,15 @@ android/.kotlin/
|
|||||||
# 本地上传文件(附件落盘目录,绝不入库)
|
# 本地上传文件(附件落盘目录,绝不入库)
|
||||||
backend/var/
|
backend/var/
|
||||||
|
|
||||||
|
# 发布的安卓安装包由 publish_apk.py 放在这里,APK 不进版本库
|
||||||
|
backend/static/apk/
|
||||||
|
|
||||||
|
# pytest 的临时目录(在 backend/ 下跑测试时生成)
|
||||||
|
backend/pytest-of-*/
|
||||||
|
|
||||||
# Vditor 运行时资源(lute/i18n/icons,约 3.8 MB)
|
# Vditor 运行时资源(lute/i18n/icons,约 3.8 MB)
|
||||||
# 由 web/scripts/vendor-vditor.mjs 从 node_modules 生成,不进版本库
|
# 由 web/scripts/vendor-vditor.mjs 从 node_modules 生成,不进版本库
|
||||||
web/public/vditor/
|
web/public/vditor/
|
||||||
|
|
||||||
|
# 本机部署/调试的临时脚本与缓存
|
||||||
|
.tmp/
|
||||||
|
|||||||
@@ -29,6 +29,14 @@
|
|||||||
| 2010 | 429 | 验证码请求太频繁 | 倒计时 60 秒 |
|
| 2010 | 429 | 验证码请求太频繁 | 倒计时 60 秒 |
|
||||||
| 2011 | 400 | 验证码错误/已过期 | 重发 |
|
| 2011 | 400 | 验证码错误/已过期 | 重发 |
|
||||||
| 2012 | 403 | 账号被禁用 | 联系管理员 |
|
| 2012 | 403 | 账号被禁用 | 联系管理员 |
|
||||||
|
| 2013 | 400 | 密码错误,或这个号还没设过密码 | 提示核对账号与密码 |
|
||||||
|
| 2014 | 400 | 该账号没有密码(保留码) | 先设密码 |
|
||||||
|
| 2015 | 422 | 密码太弱(少于 8 位 / 缺字母或数字 / 与旧密码相同) | 就地提示规则 |
|
||||||
|
| 2016 | 429 | 密码连错太多次(15 分钟内 10 次) | 提示稍后再试 |
|
||||||
|
| 2017 | 403 | 这条登录方式没开(默认只开密码) | 换密码登录 |
|
||||||
|
| 2018 | 422 | 邮箱格式不对 | 就地提示 |
|
||||||
|
| 2019 | 409 | 邮箱已被别的账号占用 | 换一个 |
|
||||||
|
| 2020 | 502 | 发信失败(SMTP 认证/收件人/网络) | 去「系统设置」核对授权码 |
|
||||||
| 3001 | 404 | 工作区不存在 | — |
|
| 3001 | 404 | 工作区不存在 | — |
|
||||||
| 3003 | 403 | 不是该工作区成员 | **不要**提示"工作区不存在" |
|
| 3003 | 403 | 不是该工作区成员 | **不要**提示"工作区不存在" |
|
||||||
| 3004 | 403 | 角色权限不足 | 隐藏对应按钮 |
|
| 3004 | 403 | 角色权限不足 | 隐藏对应按钮 |
|
||||||
@@ -61,6 +69,19 @@
|
|||||||
|
|
||||||
## 1. 认证 `/auth`
|
## 1. 认证 `/auth`
|
||||||
|
|
||||||
|
登录方式有三种,**默认只开密码那一种**。服务端把"现在开着哪些"告诉前端,前端据此渲染,
|
||||||
|
免得用户点进一条关着的路:
|
||||||
|
|
||||||
|
### `GET /auth/methods`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "password": true, "email_password": true, "sms_code": false }
|
||||||
|
```
|
||||||
|
|
||||||
|
`SMS_LOGIN_ENABLED=false`(默认)时,`POST /auth/sms/send` 与 `POST /auth/login` 都回 `2017`
|
||||||
|
「短信验证码登录暂未开放」。理由:dev 环境的固定码等于"知道手机号就能进任何账号",
|
||||||
|
这条路必须显式打开,生产打开时还会强制要求真短信通道(见 `deploy` 文档的启动校验)。
|
||||||
|
|
||||||
### `POST /auth/sms/send`
|
### `POST /auth/sms/send`
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -68,7 +89,7 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
响应 `data: { dev_code?: "123456" }`。`ENV=dev` 时才回显验证码,生产没有这个字段。
|
响应 `data: { dev_code?: "123456" }`。`ENV=dev` 时才回显验证码,生产没有这个字段。
|
||||||
60 秒内重复请求 → `2010`。
|
60 秒内重复请求 → `2010`。短信登录关着时 → `2017`。
|
||||||
|
|
||||||
### `POST /auth/login`
|
### `POST /auth/login`
|
||||||
|
|
||||||
@@ -91,9 +112,82 @@
|
|||||||
|
|
||||||
返回同形状的 `tokens`。无状态 JWT,**服务端不维护黑名单**——token 泄露只能等过期,这是 M0 已知取舍。
|
返回同形状的 `tokens`。无状态 JWT,**服务端不维护黑名单**——token 泄露只能等过期,这是 M0 已知取舍。
|
||||||
|
|
||||||
|
### `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`
|
### `GET /auth/me`
|
||||||
|
|
||||||
返回 `{ user, workspaces[] }`,`workspaces[].role` 是当前用户在该工作区的角色。
|
返回 `{ user, workspaces[] }`,`workspaces[].role` 是当前用户在该工作区的角色。
|
||||||
|
`user` 里带 `has_password`(前端据此显示"设置密码"还是"修改密码")、`email`、`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`),消息里写清是认证失败还是收件人被拒——授权码要用邮箱服务商的
|
||||||
|
**客户端授权码**,不是登录密码。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -396,7 +490,11 @@ Web 端未读数由 `AppShell` 每 30 秒轮询一次;在通知页标已读后
|
|||||||
|
|
||||||
必须带 `version`(乐观锁,冲突 `4009`)。可改 `title`/`body_md`/`summary`/`doc_type`/`tag_names`/`category_id`;
|
必须带 `version`(乐观锁,冲突 `4009`)。可改 `title`/`body_md`/`summary`/`doc_type`/`tag_names`/`category_id`;
|
||||||
传 `clear_category: true` 表示移到未分类。
|
传 `clear_category: true` 表示移到未分类。
|
||||||
**已发布的不许直接改**(`7003`,先撤回审议或归档),避免"发布后被悄悄改写"。
|
|
||||||
|
**已发布的可以直接改**:改的是"团队现在看到的这一版",改完立刻重建检索切片(`chunk_count` 当场更新),
|
||||||
|
避免"正文改了、搜出来还是旧句子"。版本号照旧 `+1`,历史仍能靠审计追。
|
||||||
|
**已归档的不许直接改**(`7003`,`allowed_actions: ["restore"]`):先恢复成草稿再改,
|
||||||
|
免得改完还挂着"现役"的样子。
|
||||||
|
|
||||||
### 5.5 知识分类 `/knowledge/categories`
|
### 5.5 知识分类 `/knowledge/categories`
|
||||||
|
|
||||||
@@ -404,7 +502,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?}` |
|
| POST | `/knowledge/categories` | `{name, parent_id?, sort_order?}` |
|
||||||
| PATCH | `/knowledge/categories/{id}` | `{name?, parent_id?, move?, sort_order?, version}` |
|
| PATCH | `/knowledge/categories/{id}` | `{name?, parent_id?, move?, sort_order?, version}` |
|
||||||
| DELETE | `/knowledge/categories/{id}?force=false` | 有子分类不让删;有知识要 `force=true`,删后知识变未分类 |
|
| DELETE | `/knowledge/categories/{id}?force=false` | 有子分类不让删;有知识要 `force=true`,删后知识变未分类 |
|
||||||
@@ -540,8 +638,6 @@ Query:
|
|||||||
|
|
||||||
### 6.3 附件错误码
|
### 6.3 附件错误码
|
||||||
|
|
||||||
### 6.3 附件错误码
|
|
||||||
|
|
||||||
| 码 | HTTP | 含义 |
|
| 码 | HTTP | 含义 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 8001 | 404 | 附件不存在,或不属于这个工作区 |
|
| 8001 | 404 | 附件不存在,或不属于这个工作区 |
|
||||||
@@ -558,7 +654,67 @@ Query:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. 健康与版本
|
## 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`。
|
必填项:`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` 换成真实供应商。
|
**当前状态是内测模式**:短信通道还没接(非 dev provider 直接 501),所以 `SMS_PROVIDER=dev` + `INTERNAL_TEST_LOGIN=true`,固定验证码 `123456`。启动日志会打 WARNING。**只允许内网访问**;短信通道接好后删掉 `INTERNAL_TEST_LOGIN` 并把 `SMS_PROVIDER` 换成真实供应商。
|
||||||
|
|
||||||
### 2.4 装服务单元
|
### 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` 登录 → 任务/工单/知识各点一遍。
|
浏览器验收:打开 `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
|
```bash
|
||||||
systemctl --user status dadaotask-api # 状态
|
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`;**数据库迁移不回滚**,回滚代码前确认旧代码兼容新表结构
|
- 回滚:`cd ~/app/dadaotask && git reset --hard <旧 commit> && bash deploy/prod/deploy.sh`;**数据库迁移不回滚**,回滚代码前确认旧代码兼容新表结构
|
||||||
- 端口:`8200`(systemd 单元与 `deploy.sh` 的 `PORT` 默认值,两处要一致)
|
- 端口:`8200`(systemd 单元与 `deploy.sh` 的 `PORT` 默认值,两处要一致)
|
||||||
|
|
||||||
## 6. 已知限制(当前阶段)
|
## 8. 已知限制(当前阶段)
|
||||||
|
|
||||||
| 限制 | 影响 | 解除方式 |
|
| 限制 | 影响 | 解除方式 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 没有 HTTPS / 域名 | 只能内网 IP 访问,令牌明文传输 | 域名 + nginx + 证书(需 root) |
|
| 没有 HTTPS / 域名 | 只能内网 IP 访问,令牌明文传输;**PWA 装不上**(非安全上下文) | 域名 + nginx + 证书(需 root) |
|
||||||
| 内测固定验证码 | 谁都能用任意手机号登录 | 接短信通道,关 `INTERNAL_TEST_LOGIN` |
|
| 内测固定验证码 | 谁都能用任意手机号登录 | 接短信通道,关 `INTERNAL_TEST_LOGIN` |
|
||||||
| 无 pgvector | 语义检索走 `real[]` 全表扫描,数据量大了会慢 | 上独立 pgvector 实例(`deploy/docker-compose.vector.yml`) |
|
| 无 pgvector | 语义检索走 `real[]` 全表扫描,数据量大了会慢 | 上独立 pgvector 实例(`deploy/docker-compose.vector.yml`) |
|
||||||
| 上传目录在本机磁盘 | 机器坏了附件就没了 | 备份 `backend/var/uploads`,或将来到对象存储 |
|
| 上传目录在本机磁盘 | 机器坏了附件就没了 | 备份 `backend/var/uploads`,或将来到对象存储 |
|
||||||
|
|||||||
@@ -58,7 +58,8 @@
|
|||||||
- [x] 手机号 + 验证码注册/登录(dev 固定码 `123456` 并回显;`ENV=prod` 禁止 `SMS_PROVIDER=dev`)
|
- [x] 手机号 + 验证码注册/登录(dev 固定码 `123456` 并回显;`ENV=prod` 禁止 `SMS_PROVIDER=dev`)
|
||||||
- [x] JWT:**access 30min / refresh 14d**(计划里写的 15min/30d,实际取宽了一点,理由是移动端弱网刷新成本高)
|
- [x] JWT:**access 30min / refresh 14d**(计划里写的 15min/30d,实际取宽了一点,理由是移动端弱网刷新成本高)
|
||||||
- [x] 发码节流(`SMS_THROTTLE_SECONDS`,默认 60s,测试可置 0)
|
- [x] 发码节流(`SMS_THROTTLE_SECONDS`,默认 60s,测试可置 0)
|
||||||
- [ ] 邮箱 + 密码(argon2)(**未做**,M1 有空再说;手机号登录先行)
|
- [x] 手机号 + **密码**登录(可选,第二把钥匙:设过密码就能用密码进,没设过的一律回 `2013`;scrypt 哈希,15 分钟连错 10 次 → `2016`)
|
||||||
|
- [ ] 邮箱 + 密码(**未做**,M1 有空再说;手机号登录先行)
|
||||||
- [ ] refresh 令牌落库可撤销(**未做**,当前是无状态 JWT;被移出团队的成员靠"每次请求查 member 表"兜底,refresh 本身不可撤销)
|
- [ ] refresh 令牌落库可撤销(**未做**,当前是无状态 JWT;被移出团队的成员靠"每次请求查 member 表"兜底,refresh 本身不可撤销)
|
||||||
- **验收**:✅ 注册 → 登录 → 刷新 → `/auth/me` 全链路通;错误码 2010/2011/2012 语义清晰
|
- **验收**:✅ 注册 → 登录 → 刷新 → `/auth/me` 全链路通;错误码 2010/2011/2012 语义清晰
|
||||||
|
|
||||||
@@ -291,15 +292,18 @@
|
|||||||
| 3.8.4 | 索引生命周期:发布自动建、改正文需 `reindex`、换模型整体重建 | 0.5 | [x] `POST /knowledge/{id}/reindex` |
|
| 3.8.4 | 索引生命周期:发布自动建、改正文需 `reindex`、换模型整体重建 | 0.5 | [x] `POST /knowledge/{id}/reindex` |
|
||||||
| 3.9 | 安卓知识列表 | 1.0 | [x] 只读列表 + 详情动作(编辑正文走安卓编辑器) |
|
| 3.9 | 安卓知识列表 | 1.0 | [x] 只读列表 + 详情动作(编辑正文走安卓编辑器) |
|
||||||
| 3.10 | 文档与 e2e | 1.0 | [x] `knowledge.mjs` 7 项 + `retrieval.mjs` 6 项 |
|
| 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.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.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)
|
### M3 完成定义(DOD)
|
||||||
- [x] 一件事办完 → 自动出现一份草稿 → 核对 → 发布,全程不用手动复制粘贴
|
- [x] 一件事办完 → 自动出现一份草稿 → 核对 → 发布,全程不用手动复制粘贴
|
||||||
- [x] 每条知识都能追到来源(除手记),点开能看到来源事项
|
- [x] 每条知识都能追到来源(除手记),点开能看到来源事项
|
||||||
- [x] 非管理员发布被拒;跨租户看不到别人的知识
|
- [x] 非管理员发布被拒;跨租户看不到别人的知识
|
||||||
- [x] 知识是备忘录:标题、正文、标签、三级分类、搜索;新建时就能把这些一次填完(Web 与安卓一致)
|
- [x] 知识是备忘录:标题、正文、标签、三级分类、搜索;新建时就能把这些一次填完(Web 与安卓一致)
|
||||||
|
- [x] 点开知识默认看到的是排好版的正文,不是编辑器的原文;要改才点「编辑」
|
||||||
- [x] 切片与语义检索跑通(`real[]` + SQL 余弦;pgvector 仍待独立实例)
|
- [x] 切片与语义检索跑通(`real[]` + SQL 余弦;pgvector 仍待独立实例)
|
||||||
- [ ] 真实用两周:新同事靠知识库自己解决过一次问题(需要真实团队)
|
- [ ] 真实用两周:新同事靠知识库自己解决过一次问题(需要真实团队)
|
||||||
|
|
||||||
@@ -355,7 +359,7 @@
|
|||||||
重做不是换配色,而是**先立设计系统、再让页面去用它**,否则下次还是东拼西凑。
|
重做不是换配色,而是**先立设计系统、再让页面去用它**,否则下次还是东拼西凑。
|
||||||
|
|
||||||
### 4.1 设计系统(两端共用一套 token)
|
### 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] 字号阶:11/12/13/14/15/18/22/26/36,正文 14、辅助 11/12;细描边 + 极轻投影 + 14dp 圆角
|
||||||
- [x] 组件:卡片 / 按钮(primary、ghost、subtle、clay、danger)/ 状态 pill / 输入 / 空态 / 骨架屏 / 抽屉
|
- [x] 组件:卡片 / 按钮(primary、ghost、subtle、clay、danger)/ 状态 pill / 输入 / 空态 / 骨架屏 / 抽屉
|
||||||
- [x] 图标:Web 用自绘线性图标集(`components/icons.tsx`),安卓用 Material Icons + 少量自绘,线宽一致
|
- [x] 图标:Web 用自绘线性图标集(`components/icons.tsx`),安卓用 Material Icons + 少量自绘,线宽一致
|
||||||
@@ -414,3 +418,188 @@
|
|||||||
- [x] 既有三套 e2e 回归通过(smoke 7 / tickets 8 / knowledge 7)
|
- [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] `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`
|
- [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`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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`。
|
||||||
|
|||||||
|
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: 72 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: 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: 144 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 58 KiB |
@@ -28,7 +28,7 @@
|
|||||||
| 安卓端任务 / 看板 / 工单 / 知识 / 成员五个页面 | ✅ 编译成 APK(10.1 MB),模拟器上逐页跑通,截图见 [`2.Design/screens/`](2.Design/screens) |
|
| 安卓端任务 / 看板 / 工单 / 知识 / 成员五个页面 | ✅ 编译成 APK(10.1 MB),模拟器上逐页跑通,截图见 [`2.Design/screens/`](2.Design/screens) |
|
||||||
| 工单:升级不搬数据、受理→办理→提交→验收、驳回、改派、站内通知 | ✅ 后端 + Web + 安卓已通,双人 e2e 8 项全绿 |
|
| 工单:升级不搬数据、受理→办理→提交→验收、驳回、改派、站内通知 | ✅ 后端 + Web + 安卓已通,双人 e2e 8 项全绿 |
|
||||||
| 知识:工单验收自动生成草稿、核对后发布、来源可追、手记 | ✅ 后端 + Web + 安卓已通,e2e 7 项全绿 |
|
| 知识:工单验收自动生成草稿、核对后发布、来源可追、手记 | ✅ 后端 + 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 节 |
|
| 富文本:备注 / 工单说明 / 知识正文支持图文混排与附件 | ✅ Web 用 Vditor 编辑、安卓用 Markwon 预览编辑(e2e 15 项全绿);接口见 [`2.Design/API.md`](2.Design/API.md) 第 6 节 |
|
||||||
| 知识切片与语义检索(混合打分、只搜已发布、带来源) | ✅ 已跑通:`real[]` + SQL 余弦(pgvector 待独立实例),`pytest` 5 个用例 + `e2e/retrieval.mjs` 6 项 |
|
| 知识切片与语义检索(混合打分、只搜已发布、带来源) | ✅ 已跑通:`real[]` + SQL 余弦(pgvector 待独立实例),`pytest` 5 个用例 + `e2e/retrieval.mjs` 6 项 |
|
||||||
| 后端测试 | ✅ 79 passed,ruff 干净 |
|
| 后端测试 | ✅ 79 passed,ruff 干净 |
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ app/src/main/java/com/dadao/task/
|
|||||||
│ ├── Api.kt OkHttp + kotlinx.serialization;信封解包、业务码抛 ApiException
|
│ ├── Api.kt OkHttp + kotlinx.serialization;信封解包、业务码抛 ApiException
|
||||||
│ └── TokenStore.kt 令牌本地存储(TODO:换 EncryptedSharedPreferences)
|
│ └── TokenStore.kt 令牌本地存储(TODO:换 EncryptedSharedPreferences)
|
||||||
└── ui/
|
└── ui/
|
||||||
├── Theme.kt 与 Web 一致的墨青 + 陶土色板
|
├── Theme.kt 与 Web 一致的靛蓝 #4F46E5 + 陶土色板
|
||||||
├── LoginScreen.kt 手机号 + 验证码
|
├── LoginScreen.kt 手机号 + 验证码
|
||||||
├── HomeScreen.kt 工作区列表 / 新建 / 生成邀请码
|
├── HomeScreen.kt 工作区列表 / 新建 / 生成邀请码
|
||||||
├── TaskListScreen.kt 任务视图切换、快速新建、勾选完成、详情与评论
|
├── TaskListScreen.kt 任务视图切换、快速新建、勾选完成、详情与评论
|
||||||
|
|||||||
@@ -26,8 +26,9 @@ android {
|
|||||||
applicationId = "com.dadao.task"
|
applicationId = "com.dadao.task"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 1
|
// 版本号是 OTA 的唯一判据:后端只比对 version_code,改这里才算"有新版本"
|
||||||
versionName = "0.1.0"
|
versionCode = 2
|
||||||
|
versionName = "0.2.0"
|
||||||
buildConfigField("String", "API_BASE", "\"$apiBase\"")
|
buildConfigField("String", "API_BASE", "\"$apiBase\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +52,37 @@ android {
|
|||||||
keyAlias = "androiddebugkey"
|
keyAlias = "androiddebugkey"
|
||||||
keyPassword = "android"
|
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 {
|
compileOptions {
|
||||||
@@ -69,6 +101,8 @@ dependencies {
|
|||||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||||
implementation("androidx.compose.material3:material3")
|
implementation("androidx.compose.material3:material3")
|
||||||
implementation("androidx.activity:activity-compose:1.9.3")
|
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("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||||
|
|||||||
@@ -2,11 +2,15 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<!-- 应用内升级:下好的 APK 交给系统安装器 -->
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="达道Task"
|
android:label="达道Task"
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.DadaoTask">
|
android:theme="@style/Theme.DadaoTask">
|
||||||
|
|
||||||
@@ -26,5 +30,16 @@
|
|||||||
<data android:scheme="https" android:host="dadaotask.com" android:pathPrefix="/join/" />
|
<data android:scheme="https" android:host="dadaotask.com" android:pathPrefix="/join/" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import com.dadao.task.data.Api
|
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.TokenStore
|
||||||
import com.dadao.task.data.Workspace
|
import com.dadao.task.data.Workspace
|
||||||
import androidx.compose.foundation.layout.Box
|
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.RichTextEditorScreen
|
||||||
import com.dadao.task.ui.TaskListScreen
|
import com.dadao.task.ui.TaskListScreen
|
||||||
import com.dadao.task.ui.TicketsScreen
|
import com.dadao.task.ui.TicketsScreen
|
||||||
|
import com.dadao.task.ui.UpdateDialog
|
||||||
import com.dadao.task.ui.WorkspaceScaffold
|
import com.dadao.task.ui.WorkspaceScaffold
|
||||||
import com.dadao.task.ui.WorkspaceTab
|
import com.dadao.task.ui.WorkspaceTab
|
||||||
|
|
||||||
@@ -80,6 +83,9 @@ private fun AppRoot(joinUri: String?) {
|
|||||||
var reopenItem by remember { mutableStateOf<String?>(null) }
|
var reopenItem by remember { mutableStateOf<String?>(null) }
|
||||||
// 上面那条是任务还是知识:决定回哪个页面去重开
|
// 上面那条是任务还是知识:决定回哪个页面去重开
|
||||||
var reopenKnowledge by remember { mutableStateOf(false) }
|
var reopenKnowledge by remember { mutableStateOf(false) }
|
||||||
|
// 应用内升级:启动后查一次,查到了就把对话框摆出来
|
||||||
|
val updater = remember { AppUpdater(context, api) }
|
||||||
|
var update by remember { mutableStateOf<AppRelease?>(null) }
|
||||||
|
|
||||||
// 有本地令牌就先静默校验一次:令牌过期/被吊销要退回登录页,而不是进了主页再崩。
|
// 有本地令牌就先静默校验一次:令牌过期/被吊销要退回登录页,而不是进了主页再崩。
|
||||||
LaunchedEffect(screen) {
|
LaunchedEffect(screen) {
|
||||||
@@ -114,11 +120,33 @@ private fun AppRoot(joinUri: String?) {
|
|||||||
unread = api.unreadCount(ws.id)
|
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) {
|
when (screen) {
|
||||||
Screen.Splash -> Unit
|
Screen.Splash -> Unit
|
||||||
Screen.Login -> LoginScreen(api = api, onLoggedIn = { screen = Screen.Home })
|
Screen.Login -> LoginScreen(api = api, onLoggedIn = { screen = Screen.Home })
|
||||||
Screen.Home -> HomeScreen(
|
Screen.Home -> HomeScreen(
|
||||||
api = api,
|
api = api,
|
||||||
|
versionName = updater.currentVersionName,
|
||||||
|
onCheckUpdate = { checkUpdate() },
|
||||||
onLogout = {
|
onLogout = {
|
||||||
tokens.clear()
|
tokens.clear()
|
||||||
screen = Screen.Login
|
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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -236,10 +236,25 @@ data class SourceRef(
|
|||||||
val status: String = "",
|
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 形如「研发 / 部署 / 回滚」。 */
|
/** 知识分类:工作区级的三级小树,fullPath 形如「研发 / 部署 / 回滚」。 */
|
||||||
@Serializable
|
@Serializable
|
||||||
data class KnowledgeCategory(
|
data class KnowledgeCategory( val id: String,
|
||||||
val id: String,
|
|
||||||
val name: String = "",
|
val name: String = "",
|
||||||
@SerialName("parent_id") val parentId: String? = null,
|
@SerialName("parent_id") val parentId: String? = null,
|
||||||
val depth: Int = 1,
|
val depth: Int = 1,
|
||||||
@@ -472,6 +487,13 @@ class Api(private val tokens: TokenStore) {
|
|||||||
auth = true,
|
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> =
|
suspend fun members(workspaceId: String): List<Member> =
|
||||||
call<MemberPage>(path = "/workspaces/$workspaceId/members", auth = true).items
|
call<MemberPage>(path = "/workspaces/$workspaceId/members", auth = true).items
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
@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()
|
val scope = rememberCoroutineScope()
|
||||||
var user by remember { mutableStateOf<User?>(null) }
|
var user by remember { mutableStateOf<User?>(null) }
|
||||||
var workspaces by remember { mutableStateOf<List<Workspace>>(emptyList()) }
|
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 name by remember { mutableStateOf("") }
|
||||||
var busy by remember { mutableStateOf(false) }
|
var busy by remember { mutableStateOf(false) }
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
// 手动检查更新的回话:查到/没查到/失败都用一句话说清楚
|
||||||
|
var updateNotice by remember { mutableStateOf<String?>(null) }
|
||||||
|
var checking by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
suspend fun reload() {
|
suspend fun reload() {
|
||||||
runCatching { api.me() }
|
runCatching { api.me() }
|
||||||
@@ -234,6 +243,38 @@ fun HomeScreen(api: Api, onLogout: () -> Unit, onOpenWorkspace: (Workspace) -> U
|
|||||||
item { SoftPill(notice, Rust, RustSoft, Modifier.fillMaxWidth()) }
|
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)) }
|
item { Spacer(Modifier.height(40.dp)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
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.Dialog
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import com.dadao.task.data.Api
|
import com.dadao.task.data.Api
|
||||||
@@ -51,15 +53,17 @@ import com.dadao.task.data.Attachment
|
|||||||
import com.dadao.task.data.KnowledgeCategory
|
import com.dadao.task.data.KnowledgeCategory
|
||||||
import com.dadao.task.data.KnowledgeDoc
|
import com.dadao.task.data.KnowledgeDoc
|
||||||
import com.dadao.task.data.Workspace
|
import com.dadao.task.data.Workspace
|
||||||
|
import io.noties.markwon.Markwon
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
// 与 Web 一致:「全部」在第一位并且是默认——先看有什么,再去看待办
|
||||||
private val knowledgeTabs = listOf(
|
private val knowledgeTabs = listOf(
|
||||||
|
"all" to "全部",
|
||||||
"draft" to "待核对",
|
"draft" to "待核对",
|
||||||
"published" to "已发布",
|
"published" to "已发布",
|
||||||
"archived" to "已归档",
|
"archived" to "已归档",
|
||||||
"all" to "全部",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
private val docTypes = listOf(
|
private val docTypes = listOf(
|
||||||
@@ -93,7 +97,7 @@ fun KnowledgeScreen(
|
|||||||
onReopened: () -> Unit = {},
|
onReopened: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var tab by remember { mutableStateOf("draft") }
|
var tab by remember { mutableStateOf("all") }
|
||||||
var docs by remember { mutableStateOf<List<KnowledgeDoc>>(emptyList()) }
|
var docs by remember { mutableStateOf<List<KnowledgeDoc>>(emptyList()) }
|
||||||
var categories by remember { mutableStateOf<List<KnowledgeCategory>>(emptyList()) }
|
var categories by remember { mutableStateOf<List<KnowledgeCategory>>(emptyList()) }
|
||||||
var loading by remember { mutableStateOf(true) }
|
var loading by remember { mutableStateOf(true) }
|
||||||
@@ -204,7 +208,13 @@ fun KnowledgeScreen(
|
|||||||
if (!loading && docs.isEmpty()) {
|
if (!loading && docs.isEmpty()) {
|
||||||
item {
|
item {
|
||||||
EmptyState(
|
EmptyState(
|
||||||
if (catFilter == null) "还没有待核对的知识" else "这个分类下还没有知识",
|
if (catFilter != null) {
|
||||||
|
"这个分类下还没有知识"
|
||||||
|
} else if (tab == "all") {
|
||||||
|
"还没有知识"
|
||||||
|
} else {
|
||||||
|
"这一类里还没有知识"
|
||||||
|
},
|
||||||
"把工单验收通过,或在任务详情里点「沉淀成知识」",
|
"把工单验收通过,或在任务详情里点「沉淀成知识」",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -299,6 +309,37 @@ fun KnowledgeScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 正文的「看」形态:Markwon 把 Markdown 排成排版好的正文。
|
||||||
|
*
|
||||||
|
* 编辑走 RichTextEditorScreen,所以这里永远是只读——点开一条知识先看到内容本身,
|
||||||
|
* 而不是一堆 `##`、`-` 原样摆着。
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun KnowledgeBody(text: String) {
|
||||||
|
if (text.isBlank()) {
|
||||||
|
Text("(还没有正文,点「编辑正文」补齐)", fontSize = 13.sp, color = InkMuted)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val context = LocalContext.current
|
||||||
|
val markwon = remember { Markwon.create(context) }
|
||||||
|
AndroidView(
|
||||||
|
factory = { ctx ->
|
||||||
|
android.widget.TextView(ctx).apply {
|
||||||
|
setTextIsSelectable(true)
|
||||||
|
textSize = 14f
|
||||||
|
setLineSpacing(0f, 1.5f)
|
||||||
|
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 {
|
private fun sourceLine(doc: KnowledgeDoc): String {
|
||||||
val src = doc.source
|
val src = doc.source
|
||||||
return if (src != null && src.title.isNotBlank()) {
|
return if (src != null && src.title.isNotBlank()) {
|
||||||
@@ -537,11 +578,8 @@ private fun KnowledgeDetailDialog(
|
|||||||
) {
|
) {
|
||||||
Text(sourceLine(doc), style = MaterialTheme.typography.labelSmall, color = InkMuted)
|
Text(sourceLine(doc), style = MaterialTheme.typography.labelSmall, color = InkMuted)
|
||||||
Spacer(Modifier.height(10.dp))
|
Spacer(Modifier.height(10.dp))
|
||||||
Text(
|
// 点开就是「看」:正文用 Markwon 排好版再显示,要看原文/改内容点「编辑正文」
|
||||||
doc.bodyMd.ifBlank { "(还没有正文,点「编辑正文」补齐)" },
|
KnowledgeBody(doc.bodyMd)
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = InkSoft,
|
|
||||||
)
|
|
||||||
|
|
||||||
// 附件:手册、日志、导出表格都能挂在这条知识下,点一下用浏览器打开
|
// 附件:手册、日志、导出表格都能挂在这条知识下,点一下用浏览器打开
|
||||||
Spacer(Modifier.height(14.dp))
|
Spacer(Modifier.height(14.dp))
|
||||||
@@ -587,8 +625,8 @@ private fun KnowledgeDetailDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 已发布的内容不能直接改,分类/标签也跟着锁住,避免"发布后又悄悄换归属"
|
// 已发布的也能改(后端改完会重建检索切片);归档的先恢复成草稿再动,免得"收起来的"被悄悄改写
|
||||||
if (action != "published") {
|
if (action != "archived") {
|
||||||
Spacer(Modifier.height(14.dp))
|
Spacer(Modifier.height(14.dp))
|
||||||
SectionLabel("分类")
|
SectionLabel("分类")
|
||||||
Row(
|
Row(
|
||||||
@@ -644,14 +682,14 @@ private fun KnowledgeDetailDialog(
|
|||||||
title = doc.title,
|
title = doc.title,
|
||||||
body = doc.bodyMd,
|
body = doc.bodyMd,
|
||||||
version = version,
|
version = version,
|
||||||
titleEnabled = action == "draft",
|
titleEnabled = action != "archived",
|
||||||
knowledge = true,
|
knowledge = true,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
onClose()
|
onClose()
|
||||||
},
|
},
|
||||||
) { Text("编辑正文") }
|
) { Text("编辑正文") }
|
||||||
if (action != "published") {
|
if (action != "archived") {
|
||||||
TextButton(
|
TextButton(
|
||||||
enabled = !savingMeta,
|
enabled = !savingMeta,
|
||||||
onClick = {
|
onClick = {
|
||||||
|
|||||||
@@ -350,7 +350,7 @@ private fun PreviewBody(
|
|||||||
textSize = 15f
|
textSize = 15f
|
||||||
setPadding(0, 0, 0, 0)
|
setPadding(0, 0, 0, 0)
|
||||||
setLineSpacing(0f, 1.5f)
|
setLineSpacing(0f, 1.5f)
|
||||||
setTextColor(android.graphics.Color.parseColor("#4A5854"))
|
setTextColor(android.graphics.Color.parseColor("#475569"))
|
||||||
markwon.setMarkdown(this, text)
|
markwon.setMarkdown(this, text)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,22 +13,22 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 与 Web 端 tailwind.config.js 同一套色板:暖白纸面 + 墨松绿主色 + 陶土橙强调。
|
* 与 Web 端 tailwind.config.js 同一套色板:冷白纸面 + 靛蓝主色(#4F46E5)+ 陶土橙强调。
|
||||||
* 两端看同一张截图时,颜色应该能对上,所以这里的值不要单独调。
|
* 两端看同一张截图时,颜色应该能对上,所以这里的值不要单独调。
|
||||||
*/
|
*/
|
||||||
val Paper = Color(0xFFFBF9F6)
|
val Paper = Color(0xFFFAFAFC)
|
||||||
val PaperSunken = Color(0xFFF3F0EA)
|
val PaperSunken = Color(0xFFF4F5F9)
|
||||||
val Line = Color(0xFFE4E0D8)
|
val Line = Color(0xFFE3E7EF)
|
||||||
val LineStrong = Color(0xFFD3CEC2)
|
val LineStrong = Color(0xFFD5DAE4)
|
||||||
|
|
||||||
val Ink = Color(0xFF1F2A28)
|
val Ink = Color(0xFF1E293B)
|
||||||
val InkSoft = Color(0xFF4A5854)
|
val InkSoft = Color(0xFF475569)
|
||||||
val InkMuted = Color(0xFF7C8985)
|
val InkMuted = Color(0xFF64748B)
|
||||||
|
|
||||||
val Primary = Color(0xFF2A5A50)
|
val Primary = Color(0xFF4F46E5)
|
||||||
val PrimaryDeep = Color(0xFF1E443C)
|
val PrimaryDeep = Color(0xFF4338CA)
|
||||||
val PrimarySoft = Color(0xFFE3EDE8)
|
val PrimarySoft = Color(0xFFE0E7FF)
|
||||||
val PrimaryTint = Color(0xFFD2E3DB)
|
val PrimaryTint = Color(0xFFC7D2FE)
|
||||||
|
|
||||||
val Clay = Color(0xFFC2703D)
|
val Clay = Color(0xFFC2703D)
|
||||||
val ClaySoft = Color(0xFFFBEADF)
|
val ClaySoft = Color(0xFFFBEADF)
|
||||||
@@ -39,8 +39,8 @@ val Wheat = Color(0xFFA97C25)
|
|||||||
val WheatSoft = Color(0xFFF8EED7)
|
val WheatSoft = Color(0xFFF8EED7)
|
||||||
val Rust = Color(0xFFB04A32)
|
val Rust = Color(0xFFB04A32)
|
||||||
val RustSoft = Color(0xFFFBE5DF)
|
val RustSoft = Color(0xFFFBE5DF)
|
||||||
val Sky = Color(0xFF3E6F91)
|
val Sky = Color(0xFF16697C)
|
||||||
val SkySoft = Color(0xFFE4EEF5)
|
val SkySoft = Color(0xFFE6F4F6)
|
||||||
|
|
||||||
private val colors = lightColorScheme(
|
private val colors = lightColorScheme(
|
||||||
primary = Primary,
|
primary = Primary,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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>
|
<resources>
|
||||||
<!-- Compose 自己管配色,这里只提供无 ActionBar 的容器主题 -->
|
<!-- Compose 自己管配色,这里只提供无 ActionBar 的容器主题 -->
|
||||||
<style name="Theme.DadaoTask" parent="android:Theme.Material.Light.NoActionBar">
|
<style name="Theme.DadaoTask" parent="android:Theme.Material.Light.NoActionBar">
|
||||||
<item name="android:statusBarColor">#3F5A68</item>
|
<item name="android:statusBarColor">#4F46E5</item>
|
||||||
<item name="android:windowBackground">#F7F8F8</item>
|
<item name="android:windowBackground">#FAFAFC</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</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"?>
|
<?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>
|
<network-security-config>
|
||||||
<domain-config cleartextTrafficPermitted="true">
|
<domain-config cleartextTrafficPermitted="true">
|
||||||
<domain includeSubdomains="false">10.0.2.2</domain>
|
<domain includeSubdomains="false">10.0.2.2</domain>
|
||||||
<domain includeSubdomains="false">127.0.0.1</domain>
|
<domain includeSubdomains="false">127.0.0.1</domain>
|
||||||
<domain includeSubdomains="false">localhost</domain>
|
<domain includeSubdomains="false">localhost</domain>
|
||||||
|
<domain includeSubdomains="false">192.168.8.188</domain>
|
||||||
</domain-config>
|
</domain-config>
|
||||||
</network-security-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,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,82 @@
|
|||||||
"""认证:手机号 + 验证码。M0 不做密码、不做第三方登录。"""
|
"""认证:手机号 / 邮箱 + 密码,手机验证码可选。
|
||||||
|
|
||||||
|
当前**默认关掉短信验证码**(`SMS_LOGIN_ENABLED=false`):内测阶段的固定验证码
|
||||||
|
等于"知道手机号就能进任意账号"。关掉后 `/auth/sms/send` 与 `/auth/login` 直接
|
||||||
|
回 2017,登录走「手机号 或 邮箱 + 密码」。登录页先问 `/auth/methods`,只显示
|
||||||
|
服务端真开着的登录方式,免得点进去才报错。
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hmac
|
import hmac
|
||||||
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, model_validator
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
from ..config import get_settings
|
from ..config import get_settings
|
||||||
from ..deps import CurrentUser, SessionDep
|
from ..deps import CurrentUser, SessionDep
|
||||||
from ..errors import AppError, ErrorCode
|
from ..errors import AppError, ErrorCode
|
||||||
from ..models import AppUser, Member, Workspace
|
from ..models import AppUser, Member, Workspace
|
||||||
from ..schemas import ok
|
from ..schemas import ok
|
||||||
from ..security import create_token_pair, decode_token
|
from ..security import (
|
||||||
|
create_token_pair,
|
||||||
|
decode_token,
|
||||||
|
hash_password,
|
||||||
|
password_problem,
|
||||||
|
verify_password,
|
||||||
|
)
|
||||||
from ..stores import get_store, sms_code_key, sms_throttle_key
|
from ..stores import get_store, sms_code_key, sms_throttle_key
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["认证"])
|
router = APIRouter(prefix="/auth", tags=["认证"])
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
PHONE_PATTERN = r"^1[3-9]\d{9}$"
|
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) -> str:
|
||||||
return f"{phone[:3]}****{phone[-4:]}" if len(phone) == 11 else "***"
|
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,
|
||||||
|
"nickname": user.nickname,
|
||||||
|
"avatar_url": user.avatar_url,
|
||||||
|
"has_password": bool(user.password_hash),
|
||||||
|
"platform_admin": 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):
|
class SendCodeIn(BaseModel):
|
||||||
phone: str = Field(pattern=PHONE_PATTERN, examples=["13800138000"])
|
phone: str = Field(pattern=PHONE_PATTERN, examples=["13800138000"])
|
||||||
|
|
||||||
@@ -36,12 +86,56 @@ class LoginIn(BaseModel):
|
|||||||
code: str = Field(min_length=4, max_length=8)
|
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):
|
class RefreshIn(BaseModel):
|
||||||
refresh_token: str = Field(min_length=10)
|
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,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sms/send", summary="发送登录验证码")
|
@router.post("/sms/send", summary="发送登录验证码")
|
||||||
async def send_code(body: SendCodeIn) -> dict:
|
async def send_code(body: SendCodeIn) -> dict:
|
||||||
|
if not settings.sms_login_enabled:
|
||||||
|
raise AppError(
|
||||||
|
ErrorCode.LOGIN_METHOD_DISABLED,
|
||||||
|
"短信验证码登录暂未开放,请用手机号或邮箱 + 密码登录",
|
||||||
|
)
|
||||||
store = get_store()
|
store = get_store()
|
||||||
if settings.sms_throttle_seconds > 0:
|
if settings.sms_throttle_seconds > 0:
|
||||||
hits = await store.incr(sms_throttle_key(body.phone), settings.sms_throttle_seconds)
|
hits = await store.incr(sms_throttle_key(body.phone), settings.sms_throttle_seconds)
|
||||||
@@ -64,6 +158,11 @@ async def send_code(body: SendCodeIn) -> dict:
|
|||||||
|
|
||||||
@router.post("/login", summary="验证码登录(不存在则自动注册)")
|
@router.post("/login", summary="验证码登录(不存在则自动注册)")
|
||||||
async def login(body: LoginIn, session: SessionDep) -> dict:
|
async def login(body: LoginIn, session: SessionDep) -> dict:
|
||||||
|
if not settings.sms_login_enabled:
|
||||||
|
raise AppError(
|
||||||
|
ErrorCode.LOGIN_METHOD_DISABLED,
|
||||||
|
"短信验证码登录暂未开放,请用手机号或邮箱 + 密码登录",
|
||||||
|
)
|
||||||
store = get_store()
|
store = get_store()
|
||||||
stored = await store.get(sms_code_key(body.phone))
|
stored = await store.get(sms_code_key(body.phone))
|
||||||
if stored is None:
|
if stored is None:
|
||||||
@@ -81,40 +180,62 @@ async def login(body: LoginIn, session: SessionDep) -> dict:
|
|||||||
await session.flush()
|
await session.flush()
|
||||||
|
|
||||||
tokens = create_token_pair(user.id)
|
tokens = create_token_pair(user.id)
|
||||||
return ok(
|
return ok({"user": _user_out(user), "tokens": _tokens_out(tokens)})
|
||||||
{
|
|
||||||
"user": {
|
|
||||||
"id": str(user.id),
|
@router.post("/login/password", summary="手机号 / 邮箱 + 密码登录")
|
||||||
"phone": mask_phone(user.phone),
|
async def login_password(body: PasswordLoginIn, session: SessionDep) -> dict:
|
||||||
"nickname": user.nickname,
|
"""手机号或邮箱 + 密码登录。
|
||||||
"created_at": user.created_at,
|
|
||||||
},
|
三个细节:
|
||||||
"tokens": {
|
- **不区分"没这个人"和"密码错"**,同一个错误码,别让登录接口变成账号探测工具。
|
||||||
"access_token": tokens.access_token,
|
- 连错 PASSWORD_FAIL_LIMIT 次锁 PASSWORD_FAIL_TTL 秒;成功即清零,正常用户不会感知。
|
||||||
"refresh_token": tokens.refresh_token,
|
失败计数按"填的那个账号"记,换手机号/邮箱轮着试也照样累计。
|
||||||
"token_type": "bearer",
|
- 邮箱大小写不敏感(存的时候已经统一小写,这里再 lower 一次兜住历史数据)。
|
||||||
"expires_in": tokens.expires_in,
|
"""
|
||||||
},
|
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="刷新访问令牌")
|
@router.post("/refresh", summary="刷新访问令牌")
|
||||||
async def refresh(body: RefreshIn) -> dict:
|
async def refresh(body: RefreshIn) -> dict:
|
||||||
user_id = decode_token(body.refresh_token, "refresh")
|
user_id = decode_token(body.refresh_token, "refresh")
|
||||||
tokens = create_token_pair(user_id)
|
tokens = create_token_pair(user_id)
|
||||||
return ok(
|
# 这里的形状保持扁平(不带 tokens 信封),老客户端与安卓端都按这个取
|
||||||
{
|
return ok(_tokens_out(tokens))
|
||||||
"access_token": tokens.access_token,
|
|
||||||
"refresh_token": tokens.refresh_token,
|
|
||||||
"token_type": "bearer",
|
|
||||||
"expires_in": tokens.expires_in,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", summary="当前用户与所属工作区")
|
@router.get("/me", summary="当前用户与所属工作区")
|
||||||
async def me(user: CurrentUser, session: SessionDep) -> dict:
|
async def me(user: CurrentUser, session: SessionDep) -> dict:
|
||||||
|
row = await session.get(AppUser, user.id)
|
||||||
|
if row is None:
|
||||||
|
raise AppError(ErrorCode.UNAUTHORIZED, "账号不存在")
|
||||||
|
|
||||||
rows = (
|
rows = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
select(Workspace, Member.role)
|
select(Workspace, Member.role)
|
||||||
@@ -126,11 +247,7 @@ async def me(user: CurrentUser, session: SessionDep) -> dict:
|
|||||||
|
|
||||||
return ok(
|
return ok(
|
||||||
{
|
{
|
||||||
"user": {
|
"user": _user_out(row),
|
||||||
"id": str(user.id),
|
|
||||||
"phone": mask_phone(user.phone),
|
|
||||||
"nickname": user.nickname,
|
|
||||||
},
|
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
{
|
{
|
||||||
"id": str(ws.id),
|
"id": str(ws.id),
|
||||||
@@ -144,3 +261,63 @@ 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})
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
@@ -73,6 +74,8 @@ class CategoryIn(BaseModel):
|
|||||||
name: str = Field(min_length=1, max_length=40)
|
name: str = Field(min_length=1, max_length=40)
|
||||||
parent_id: uuid.UUID | None = None
|
parent_id: uuid.UUID | None = None
|
||||||
sort_order: int = 0
|
sort_order: int = 0
|
||||||
|
# 分类色,卡片上边线用它。不传就不上色(前端按 id 稳定地挑一个默认色)。
|
||||||
|
color: str | None = Field(default=None, max_length=7)
|
||||||
|
|
||||||
|
|
||||||
class CategoryPatch(BaseModel):
|
class CategoryPatch(BaseModel):
|
||||||
@@ -80,6 +83,8 @@ class CategoryPatch(BaseModel):
|
|||||||
parent_id: uuid.UUID | None = None
|
parent_id: uuid.UUID | None = None
|
||||||
move: bool = False
|
move: bool = False
|
||||||
sort_order: int | None = None
|
sort_order: int | None = None
|
||||||
|
# 不传 = 不改;传空串 = 清成默认色(分类色是“可选项”,得能取消)。
|
||||||
|
color: str | None = Field(default=None, max_length=7)
|
||||||
version: int
|
version: int
|
||||||
|
|
||||||
|
|
||||||
@@ -147,6 +152,29 @@ def _doc_out(doc: KnowledgeDoc) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
# ── 分类 ────────────────────────────────────────────────────────────────
|
# ── 分类 ────────────────────────────────────────────────────────────────
|
||||||
|
_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(
|
async def _categories(
|
||||||
session: AsyncSession, ws_id: uuid.UUID
|
session: AsyncSession, ws_id: uuid.UUID
|
||||||
) -> dict[uuid.UUID, KnowledgeCategory]:
|
) -> dict[uuid.UUID, KnowledgeCategory]:
|
||||||
@@ -175,12 +203,38 @@ def _category_payload(
|
|||||||
"name": cat.name,
|
"name": cat.name,
|
||||||
"depth": cat.depth,
|
"depth": cat.depth,
|
||||||
"sort_order": cat.sort_order,
|
"sort_order": cat.sort_order,
|
||||||
|
"color": cat.color,
|
||||||
"version": cat.version,
|
"version": cat.version,
|
||||||
"path": names,
|
"path": names,
|
||||||
"full_path": " / ".join(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]:
|
def _subtree_ids(root_id: uuid.UUID, by_id: dict[uuid.UUID, KnowledgeCategory]) -> list[uuid.UUID]:
|
||||||
children: dict[uuid.UUID, list[uuid.UUID]] = {}
|
children: dict[uuid.UUID, list[uuid.UUID]] = {}
|
||||||
for cat in by_id.values():
|
for cat in by_id.values():
|
||||||
@@ -303,7 +357,7 @@ async def list_knowledge(
|
|||||||
stmt = stmt.where(
|
stmt = stmt.where(
|
||||||
or_(
|
or_(
|
||||||
Item.updated_at < cur.created_at,
|
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 +412,7 @@ async def list_categories(ctx: MemberDep, session: SessionDep) -> dict:
|
|||||||
).all()
|
).all()
|
||||||
)
|
)
|
||||||
items = []
|
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 = _category_payload(cat, by_id)
|
||||||
data["doc_count"] = counts.get(cat.id, 0)
|
data["doc_count"] = counts.get(cat.id, 0)
|
||||||
items.append(data)
|
items.append(data)
|
||||||
@@ -387,6 +441,7 @@ async def create_category(body: CategoryIn, ctx: MemberDep, session: SessionDep)
|
|||||||
name=name,
|
name=name,
|
||||||
depth=depth,
|
depth=depth,
|
||||||
sort_order=body.sort_order,
|
sort_order=body.sort_order,
|
||||||
|
color=_norm_color(body.color),
|
||||||
)
|
)
|
||||||
session.add(cat)
|
session.add(cat)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
@@ -447,6 +502,9 @@ async def patch_category(
|
|||||||
values.update(parent_id=new_parent_id, depth=new_depth)
|
values.update(parent_id=new_parent_id, depth=new_depth)
|
||||||
if body.sort_order is not None:
|
if body.sort_order is not None:
|
||||||
values["sort_order"] = body.sort_order
|
values["sort_order"] = body.sort_order
|
||||||
|
# 只传了 color 才算改色;没传保持原样,传空串等于取消上色。
|
||||||
|
if body.color is not None:
|
||||||
|
values["color"] = _norm_color(body.color)
|
||||||
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
update(KnowledgeCategory)
|
update(KnowledgeCategory)
|
||||||
@@ -636,11 +694,12 @@ async def patch_knowledge(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
ws_id = ctx.ws_id
|
ws_id = ctx.ws_id
|
||||||
item, doc = await _load_doc(session, ws_id, item_id)
|
item, doc = await _load_doc(session, ws_id, item_id)
|
||||||
if item.status == "published":
|
if item.status == "archived":
|
||||||
|
# 已归档是先收起来,不是不能改:要改先恢复成草稿,免得改完"发布着"的样子被人当成现役
|
||||||
raise AppError(
|
raise AppError(
|
||||||
ErrorCode.KNOWLEDGE_STATUS_INVALID,
|
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:
|
if body.doc_type is not None and body.doc_type not in DOC_TYPES:
|
||||||
raise AppError(ErrorCode.VALIDATION_ERROR, f"未知的知识类型:{body.doc_type}")
|
raise AppError(ErrorCode.VALIDATION_ERROR, f"未知的知识类型:{body.doc_type}")
|
||||||
@@ -689,6 +748,11 @@ async def patch_knowledge(
|
|||||||
if body.tag_names is not None:
|
if body.tag_names is not None:
|
||||||
await _set_tags_for(session, ws_id, item, body.tag_names)
|
await _set_tags_for(session, ws_id, item, body.tag_names)
|
||||||
await session.flush()
|
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))
|
return ok(await _knowledge_out(session, ws_id, item, doc))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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})
|
||||||
@@ -4,10 +4,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import Literal
|
from typing import Annotated, Literal
|
||||||
|
|
||||||
from pydantic import Field, field_validator
|
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 - 仅开发默认值,生产启动时会被校验拦下
|
DEV_JWT_SECRET = "dev-only-change-me" # noqa: S105 - 仅开发默认值,生产启动时会被校验拦下
|
||||||
|
|
||||||
@@ -60,6 +60,30 @@ class Settings(BaseSettings):
|
|||||||
# 内测开关:生产默认禁止固定验证码。确实要在内网试用时显式打开,
|
# 内测开关:生产默认禁止固定验证码。确实要在内网试用时显式打开,
|
||||||
# 启动会打 WARNING(见 main.py),其余生产校验照旧——绝不能配合公网暴露。
|
# 启动会打 WARNING(见 main.py),其余生产校验照旧——绝不能配合公网暴露。
|
||||||
internal_test_login: bool = False
|
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
|
||||||
|
|
||||||
# ── 幂等 ──────────────────────────────────────────────
|
# ── 幂等 ──────────────────────────────────────────────
|
||||||
idempotency_enabled: bool = True
|
idempotency_enabled: bool = True
|
||||||
@@ -108,14 +132,19 @@ class Settings(BaseSettings):
|
|||||||
# 指向 web/dist 的绝对路径;留空则只提供 API。
|
# 指向 web/dist 的绝对路径;留空则只提供 API。
|
||||||
web_dist_dir: str | None = None
|
web_dist_dir: str | None = None
|
||||||
|
|
||||||
|
# ── 安卓安装包(OTA)──────────────────────────────────
|
||||||
|
# 放 latest.json + 对应 apk 的目录;留空则用 backend/static/apk。
|
||||||
|
# 发布目录是构建产物,不入库,部署脚本也不清理(git reset 不动未跟踪文件)。
|
||||||
|
apk_dir: str | None = None
|
||||||
|
|
||||||
# ── 构建标记(部署校验用)──────────────────────────────
|
# ── 构建标记(部署校验用)──────────────────────────────
|
||||||
build_commit: str = "unknown"
|
build_commit: str = "unknown"
|
||||||
build_time: str = "unknown"
|
build_time: str = "unknown"
|
||||||
|
|
||||||
@field_validator("cors_origins", mode="before")
|
@field_validator("cors_origins", "platform_admin_phones", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _split_origins(cls, value: object) -> object:
|
def _split_origins(cls, value: object) -> object:
|
||||||
"""支持 CORS_ORIGINS="a,b" 这种写法。"""
|
"""支持 CORS_ORIGINS="a,b" 这种写法(平台管理员白名单同理)。"""
|
||||||
if isinstance(value, str) and not value.startswith("["):
|
if isinstance(value, str) and not value.startswith("["):
|
||||||
return [item.strip() for item in value.split(",") if item.strip()]
|
return [item.strip() for item in value.split(",") if item.strip()]
|
||||||
return value
|
return value
|
||||||
@@ -129,10 +158,10 @@ class Settings(BaseSettings):
|
|||||||
problems.append("JWT_SECRET 仍是开发默认值或长度不足 32")
|
problems.append("JWT_SECRET 仍是开发默认值或长度不足 32")
|
||||||
if "dadao_dev_pw" in self.database_url:
|
if "dadao_dev_pw" in self.database_url:
|
||||||
problems.append("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(
|
problems.append(
|
||||||
"生产环境不允许 SMS_PROVIDER=dev(任意验证码可登录);"
|
"短信验证码登录开着(SMS_LOGIN_ENABLED=true),但 SMS_PROVIDER=dev 只会发固定验证码"
|
||||||
"内网试用确需固定验证码时显式设置 INTERNAL_TEST_LOGIN=true"
|
"(等于知道手机号就能进);接真短信通道之前请把 SMS_LOGIN_ENABLED 关掉"
|
||||||
)
|
)
|
||||||
if problems:
|
if problems:
|
||||||
raise RuntimeError("配置校验失败:" + ";".join(problems))
|
raise RuntimeError("配置校验失败:" + ";".join(problems))
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from fastapi import Depends, Header, Path
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from .config import get_settings
|
||||||
from .db import bind_tenant, bind_user, get_session
|
from .db import bind_tenant, bind_user, get_session
|
||||||
from .errors import AppError, ErrorCode
|
from .errors import AppError, ErrorCode
|
||||||
from .logging_setup import user_id_ctx, workspace_id_ctx
|
from .logging_setup import user_id_ctx, workspace_id_ctx
|
||||||
@@ -112,3 +113,18 @@ def require_role(minimum: str):
|
|||||||
|
|
||||||
OwnerDep = Annotated[TenantCtx, Depends(require_role("owner"))]
|
OwnerDep = Annotated[TenantCtx, Depends(require_role("owner"))]
|
||||||
AdminDep = Annotated[TenantCtx, Depends(require_role("admin"))]
|
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,14 @@ class ErrorCode(IntEnum):
|
|||||||
SMS_CODE_INVALID = 2010
|
SMS_CODE_INVALID = 2010
|
||||||
SMS_TOO_FREQUENT = 2011
|
SMS_TOO_FREQUENT = 2011
|
||||||
ACCOUNT_DISABLED = 2012
|
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
|
||||||
|
|
||||||
# 3xxx 租户 / 权限
|
# 3xxx 租户 / 权限
|
||||||
WORKSPACE_NOT_FOUND = 3001
|
WORKSPACE_NOT_FOUND = 3001
|
||||||
@@ -66,6 +74,9 @@ class ErrorCode(IntEnum):
|
|||||||
ATTACHMENT_FORBIDDEN = 8003
|
ATTACHMENT_FORBIDDEN = 8003
|
||||||
CONFIRM_REQUIRED = 8004
|
CONFIRM_REQUIRED = 8004
|
||||||
|
|
||||||
|
# 9xxx 客户端发布(OTA)
|
||||||
|
APP_RELEASE_NOT_FOUND = 9001
|
||||||
|
|
||||||
|
|
||||||
# 默认 HTTP 状态:业务码自己带语义,HTTP 只是传输层
|
# 默认 HTTP 状态:业务码自己带语义,HTTP 只是传输层
|
||||||
_HTTP_STATUS: dict[ErrorCode, int] = {
|
_HTTP_STATUS: dict[ErrorCode, int] = {
|
||||||
@@ -83,6 +94,14 @@ _HTTP_STATUS: dict[ErrorCode, int] = {
|
|||||||
ErrorCode.SMS_CODE_INVALID: 400,
|
ErrorCode.SMS_CODE_INVALID: 400,
|
||||||
ErrorCode.SMS_TOO_FREQUENT: 429,
|
ErrorCode.SMS_TOO_FREQUENT: 429,
|
||||||
ErrorCode.ACCOUNT_DISABLED: 403,
|
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.WORKSPACE_NOT_FOUND: 404,
|
ErrorCode.WORKSPACE_NOT_FOUND: 404,
|
||||||
ErrorCode.NOT_A_MEMBER: 403,
|
ErrorCode.NOT_A_MEMBER: 403,
|
||||||
ErrorCode.INSUFFICIENT_ROLE: 403,
|
ErrorCode.INSUFFICIENT_ROLE: 403,
|
||||||
@@ -111,6 +130,7 @@ _HTTP_STATUS: dict[ErrorCode, int] = {
|
|||||||
ErrorCode.ATTACHMENT_TOO_LARGE: 413,
|
ErrorCode.ATTACHMENT_TOO_LARGE: 413,
|
||||||
ErrorCode.ATTACHMENT_FORBIDDEN: 403,
|
ErrorCode.ATTACHMENT_FORBIDDEN: 403,
|
||||||
ErrorCode.CONFIRM_REQUIRED: 409,
|
ErrorCode.CONFIRM_REQUIRED: 409,
|
||||||
|
ErrorCode.APP_RELEASE_NOT_FOUND: 404,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ from starlette.responses import Response
|
|||||||
from starlette.staticfiles import StaticFiles
|
from starlette.staticfiles import StaticFiles
|
||||||
from starlette.types import Scope
|
from starlette.types import Scope
|
||||||
|
|
||||||
from .api import attachments, auth, items, knowledge, tickets, workspaces
|
from .api import app_release, attachments, auth, items, knowledge, tickets, workspaces
|
||||||
|
from .api import settings as settings_api
|
||||||
from .config import get_settings
|
from .config import get_settings
|
||||||
from .db import SessionFactory, current_app_role, engine
|
from .db import SessionFactory, current_app_role, engine
|
||||||
from .errors import AppError, ErrorCode, http_status_for
|
from .errors import AppError, ErrorCode, http_status_for
|
||||||
@@ -31,9 +32,9 @@ logger = get_logger("app")
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
settings.validate_runtime()
|
settings.validate_runtime()
|
||||||
if settings.env == "prod" and settings.internal_test_login:
|
if settings.env == "prod" and settings.sms_login_enabled:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"内测登录已开启:任意手机号可用固定验证码登录,只能在内网访问,切勿暴露公网",
|
"短信验证码登录开着,只能配真通道;用 dev 固定码等于知道手机号就能进任何账号",
|
||||||
extra={"extra_fields": {"sms_provider": settings.sms_provider}},
|
extra={"extra_fields": {"sms_provider": settings.sms_provider}},
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -115,6 +116,8 @@ app.include_router(tickets.router, prefix=settings.api_prefix)
|
|||||||
app.include_router(tickets.notify_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(knowledge.router, prefix=settings.api_prefix)
|
||||||
app.include_router(attachments.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.get("/health", tags=["运维"], summary="存活探针")
|
@app.get("/health", tags=["运维"], summary="存活探针")
|
||||||
|
|||||||
@@ -45,6 +45,27 @@ class AppUser(Base, Timestamps):
|
|||||||
nickname: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
nickname: Mapped[str] = mapped_column(String(64), nullable=False, default="")
|
||||||
avatar_url: Mapped[str | None] = mapped_column(String(512))
|
avatar_url: Mapped[str | None] = mapped_column(String(512))
|
||||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
|
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))
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
class Workspace(Base, Timestamps):
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ class KnowledgeCategory(Base, Timestamps):
|
|||||||
|
|
||||||
`depth` 是冗余列,但值得:每次筛选、移动子树都要判断层级,存下来就不用
|
`depth` 是冗余列,但值得:每次筛选、移动子树都要判断层级,存下来就不用
|
||||||
递归数祖先。写入时由 API 计算,读到不一致的行属于数据事故。
|
递归数祖先。写入时由 API 计算,读到不一致的行属于数据事故。
|
||||||
|
|
||||||
|
`color` 是给人看的分类色(`#RRGGBB`)。它不参与任何判断,只负责让
|
||||||
|
"这条知识属于哪一类"在卡片墙上一眼分得出来——所以允许为空(前端给默认色),
|
||||||
|
但一旦有值就必须是合法十六进制,否则渲染会静默丢样式。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "knowledge_category"
|
__tablename__ = "knowledge_category"
|
||||||
@@ -55,6 +59,7 @@ class KnowledgeCategory(Base, Timestamps):
|
|||||||
Index("ix_kcat_ws_parent", "workspace_id", "parent_id"),
|
Index("ix_kcat_ws_parent", "workspace_id", "parent_id"),
|
||||||
CheckConstraint("depth BETWEEN 1 AND 3", name="ck_kcat_depth"),
|
CheckConstraint("depth BETWEEN 1 AND 3", name="ck_kcat_depth"),
|
||||||
CheckConstraint("length(btrim(name)) > 0", name="ck_kcat_name"),
|
CheckConstraint("length(btrim(name)) > 0", name="ck_kcat_name"),
|
||||||
|
CheckConstraint("color IS NULL OR color ~ '^#[0-9A-F]{6}$'", name="ck_kcat_color"),
|
||||||
{"comment": "知识分类树,深不过三"},
|
{"comment": "知识分类树,深不过三"},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -68,6 +73,7 @@ class KnowledgeCategory(Base, Timestamps):
|
|||||||
name: Mapped[str] = mapped_column(String(40), nullable=False)
|
name: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||||
depth: Mapped[int] = mapped_column(nullable=False, default=1)
|
depth: Mapped[int] = mapped_column(nullable=False, default=1)
|
||||||
sort_order: Mapped[int] = mapped_column(nullable=False, default=0)
|
sort_order: Mapped[int] = mapped_column(nullable=False, default=0)
|
||||||
|
color: Mapped[str | None] = mapped_column(String(7))
|
||||||
|
|
||||||
|
|
||||||
class KnowledgeDoc(Base, Timestamps):
|
class KnowledgeDoc(Base, Timestamps):
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import secrets
|
import secrets
|
||||||
import time
|
import time
|
||||||
@@ -78,3 +80,64 @@ def new_invite_code() -> str:
|
|||||||
|
|
||||||
def invite_code_matches(provided: str, expected: str) -> bool:
|
def invite_code_matches(provided: str, expected: str) -> bool:
|
||||||
return hmac.compare_digest(provided.strip().upper(), expected)
|
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,202 @@
|
|||||||
|
"""发信(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,
|
||||||
|
}
|
||||||
@@ -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")
|
os.environ.setdefault("SMS_DEV_CODE", "123456")
|
||||||
# 测试里关掉发码节流,否则"同一手机号再登录一次"要等 60 秒
|
# 测试里关掉发码节流,否则"同一手机号再登录一次"要等 60 秒
|
||||||
os.environ.setdefault("SMS_THROTTLE_SECONDS", "0")
|
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),
|
# 测试库由 config 的 test_database_url / test_migration_database_url 决定(见 backend/.env),
|
||||||
# 这里不再改 os.environ —— 配置对象自己会读 .env。
|
# 这里不再改 os.environ —— 配置对象自己会读 .env。
|
||||||
|
|
||||||
@@ -36,7 +39,7 @@ from app.stores import reset_store_for_tests
|
|||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
DEV_CODE = settings.sms_dev_code
|
DEV_CODE = settings.sms_dev_code
|
||||||
|
|
||||||
_TABLES = "audit_log, invitation, member, workspace, app_user"
|
_TABLES = "system_setting, audit_log, invitation, member, workspace, app_user"
|
||||||
|
|
||||||
|
|
||||||
def _run_migrations() -> None:
|
def _run_migrations() -> None:
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
"""账号功能:昵称、密码设置/修改、密码登录。
|
||||||
|
|
||||||
|
这些都是"账号被占"的高风险入口,所以断言不只看 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",
|
||||||
|
"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
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""OTA:最新版本清单与安装包下载。
|
||||||
|
|
||||||
|
不依赖数据库,也不需要登录——App 还没登录就可能在检查更新。
|
||||||
|
发布目录用 tmp_path 顶替,绝不去碰真实产物。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
from app.api import app_release
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def release_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||||
|
monkeypatch.setattr(app_release.settings, "apk_dir", str(tmp_path))
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
def _publish(directory: Path, *, version_code: int = 2, version_name: str = "0.2.0") -> bytes:
|
||||||
|
payload = b"PK\x03\x04 fake apk bytes"
|
||||||
|
name = f"dadaotask-{version_name}.apk"
|
||||||
|
(directory / name).write_bytes(payload)
|
||||||
|
manifest = {
|
||||||
|
"platform": "android",
|
||||||
|
"version_code": version_code,
|
||||||
|
"version_name": version_name,
|
||||||
|
"notes": "修了分类侧栏的层级",
|
||||||
|
"file": name,
|
||||||
|
"size": len(payload),
|
||||||
|
"sha256": hashlib.sha256(payload).hexdigest(),
|
||||||
|
"min_sdk": 26,
|
||||||
|
"published_at": "2026-10-31T02:00:00+00:00",
|
||||||
|
}
|
||||||
|
(directory / "latest.json").write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
async def test_latest_release_is_public_and_reports_update(
|
||||||
|
client: AsyncClient, release_dir: Path
|
||||||
|
) -> None:
|
||||||
|
# 没发布过:明确告诉客户端没有安装包,而不是 500
|
||||||
|
resp = await client.get("/api/v1/app/release/latest")
|
||||||
|
body = resp.json()
|
||||||
|
assert resp.status_code == 404
|
||||||
|
assert body["code"] == 9001
|
||||||
|
|
||||||
|
|
||||||
|
async def test_latest_release_payload(client: AsyncClient, release_dir: Path) -> None:
|
||||||
|
_publish(release_dir)
|
||||||
|
|
||||||
|
resp = await client.get("/api/v1/app/release/latest")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()["data"]
|
||||||
|
assert data["version_code"] == 2
|
||||||
|
assert data["version_name"] == "0.2.0"
|
||||||
|
assert data["notes"] == "修了分类侧栏的层级"
|
||||||
|
assert data["download_url"] == "/api/v1/app/release/download/dadaotask-0.2.0.apk"
|
||||||
|
assert data["has_update"] is False # 没带当前版本就不猜
|
||||||
|
|
||||||
|
|
||||||
|
async def test_latest_release_tells_client_whether_to_upgrade(
|
||||||
|
client: AsyncClient, release_dir: Path
|
||||||
|
) -> None:
|
||||||
|
_publish(release_dir, version_code=2)
|
||||||
|
|
||||||
|
older = await client.get("/api/v1/app/release/latest?version_code=1")
|
||||||
|
assert older.json()["data"]["has_update"] is True
|
||||||
|
|
||||||
|
same = await client.get("/api/v1/app/release/latest?version_code=2")
|
||||||
|
assert same.json()["data"]["has_update"] is False
|
||||||
|
|
||||||
|
newer = await client.get("/api/v1/app/release/latest?version_code=9")
|
||||||
|
assert newer.json()["data"]["has_update"] is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_download_returns_the_apk_bytes(client: AsyncClient, release_dir: Path) -> None:
|
||||||
|
payload = _publish(release_dir)
|
||||||
|
|
||||||
|
resp = await client.get("/api/v1/app/release/download/dadaotask-0.2.0.apk")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.headers["content-type"] == "application/vnd.android.package-archive"
|
||||||
|
assert resp.content == payload
|
||||||
|
|
||||||
|
|
||||||
|
async def test_download_refuses_files_outside_the_manifest(
|
||||||
|
client: AsyncClient, release_dir: Path
|
||||||
|
) -> None:
|
||||||
|
_publish(release_dir)
|
||||||
|
|
||||||
|
resp = await client.get("/api/v1/app/release/download/..%2Fsecret.txt")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
missing = await client.get("/api/v1/app/release/download/dadaotask-9.9.9.apk")
|
||||||
|
assert missing.status_code == 404
|
||||||
|
assert missing.json()["code"] == 9001
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
"""生产配置校验:固定验证码必须显式开关,否则启动即失败。
|
"""生产配置校验:固定验证码必须显式开关,否则启动即失败。
|
||||||
|
|
||||||
这两条是「部署纪律」的守门员:生产一旦误用 dev 验证码,等于任意手机号可登录。
|
这两条是「部署纪律」的守门员:生产一旦误用 dev 验证码,等于任意手机号可登录。
|
||||||
|
现在的规则更简单——**短信验证码登录默认关着**,关着时 SMS_PROVIDER 是 dev 也无所谓,
|
||||||
|
因为那条路根本不通;只有在 SMS_LOGIN_ENABLED=true 时才要求真通道。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -24,14 +26,18 @@ def _prod(**overrides: object) -> Settings:
|
|||||||
return Settings(**values)
|
return Settings(**values)
|
||||||
|
|
||||||
|
|
||||||
def test_prod_rejects_dev_sms_without_switch() -> None:
|
def test_prod_rejects_dev_sms_when_login_enabled() -> None:
|
||||||
with pytest.raises(RuntimeError, match="SMS_PROVIDER=dev"):
|
with pytest.raises(RuntimeError, match="SMS_LOGIN_ENABLED"):
|
||||||
_prod(sms_provider="dev").validate_runtime()
|
_prod(sms_provider="dev", sms_login_enabled=True).validate_runtime()
|
||||||
|
|
||||||
|
|
||||||
def test_prod_allows_dev_sms_with_internal_test_login() -> None:
|
def test_prod_is_fine_with_sms_login_closed() -> None:
|
||||||
# 内测模式:显式开关后放行,启动时 main.lifespan 会打 WARNING
|
# 默认就是这条:短信登录关着,provider 配成 dev 也不会有人能用它进来
|
||||||
_prod(sms_provider="dev", internal_test_login=True).validate_runtime()
|
_prod(sms_provider="dev", sms_login_enabled=False).validate_runtime()
|
||||||
|
|
||||||
|
|
||||||
|
def test_prod_allows_real_sms_provider_when_enabled() -> None:
|
||||||
|
_prod(sms_provider="aliyun", sms_login_enabled=True).validate_runtime()
|
||||||
|
|
||||||
|
|
||||||
def test_prod_still_rejects_weak_jwt_secret() -> None:
|
def test_prod_still_rejects_weak_jwt_secret() -> None:
|
||||||
|
|||||||
@@ -200,13 +200,18 @@ async def test_publish_requires_admin(client: AsyncClient) -> None:
|
|||||||
assert published["status"] == "published"
|
assert published["status"] == "published"
|
||||||
assert published["knowledge"]["published_at"] is not None
|
assert published["knowledge"]["published_at"] is not None
|
||||||
|
|
||||||
# 已发布的内容不许直接改,先撤回
|
# 已发布的能直接改正文:改完后端自动重建切片,状态仍是已发布
|
||||||
blocked = await client.patch(
|
edited = unwrap(
|
||||||
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}",
|
await client.patch(
|
||||||
json={"title": "值班手册 v2", "version": published["version"]},
|
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}",
|
||||||
headers=owner["headers"],
|
json={"title": "值班手册 v2", "version": published["version"]},
|
||||||
|
headers=owner["headers"],
|
||||||
|
)
|
||||||
)
|
)
|
||||||
assert blocked.json()["code"] == 7003
|
assert edited["title"] == "值班手册 v2"
|
||||||
|
assert edited["knowledge"]["status_hint"] == "published"
|
||||||
|
assert edited["version"] == published["version"] + 1
|
||||||
|
published = edited
|
||||||
|
|
||||||
archived = unwrap(
|
archived = unwrap(
|
||||||
await client.post(
|
await client.post(
|
||||||
@@ -218,6 +223,14 @@ async def test_publish_requires_admin(client: AsyncClient) -> None:
|
|||||||
assert archived["status"] == "archived"
|
assert archived["status"] == "archived"
|
||||||
assert archived["knowledge"]["archived_at"] is not None
|
assert archived["knowledge"]["archived_at"] is not None
|
||||||
|
|
||||||
|
# 归档是先收起来;要改先恢复成草稿,免得改完还挂着"现役"的样子
|
||||||
|
blocked = await client.patch(
|
||||||
|
f"{V1}/workspaces/{ws['id']}/knowledge/{doc['id']}",
|
||||||
|
json={"title": "值班手册 v3", "version": archived["version"]},
|
||||||
|
headers=owner["headers"],
|
||||||
|
)
|
||||||
|
assert blocked.json()["code"] == 7003
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_illegal_action_reports_allowed(client: AsyncClient) -> None:
|
async def test_illegal_action_reports_allowed(client: AsyncClient) -> None:
|
||||||
@@ -368,3 +381,45 @@ async def test_draft_timeline_reads_like_a_story(client: AsyncClient) -> None:
|
|||||||
body = doc["body_md"]
|
body = doc["body_md"]
|
||||||
assert "待办 → 进行中" in body
|
assert "待办 → 进行中" in body
|
||||||
assert "进行中 → 已完成" in body
|
assert "进行中 → 已完成" in body
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_pages_through_every_document(client: AsyncClient) -> None:
|
||||||
|
"""翻页要能翻到底,且不重不漏。
|
||||||
|
|
||||||
|
这条用例是有来历的:知识列表的游标解出来是 `Cursor(created_at, id)`,
|
||||||
|
查询里却写成了 `cur.item_id`——属性不存在,第二页必 500。
|
||||||
|
库小于一页(默认 30 条)时永远走不到这条路径,所以一直没暴露,
|
||||||
|
直到把旧备忘导进来 84 条,用户点「加载更多」就看到"服务器内部错误"。
|
||||||
|
"""
|
||||||
|
owner = await login(client)
|
||||||
|
ws = await create_workspace(client, owner, "翻页")
|
||||||
|
titles = [f"备忘 {i:02d}" for i in range(25)]
|
||||||
|
for title in titles:
|
||||||
|
unwrap(
|
||||||
|
await client.post(
|
||||||
|
f"{V1}/workspaces/{ws['id']}/knowledge",
|
||||||
|
json={"title": title},
|
||||||
|
headers=owner["headers"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
seen: list[str] = []
|
||||||
|
cursor: str | None = None
|
||||||
|
for _ in range(10): # 25 条 / 每页 10 条,三轮就该到底,多留几次余地
|
||||||
|
params: dict[str, Any] = {"limit": 10}
|
||||||
|
if cursor:
|
||||||
|
params["cursor"] = cursor
|
||||||
|
page = unwrap(
|
||||||
|
await client.get(
|
||||||
|
f"{V1}/workspaces/{ws['id']}/knowledge", params=params, headers=owner["headers"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
seen.extend(i["title"] for i in page["items"])
|
||||||
|
cursor = page["next_cursor"]
|
||||||
|
if not page["has_more"] or not cursor:
|
||||||
|
break
|
||||||
|
|
||||||
|
assert cursor is None
|
||||||
|
assert sorted(seen) == titles
|
||||||
|
assert len(seen) == len(set(seen)), "翻页翻出了重复条目"
|
||||||
|
|||||||
@@ -303,3 +303,116 @@ async def test_patch_knowledge_accepts_doc_type_and_bumps_version(client: AsyncC
|
|||||||
assert patched["body_md"] == "改过的正文"
|
assert patched["body_md"] == "改过的正文"
|
||||||
assert patched["version"] == doc["version"] + 1
|
assert patched["version"] == doc["version"] + 1
|
||||||
assert {t["name"] for t in patched["tags"]} == {"写法"}
|
assert {t["name"] for t in patched["tags"]} == {"写法"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_categories_come_back_as_a_tree(client: AsyncClient) -> None:
|
||||||
|
"""列表按「父在前、子紧跟」排:前端照着 depth 缩进才像一棵树。
|
||||||
|
|
||||||
|
之前按 (depth, sort_order) 排,二级分类全堆在一级后面,
|
||||||
|
侧栏看着像"所有一级在一起、所有二级在一起"。
|
||||||
|
"""
|
||||||
|
user, ws_id = await _ws(client)
|
||||||
|
tech = await _category(client, user, ws_id, "技术开发")
|
||||||
|
biz = await _category(client, user, ws_id, "餐饮业务")
|
||||||
|
child = await _category(client, user, ws_id, "技术", tech["id"])
|
||||||
|
await _category(client, user, ws_id, "部署", child["id"])
|
||||||
|
assert biz["depth"] == 1
|
||||||
|
|
||||||
|
items = unwrap(
|
||||||
|
await client.get(f"{V1}/workspaces/{ws_id}/knowledge/categories", headers=user["headers"])
|
||||||
|
)["items"]
|
||||||
|
assert [i["name"] for i in items] == ["技术开发", "技术", "部署", "餐饮业务"]
|
||||||
|
assert [i["full_path"] for i in items] == [
|
||||||
|
"技术开发",
|
||||||
|
"技术开发 / 技术",
|
||||||
|
"技术开发 / 技术 / 部署",
|
||||||
|
"餐饮业务",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_category_color_roundtrip(client: AsyncClient) -> None:
|
||||||
|
"""分类色:建时能给、能改、能取消,非法值当场拒绝。
|
||||||
|
|
||||||
|
颜色是直接拼进 CSS 的(卡片上边线),所以格式必须在入口挡住:
|
||||||
|
一个 `red` 或 `#4f46e5cc` 进了库,前端只会静默退化成灰边框,没人知道为什么。
|
||||||
|
"""
|
||||||
|
user, ws_id = await _ws(client)
|
||||||
|
base = f"{V1}/workspaces/{ws_id}/knowledge/categories"
|
||||||
|
|
||||||
|
created = unwrap(
|
||||||
|
await client.post(
|
||||||
|
url=base, json={"name": "部署", "color": "#4f46e5"}, headers=user["headers"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# 统一成大写,前端不用再 casefold
|
||||||
|
assert created["color"] == "#4F46E5"
|
||||||
|
|
||||||
|
listed = unwrap(await client.get(base, headers=user["headers"]))["items"]
|
||||||
|
assert [c["color"] for c in listed] == ["#4F46E5"]
|
||||||
|
|
||||||
|
recolored = unwrap(
|
||||||
|
await client.patch(
|
||||||
|
f"{base}/{created['id']}",
|
||||||
|
json={"color": "#0EA5E9", "version": created["version"]},
|
||||||
|
headers=user["headers"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert recolored["color"] == "#0EA5E9"
|
||||||
|
assert recolored["name"] == "部署" # 只改色不动名字
|
||||||
|
|
||||||
|
cleared = unwrap(
|
||||||
|
await client.patch(
|
||||||
|
f"{base}/{created['id']}",
|
||||||
|
json={"color": "", "version": recolored["version"]},
|
||||||
|
headers=user["headers"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert cleared["color"] is None
|
||||||
|
|
||||||
|
# 不传 color 的普通改名不该顺手把颜色清掉
|
||||||
|
colored = unwrap(
|
||||||
|
await client.patch(
|
||||||
|
f"{base}/{created['id']}",
|
||||||
|
json={"color": "#10B981", "version": cleared["version"]},
|
||||||
|
headers=user["headers"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
renamed = unwrap(
|
||||||
|
await client.patch(
|
||||||
|
f"{base}/{created['id']}",
|
||||||
|
json={"name": "上线", "version": colored["version"]},
|
||||||
|
headers=user["headers"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert renamed["name"] == "上线"
|
||||||
|
assert renamed["color"] == "#10B981"
|
||||||
|
|
||||||
|
bad = await client.post(
|
||||||
|
url=base, json={"name": "业务", "color": "red"}, headers=user["headers"]
|
||||||
|
)
|
||||||
|
assert bad.status_code == 422, bad.text
|
||||||
|
assert _code(bad) == 1002
|
||||||
|
|
||||||
|
too_long = await client.post(
|
||||||
|
json={"name": "业务2", "color": "#4f46e5cc"}, url=base, headers=user["headers"]
|
||||||
|
)
|
||||||
|
assert too_long.status_code == 422, too_long.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_knowledge_card_carries_category_color(client: AsyncClient) -> None:
|
||||||
|
"""知识列表里带上分类(含颜色),前端画卡片上边线不用再查一次。"""
|
||||||
|
user, ws_id = await _ws(client)
|
||||||
|
cat = unwrap(
|
||||||
|
await client.post(
|
||||||
|
f"{V1}/workspaces/{ws_id}/knowledge/categories",
|
||||||
|
json={"name": "复盘", "color": "#EF4444"},
|
||||||
|
headers=user["headers"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await _note(client, user, ws_id, "一次事故复盘", category_id=cat["id"])
|
||||||
|
|
||||||
|
page = unwrap(await client.get(f"{V1}/workspaces/{ws_id}/knowledge", headers=user["headers"]))
|
||||||
|
assert page["items"][0]["category"]["color"] == "#EF4444"
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"""登录方式的开关,以及用邮箱登录。
|
||||||
|
|
||||||
|
三条是这个功能的重点,也是测试的重点:
|
||||||
|
|
||||||
|
1. **短信验证码能整体关掉**。关掉后连"发码"这一步都不该通——否则等于留了个后门:
|
||||||
|
内置验证码仍然对得上,只是没人去点。
|
||||||
|
2. **邮箱是账号名,不是联系方式**:绑定之后能用它登录,改绑立刻生效、解绑立刻失效。
|
||||||
|
3. **邮箱唯一且大小写不敏感**:`Shufen@126.com` 和 `shufen@126.com` 是同一个人,
|
||||||
|
不能让第二个账号绑走。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
from .conftest import login, unwrap
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
async def _set_password(client: AsyncClient, user: dict, password: str) -> None:
|
||||||
|
unwrap(
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/auth/password", json={"password": password}, headers=user["headers"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _bind_email(client: AsyncClient, user: dict, email: str) -> dict:
|
||||||
|
return unwrap(
|
||||||
|
await client.patch("/api/v1/auth/me", json={"email": email}, headers=user["headers"])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_methods_lists_what_is_open(client: AsyncClient) -> None:
|
||||||
|
data = unwrap(await client.get("/api/v1/auth/methods"))
|
||||||
|
assert data["password"] is True
|
||||||
|
assert data["email_password"] is True
|
||||||
|
# 测试环境把短信开着(夹具要靠它造用户);线上默认是 false
|
||||||
|
assert data["sms_code"] is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_sms_login_can_be_closed(client: AsyncClient, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr(settings, "sms_login_enabled", False)
|
||||||
|
|
||||||
|
methods = unwrap(await client.get("/api/v1/auth/methods"))
|
||||||
|
assert methods["sms_code"] is False
|
||||||
|
|
||||||
|
sent = await client.post("/api/v1/auth/sms/send", json={"phone": "13800138000"})
|
||||||
|
assert sent.json()["code"] == 2017
|
||||||
|
|
||||||
|
# 就算手里还捏着旧验证码,登录这一步也必须关死
|
||||||
|
logged = await client.post(
|
||||||
|
"/api/v1/auth/login", json={"phone": "13800138000", "code": settings.sms_dev_code}
|
||||||
|
)
|
||||||
|
assert logged.json()["code"] == 2017
|
||||||
|
|
||||||
|
|
||||||
|
async def test_bind_email_and_login_with_it(client: AsyncClient) -> None:
|
||||||
|
user = await login(client)
|
||||||
|
await _set_password(client, user, "Dadao2026pw")
|
||||||
|
|
||||||
|
data = await _bind_email(client, user, " Shufen@126.com ")
|
||||||
|
# 存的是小写,避免大小写绕出第二个账号
|
||||||
|
assert data["user"]["email"] == "shufen@126.com"
|
||||||
|
|
||||||
|
logged = unwrap(
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/auth/login/password",
|
||||||
|
json={"email": "SHUFEN@126.COM", "password": "Dadao2026pw"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert logged["user"]["id"] == user["user_id"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_email_login_rejects_wrong_password(client: AsyncClient) -> None:
|
||||||
|
user = await login(client)
|
||||||
|
await _set_password(client, user, "Dadao2026pw")
|
||||||
|
await _bind_email(client, user, "shufen@126.com")
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/auth/login/password", json={"email": "shufen@126.com", "password": "nope-nope-1"}
|
||||||
|
)
|
||||||
|
assert resp.json()["code"] == 2013
|
||||||
|
|
||||||
|
|
||||||
|
async def test_email_is_validated_and_unique(client: AsyncClient) -> None:
|
||||||
|
first = await login(client)
|
||||||
|
bad = await client.patch(
|
||||||
|
"/api/v1/auth/me", json={"email": "not-an-email"}, headers=first["headers"]
|
||||||
|
)
|
||||||
|
assert bad.json()["code"] == 2018
|
||||||
|
|
||||||
|
await _bind_email(client, first, "shufen@126.com")
|
||||||
|
|
||||||
|
second = await login(client)
|
||||||
|
taken = await client.patch(
|
||||||
|
"/api/v1/auth/me", json={"email": "SHUFEN@126.com"}, headers=second["headers"]
|
||||||
|
)
|
||||||
|
assert taken.json()["code"] == 2019
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unbind_email_then_login_by_email_fails(client: AsyncClient) -> None:
|
||||||
|
user = await login(client)
|
||||||
|
await _set_password(client, user, "Dadao2026pw")
|
||||||
|
await _bind_email(client, user, "shufen@126.com")
|
||||||
|
|
||||||
|
cleared = await _bind_email(client, user, "")
|
||||||
|
assert cleared["user"]["email"] is None
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/auth/login/password",
|
||||||
|
json={"email": "shufen@126.com", "password": "Dadao2026pw"},
|
||||||
|
)
|
||||||
|
assert resp.json()["code"] == 2013
|
||||||
|
|
||||||
|
|
||||||
|
async def test_phone_login_still_works(client: AsyncClient) -> None:
|
||||||
|
"""老客户端/安卓只发 phone,这条路不能因为加了邮箱就断。"""
|
||||||
|
user = await login(client)
|
||||||
|
await _set_password(client, user, "Dadao2026pw")
|
||||||
|
|
||||||
|
logged = unwrap(
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/auth/login/password",
|
||||||
|
json={"phone": user["phone"], "password": "Dadao2026pw"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert logged["user"]["id"] == user["user_id"]
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
"""系统设置(SMTP):只有平台管理员能改,授权码不回显,改完立刻生效。
|
||||||
|
|
||||||
|
为什么值得单独测:这是全站唯一一个**跨租户**的写接口。租户隔离测试管不到它,
|
||||||
|
一旦漏了权限判断,任何登录用户都能改掉全平台发信账号(等于劫持找回密码邮件)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.services import mail
|
||||||
|
|
||||||
|
from .conftest import login, unwrap
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
SMTP = {
|
||||||
|
"host": "smtp.126.com",
|
||||||
|
"port": 465,
|
||||||
|
"username": "shufenhf@126.com",
|
||||||
|
"password": "CH46ngqQ7Z2XE2eZ",
|
||||||
|
"from_name": "达道Task",
|
||||||
|
"use_ssl": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _admin(client: AsyncClient) -> dict:
|
||||||
|
"""白名单里的手机号登录,就是平台管理员。"""
|
||||||
|
user = await login(client, phone="13605513683")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def test_settings_require_platform_admin(client: AsyncClient, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr(settings, "platform_admin_phones", ["13605513683"])
|
||||||
|
outsider = await login(client)
|
||||||
|
|
||||||
|
read = await client.get("/api/v1/settings/smtp", headers=outsider["headers"])
|
||||||
|
assert read.json()["code"] == 3004
|
||||||
|
|
||||||
|
write = await client.put("/api/v1/settings/smtp", json=SMTP, headers=outsider["headers"])
|
||||||
|
assert write.json()["code"] == 3004
|
||||||
|
|
||||||
|
|
||||||
|
async def test_platform_admin_flag_comes_back_in_me(client: AsyncClient, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr(settings, "platform_admin_phones", ["13605513683"])
|
||||||
|
admin = await _admin(client)
|
||||||
|
outsider = await login(client)
|
||||||
|
|
||||||
|
me_admin = unwrap(await client.get("/api/v1/auth/me", headers=admin["headers"]))
|
||||||
|
me_outsider = unwrap(await client.get("/api/v1/auth/me", headers=outsider["headers"]))
|
||||||
|
assert me_admin["user"]["platform_admin"] is True
|
||||||
|
assert me_outsider["user"]["platform_admin"] is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_smtp_hides_password_and_persists(client: AsyncClient, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr(settings, "platform_admin_phones", ["13605513683"])
|
||||||
|
admin = await _admin(client)
|
||||||
|
|
||||||
|
empty = unwrap(await client.get("/api/v1/settings/smtp", headers=admin["headers"]))
|
||||||
|
assert empty["configured"] is False
|
||||||
|
|
||||||
|
saved = unwrap(await client.put("/api/v1/settings/smtp", json=SMTP, headers=admin["headers"]))
|
||||||
|
assert saved["configured"] is True
|
||||||
|
assert saved["source"] == "db"
|
||||||
|
assert saved["host"] == "smtp.126.com"
|
||||||
|
assert saved["password_set"] is True
|
||||||
|
# 授权码一个字节都不能回给页面
|
||||||
|
assert "password" not in saved
|
||||||
|
assert "CH46ngqQ7Z2XE2eZ" not in str(saved)
|
||||||
|
|
||||||
|
again = unwrap(await client.get("/api/v1/settings/smtp", headers=admin["headers"]))
|
||||||
|
assert again["username"] == "shufenhf@126.com"
|
||||||
|
assert again["port"] == 465
|
||||||
|
|
||||||
|
|
||||||
|
async def test_saving_without_password_keeps_old_one(client: AsyncClient, monkeypatch) -> None:
|
||||||
|
"""授权码留空 = 沿用原来的。否则每次改端口都得重新粘贴一遍授权码。"""
|
||||||
|
monkeypatch.setattr(settings, "platform_admin_phones", ["13605513683"])
|
||||||
|
admin = await _admin(client)
|
||||||
|
await client.put("/api/v1/settings/smtp", json=SMTP, headers=admin["headers"])
|
||||||
|
|
||||||
|
payload = {**SMTP, "port": 587, "use_ssl": False, "password": ""}
|
||||||
|
resp = await client.put("/api/v1/settings/smtp", json=payload, headers=admin["headers"])
|
||||||
|
saved = unwrap(resp)
|
||||||
|
assert saved["port"] == 587
|
||||||
|
assert saved["password_set"] is True
|
||||||
|
|
||||||
|
from app.db import SessionFactory
|
||||||
|
|
||||||
|
async with SessionFactory() as session:
|
||||||
|
cfg = await mail.load_config(session)
|
||||||
|
assert cfg is not None
|
||||||
|
assert cfg.password == "CH46ngqQ7Z2XE2eZ"
|
||||||
|
assert cfg.port == 587
|
||||||
|
|
||||||
|
|
||||||
|
async def test_test_mail_reports_missing_config(client: AsyncClient, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr(settings, "platform_admin_phones", ["13605513683"])
|
||||||
|
admin = await _admin(client)
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/settings/smtp/test", json={"to": "steven@example.com"}, headers=admin["headers"]
|
||||||
|
)
|
||||||
|
assert resp.json()["code"] == 2020
|
||||||
|
|
||||||
|
|
||||||
|
async def test_test_mail_actually_sends(client: AsyncClient, monkeypatch) -> None:
|
||||||
|
"""真发信在这里被替换掉——测的是"取到配置、走对收件人",不是 SMTP 本身。"""
|
||||||
|
monkeypatch.setattr(settings, "platform_admin_phones", ["13605513683"])
|
||||||
|
admin = await _admin(client)
|
||||||
|
await client.put("/api/v1/settings/smtp", json=SMTP, headers=admin["headers"])
|
||||||
|
|
||||||
|
sent: list[tuple] = []
|
||||||
|
|
||||||
|
def fake_send(cfg, to, subject, text, html): # noqa: ANN001 - 与真实签名一致
|
||||||
|
sent.append((cfg.host, cfg.username, to, subject, text))
|
||||||
|
|
||||||
|
monkeypatch.setattr(mail, "_send_sync", fake_send)
|
||||||
|
|
||||||
|
data = unwrap(
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/settings/smtp/test",
|
||||||
|
json={"to": "steven@example.com"},
|
||||||
|
headers=admin["headers"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert data["sent"] is True
|
||||||
|
assert data["to"] == "steven@example.com"
|
||||||
|
assert sent and sent[0][0] == "smtp.126.com"
|
||||||
|
assert sent[0][1] == "shufenhf@126.com"
|
||||||
@@ -11,6 +11,16 @@
|
|||||||
# 首次部署前准备好 ~/.config/dadaotask/env.production(见 env.production.example)。
|
# 首次部署前准备好 ~/.config/dadaotask/env.production(见 env.production.example)。
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
# 这个脚本会 git reset 自己所在的仓库:bash 按字节偏移边读边执行,脚本被改写后
|
||||||
|
# 可能读到新旧混合的内容(实测踩过)——先把自己复制到临时文件再执行。
|
||||||
|
if [ -z "${DADAO_DEPLOY_SNAPSHOT:-}" ]; then
|
||||||
|
_snap=$(mktemp /tmp/dadao-deploy.XXXXXX.sh)
|
||||||
|
cp "$0" "$_snap"
|
||||||
|
trap 'rm -f "$_snap"' EXIT
|
||||||
|
DADAO_DEPLOY_SNAPSHOT=1 bash "$_snap" "$@"
|
||||||
|
exit $?
|
||||||
|
fi
|
||||||
|
|
||||||
APP_DIR=${APP_DIR:-/home/openclaw/app/dadaotask}
|
APP_DIR=${APP_DIR:-/home/openclaw/app/dadaotask}
|
||||||
CONF_DIR=${CONF_DIR:-$HOME/.config/dadaotask}
|
CONF_DIR=${CONF_DIR:-$HOME/.config/dadaotask}
|
||||||
ENV_FILE=$CONF_DIR/env.production
|
ENV_FILE=$CONF_DIR/env.production
|
||||||
@@ -61,10 +71,11 @@ step "4. 前端构建(只在服务器上、只用 HEAD 的源码)"
|
|||||||
( cd web && npm ci --no-audit --no-fund --registry="$NPM_REGISTRY" && npm run build )
|
( cd web && npm ci --no-audit --no-fund --registry="$NPM_REGISTRY" && npm run build )
|
||||||
test -f web/dist/index.html || die "前端构建产物缺失:web/dist/index.html"
|
test -f web/dist/index.html || die "前端构建产物缺失:web/dist/index.html"
|
||||||
|
|
||||||
step "5. 写入构建标记并重启服务"
|
step "5. 写入构建标记、刷新 systemd 单元并重启服务"
|
||||||
mkdir -p "$CONF_DIR" backend/var/uploads
|
mkdir -p "$CONF_DIR" "$HOME/.config/systemd/user" backend/var/uploads
|
||||||
chmod 700 "$CONF_DIR"
|
chmod 700 "$CONF_DIR"
|
||||||
printf 'BUILD_COMMIT=%s\nBUILD_TIME=%s\n' "$SHORT" "$(date -Iseconds)" >"$CONF_DIR/env.build"
|
cp deploy/prod/dadaotask-api.service "$HOME/.config/systemd/user/"
|
||||||
|
printf 'BUILD_COMMIT=%s\nBUILD_TIME=%s\n' "$SHORT" "$(TZ=Asia/Shanghai date -Iseconds)" >"$CONF_DIR/env.build"
|
||||||
chmod 600 "$CONF_DIR/env.build" "$ENV_FILE"
|
chmod 600 "$CONF_DIR/env.build" "$ENV_FILE"
|
||||||
systemctl --user daemon-reload
|
systemctl --user daemon-reload
|
||||||
systemctl --user enable "$SERVICE" >/dev/null 2>&1 || true
|
systemctl --user enable "$SERVICE" >/dev/null 2>&1 || true
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ DATABASE_URL=postgresql+asyncpg://dadao_prod_app:__APP_PW__@192.168.8.160:8432/d
|
|||||||
MIGRATION_DATABASE_URL=postgresql+asyncpg://dadao_prod_owner:__OWNER_PW__@192.168.8.160:8432/dadaotask_prod
|
MIGRATION_DATABASE_URL=postgresql+asyncpg://dadao_prod_owner:__OWNER_PW__@192.168.8.160:8432/dadaotask_prod
|
||||||
|
|
||||||
# ── Redis:fnsvr-01 共享实例,独立 db 号隔离(不使用默认 0 号库)──────
|
# ── Redis:fnsvr-01 共享实例,独立 db 号隔离(不使用默认 0 号库)──────
|
||||||
REDIS_URL=redis://192.168.8.160:6379/6
|
# 该实例开了 requirepass(口令见 fnsvr-01 上 `docker inspect redis`),
|
||||||
|
# URL 里的口令必须百分号编码(例如 # → %23),否则 asyncpg/redis 解析失败:
|
||||||
|
# REDIS_URL=redis://:__REDIS_PW_URLENCODED__@192.168.8.160:6379/6
|
||||||
|
REDIS_URL=redis://:__REDIS_PW_URLENCODED__@192.168.8.160:6379/6
|
||||||
|
|
||||||
# ── 认证 ─────────────────────────────────────────────────────────────
|
# ── 认证 ─────────────────────────────────────────────────────────────
|
||||||
# 生产校验:长度 ≥ 32 且不是开发默认值
|
# 生产校验:长度 ≥ 32 且不是开发默认值
|
||||||
@@ -28,8 +31,9 @@ SMS_PROVIDER=dev
|
|||||||
SMS_DEV_CODE=123456
|
SMS_DEV_CODE=123456
|
||||||
SMS_THROTTLE_SECONDS=60
|
SMS_THROTTLE_SECONDS=60
|
||||||
|
|
||||||
# 同源部署(后端直接托管 web/dist),CORS 只留本机调试用
|
# 同源部署(后端直接托管 web/dist),CORS 只留本机调试用。
|
||||||
CORS_ORIGINS=http://127.0.0.1:8200,http://192.168.8.188:8200
|
# 注意:这是 list 字段,必须写成 JSON 数组(逗号分隔会被 pydantic-settings 当成 JSON 解析而报错)
|
||||||
|
CORS_ORIGINS='["http://192.168.8.188:8200","http://127.0.0.1:8200"]'
|
||||||
|
|
||||||
# ── 前端产物与上传目录(绝对路径)─────────────────────────────────────
|
# ── 前端产物与上传目录(绝对路径)─────────────────────────────────────
|
||||||
WEB_DIST_DIR=/home/openclaw/app/dadaotask/web/dist
|
WEB_DIST_DIR=/home/openclaw/app/dadaotask/web/dist
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""按 Web Logo(icons.tsx 的 <Logo/>)重绘 PWA 图标:靛蓝底 + 三条白线 + 一点淡紫。
|
||||||
|
|
||||||
|
用法:python tools/make-icons.py
|
||||||
|
输出覆盖 web/public/icons/*.png(与 SVG 同一套比例:64 画布,rx=18,线宽 4,点半径 6)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
OUT = ROOT / "web" / "public" / "icons"
|
||||||
|
|
||||||
|
BG = "#4F46E5"
|
||||||
|
LINE = "#FFFFFF"
|
||||||
|
DOT = "#C7D2FE"
|
||||||
|
|
||||||
|
SS = 8 # 超采样倍数,边缘才不毛
|
||||||
|
|
||||||
|
|
||||||
|
def draw(size: int, *, rounded: bool, inset: float = 0.0) -> Image.Image:
|
||||||
|
"""在 size×size 上画 logo。inset 是安全区留白比例(maskable 图标要留)。"""
|
||||||
|
big = size * SS
|
||||||
|
img = Image.new("RGBA", (big, big), (0, 0, 0, 0))
|
||||||
|
d = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
if rounded:
|
||||||
|
d.rounded_rectangle([0, 0, big - 1, big - 1], radius=big * 18 / 64, fill=BG)
|
||||||
|
else:
|
||||||
|
d.rectangle([0, 0, big, big], fill=BG)
|
||||||
|
|
||||||
|
# 64 画布坐标 → 像素;inset 让图形整体缩小居中(给 maskable 留安全区)
|
||||||
|
scale = big / 64 * (1 - 2 * inset)
|
||||||
|
off = big * inset
|
||||||
|
|
||||||
|
def box(x: float, y: float) -> tuple[float, float]:
|
||||||
|
return off + x * scale, off + y * scale
|
||||||
|
|
||||||
|
stroke = max(1, round(4 * scale))
|
||||||
|
for y, x2 in ((23, 46), (32, 37), (41, 30)):
|
||||||
|
x1, yy = box(18, y)
|
||||||
|
d.line([x1, yy, *box(x2, y)], fill=LINE, width=stroke)
|
||||||
|
# 圆头:PIL 的 line 没有 cap,用小圆补两端
|
||||||
|
r = stroke / 2
|
||||||
|
for cx, cy in ((x1, yy), box(x2, y)):
|
||||||
|
d.ellipse([cx - r, cy - r, cx + r, cy + r], fill=LINE)
|
||||||
|
|
||||||
|
cx, cy = box(46, 41)
|
||||||
|
r = 6 * scale
|
||||||
|
d.ellipse([cx - r, cy - r, cx + r, cy + r], fill=DOT)
|
||||||
|
|
||||||
|
return img.resize((size, size), Image.LANCZOS)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
OUT.mkdir(parents=True, exist_ok=True)
|
||||||
|
# 圆角版:favicon 之外的常规 PWA 图标
|
||||||
|
draw(192, rounded=True).save(OUT / "icon-192.png")
|
||||||
|
draw(512, rounded=True).save(OUT / "icon-512.png")
|
||||||
|
# iOS 会自己裁圆角,底图必须是方的
|
||||||
|
draw(180, rounded=False).save(OUT / "apple-touch-icon.png")
|
||||||
|
# maskable:图形缩进 12% 安全区
|
||||||
|
draw(512, rounded=False, inset=0.12).save(OUT / "maskable-512.png")
|
||||||
|
# 文档里引用的安卓图标预览
|
||||||
|
preview = ROOT / "2.Design" / "screens" / "android-icon-512.png"
|
||||||
|
preview.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
draw(512, rounded=True).save(preview)
|
||||||
|
for p in sorted(OUT.glob("*.png")):
|
||||||
|
print(p.relative_to(ROOT), Image.open(p).size)
|
||||||
|
print(preview.relative_to(ROOT), Image.open(preview).size)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
/**
|
||||||
|
* 账号功能的验收脚本:首页外壳、账号设置、密码登录。
|
||||||
|
*
|
||||||
|
* 只跑真浏览器、真接口:断言都落在"页面上看得见什么"和"接口回了什么",
|
||||||
|
* 这样截图与断言是同一次运行,文档里贴的图不会是另一版界面的。
|
||||||
|
*
|
||||||
|
* 两个坑记在这里:
|
||||||
|
* 1. 短信验证码有 60 秒节流,反复跑脚本会拿到 429,于是"发码"这一步要先看
|
||||||
|
* 是不是被拦了;登录态优先用密码直接从接口换,别每次都走验证码。
|
||||||
|
* 2. 改密码要先验证旧密码,而系统不会告诉你旧密码——上一轮的密码记在
|
||||||
|
* .local/e2e-account-password.txt 里,脚本自己维护。
|
||||||
|
*/
|
||||||
|
import { chromium } from "playwright-core";
|
||||||
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
|
||||||
|
const WEB = process.env.WEB ?? "http://127.0.0.1:5180";
|
||||||
|
const API = process.env.API ?? "http://127.0.0.1:8100/api/v1";
|
||||||
|
const PHONE = process.env.PHONE ?? "13900000001";
|
||||||
|
const CODE = process.env.CODE ?? "123456";
|
||||||
|
const PWD_FILE = new URL("../../.local/e2e-account-password.txt", import.meta.url);
|
||||||
|
const OUT = "e2e/artifacts";
|
||||||
|
|
||||||
|
let pass = 0;
|
||||||
|
let fail = 0;
|
||||||
|
function check(name, ok, extra = "") {
|
||||||
|
if (ok) {
|
||||||
|
pass += 1;
|
||||||
|
console.log(`PASS ${name}`);
|
||||||
|
} else {
|
||||||
|
fail += 1;
|
||||||
|
console.log(`FAIL ${name} ${extra}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function savedPassword() {
|
||||||
|
try {
|
||||||
|
return readFileSync(PWD_FILE, "utf8").trim() || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomPassword() {
|
||||||
|
return `dadao${Math.floor(Math.random() * 900000 + 100000)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiLogin(password) {
|
||||||
|
if (!password) return null;
|
||||||
|
const r = await fetch(`${API}/auth/login/password`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ phone: PHONE, password }),
|
||||||
|
});
|
||||||
|
const body = await r.json();
|
||||||
|
return body?.code === 0 ? body.data.tokens : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
mkdirSync(OUT, { recursive: true });
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
executablePath:
|
||||||
|
process.env.CHROME ??
|
||||||
|
`${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`,
|
||||||
|
});
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } });
|
||||||
|
const errors = [];
|
||||||
|
page.on("console", (m) => m.type() === "error" && errors.push(m.text()));
|
||||||
|
page.on("pageerror", (e) => errors.push(String(e)));
|
||||||
|
|
||||||
|
// ① 先拿到登录态:有密码就直接换令牌,省掉一次发码
|
||||||
|
let tokens = await apiLogin(savedPassword());
|
||||||
|
if (tokens) {
|
||||||
|
await page.addInitScript(
|
||||||
|
([access, refresh]) => {
|
||||||
|
localStorage.setItem("dadao.access", access);
|
||||||
|
localStorage.setItem("dadao.refresh", refresh);
|
||||||
|
},
|
||||||
|
[tokens.access_token, tokens.refresh_token],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await page.goto(WEB);
|
||||||
|
if (!tokens) {
|
||||||
|
// 第一次跑(还没有密码)只能走验证码;被节流就等一会儿再发
|
||||||
|
await page.goto(`${WEB}/login`);
|
||||||
|
await page.getByLabel("手机号").fill(PHONE);
|
||||||
|
for (let i = 0; i < 2; i += 1) {
|
||||||
|
await page.getByRole("button", { name: "获取验证码" }).click();
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
const throttled = await page.locator("text=/秒后再试|稍后/").count();
|
||||||
|
if (!throttled) break;
|
||||||
|
await page.waitForTimeout(61_000);
|
||||||
|
}
|
||||||
|
await page.getByLabel("验证码").fill(CODE);
|
||||||
|
await page.getByRole("button", { name: "登录" }).click();
|
||||||
|
await page.waitForURL(`${WEB}/`);
|
||||||
|
}
|
||||||
|
await page.waitForSelector("text=我的工作区");
|
||||||
|
check("登录后进入首页", true);
|
||||||
|
|
||||||
|
// ② 首页与工作区页是同一套外壳:侧栏都在,宽度不变
|
||||||
|
const homeRail = await page.locator("aside").first().boundingBox();
|
||||||
|
const homeMain = await page.locator("main").first().boundingBox();
|
||||||
|
check("首页有左侧栏", Boolean(homeRail && homeRail.width > 200), JSON.stringify(homeRail));
|
||||||
|
check("首页内容区贴着侧栏", Boolean(homeMain && homeMain.x >= (homeRail?.x ?? 0)));
|
||||||
|
await page.screenshot({ path: `${OUT}/account-home.png` });
|
||||||
|
|
||||||
|
await page.locator("main button", { hasText: "我的备忘" }).first().click();
|
||||||
|
await page.waitForURL(/\/w\//);
|
||||||
|
await page.waitForSelector("aside");
|
||||||
|
const wsRail = await page.locator("aside").first().boundingBox();
|
||||||
|
check(
|
||||||
|
"进了工作区侧栏宽度不变",
|
||||||
|
Boolean(wsRail && homeRail && Math.abs(wsRail.width - homeRail.width) < 1),
|
||||||
|
`home=${homeRail?.width} ws=${wsRail?.width}`,
|
||||||
|
);
|
||||||
|
await page.screenshot({ path: `${OUT}/account-workspace.png` });
|
||||||
|
|
||||||
|
// ③ 账号设置:侧栏有入口,能改昵称、能设/改密码
|
||||||
|
await page.goto(`${WEB}/account`);
|
||||||
|
await page.waitForSelector("text=登录密码");
|
||||||
|
const accountRail = await page.locator("aside").first().boundingBox();
|
||||||
|
check(
|
||||||
|
"账号页复用了同一条侧栏",
|
||||||
|
Boolean(accountRail && Math.abs(accountRail.width - (homeRail?.width ?? 0)) < 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
const nickname = `用户${Math.floor(Math.random() * 9000 + 1000)}`;
|
||||||
|
// 等 /auth/me 回填完再改,否则 backfill 会把刚输入的值盖回去
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => document.querySelector('input[aria-label="昵称"]')?.value?.length > 0,
|
||||||
|
);
|
||||||
|
await page.getByLabel("昵称").fill(nickname);
|
||||||
|
await page.getByRole("button", { name: "保存资料" }).click();
|
||||||
|
await page.waitForSelector("text=资料已更新");
|
||||||
|
check("昵称保存成功", true);
|
||||||
|
|
||||||
|
const currentField = page.getByLabel("当前密码");
|
||||||
|
const hadPassword = (await currentField.count()) > 0;
|
||||||
|
if (hadPassword) await currentField.fill(savedPassword());
|
||||||
|
const password = randomPassword();
|
||||||
|
await page.getByLabel(hadPassword ? "新密码" : "密码", { exact: true }).fill(password);
|
||||||
|
await page.getByLabel("确认密码").fill(password);
|
||||||
|
await page.getByRole("button", { name: /设置密码|修改密码/ }).click();
|
||||||
|
await page.waitForSelector("text=/密码已(设置|修改)/");
|
||||||
|
writeFileSync(PWD_FILE, password, "utf8");
|
||||||
|
await page.screenshot({ path: `${OUT}/account-settings.png` });
|
||||||
|
check(hadPassword ? "密码修改成功" : "密码设置成功", true);
|
||||||
|
|
||||||
|
// 刷新后仍是"已设置密码",说明后端真的存住了
|
||||||
|
await page.reload();
|
||||||
|
await page.waitForSelector("text=已设置密码", { timeout: 15_000 });
|
||||||
|
check("刷新后仍显示已设置密码", true);
|
||||||
|
|
||||||
|
// ④ 退出,改用密码登录
|
||||||
|
await page.goto(`${WEB}/account`);
|
||||||
|
await page.getByRole("button", { name: "退出登录" }).last().click();
|
||||||
|
await page.waitForURL(/\/login/);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "密码", exact: true }).click();
|
||||||
|
await page.getByLabel("手机号").fill(PHONE);
|
||||||
|
await page.getByLabel("密码").fill("wrong-pass1");
|
||||||
|
await page.getByRole("button", { name: "登录" }).click();
|
||||||
|
await page.waitForSelector("text=手机号或密码不对");
|
||||||
|
check("错误密码被拒绝", true);
|
||||||
|
|
||||||
|
await page.getByLabel("密码").fill(password);
|
||||||
|
await page.getByRole("button", { name: "登录" }).click();
|
||||||
|
await page.waitForURL(`${WEB}/`, { timeout: 15_000 });
|
||||||
|
check("密码登录成功", true);
|
||||||
|
|
||||||
|
const me = await page.evaluate(async (api) => {
|
||||||
|
const token = localStorage.getItem("dadao.access");
|
||||||
|
const r = await fetch(`${api}/auth/me`, { headers: { Authorization: `Bearer ${token}` } });
|
||||||
|
return r.json();
|
||||||
|
}, API);
|
||||||
|
check(
|
||||||
|
"接口里 has_password=true、昵称是刚改的",
|
||||||
|
me?.data?.user?.has_password === true && me?.data?.user?.nickname === nickname,
|
||||||
|
JSON.stringify(me?.data?.user ?? null),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ⑤ 退出后用验证码也能进来(登录页两种模式都得通)
|
||||||
|
await page.goto(`${WEB}/account`);
|
||||||
|
await page.getByRole("button", { name: "退出登录" }).last().click();
|
||||||
|
await page.waitForURL(/\/login/);
|
||||||
|
await page.getByRole("button", { name: "验证码", exact: true }).click();
|
||||||
|
await page.getByLabel("手机号").fill(PHONE);
|
||||||
|
await page.getByRole("button", { name: /获取验证码|重新获取/ }).click();
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
await page.getByLabel("验证码").fill(CODE);
|
||||||
|
await page.getByRole("button", { name: "登录" }).click();
|
||||||
|
const codeLoginOk = await page
|
||||||
|
.waitForURL(`${WEB}/`, { timeout: 15_000 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
if (codeLoginOk) check("验证码登录也照旧能用", true);
|
||||||
|
else console.log("SKIP 验证码登录(本次发码被 60 秒节流挡住)");
|
||||||
|
|
||||||
|
const realErrors = errors.filter((e) => !e.includes("429") && !e.includes("400"));
|
||||||
|
check("控制台零报错", realErrors.length === 0, realErrors.slice(0, 3).join(" | "));
|
||||||
|
|
||||||
|
// ⑥ 窄屏:侧栏收成顶条,别把整页挤成一列
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.goto(WEB);
|
||||||
|
await page.waitForSelector("main h1");
|
||||||
|
check("窄屏首页是顶条而不是侧栏", (await page.locator("header").count()) > 0);
|
||||||
|
await page.screenshot({ path: `${OUT}/account-home-mobile.png`, fullPage: false });
|
||||||
|
await page.goto(`${WEB}/account`);
|
||||||
|
await page.waitForSelector("text=登录密码");
|
||||||
|
await page.screenshot({ path: `${OUT}/account-settings-mobile.png`, fullPage: false });
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
console.log(`\n${fail === 0 ? "ALL PASS" : "SOME FAILED"} — ${pass} passed, ${fail} failed`);
|
||||||
|
if (fail > 0) process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.log(`FAIL 脚本异常 — ${err?.message ?? err}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -77,6 +77,15 @@ try {
|
|||||||
await page.getByRole("button", { name: /^部署/, exact: false }).first().waitFor();
|
await page.getByRole("button", { name: /^部署/, exact: false }).first().waitFor();
|
||||||
check("能在侧栏建出「研发 / 部署」两级分类", true);
|
check("能在侧栏建出「研发 / 部署」两级分类", true);
|
||||||
|
|
||||||
|
// 树要按「父在前、子紧跟」排:部署必须紧挨在研发后面,而不是堆在别的根分类后面
|
||||||
|
const navTexts = await page.locator("aside nav button").allInnerTexts();
|
||||||
|
const navNames = navTexts.map((t) => t.replace(/[·\d]/g, "").trim());
|
||||||
|
check(
|
||||||
|
"侧栏分类按树的顺序排(子紧跟在父后面)",
|
||||||
|
navNames.indexOf("部署") === navNames.indexOf("研发") + 1,
|
||||||
|
navNames.join(" / "),
|
||||||
|
);
|
||||||
|
|
||||||
// ② 记一条:一个面板里填完标题/类型/分类/标签/正文
|
// ② 记一条:一个面板里填完标题/类型/分类/标签/正文
|
||||||
await page.getByRole("button", { name: "记一条", exact: true }).click();
|
await page.getByRole("button", { name: "记一条", exact: true }).click();
|
||||||
await page.getByLabel("标题").fill("回滚前先停灰度");
|
await page.getByLabel("标题").fill("回滚前先停灰度");
|
||||||
@@ -170,6 +179,16 @@ try {
|
|||||||
),
|
),
|
||||||
page.getByRole("button", { name: "打开", exact: true }).first().click(),
|
page.getByRole("button", { name: "打开", exact: true }).first().click(),
|
||||||
]);
|
]);
|
||||||
|
// 详情默认是「看」:Markdown 渲染后的样子,不是编辑器
|
||||||
|
await page.locator(".prose-dadao h2").first().waitFor({ timeout: 10_000 });
|
||||||
|
check(
|
||||||
|
"详情抽屉默认渲染样式(不是进来就编辑)",
|
||||||
|
(await page.locator(".vditor-ir").count()) === 0 &&
|
||||||
|
(await page.locator(".prose-dadao h2").first().textContent())?.includes("步骤") === true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 要改才点「编辑」
|
||||||
|
await page.getByRole("button", { name: "编辑", exact: true }).last().click();
|
||||||
const tagInput = page.getByLabel("标签", { exact: true }).last();
|
const tagInput = page.getByLabel("标签", { exact: true }).last();
|
||||||
await tagInput.waitFor({ timeout: 10_000 });
|
await tagInput.waitFor({ timeout: 10_000 });
|
||||||
// 先等抽屉把自己拉回来的旧标签填上——那一刻之后才没有"迟到的响应"来覆盖输入
|
// 先等抽屉把自己拉回来的旧标签填上——那一刻之后才没有"迟到的响应"来覆盖输入
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* 知识列表翻页的界面验收:点「加载更多」能把 84 条翻到底,不出错。
|
||||||
|
*
|
||||||
|
* 这条脚本是有来历的:接口的游标字段名写错(`cur.item_id`),第二页直接 500,
|
||||||
|
* 但库小于一页时永远碰不到——导入 84 条旧备忘之后用户一点就撞上了。
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* node web/e2e/knowledge-paging.mjs # 默认打生产
|
||||||
|
* WEB=http://127.0.0.1:5180 PHONE=13900000001 node web/e2e/knowledge-paging.mjs
|
||||||
|
*/
|
||||||
|
import { chromium } from "playwright-core";
|
||||||
|
|
||||||
|
const WEB = process.env.WEB ?? "http://192.168.8.188:8200";
|
||||||
|
const API = process.env.API ?? `${WEB}/api/v1`;
|
||||||
|
const PHONE = process.env.PHONE ?? "13605513683";
|
||||||
|
const CODE = process.env.CODE ?? "123456";
|
||||||
|
const CODE_PIN = process.env.CODE_PIN ?? "123456"; // 生产 pin 就是验证码本身
|
||||||
|
const CHROME =
|
||||||
|
process.env.CHROME ??
|
||||||
|
`${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`;
|
||||||
|
|
||||||
|
let pass = 0;
|
||||||
|
let fail = 0;
|
||||||
|
function check(name, ok, extra = "") {
|
||||||
|
if (ok) {
|
||||||
|
pass += 1;
|
||||||
|
console.log(`PASS ${name}`);
|
||||||
|
} else {
|
||||||
|
fail += 1;
|
||||||
|
console.log(`FAIL ${name} ${extra}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login() {
|
||||||
|
const r = await fetch(`${API}/auth/sms/send`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ phone: PHONE }),
|
||||||
|
});
|
||||||
|
const sent = await r.json();
|
||||||
|
if (sent.code !== 0) throw new Error(`发码失败:${sent.message}`);
|
||||||
|
const login = await fetch(`${API}/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ phone: PHONE, code: CODE_PIN }),
|
||||||
|
});
|
||||||
|
const body = await login.json();
|
||||||
|
if (body.code !== 0) throw new Error(`登录失败:${body.message}`);
|
||||||
|
return { tokens: body.data.tokens, workspaces: await workspaces(body.data.tokens) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function workspaces(tokens) {
|
||||||
|
const r = await fetch(`${API}/workspaces`, {
|
||||||
|
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
||||||
|
});
|
||||||
|
const body = await r.json();
|
||||||
|
return body.data?.items ?? body.data ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
void CODE;
|
||||||
|
const { tokens, workspaces: spaces } = await login();
|
||||||
|
if (!spaces.length) throw new Error("这个账号没有工作区");
|
||||||
|
const ws = spaces[0];
|
||||||
|
|
||||||
|
// 先问接口一共有多少条,作为界面上应该数出来的数字
|
||||||
|
const head = await fetch(`${API}/workspaces/${ws.id}/knowledge?limit=100`, {
|
||||||
|
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
||||||
|
});
|
||||||
|
const headBody = await head.json();
|
||||||
|
const total = (headBody.data?.items ?? []).length;
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ executablePath: CHROME });
|
||||||
|
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||||
|
await context.addInitScript(
|
||||||
|
([access, refresh]) => {
|
||||||
|
localStorage.setItem("dadao.access", access);
|
||||||
|
localStorage.setItem("dadao.refresh", refresh);
|
||||||
|
},
|
||||||
|
[tokens.access_token, tokens.refresh_token],
|
||||||
|
);
|
||||||
|
const page = await context.newPage();
|
||||||
|
const errors = [];
|
||||||
|
const bad = [];
|
||||||
|
page.on("pageerror", (e) => errors.push(String(e)));
|
||||||
|
page.on("response", (r) => {
|
||||||
|
if (r.url().includes("/knowledge?") && r.status() >= 400) bad.push(`${r.status()} ${r.url()}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`${WEB}/w/${ws.id}/knowledge`, { waitUntil: "networkidle" });
|
||||||
|
const cards = () => page.locator("li");
|
||||||
|
const first = await cards().count();
|
||||||
|
check("首屏有列表", first > 0, `count=${first}`);
|
||||||
|
|
||||||
|
let rounds = 0;
|
||||||
|
while (rounds < 20) {
|
||||||
|
const more = page.getByRole("button", { name: "加载更多" });
|
||||||
|
if ((await more.count()) === 0) break;
|
||||||
|
const before = await cards().count();
|
||||||
|
await more.first().click();
|
||||||
|
const grew = async () => (await cards().count()) > before;
|
||||||
|
for (let i = 0; i < 40 && !(await grew()); i += 1) await page.waitForTimeout(100);
|
||||||
|
if (!(await grew())) break;
|
||||||
|
rounds += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shown = await cards().count();
|
||||||
|
check("点「加载更多」能把列表翻到底", shown >= total, `shown=${shown} total=${total}`);
|
||||||
|
check("翻页接口没有报错", bad.length === 0, bad.slice(0, 3).join(" | "));
|
||||||
|
const toast = await page.locator("text=服务器内部错误").count();
|
||||||
|
check("页面上没有「服务器内部错误」", toast === 0);
|
||||||
|
check("控制台零报错", errors.length === 0, errors.slice(0, 2).join(" | "));
|
||||||
|
|
||||||
|
await page.screenshot({ path: "e2e/artifacts/knowledge-paging.png" });
|
||||||
|
await browser.close();
|
||||||
|
|
||||||
|
console.log(`\n${fail === 0 ? "ALL PASS" : "SOME FAILED"} — pass ${pass}, fail ${fail}`);
|
||||||
|
process.exit(fail === 0 ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("脚本异常 —", err?.message ?? err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -104,10 +104,17 @@ try {
|
|||||||
page.getByRole("button", { name: "发布", exact: true }).click(),
|
page.getByRole("button", { name: "发布", exact: true }).click(),
|
||||||
]);
|
]);
|
||||||
check("发布请求成功", publishResp.ok());
|
check("发布请求成功", publishResp.ok());
|
||||||
await page.getByText(/已发布 · 版本 \d+/, { exact: false }).waitFor({ timeout: 10_000 });
|
await page.getByText(/发布于 /, { exact: false }).first().waitFor({ timeout: 10_000 });
|
||||||
const published = await page.getByText(/已发布 · 版本 \d+/, { exact: false }).first().isVisible();
|
const published = await page.getByText(/发布于 /, { exact: false }).first().isVisible();
|
||||||
check("核对后可以发布", published);
|
check("核对后可以发布", published);
|
||||||
|
|
||||||
|
// 发布之后也得能改:点「编辑」回到编辑器,改完还是已发布
|
||||||
|
const editButton = page.getByRole("button", { name: "编辑", exact: true }).last();
|
||||||
|
check("已发布的知识也有「编辑」按钮", await editButton.isVisible());
|
||||||
|
await editButton.click();
|
||||||
|
await page.locator(".vditor-ir").first().waitFor({ timeout: 10_000 });
|
||||||
|
check("点「编辑」回到编辑器", (await page.locator(".vditor-ir").count()) > 0);
|
||||||
|
|
||||||
const again = await fetch(`${API}/workspaces/${ws.id}/knowledge`, {
|
const again = await fetch(`${API}/workspaces/${ws.id}/knowledge`, {
|
||||||
headers: { authorization: `Bearer ${token}` },
|
headers: { authorization: `Bearer ${token}` },
|
||||||
}).then((r) => r.json());
|
}).then((r) => r.json());
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
/**
|
||||||
|
* 登录方式与系统设置的验收:短信关着、邮箱能登录、SMTP 后台可配。
|
||||||
|
*
|
||||||
|
* 三个都要真跑,因为这三件事全是"接口回 200 但人用不了"的高危区:
|
||||||
|
* - 短信关着,登录页就不能出现「验证码」这一档(不然用户点进去只会看到 2017);
|
||||||
|
* - 邮箱 + 密码要能真的登进来,不是只把邮箱存进库;
|
||||||
|
* - SMTP 存进库之后要能立刻发信,别等到真要用的时候才发现授权码是错的。
|
||||||
|
*
|
||||||
|
* 授权码不进仓库:发信那一步只在给了 SMTP_PASS 环境变量时才跑。
|
||||||
|
*/
|
||||||
|
import { chromium } from "playwright-core";
|
||||||
|
import { mkdirSync, readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
const WEB = process.env.WEB ?? "http://127.0.0.1:5180";
|
||||||
|
const API = process.env.API ?? "http://127.0.0.1:8100/api/v1";
|
||||||
|
const PHONE = process.env.PHONE ?? "13900000001";
|
||||||
|
const PWD_FILE = new URL("../../.local/e2e-account-password.txt", import.meta.url);
|
||||||
|
const OUT = "e2e/artifacts";
|
||||||
|
|
||||||
|
const SMTP_HOST = process.env.SMTP_HOST ?? "smtp.126.com";
|
||||||
|
const SMTP_USER = process.env.SMTP_USER ?? "";
|
||||||
|
const SMTP_PASS = process.env.SMTP_PASS ?? "";
|
||||||
|
|
||||||
|
let pass = 0;
|
||||||
|
let fail = 0;
|
||||||
|
function check(name, ok, extra = "") {
|
||||||
|
if (ok) {
|
||||||
|
pass += 1;
|
||||||
|
console.log(`PASS ${name}`);
|
||||||
|
} else {
|
||||||
|
fail += 1;
|
||||||
|
console.log(`FAIL ${name} ${extra}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function savedPassword() {
|
||||||
|
try {
|
||||||
|
return readFileSync(PWD_FILE, "utf8").trim() || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post(path, body, token) {
|
||||||
|
const r = await fetch(`${API}${path}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body ?? {}),
|
||||||
|
});
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function get(path, token) {
|
||||||
|
const r = await fetch(`${API}${path}`, {
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
});
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function patch(path, body, token) {
|
||||||
|
const r = await fetch(`${API}${path}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
mkdirSync(OUT, { recursive: true });
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
executablePath:
|
||||||
|
process.env.CHROME ??
|
||||||
|
`${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`,
|
||||||
|
});
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } });
|
||||||
|
const errors = [];
|
||||||
|
page.on("console", (m) => m.type() === "error" && errors.push(m.text()));
|
||||||
|
page.on("pageerror", (e) => errors.push(String(e)));
|
||||||
|
|
||||||
|
const password = savedPassword();
|
||||||
|
|
||||||
|
// ① 服务端说清楚现在开着哪几条路
|
||||||
|
const methods = await get("/auth/methods");
|
||||||
|
check(
|
||||||
|
"① /auth/methods 说短信关着、密码与邮箱密码开着",
|
||||||
|
methods.code === 0 &&
|
||||||
|
methods.data.password === true &&
|
||||||
|
methods.data.email_password === true &&
|
||||||
|
methods.data.sms_code === false,
|
||||||
|
JSON.stringify(methods.data),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ② 关着的那条路真的挡住了
|
||||||
|
const sms = await post("/auth/sms/send", { phone: PHONE });
|
||||||
|
check("② 发验证码的接口回 2017(不是静默成功)", sms.code === 2017, JSON.stringify(sms));
|
||||||
|
|
||||||
|
// ③ 登录页不该再出现「验证码」这一档
|
||||||
|
await page.goto(`${WEB}/login`);
|
||||||
|
await page.waitForSelector("#account");
|
||||||
|
const codeTab = await page.getByRole("button", { name: "验证码", exact: true }).count();
|
||||||
|
const emailTab = await page.getByRole("tab", { name: "邮箱" }).count();
|
||||||
|
const pwdInput = await page.locator("#password").count();
|
||||||
|
check("③ 登录页没有验证码那一档、有邮箱档与密码框", codeTab === 0 && emailTab === 1 && pwdInput === 1);
|
||||||
|
|
||||||
|
// ④ 手机号 + 密码,从界面登录进去
|
||||||
|
let loggedIn = false;
|
||||||
|
if (password) {
|
||||||
|
await page.getByLabel("手机号").fill(PHONE);
|
||||||
|
await page.locator("#password").fill(password);
|
||||||
|
await page.getByRole("button", { name: "登录", exact: true }).click();
|
||||||
|
loggedIn = await page
|
||||||
|
.waitForURL(`${WEB}/`, { timeout: 8000 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
}
|
||||||
|
check("④ 手机号 + 密码能从登录页进首页", loggedIn, "(缺 .local/e2e-account-password.txt 时跳过)");
|
||||||
|
|
||||||
|
const tokens = loggedIn
|
||||||
|
? await page.evaluate(() => localStorage.getItem("dadao.access"))
|
||||||
|
: (await post("/auth/login/password", { phone: PHONE, password })).data?.tokens?.access_token;
|
||||||
|
|
||||||
|
// ⑤ 绑定邮箱,然后**用邮箱登录**
|
||||||
|
const email = `dadao-e2e-${Date.now()}@example.com`;
|
||||||
|
const bind = await patch("/auth/me", { email }, tokens);
|
||||||
|
check("⑤ 账号页能绑邮箱", bind.code === 0 && bind.data.user.email === email, JSON.stringify(bind));
|
||||||
|
|
||||||
|
const byEmail = await post("/auth/login/password", { email: email.toUpperCase(), password });
|
||||||
|
const emailLogin = await post("/auth/login/password", { email, password });
|
||||||
|
check(
|
||||||
|
"⑥ 邮箱 + 密码能登录(大小写不敏感)",
|
||||||
|
emailLogin.code === 0 && emailLogin.data?.tokens?.access_token,
|
||||||
|
JSON.stringify(emailLogin),
|
||||||
|
);
|
||||||
|
check("⑥b 全大写邮箱也能登录", byEmail.code === 0, JSON.stringify(byEmail));
|
||||||
|
|
||||||
|
// ⑦ 解绑之后这条路就该关掉,而不是留个后门
|
||||||
|
const unbind = await patch("/auth/me", { email: "" }, tokens);
|
||||||
|
const afterUnbind = await post("/auth/login/password", { email, password });
|
||||||
|
check(
|
||||||
|
"⑦ 解绑邮箱后邮箱登录回 2013",
|
||||||
|
unbind.code === 0 && unbind.data.user.email === null && afterUnbind.code === 2013,
|
||||||
|
JSON.stringify(afterUnbind),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ⑧ 平台管理员才看得到「系统设置」
|
||||||
|
const me = await get("/auth/me", tokens);
|
||||||
|
check("⑧ 当前账号被标成平台管理员", me.data?.user?.platform_admin === true, JSON.stringify(me.data?.user));
|
||||||
|
await page.goto(`${WEB}/account`);
|
||||||
|
await page.waitForSelector('input[aria-label="昵称"]');
|
||||||
|
// 侧栏按 /auth/me 的 platform_admin 决定要不要渲染这一条,等它落下来再数
|
||||||
|
const settingsLink = await page
|
||||||
|
.waitForFunction(() => document.querySelectorAll('aside a[href="/settings"]').length, {
|
||||||
|
timeout: 5000,
|
||||||
|
})
|
||||||
|
.then((handle) => handle.jsonValue())
|
||||||
|
.catch(() => 0);
|
||||||
|
check("⑧b 侧栏里有「系统设置」入口", settingsLink === 1, `count=${settingsLink}`);
|
||||||
|
await page.screenshot({ path: `${OUT}/settings-entry.png` });
|
||||||
|
|
||||||
|
// ⑨ 系统设置页能读、能存;授权码只写不读
|
||||||
|
await page.goto(`${WEB}/settings`);
|
||||||
|
await page.waitForSelector('input[aria-label="服务器地址"]');
|
||||||
|
const before = await get("/settings/smtp", tokens);
|
||||||
|
check("⑨ 系统设置页读得到当前配置", before.code === 0 && before.data.password_set !== undefined);
|
||||||
|
|
||||||
|
if (SMTP_PASS) {
|
||||||
|
await page.getByLabel("服务器地址").fill(SMTP_HOST);
|
||||||
|
await page.getByLabel("端口").fill("465");
|
||||||
|
await page.locator('input[aria-label="账号"]').fill(SMTP_USER);
|
||||||
|
await page.getByLabel("授权码").fill(SMTP_PASS);
|
||||||
|
await page.getByRole("button", { name: "保存", exact: true }).click();
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
const saved = await get("/settings/smtp", tokens);
|
||||||
|
check(
|
||||||
|
"⑩ 授权码存进库了,但接口不回显",
|
||||||
|
saved.data?.password_set === true &&
|
||||||
|
saved.data?.configured === true &&
|
||||||
|
!JSON.stringify(saved.data).includes(SMTP_PASS),
|
||||||
|
JSON.stringify(saved.data),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ⑪ 真发一封,确认这条通道是通的(不是"存了个假配置")
|
||||||
|
const sent = await post("/settings/smtp/test", { to: SMTP_USER }, tokens);
|
||||||
|
check("⑪ 测试邮件发送成功", sent.code === 0, JSON.stringify(sent));
|
||||||
|
if (sent.code !== 0) {
|
||||||
|
console.log(` 发信失败原文:${sent.message}`);
|
||||||
|
}
|
||||||
|
await page.screenshot({ path: `${OUT}/settings-smtp.png` });
|
||||||
|
} else {
|
||||||
|
console.log("SKIP ⑩⑪ 没给 SMTP_PASS,跳过保存与发信");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⑫ 账号页上的邮箱框要能回填刚绑的值(说明前端读的是接口,不是本地缓存)
|
||||||
|
const rebind = await patch("/auth/me", { email }, tokens);
|
||||||
|
await page.goto(`${WEB}/account`);
|
||||||
|
await page.waitForSelector('input[aria-label="邮箱"]');
|
||||||
|
const emailInput = await page.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const el = document.querySelector('input[aria-label="邮箱"]');
|
||||||
|
return el && el.value.includes("@") ? el.value : null;
|
||||||
|
},
|
||||||
|
{ timeout: 5000 },
|
||||||
|
);
|
||||||
|
const shownEmail = await emailInput.jsonValue();
|
||||||
|
check("⑫ 账号页回填刚绑的邮箱", rebind.code === 0 && shownEmail === email, shownEmail);
|
||||||
|
|
||||||
|
check("⑬ 控制台零报错", errors.length === 0, errors.slice(0, 2).join(" | "));
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
console.log(`\n${fail === 0 ? "ALL PASS" : "SOME FAILED"} — pass ${pass}, fail ${fail}`);
|
||||||
|
process.exit(fail === 0 ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("脚本异常", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* 生产只读核对:首页、工作区页、账号页是不是同一套外壳,宽度是否一致。
|
||||||
|
*
|
||||||
|
* 只读:不设密码、不改昵称,只看界面。生产是内测模式(验证码 123456)。
|
||||||
|
* 注意发码有 60 秒节流:被挡住时要等,别把它当成登录坏了。
|
||||||
|
*/
|
||||||
|
import { chromium } from "playwright-core";
|
||||||
|
|
||||||
|
const WEB = process.env.WEB ?? "http://192.168.8.188:8200";
|
||||||
|
const PHONE = process.env.PHONE ?? "13605513683";
|
||||||
|
const CODE = process.env.CODE ?? "123456";
|
||||||
|
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
executablePath:
|
||||||
|
process.env.CHROME ??
|
||||||
|
`${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`,
|
||||||
|
});
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } });
|
||||||
|
const errors = [];
|
||||||
|
page.on("console", (m) => m.type() === "error" && errors.push(m.text()));
|
||||||
|
|
||||||
|
await page.goto(`${WEB}/login`);
|
||||||
|
await page.getByLabel("手机号").fill(PHONE);
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= 3 && new URL(page.url()).pathname === "/login"; attempt += 1) {
|
||||||
|
await page.getByRole("button", { name: /获取验证码|重新获取|秒/ }).click();
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
const text = await page.locator("body").innerText();
|
||||||
|
if (/秒后再试|太频繁/.test(text)) {
|
||||||
|
console.log(`发码被节流,等 61 秒(第 ${attempt} 次)`);
|
||||||
|
await page.waitForTimeout(61_000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await page.getByLabel("验证码").fill(CODE);
|
||||||
|
await page.getByRole("button", { name: "登录" }).click();
|
||||||
|
await page.waitForTimeout(2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.waitForURL(`${WEB}/`, { timeout: 15_000 });
|
||||||
|
await page.waitForSelector("text=我的工作区");
|
||||||
|
const homeRail = await page.locator("aside").first().boundingBox();
|
||||||
|
console.log("首页侧栏宽度:", homeRail?.width);
|
||||||
|
await page.screenshot({ path: "e2e/artifacts/prod-home.png" });
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: /我的备忘/ }).first().click();
|
||||||
|
await page.waitForURL(/\/w\//);
|
||||||
|
await page.waitForSelector("aside");
|
||||||
|
const wsRail = await page.locator("aside").first().boundingBox();
|
||||||
|
console.log(
|
||||||
|
"工作区侧栏宽度:",
|
||||||
|
wsRail?.width,
|
||||||
|
"与首页差值:",
|
||||||
|
Math.abs((wsRail?.width ?? 0) - (homeRail?.width ?? 0)),
|
||||||
|
);
|
||||||
|
await page.screenshot({ path: "e2e/artifacts/prod-workspace.png" });
|
||||||
|
|
||||||
|
await page.goto(`${WEB}/account`);
|
||||||
|
await page.waitForSelector("text=登录密码");
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => document.querySelector('input[aria-label="昵称"]')?.value?.length > 0,
|
||||||
|
null,
|
||||||
|
{ timeout: 10_000 },
|
||||||
|
);
|
||||||
|
const accountRail = await page.locator("aside").first().boundingBox();
|
||||||
|
console.log("账号页侧栏宽度:", accountRail?.width);
|
||||||
|
const accountText = await page.locator("main").innerText();
|
||||||
|
console.log("账号页:", accountText.replace(/\s+/g, " | ").slice(0, 260));
|
||||||
|
await page.screenshot({ path: "e2e/artifacts/prod-account.png" });
|
||||||
|
|
||||||
|
console.log("控制台报错:", errors.length === 0 ? "无" : errors.slice(0, 3));
|
||||||
|
await browser.close();
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* PWA 验收脚本:装得上、离线开得开、有新版本提醒得了。
|
||||||
|
*
|
||||||
|
* 跑法(必须先有一个"生产同构"的站点:后端用 WEB_DIST_DIR 指向 web/dist):
|
||||||
|
* WEB_DIST_DIR=<repo>/web/dist ../backend/.venv/Scripts/python.exe -m uvicorn app.main:app --port 8101
|
||||||
|
* node web/e2e/pwa.mjs # 默认 BASE=http://127.0.0.1:8101
|
||||||
|
*
|
||||||
|
* 两个坑:
|
||||||
|
* 1. Service Worker 只在安全上下文(https / localhost)注册,所以这里必须走 127.0.0.1,
|
||||||
|
* 不能换成内网 IP——那台机器上要真的装上,得先有 https。
|
||||||
|
* 2. 无头 Chromium 不会触发 beforeinstallprompt,所以"安装"那段用 iPhone 的 UA 走
|
||||||
|
* iOS 分支(分享 → 添加到主屏幕),这是唯一能稳定断言到的安装引导。
|
||||||
|
*/
|
||||||
|
import { chromium } from "playwright-core";
|
||||||
|
import { mkdirSync, readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
const BASE = process.env.BASE ?? "http://127.0.0.1:8101";
|
||||||
|
const PHONE = process.env.PHONE ?? "13900000001";
|
||||||
|
const CODE = process.env.CODE ?? "123456";
|
||||||
|
const PWD_FILE = new URL("../../.local/e2e-account-password.txt", import.meta.url);
|
||||||
|
const OUT = "e2e/artifacts";
|
||||||
|
const CHROME =
|
||||||
|
process.env.CHROME ??
|
||||||
|
`${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`;
|
||||||
|
|
||||||
|
const IOS_UA =
|
||||||
|
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1";
|
||||||
|
|
||||||
|
let pass = 0;
|
||||||
|
let fail = 0;
|
||||||
|
function check(name, ok, extra = "") {
|
||||||
|
if (ok) {
|
||||||
|
pass += 1;
|
||||||
|
console.log(`PASS ${name}`);
|
||||||
|
} else {
|
||||||
|
fail += 1;
|
||||||
|
console.log(`FAIL ${name} ${extra}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function token() {
|
||||||
|
// 先试密码(快、不撞短信节流),不行再发验证码
|
||||||
|
try {
|
||||||
|
const saved = readFileSync(PWD_FILE, "utf8").trim();
|
||||||
|
const r = await fetch(`${BASE}/api/v1/auth/login/password`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ phone: PHONE, password: saved }),
|
||||||
|
});
|
||||||
|
const body = await r.json();
|
||||||
|
if (body?.code === 0) return body.data.tokens;
|
||||||
|
} catch {
|
||||||
|
// 没存过密码:往下走验证码
|
||||||
|
}
|
||||||
|
await fetch(`${BASE}/api/v1/auth/sms/send`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ phone: PHONE }),
|
||||||
|
});
|
||||||
|
const r = await fetch(`${BASE}/api/v1/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ phone: PHONE, code: CODE }),
|
||||||
|
});
|
||||||
|
const body = await r.json();
|
||||||
|
if (body?.code !== 0) throw new Error(`登录失败:${JSON.stringify(body)}`);
|
||||||
|
return body.data.tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
mkdirSync(OUT, { recursive: true });
|
||||||
|
const tokens = await token();
|
||||||
|
const browser = await chromium.launch({ executablePath: CHROME });
|
||||||
|
|
||||||
|
// ── 一、桌面尺寸:Service Worker 与 manifest ─────────────────────
|
||||||
|
const desktop = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||||
|
await desktop.addInitScript(
|
||||||
|
([access, refresh]) => {
|
||||||
|
localStorage.setItem("dadao.access", access);
|
||||||
|
localStorage.setItem("dadao.refresh", refresh);
|
||||||
|
},
|
||||||
|
[tokens.access_token, tokens.refresh_token],
|
||||||
|
);
|
||||||
|
const page = await desktop.newPage();
|
||||||
|
const errors = [];
|
||||||
|
page.on("pageerror", (e) => errors.push(String(e)));
|
||||||
|
await page.goto(`${BASE}/`, { waitUntil: "networkidle" });
|
||||||
|
|
||||||
|
const manifestHref = await page.getAttribute('link[rel="manifest"]', "href");
|
||||||
|
check("页面挂了 manifest", Boolean(manifestHref), String(manifestHref));
|
||||||
|
|
||||||
|
const manifest = await (await fetch(`${BASE}${manifestHref}`)).json();
|
||||||
|
check("manifest 名字是「达道Task」", manifest.short_name === "达道Task", JSON.stringify(manifest.name));
|
||||||
|
check("独立窗口打开(display=standalone)", manifest.display === "standalone", String(manifest.display));
|
||||||
|
check("三个图标齐全", (manifest.icons ?? []).length === 3, JSON.stringify(manifest.icons));
|
||||||
|
check(
|
||||||
|
"有一个 maskable 图标",
|
||||||
|
(manifest.icons ?? []).some((i) => String(i.purpose).includes("maskable")),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const icon of manifest.icons ?? []) {
|
||||||
|
const res = await fetch(`${BASE}/${String(icon.src).replace(/^\//, "")}`);
|
||||||
|
check(`图标可下载 ${icon.sizes}`, res.ok && res.headers.get("content-type") === "image/png");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sw = await page.evaluate(async () => {
|
||||||
|
const reg = await navigator.serviceWorker.ready;
|
||||||
|
return { scope: reg.scope, state: reg.active?.state ?? "none" };
|
||||||
|
});
|
||||||
|
check("Service Worker 已激活", sw.state === "activated", JSON.stringify(sw));
|
||||||
|
check("作用域是整站", sw.scope.endsWith("/"), sw.scope);
|
||||||
|
|
||||||
|
const shellCached = await page.evaluate(async () => {
|
||||||
|
const cache = await caches.open("workbox-precache-v2-" + location.origin + "/");
|
||||||
|
const keys = await cache.keys();
|
||||||
|
return keys.some((k) => k.url.includes("index.html"));
|
||||||
|
});
|
||||||
|
check("页面骨架已预缓存", shellCached);
|
||||||
|
|
||||||
|
const themeColor = await page.getAttribute('meta[name="theme-color"]', "content");
|
||||||
|
check("主题色是品牌靛蓝", themeColor === "#4f46e5", String(themeColor));
|
||||||
|
check("桌面尺寸没有控制台报错", errors.length === 0, errors.join(" | "));
|
||||||
|
await desktop.close();
|
||||||
|
|
||||||
|
// ── 二、iPhone 尺寸:安装引导(iOS 只能靠「分享 → 添加到主屏幕」)─────
|
||||||
|
const phone = await browser.newContext({
|
||||||
|
viewport: { width: 390, height: 844 },
|
||||||
|
userAgent: IOS_UA,
|
||||||
|
deviceScaleFactor: 2,
|
||||||
|
});
|
||||||
|
await phone.addInitScript(
|
||||||
|
([access, refresh]) => {
|
||||||
|
localStorage.setItem("dadao.access", access);
|
||||||
|
localStorage.setItem("dadao.refresh", refresh);
|
||||||
|
},
|
||||||
|
[tokens.access_token, tokens.refresh_token],
|
||||||
|
);
|
||||||
|
const m = await phone.newPage();
|
||||||
|
await m.goto(`${BASE}/`, { waitUntil: "networkidle" });
|
||||||
|
await m.waitForSelector("text=把达道Task装到桌面", { timeout: 10000 });
|
||||||
|
const hint = await m.locator("text=把达道Task装到桌面").first().isVisible();
|
||||||
|
check("窄屏首页有安装引导", hint);
|
||||||
|
const iosText = await m.locator("text=添加到主屏幕").count();
|
||||||
|
check("iOS 给出的是「分享 → 添加到主屏幕」", iosText > 0);
|
||||||
|
await m.screenshot({ path: `${OUT}/pwa-install-hint.png` });
|
||||||
|
|
||||||
|
await m.getByRole("button", { name: "不再提示安装" }).click();
|
||||||
|
await m.reload({ waitUntil: "networkidle" });
|
||||||
|
const stillThere = await m.locator("text=把达道Task装到桌面").count();
|
||||||
|
check("关掉之后不再打扰(刷新也不回来)", stillThere === 0);
|
||||||
|
|
||||||
|
// ── 三、有新版时提醒,点「稍后」收起 ─────────────────────────────
|
||||||
|
await m.evaluate(() => window.dispatchEvent(new Event("dadao:pwa-update-ready")));
|
||||||
|
await m.waitForSelector("text=有新版了", { timeout: 5000 });
|
||||||
|
check("检测到新版本会提示刷新", (await m.locator("text=有新版了").count()) > 0);
|
||||||
|
await m.screenshot({ path: `${OUT}/pwa-update-prompt.png` });
|
||||||
|
await m.getByRole("button", { name: "稍后" }).click();
|
||||||
|
check("点「稍后」会收起", (await m.locator("text=有新版了").count()) === 0);
|
||||||
|
|
||||||
|
// ── 四、断网还能打开(这是装到桌面的实际价值)─────────────────────
|
||||||
|
await m.evaluate(() => localStorage.clear());
|
||||||
|
await m.reload({ waitUntil: "networkidle" });
|
||||||
|
await phone.setOffline(true);
|
||||||
|
await m.reload({ waitUntil: "domcontentloaded" }).catch(() => {});
|
||||||
|
const body = await m.locator("body").innerText();
|
||||||
|
check("断网后页面骨架仍能打开", body.length > 0 && /登录|达道/.test(body), body.slice(0, 80));
|
||||||
|
await phone.setOffline(false);
|
||||||
|
await m.screenshot({ path: `${OUT}/pwa-offline.png` });
|
||||||
|
|
||||||
|
await phone.close();
|
||||||
|
await browser.close();
|
||||||
|
|
||||||
|
console.log(`\n${fail === 0 ? "ALL PASS" : "SOME FAILED"} — pass ${pass}, fail ${fail}`);
|
||||||
|
process.exit(fail === 0 ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("脚本异常 —", err?.message ?? err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* 拍两张文档用的截图:登录页(两种账号名 + 密码)、账号页(邮箱)、系统设置页。
|
||||||
|
* 需要前端 5180、后端 8100 起着;账号用 dev 的平台管理员。
|
||||||
|
*/
|
||||||
|
import { chromium } from "playwright-core";
|
||||||
|
import { mkdirSync, readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
const WEB = process.env.WEB ?? "http://127.0.0.1:5180";
|
||||||
|
const API = process.env.API ?? "http://127.0.0.1:8100/api/v1";
|
||||||
|
const PHONE = process.env.PHONE ?? "13900000001";
|
||||||
|
const OUT = "e2e/artifacts";
|
||||||
|
const PWD_FILE = new URL("../../.local/e2e-account-password.txt", import.meta.url);
|
||||||
|
|
||||||
|
function savedPassword() {
|
||||||
|
try {
|
||||||
|
return readFileSync(PWD_FILE, "utf8").trim();
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
executablePath:
|
||||||
|
process.env.CHROME ??
|
||||||
|
`${process.env.LOCALAPPDATA}/ms-playwright/chromium-1223/chrome-win64/chrome.exe`,
|
||||||
|
});
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } });
|
||||||
|
mkdirSync(OUT, { recursive: true });
|
||||||
|
|
||||||
|
await page.goto(`${WEB}/login`);
|
||||||
|
await page.waitForSelector("#account");
|
||||||
|
await page.screenshot({ path: `${OUT}/login-password.png` });
|
||||||
|
|
||||||
|
const r = await fetch(`${API}/auth/login/password`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ phone: PHONE, password: savedPassword() }),
|
||||||
|
});
|
||||||
|
const data = await r.json();
|
||||||
|
await page.addInitScript(
|
||||||
|
([a, rf]) => {
|
||||||
|
localStorage.setItem("dadao.access", a);
|
||||||
|
localStorage.setItem("dadao.refresh", rf);
|
||||||
|
},
|
||||||
|
[data.data.tokens.access_token, data.data.tokens.refresh_token],
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto(`${WEB}/account`);
|
||||||
|
await page.waitForSelector('input[aria-label="邮箱"]');
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
await page.screenshot({ path: `${OUT}/account-page.png` });
|
||||||
|
|
||||||
|
await page.goto(`${WEB}/settings`);
|
||||||
|
await page.waitForSelector('input[aria-label="服务器地址"]');
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
await page.screenshot({ path: `${OUT}/settings-page.png` });
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
console.log("screenshots written to", OUT);
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* 截图脚本:把导入的备忘录点开,看「浏览」样式是否真的渲染成 Markdown。
|
||||||
|
*
|
||||||
|
* 只用于出图,不做断言;账号用 dev 的 13900000001(导入数据的归属)。
|
||||||
|
*/
|
||||||
|
import { chromium } from "playwright-core";
|
||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
|
||||||
|
const WEB = process.env.WEB ?? "http://127.0.0.1:5180";
|
||||||
|
const API = process.env.API ?? "http://127.0.0.1:8100/api/v1";
|
||||||
|
const PHONE = process.env.PHONE ?? "13900000001";
|
||||||
|
const WS_NAME = process.env.WS_NAME ?? "我的备忘";
|
||||||
|
|
||||||
|
async function call(method, path, body, token) {
|
||||||
|
const res = await fetch(`${API}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||||
|
...(method === "GET" ? {} : { "idempotency-key": `shot-${Math.random()}` }),
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
const json = text ? JSON.parse(text) : {};
|
||||||
|
if (json.code !== 0) throw new Error(`${method} ${path} -> ${json.code} ${json.message}`);
|
||||||
|
return json.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginAs(phone) {
|
||||||
|
// dev 下验证码固定,发码接口有 60 秒节流:能直接登录就直接登,被节流就等够了再发
|
||||||
|
try {
|
||||||
|
return await call("POST", "/auth/login", { phone, code: "123456" });
|
||||||
|
} catch {
|
||||||
|
/* 验证码过期或没发过,往下走发码 */
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 2; i += 1) {
|
||||||
|
try {
|
||||||
|
await call("POST", "/auth/sms/send", { phone });
|
||||||
|
break;
|
||||||
|
} catch (err) {
|
||||||
|
if (!String(err.message).includes("2011") || i === 1) throw err;
|
||||||
|
await new Promise((r) => setTimeout(r, 61_000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return await call("POST", "/auth/login", { phone, code: "123456" });
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync("e2e/artifacts", { recursive: true });
|
||||||
|
|
||||||
|
const login = await loginAs(PHONE);
|
||||||
|
const token = login.tokens.access_token;
|
||||||
|
const me = await call("GET", "/auth/me", undefined, token);
|
||||||
|
const ws = me.workspaces.find((w) => w.name === WS_NAME) ?? me.workspaces[0];
|
||||||
|
if (!ws) throw new Error(`${PHONE} 名下没有工作区`);
|
||||||
|
console.log(`工作区:${ws.name} ${ws.id}`);
|
||||||
|
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
executablePath:
|
||||||
|
process.env.CHROME ??
|
||||||
|
`${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`,
|
||||||
|
});
|
||||||
|
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
await page.addInitScript(
|
||||||
|
([access, refresh]) => {
|
||||||
|
localStorage.setItem("dadao.access", access);
|
||||||
|
localStorage.setItem("dadao.refresh", refresh);
|
||||||
|
},
|
||||||
|
[token, login.tokens.refresh_token],
|
||||||
|
);
|
||||||
|
await page.goto(`${WEB}/w/${ws.id}/knowledge`, { waitUntil: "networkidle" });
|
||||||
|
await page.getByRole("heading", { name: "知识" }).waitFor({ timeout: 20_000 });
|
||||||
|
// 导入的备忘都是发布过的,切到「已发布」才看得到;ALL=1 时留在默认的「全部」
|
||||||
|
if (!process.env.ALL) {
|
||||||
|
await page.getByRole("button", { name: "已发布", exact: true }).first().click();
|
||||||
|
}
|
||||||
|
if (process.env.TITLE) {
|
||||||
|
// 挑一条带标题/列表的,截图里才看得出 Markdown 真的渲染了
|
||||||
|
const target = page.locator("li").filter({ hasText: process.env.TITLE }).first();
|
||||||
|
await target.waitFor({ timeout: 20_000 });
|
||||||
|
await page.screenshot({ path: "e2e/artifacts/memo-list.png" });
|
||||||
|
await target.getByRole("button", { name: "打开", exact: true }).click();
|
||||||
|
} else {
|
||||||
|
await page.getByRole("button", { name: "打开", exact: true }).first().waitFor({ timeout: 20_000 });
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
await page.screenshot({ path: "e2e/artifacts/memo-list.png" });
|
||||||
|
await page.getByRole("button", { name: "打开", exact: true }).first().click();
|
||||||
|
}
|
||||||
|
await page.locator(".prose-dadao").first().waitFor({ timeout: 20_000 });
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
await page.screenshot({ path: "e2e/artifacts/memo-read.png" });
|
||||||
|
|
||||||
|
if (process.env.EDIT) {
|
||||||
|
// 编辑态:正文编辑器要占满剩下的高度,不能只露几行
|
||||||
|
await page.getByRole("button", { name: "编辑", exact: true }).last().click();
|
||||||
|
await page.locator(".vditor-ir [contenteditable]").first().waitFor({ timeout: 20_000 });
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
await page.screenshot({ path: "e2e/artifacts/memo-edit.png" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
console.log("OK");
|
||||||
@@ -2,14 +2,21 @@
|
|||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<title>达道Task</title>
|
<title>达道Task</title>
|
||||||
<meta name="description" content="达道Task:任务、工单、知识库,一条道走到底" />
|
<meta name="description" content="达道Task:任务、工单、知识库,一条道走到底" />
|
||||||
<!-- 内联 SVG 图标:不额外发请求,也就不会有 favicon 404 -->
|
<!-- 和 App 内左上角、安卓桌面同一枚图标:靛蓝底 + 三条白线 + 一点淡紫 -->
|
||||||
<link
|
<link
|
||||||
rel="icon"
|
rel="icon"
|
||||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%233F5A68'/%3E%3Cpath d='M18 34l8 8 20-20' stroke='%23C2703D' stroke-width='7' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"
|
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='18' fill='%234F46E5'/%3E%3Cg stroke='%23FFFFFF' stroke-width='4' stroke-linecap='round'%3E%3Cpath d='M18 23h28'/%3E%3Cpath d='M18 32h19'/%3E%3Cpath d='M18 41h12'/%3E%3C/g%3E%3Ccircle cx='46' cy='41' r='6' fill='%23C7D2FE'/%3E%3C/svg%3E"
|
||||||
/>
|
/>
|
||||||
|
<!-- 装到手机主屏之后:状态栏跟品牌色走,iOS 用不透明底图 -->
|
||||||
|
<meta name="theme-color" content="#4f46e5" />
|
||||||
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="达道Task" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^6.0.5"
|
"vite": "^6.0.5",
|
||||||
|
"vite-plugin-pwa": "^2.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
@@ -1,6 +1,7 @@
|
|||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||||
import { Navigate, Route, Routes, useNavigate } from "react-router-dom";
|
import { Navigate, Route, Routes, useNavigate } from "react-router-dom";
|
||||||
import { ToastProvider } from "./components/ui";
|
import { ToastProvider } from "./components/ui";
|
||||||
|
import { UpdatePrompt } from "./components/UpdatePrompt";
|
||||||
import { api, authExpired, session } from "./lib/client";
|
import { api, authExpired, session } from "./lib/client";
|
||||||
import type { User, Workspace } from "./lib/types";
|
import type { User, Workspace } from "./lib/types";
|
||||||
import { LoginPage } from "./pages/Login";
|
import { LoginPage } from "./pages/Login";
|
||||||
@@ -12,6 +13,8 @@ import { TicketsPage } from "./pages/Tickets";
|
|||||||
import { KnowledgePage } from "./pages/Knowledge";
|
import { KnowledgePage } from "./pages/Knowledge";
|
||||||
import { NotificationsPage } from "./pages/Notifications";
|
import { NotificationsPage } from "./pages/Notifications";
|
||||||
import { JoinPage } from "./pages/Join";
|
import { JoinPage } from "./pages/Join";
|
||||||
|
import { AccountPage } from "./pages/Account";
|
||||||
|
import { SettingsPage } from "./pages/Settings";
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
@@ -87,6 +90,8 @@ export function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
|
<Route path="/account" element={<AccountPage />} />
|
||||||
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="/w/:wsId" element={<TasksPage />} />
|
<Route path="/w/:wsId" element={<TasksPage />} />
|
||||||
<Route path="/w/:wsId/board" element={<BoardPage />} />
|
<Route path="/w/:wsId/board" element={<BoardPage />} />
|
||||||
<Route path="/w/:wsId/tickets" element={<TicketsPage />} />
|
<Route path="/w/:wsId/tickets" element={<TicketsPage />} />
|
||||||
@@ -96,6 +101,7 @@ export function App() {
|
|||||||
<Route path="/join/:wsId/:code" element={<JoinPage />} />
|
<Route path="/join/:wsId/:code" element={<JoinPage />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
<UpdatePrompt />
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
);
|
);
|
||||||
|
|||||||