比较提交
| 作者 | SHA1 | 提交日期 | |
|---|---|---|---|
|
|
f36149efc3 | ||
|
|
3751dffef9 | ||
|
|
1bf961f6b3 | ||
|
|
df7db3582e | ||
|
|
23799b4ea5 | ||
|
|
9469a61bbc | ||
|
|
342da56d4d |
@@ -8,10 +8,28 @@
|
|||||||
data/
|
data/
|
||||||
logs/
|
logs/
|
||||||
|
|
||||||
|
# ---- 备份归档:必须排除,这条是硬要求 ----
|
||||||
|
# backups/ 里的 zip 含 data/instance.json,而那个文件躺着 SECRET_KEY 与
|
||||||
|
# cookie_key。漏了这一行,`docker build` 会把整库密钥原样烤进镜像层 ——
|
||||||
|
# 推一次镜像就等于把密钥分发给所有能拉镜像的人,而 Dockerfile 里后续的
|
||||||
|
# chown/chmod 都改不掉「镜像层里已经有一份」这个事实(层是只读叠加的,
|
||||||
|
# 想靠 COPY 之后再 RUN rm 抹掉也只会多留一个含内容的中间层)。
|
||||||
|
# 换言之:这不是「少拷一个目录」,是**凭证泄露**,只能在这里挡。
|
||||||
|
backups/
|
||||||
|
|
||||||
|
# 示例数据是 tools/demo_data.py 的产物,运行时不需要(镜像再小一点)
|
||||||
|
data/demo/
|
||||||
|
|
||||||
# Python 缓存
|
# Python 缓存
|
||||||
|
# 注意:`__pycache__/` 只能匹配上下文**根目录**下的同名目录,
|
||||||
|
# 嵌套的(如 tools/__pycache__)必须用 `**/` 前缀——否则会被原样打进镜像。
|
||||||
|
# 实测过:只写单条时,镜像里仍有 /app/tools/__pycache__。
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
**/__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
**/*.py[cod]
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
|
**/*.egg-info/
|
||||||
.venv/
|
.venv/
|
||||||
venv/
|
venv/
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# 统一的编辑器约定。与 .gitattributes(* text=auto eol=lf)保持一致。
|
||||||
|
# 这样无论谁在什么系统上编辑,提交进来的都是 LF 与 UTF-8。
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[*.{html,css,js,json,yml,yaml,svg}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.py]
|
||||||
|
indent_size = 4
|
||||||
|
max_line_length = 100
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
# Markdown 里行尾两个空格是有意义的分行,别自动删掉
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
[*.sh]
|
||||||
|
indent_size = 4
|
||||||
|
# 容器 entrypoint 必须是 LF,否则报 exec format error / no such file or directory
|
||||||
|
end_of_line = lf
|
||||||
|
|
||||||
|
[Makefile]
|
||||||
|
indent_style = tab
|
||||||
|
|
||||||
|
[*.{png,jpg,jpeg,gif,ico,woff,woff2,sqlite}]
|
||||||
|
insert_final_newline = false
|
||||||
@@ -10,14 +10,62 @@ WB_PORT=8848
|
|||||||
TZ=Asia/Shanghai
|
TZ=Asia/Shanghai
|
||||||
|
|
||||||
# ---------- 首个管理员(只在数据库为空时生效)----------
|
# ---------- 首个管理员(只在数据库为空时生效)----------
|
||||||
# 强烈建议:首次启动前就设好,避免用默认的 admin/admin123 暴露在局域网上
|
# 强烈建议:首次启动前就设好。
|
||||||
|
# 留空时程序会生成**随机**口令,并且只在启动日志里打印一次:
|
||||||
|
# docker compose logs portal | grep -A1 口令
|
||||||
|
# 抄下来登录后立刻改掉。(1.4.0 起不再有 admin123 这类硬编码兜底。)
|
||||||
WB_ADMIN_USER=admin
|
WB_ADMIN_USER=admin
|
||||||
WB_ADMIN_PASSWORD=
|
WB_ADMIN_PASSWORD=
|
||||||
|
|
||||||
|
# ---------- 容器资源上限 ----------
|
||||||
|
# 应用层已经有「采集频率 / 并发 / 采集跨度」三重业务刹车,这里是**进程**层面的
|
||||||
|
# 兜底:一次异常(内存泄漏、超大导出、正则回溯)不能把整台机器带下去。
|
||||||
|
# 单写者架构下不要靠加副本扛负载,限制单实例资源才是正解。
|
||||||
|
#
|
||||||
|
# CPUS : CPU 上限,允许小数。SQLite 是单写者,1.0 已经够用;
|
||||||
|
# 压测后再调大,别一上来就给满宿主机的核。
|
||||||
|
# MEM_LIMIT : 内存上限。500 条/页采集时的常驻内存在 100~200 MB 量级,
|
||||||
|
# 512m 留了充裕余量;内存与 swap 同时设成这个值 = 禁用 swap,
|
||||||
|
# 这样超限会「被 OOM 杀掉」而不是「越来越慢」——后者更难查。
|
||||||
|
# PIDS_LIMIT : 进程/线程数上限,挡 fork 炸弹。tini + python + waitress
|
||||||
|
# 线程模型下 256 很宽松。
|
||||||
|
WB_CPUS=1.0
|
||||||
|
WB_MEM_LIMIT=512m
|
||||||
|
WB_PIDS_LIMIT=256
|
||||||
|
|
||||||
|
# ---------- waitress 线程数 ----------
|
||||||
|
# 这是「单实例能同时吃进几个慢请求」的上限(采集 / 导出 / 备份恢复都算慢请求)。
|
||||||
|
# 与 CPU 上限配套:1 核配 8 线程容易出现「都在等 CPU」的假并发,默认 4。
|
||||||
|
WB_THREADS=4
|
||||||
|
|
||||||
|
# ---------- 反向代理与传输安全(三个必须一起决定)----------
|
||||||
|
# WB_TRUST_PROXY:是否信任 X-Forwarded-For。**默认 0 = 不信任**。
|
||||||
|
# 直接暴露给公网时必须留 0。置 1 的后果很严重:攻击者每次换一个伪造的
|
||||||
|
# XFF,验证码限速、注册配额、登录锁定这三道 IP 防线会**同时失效**。
|
||||||
|
# 置 1 的前提只有一个:**你自己的**反向代理会写这个头。
|
||||||
|
# 开启后程序取 XFF 里**最右侧**的合法 IP,而最右侧是离你最近的那一跳
|
||||||
|
# (由你的代理写入),客户端伪造不了。因此 nginx 侧两种写法都安全:
|
||||||
|
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 保留链路,便于排查
|
||||||
|
# proxy_set_header X-Forwarded-For $remote_addr; # 覆盖,最保守
|
||||||
|
# 真正不能做的是:去信 XFF 里**最左边**那一段(那是客户端自己填的)。
|
||||||
|
WB_TRUST_PROXY=0
|
||||||
|
# 前面挂了 HTTPS 反代时置 1(读到 X-Forwarded-Proto: https 就不跳转)
|
||||||
|
WB_FORCE_HTTPS=0
|
||||||
|
# 是否记录访问日志(写进 logs/app.log,静态资源与验证码图片除外)
|
||||||
|
WB_ACCESS_LOG=1
|
||||||
|
|
||||||
# ---------- 调度 ----------
|
# ---------- 调度 ----------
|
||||||
# 一个容器一份调度。只有跑多副本时才把除第一份之外的都设成 1。
|
# 一个容器一份调度。只有跑多副本时才把除第一份之外的都设成 1。
|
||||||
WB_DISABLE_SCHEDULER=0
|
WB_DISABLE_SCHEDULER=0
|
||||||
|
|
||||||
|
# ---------- 会话安全 ----------
|
||||||
|
# 会话 Cookie 是否只允许走 HTTPS。
|
||||||
|
# 0 = 关闭(默认,纯 HTTP / 局域网部署的正确值)
|
||||||
|
# 1 = 只在 HTTPS 下发送。**如果你用 http:// 访问却设成 1,会出现
|
||||||
|
# 「登录成功又立刻跳回登录页」**,因为浏览器根本不会回传会话 Cookie。
|
||||||
|
# 只有在前面挂了 HTTPS 反向代理、并且用域名访问时才设为 1。
|
||||||
|
WB_COOKIE_SECURE=0
|
||||||
|
|
||||||
# ---------- 可选:启动时自动导入 ----------
|
# ---------- 可选:启动时自动导入 ----------
|
||||||
# 1 = 尝试从挂载进来的编辑器 settings.json 读取 codebuddyUsage.* 写入数据库
|
# 1 = 尝试从挂载进来的编辑器 settings.json 读取 codebuddyUsage.* 写入数据库
|
||||||
WB_IMPORT_CREDS=0
|
WB_IMPORT_CREDS=0
|
||||||
@@ -26,11 +74,12 @@ WB_IMPORT_XLSX=
|
|||||||
|
|
||||||
# ---------- 镜像名(推送 Gitea 注册表时用)----------
|
# ---------- 镜像名(推送 Gitea 注册表时用)----------
|
||||||
# WB_IMAGE=git.iwali.top/wangchuanli/workbuddy-portal:latest
|
# WB_IMAGE=git.iwali.top/wangchuanli/workbuddy-portal:latest
|
||||||
# WB_IMAGE=git.iwali.top/wangchuanli/workbuddy-portal:1.1.0
|
# WB_IMAGE=git.iwali.top/wangchuanli/workbuddy-portal:1.4.0
|
||||||
|
|
||||||
# ---------- 仅叠加 docker-compose.hostdir.yml 时有效 ----------
|
# ---------- 仅叠加 docker-compose.hostdir.yml 时有效 ----------
|
||||||
# 把数据/日志放到宿主机目录而不是命名卷。**只建议 Linux 宿主机使用**:
|
# 把数据/日志/备份放到宿主机目录而不是命名卷。**只建议 Linux 宿主机使用**:
|
||||||
# Windows + Docker Desktop 的 9p 挂载下,宿主进程访问过 WAL 库之后,
|
# Windows + Docker Desktop 的 9p 挂载下,宿主进程访问过 WAL 库之后,
|
||||||
# 容器侧会打不开数据库且不自愈(详见 docs/DEPLOYMENT.md)。
|
# 容器侧会打不开数据库且不自愈(详见 docs/DEPLOYMENT.md)。
|
||||||
# WB_HOST_DATA_DIR=./data
|
# WB_HOST_DATA_DIR=./data
|
||||||
# WB_HOST_LOG_DIR=./logs
|
# WB_HOST_LOG_DIR=./logs
|
||||||
|
# WB_HOST_BACKUP_DIR=./backups
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
name: Bug 报告
|
||||||
|
description: 报告一个可复现的问题
|
||||||
|
title: "[Bug] "
|
||||||
|
labels: [bug]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
感谢反馈。提交前请先确认:
|
||||||
|
|
||||||
|
1. 你已经读过 [FAQ](https://git.iwali.top/wangchuanli/workbuddy-portal/src/branch/main/docs/FAQ.md) 与 [部署排错](https://git.iwali.top/wangchuanli/workbuddy-portal/src/branch/main/docs/DEPLOYMENT.md);
|
||||||
|
2. **不要贴真实 Cookie、secret_key 或真实用量数据**——需要复现请用 `python tools/demo_data.py` 生成的示例数据。
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: 版本 / 提交号
|
||||||
|
description: 例如 1.1.0,或 `git rev-parse --short HEAD` 的输出
|
||||||
|
placeholder: 1.1.0 / 118e27f
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: deploy
|
||||||
|
attributes:
|
||||||
|
label: 部署方式
|
||||||
|
options:
|
||||||
|
- Docker Compose(命名卷,推荐)
|
||||||
|
- Docker Compose + hostdir 叠加层(绑定挂载)
|
||||||
|
- 裸机 waitress
|
||||||
|
- 裸机 Flask 开发服务器
|
||||||
|
- 其他(请在补充说明里写)
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: env
|
||||||
|
attributes:
|
||||||
|
label: 运行环境
|
||||||
|
description: 操作系统与 Python 版本
|
||||||
|
placeholder: Windows 11 + Docker Desktop 4.3x / Ubuntu 24.04 + Python 3.13
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: what
|
||||||
|
attributes:
|
||||||
|
label: 现象
|
||||||
|
description: 发生了什么?预期是什么?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: repro
|
||||||
|
attributes:
|
||||||
|
label: 复现步骤
|
||||||
|
description: 越具体越好。涉及数据口径的问题请说明用的是示例数据还是真实数据。
|
||||||
|
placeholder: |
|
||||||
|
1. `docker compose up -d --build`
|
||||||
|
2. 打开 /dashboard
|
||||||
|
3. 点击「30 天」
|
||||||
|
4. 看到 …
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: 日志 / 报错
|
||||||
|
description: |
|
||||||
|
相关日志。**请先脱敏**(抹掉 Cookie、域名、内网 IP、真实模型名)。
|
||||||
|
大屏类问题请附浏览器 Console 的报错。
|
||||||
|
render: text
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: checks
|
||||||
|
attributes:
|
||||||
|
label: 已做过的自查
|
||||||
|
description: 例如是否跑过 `tools/check_live.py`、是否换过浏览器、是否重启过容器
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: 使用问题 / 部署排错
|
||||||
|
url: https://git.iwali.top/wangchuanli/workbuddy-portal/src/branch/main/docs/FAQ.md
|
||||||
|
about: 常见问题、Cookie 获取、时区与调度、备份恢复等,先看 FAQ 与部署文档
|
||||||
|
- name: 安全漏洞
|
||||||
|
url: https://git.iwali.top/wangchuanli/workbuddy-portal/src/branch/main/SECURITY.md
|
||||||
|
about: 请勿公开提交可直接利用的漏洞细节,按 SECURITY.md 走私有渠道
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
name: 功能建议
|
||||||
|
description: 提出一个新功能或改进
|
||||||
|
title: "[Feature] "
|
||||||
|
labels: [enhancement]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
在提建议前,请先看一眼 [架构说明](https://git.iwali.top/wangchuanli/workbuddy-portal/src/branch/main/docs/ARCHITECTURE.md)
|
||||||
|
里的「已知边界」——有些能力是**刻意不做**的(例如不引入 APScheduler、不新增第三方依赖)。
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: 你想解决什么问题
|
||||||
|
description: 先说场景与痛点,再说方案。这样更容易判断有没有更简单的做法。
|
||||||
|
placeholder: 我在做 … 的时候,必须手动 … ,很费时。
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: proposal
|
||||||
|
attributes:
|
||||||
|
label: 你期望的做法
|
||||||
|
description: 如果有具体的接口/页面/参数设计,写在这里
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: 考虑过的替代方案
|
||||||
|
description: 以及为什么它们不够好
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: constraints
|
||||||
|
attributes:
|
||||||
|
label: 约束自查
|
||||||
|
options:
|
||||||
|
- label: 该功能不需要新增第三方依赖(或已在下方说明理由)
|
||||||
|
required: false
|
||||||
|
- label: 该功能不破坏「SQLite 单写者」这一前提
|
||||||
|
required: false
|
||||||
|
- label: 若涉及列表类接口,我不会在其中返回 `prompt` 全文
|
||||||
|
required: false
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
## 这个 PR 做了什么
|
||||||
|
|
||||||
|
<!-- 一句话说清。若是修 bug,请写清根因,而不是只写「修了个 bug」。 -->
|
||||||
|
|
||||||
|
## 关联 Issue
|
||||||
|
|
||||||
|
<!-- 例如 Closes #12 -->
|
||||||
|
|
||||||
|
## 改动类型
|
||||||
|
|
||||||
|
- [ ] Bug 修复
|
||||||
|
- [ ] 新功能
|
||||||
|
- [ ] 重构(不改变外部行为)
|
||||||
|
- [ ] 文档
|
||||||
|
- [ ] 构建 / 部署 / CI
|
||||||
|
|
||||||
|
## 改动清单
|
||||||
|
|
||||||
|
<!-- 文件路径 + 做了什么,便于快速 review。例:
|
||||||
|
- `workbuddy_portal/query.py` —— bundle() 里 daily 改为全量下发
|
||||||
|
- `docs/API.md` —— 同步说明 daily 的范围语义
|
||||||
|
-->
|
||||||
|
|
||||||
|
## 验证情况
|
||||||
|
|
||||||
|
<!-- 请贴出实际输出,不要只写「已测试」。至少覆盖第 2 层。 -->
|
||||||
|
|
||||||
|
| 检查 | 结果 |
|
||||||
|
|---|---|
|
||||||
|
| `python -m compileall -q workbuddy_portal manage.py tools` | |
|
||||||
|
| `python tools/smoke.py` | `RESULT: ok=?? fail=0` |
|
||||||
|
| `python tools/check_live.py`(如起了服务) | `RESULT: ok=?? fail=0` |
|
||||||
|
| `python tools/shots.py`(如改了前端) | 无 JS 报错 |
|
||||||
|
| `docker compose up -d --build`(如改了容器相关) | 容器 healthy |
|
||||||
|
|
||||||
|
## 破坏性变更 / 需要部署方做的事
|
||||||
|
|
||||||
|
<!-- 有就写:环境变量、数据迁移、需要重建镜像、需要改反向代理配置…;没有就写「无」 -->
|
||||||
|
|
||||||
|
## 自查确认
|
||||||
|
|
||||||
|
- [ ] 没有提交任何凭据、`data/usage.sqlite`、`logs/*`、`instance.json`(已用 `git check-ignore -v` 复核过忽略规则)
|
||||||
|
- [ ] 没有在列表类接口里新增返回 `prompt` 全文
|
||||||
|
- [ ] 若涉及数据口径变更,我已用**独立聚合**与页面结果逐项比对
|
||||||
|
- [ ] 新增的第三方资源已登记到 `THIRD-PARTY-NOTICES.md`
|
||||||
|
- [ ] 文档(README / docs / CHANGELOG)已同步更新
|
||||||
@@ -5,21 +5,46 @@
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# ---- 数据与运行产物:正本不进版本库(体积大、含凭证衍生物)----
|
# ---- 数据与运行产物:正本不进版本库(体积大、含凭证衍生物)----
|
||||||
|
# 同时写 data/* 与 data/**/* 两种:只写前者会漏掉子目录
|
||||||
|
# (曾因此让 data/demo/usage.sqlite 逃过忽略规则)
|
||||||
data/*.sqlite
|
data/*.sqlite
|
||||||
data/*.sqlite-wal
|
data/*.sqlite-wal
|
||||||
data/*.sqlite-shm
|
data/*.sqlite-shm
|
||||||
|
data/**/*.sqlite
|
||||||
|
data/**/*.sqlite-wal
|
||||||
|
data/**/*.sqlite-shm
|
||||||
|
|
||||||
# 含 secret_key,泄露等于会话签名密钥外泄,绝不可提交
|
# 含 secret_key,泄露等于会话签名密钥外泄,绝不可提交
|
||||||
data/instance.json
|
data/instance.json
|
||||||
|
data/**/instance.json
|
||||||
|
|
||||||
data/exports/*.csv
|
data/exports/*.csv
|
||||||
|
|
||||||
|
# 兜底:手工 cp 出来的快照(如 usage.sqlite.bak-pre-v13)绝不能入库,
|
||||||
|
# 它含 settings 的凭证密文与 users 的密码哈希
|
||||||
|
data/*.bak*
|
||||||
|
data/*.sqlite-bak*
|
||||||
|
|
||||||
# 界面截图(tools/shots.py 生成的临时产物;手册配图在 docs/images/)
|
# 界面截图(tools/shots.py 生成的临时产物;手册配图在 docs/images/)
|
||||||
data/shots/
|
data/shots/
|
||||||
|
|
||||||
|
# 示例数据(tools/demo_data.py 生成,随时可重建,不必入库)
|
||||||
|
data/demo/
|
||||||
|
|
||||||
# ---- 日志 ----
|
# ---- 日志 ----
|
||||||
logs/*
|
logs/*
|
||||||
|
|
||||||
|
# ---- 数据库备份 ----
|
||||||
|
# 全目录忽略,只放行说明文件。备份含凭证密文与密码哈希,永不入库。
|
||||||
|
# 刻意不放在 data/:data/ 是 Docker 卷,down -v 会把备份和正本一起删掉。
|
||||||
|
backups/*
|
||||||
|
!backups/README.md
|
||||||
|
!backups/.gitkeep
|
||||||
|
|
||||||
|
# ---- v1.0 旧版归档 ----
|
||||||
|
# 若仓库被整体移到工作区根,legacy-v1/(含真实 CSV)必须继续被忽略
|
||||||
|
legacy-v1/
|
||||||
|
|
||||||
# ---- 保留目录本身 ----
|
# ---- 保留目录本身 ----
|
||||||
# docker-compose 是绑定挂载,宿主机目录必须先存在,
|
# docker-compose 是绑定挂载,宿主机目录必须先存在,
|
||||||
# 否则 Docker 会以 root 自动创建,Linux 上会引发「unable to open database file」
|
# 否则 Docker 会以 root 自动创建,Linux 上会引发「unable to open database file」
|
||||||
@@ -41,6 +66,12 @@ venv/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# ---- AI 助手的工作记忆 ----
|
||||||
|
# .codebuddy/ 里是会话记忆与技能缓存,属于「开发过程产物」而不是项目资产,
|
||||||
|
# 与代码版本无关。对外开源后也不该混进仓库(README 里不需要它,
|
||||||
|
# 而 tools/check_docs.py 会把其中的 .md 当成项目文档一起扫)。
|
||||||
|
.codebuddy/
|
||||||
|
|
||||||
# ---- 部署本地配置(含明文密码,只提交 .env.example)----
|
# ---- 部署本地配置(含明文密码,只提交 .env.example)----
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# 行为准则(Code of Conduct)
|
||||||
|
|
||||||
|
## 我们的承诺
|
||||||
|
|
||||||
|
为了营造开放、友善的协作环境,我们作为贡献者与维护者承诺:**让每个人参与本项目的体验都不受骚扰**,
|
||||||
|
无论其年龄、体型、可见或不可见的残障、族裔、性别认同与表达、经验水平、教育程度、
|
||||||
|
社会经济状况、国籍、外貌、种族、宗教,或性取向与身份认同。
|
||||||
|
|
||||||
|
## 我们的标准
|
||||||
|
|
||||||
|
**有助于营造积极环境的行为:**
|
||||||
|
|
||||||
|
- 对他人展现同理心与善意
|
||||||
|
- 尊重不同的意见、观点与经验
|
||||||
|
- 给出并优雅地接受建设性的反馈
|
||||||
|
- 承担责任、向被我们影响的人致歉,并从中学习
|
||||||
|
- 关注对整个社区最有利的事,而不只是对我们个人
|
||||||
|
|
||||||
|
**不可接受的行为:**
|
||||||
|
|
||||||
|
- 性化的言语或图像,以及任何形式的性关注或挑逗
|
||||||
|
- 挑衅、侮辱或贬损性评论,以及人身攻击或政治攻击
|
||||||
|
- 公开或私下的骚扰
|
||||||
|
- 未经明确许可,公布他人的私人信息(如真实姓名、住址、邮箱、凭据等)
|
||||||
|
- 其他在专业场合中可被合理视为不当的行为
|
||||||
|
|
||||||
|
> 与本项目技术定位直接相关的一条:**请勿在 Issue、PR、讨论或截图中提交真实的账号凭据、
|
||||||
|
> 云端 Cookie、`secret_key` 或真实用户的用量数据**。需要复现时请用 `tools/demo_data.py`
|
||||||
|
> 生成的示例数据,或自行脱敏。这类内容会被立即删除。
|
||||||
|
|
||||||
|
## 维护者的责任
|
||||||
|
|
||||||
|
维护者负责澄清并执行上述标准,对任何被视为不当、威胁、冒犯或有害的行为,
|
||||||
|
有权采取适当且公平的纠正措施,包括删除、编辑或拒绝评论、提交、代码与 Issue,
|
||||||
|
并在必要时临时或永久禁止任何贡献者参与。
|
||||||
|
|
||||||
|
## 适用范围
|
||||||
|
|
||||||
|
本准则适用于所有项目空间,也适用于个人在**公开场合代表本项目**时的言行。
|
||||||
|
|
||||||
|
## 报告与执行
|
||||||
|
|
||||||
|
如遇滥用、骚扰或其他不可接受的行为,请通过维护者在代码托管平台上公布的联系方式私下报告
|
||||||
|
(参见 [SECURITY.md](SECURITY.md) 中的私有渠道)。所有投诉都会被及时、公正地审查与处理。
|
||||||
|
维护者有义务尊重报告者的隐私与安全。
|
||||||
|
|
||||||
|
## 执行准则
|
||||||
|
|
||||||
|
维护者将按下述梯度决定后果:
|
||||||
|
|
||||||
|
1. **更正** — 私下书面警告,说明违规性质并解释为何不当。必要时要求公开致歉。
|
||||||
|
2. **警告** — 在一段时间内禁止与相关人员互动;违反将导致临时或永久封禁。
|
||||||
|
3. **临时封禁** — 在指定期限内禁止任何形式的公开或私下互动。
|
||||||
|
4. **永久封禁** — 永久禁止在项目内进行任何形式的公开互动。
|
||||||
|
|
||||||
|
## 致谢
|
||||||
|
|
||||||
|
本准则改编自 [Contributor Covenant](https://www.contributor-covenant.org/) 2.1 版
|
||||||
|
(原文以 CC BY 4.0 发布)。执行准则参考其「Enforcement Guidelines」部分。
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
# 贡献指南(Contributing)
|
||||||
|
|
||||||
|
感谢你有兴趣改进 WorkBuddy Portal。这是一个**单进程 Flask + SQLite** 的轻量项目,
|
||||||
|
刻意保持了很小的依赖面与很平的目录结构——请先花两分钟读完本文,你的改动会更顺利被接受。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、开发环境
|
||||||
|
|
||||||
|
| 项 | 要求 |
|
||||||
|
|---|---|
|
||||||
|
| Python | **3.13**(Docker 镜像用的就是 3.13-slim;3.11+ 一般也可) |
|
||||||
|
| 操作系统 | Linux / macOS / Windows 均可,但**注意**下面「Windows 特有坑」一节 |
|
||||||
|
| 依赖 | `pip install -r requirements.txt` |
|
||||||
|
| 可选 | Playwright + Chromium(只有跑界面截图才需要) |
|
||||||
|
|
||||||
|
起步:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <你的仓库地址> workbuddy-portal
|
||||||
|
cd workbuddy-portal
|
||||||
|
python -m venv .venv && . .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
python manage.py init # 建表 + 建管理员(默认 admin/admin123)
|
||||||
|
python manage.py serve --port 8848 # 或 docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## 二、用示例数据开发,别用真实数据
|
||||||
|
|
||||||
|
`tools/demo_data.py` 会生成一份**完全合成**的数据集(假模型名、假 Prompt、假 Cookie 串、
|
||||||
|
偏斜的积分分布),放在 `data/demo/` 下(该目录已在 `.gitignore` 内)。
|
||||||
|
它会造**两个账号**(`admin` 管理员 + `demo` 普通用户),好让你顺手验证多用户隔离:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tools/demo_data.py # 默认 data/demo;两个账号口令都是 admin123
|
||||||
|
WB_DATA_DIR=$PWD/data/demo python manage.py serve --port 8849 --no-scheduler
|
||||||
|
```
|
||||||
|
|
||||||
|
> 目录里的 `usage.sqlite` **在容器里生成**再截图才是对的:宿主机跑会让启动日志印出
|
||||||
|
> `C:\Users\<用户名>\…`,那一行正好会出现在「日志管理」页的截图上。
|
||||||
|
|
||||||
|
这样你既能有一个「看起来像真的」的界面来调试,也不会把任何真实用量带进仓库或截图。
|
||||||
|
|
||||||
|
## 三、改动前请先跑一遍验证
|
||||||
|
|
||||||
|
项目有一层层递进的验证,**代价从低到高**,改完至少跑到第 2 层;
|
||||||
|
**只要动过 `*.md`,第 1.5 层必须跑**:
|
||||||
|
|
||||||
|
| # | 命令 | 覆盖什么 | 需要什么 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | `python -m compileall -q workbuddy_portal manage.py tools` | 语法 | — |
|
||||||
|
| 1.5 | `python tools/check_docs.py` | **文档自检**:内部链接与跨文件锚点、图片引用、**绝对路径泄漏**、版本一致性、产品名硬编码。改过任何 md 都跑,否则章节重排造成的**锚点静默失效**会一路漏到线上。文档清单自动发现,不写死文件名 | — |
|
||||||
|
| 2 | `python tools/smoke.py` | **215 项**离线断言:全页面只读渲染、模板残留检测、多用户隔离与凭证保密、注册与验证码、**非管理员越权面全关死**、**全局键必须落在实例级**、历史缺陷防回归、静态资源、CSS 类名对账 | 无(用 Flask test_client,不启服务) |
|
||||||
|
| 3 | `python tools/check_live.py --base http://127.0.0.1:8848 --as demo:admin123` | **122 项**真实 HTTP 断言,含登录/CSRF/开放重定向/验证码/安全响应头,`--as` 追加普通账号越权验收 | 一个运行中的服务 |
|
||||||
|
| 4 | `python tools/shots.py --base http://127.0.0.1:8849` | 登录后逐页截图并收集 `console`/`pageerror`,**含普通账号只读视角**与越权面探测 | Playwright + Chromium |
|
||||||
|
| 5 | `docker compose up -d --build && docker compose ps` | 容器化路径 | Docker |
|
||||||
|
|
||||||
|
> `tools/shots.py` 是**最有价值的一层**:项目曾经出过「大屏整页全白」的 bug,
|
||||||
|
> 只有它抓到了(`smoke` 与 `check_live` 都放过了)。改前端务必跑。
|
||||||
|
|
||||||
|
`check_live.py` 与 `shots.py` 都接受 `--db <路径>`:给了之后它们会直接从库里读验证码答案,
|
||||||
|
从而**自动过掉登录页与注册页的验证码**——不然脚本会被验证码挡在门外。
|
||||||
|
|
||||||
|
这三支脚本都会打印 `RESULT: ok=N fail=0`,`fail` 不为 0 时退出码是 1,可直接接进 CI。
|
||||||
|
|
||||||
|
## 四、必须遵守的几条不变量
|
||||||
|
|
||||||
|
这些是踩过坑之后定下来的,破坏它们会在生产上以很隐蔽的方式出问题:
|
||||||
|
|
||||||
|
1. **SQLite 只允许一个写者。** 任何采集动作都要走 `collect._Lock()`(`data/collect.lock`)。
|
||||||
|
不要起多个带调度的进程;多实例部署时其余实例设 `WB_DISABLE_SCHEDULER=1`。
|
||||||
|
2. **列表类接口默认不返回 `prompt` 全文。** 它占原始体积约 80%。只有 `/api/top` 与
|
||||||
|
`/api/records` 带,且都做截断。新增接口请沿用这个约定。
|
||||||
|
3. **两种字段命名契约不要互相「统一」**:`/api/bundle` 用短键(`d/c/k/m/cl/t/px`,大屏页依赖),
|
||||||
|
`/api/records` 用可读全名。改错会让大屏静默渲染成空白。
|
||||||
|
4. **流式响应里不要复用 `db.get_db()`。** Flask 在响应迭代开始前就会关掉请求上下文里的连接,
|
||||||
|
生成器一读库就 `Cannot operate on a closed database`。要在生成器内部自建连接并 `finally` 关闭。
|
||||||
|
5. **Docker 部署用命名卷,不要退回绑定挂载。** Windows + Docker Desktop 走 9p,
|
||||||
|
宿主进程碰过 WAL 库之后容器会永久打不开数据库(详见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md))。
|
||||||
|
6. **`.gitignore` 不支持行尾注释**——规则后跟 `# 注释` 会让整行失效。注释必须单独占一行,
|
||||||
|
改完用 `git check-ignore -v <file>` 逐条确认命中。
|
||||||
|
|
||||||
|
### 多用户与权限相关的六条(v1.2.0 起,v1.3.0 扩充)
|
||||||
|
|
||||||
|
7. **`uid` 必须是 `conn` 之后的第一个位置参数,且不给默认值。**
|
||||||
|
这是防越权的核心机制:漏传就直接 `TypeError`,而不是静默返回所有人的数据。
|
||||||
|
`query.*` / `collect.*` 全链路都遵循它。新写一个查询函数时请照做,**不要**加 `uid=0` 这种默认值。
|
||||||
|
8. **凭证不参与回落。** `db.NO_FALLBACK_KEYS = {"cookie", "user_agent"}`:
|
||||||
|
个人级没有值时**不许**落回实例级,否则等于拿别人的 Cookie 去采集(串号)。
|
||||||
|
新增任何「账号身份相关」的配置键,都要考虑是否该进这个集合。
|
||||||
|
9. **验证码答案只能放服务端。** 不要图省事塞进 `session`——Flask 的 session 是
|
||||||
|
「签名 + base64」而非加密,客户端能直接解开读到答案。下发给浏览器的只有随机 `captcha_id`;
|
||||||
|
且校验时**先删后判**(一次性)。同理,验证码图不要用 SVG 渲染,那玩意是文本。
|
||||||
|
10. **写权限只有一个入口:`config.writable_by(key, is_admin)`。**(v1.3.0)
|
||||||
|
页面上的置灰 / 隐藏只是「不给误导性按钮」,真正的闸门是服务端判断 ——
|
||||||
|
所以别在模板里另写一套「哪些键只读」的条件,那必然会和接口判断漂移。
|
||||||
|
新增一个可配置项时,先决定它的归属:`GLOBAL_KEYS`(实例级、仅管理员)
|
||||||
|
还是 `USER_EDITABLE_KEYS`(个人级、人人可写本人那份),然后只改这一处。
|
||||||
|
11. **「键存哪一级」和「谁能写」必须对齐。**(v1.3.0)
|
||||||
|
反例:把采集参数只写进管理员自己的 `user_id`,其它账号读取时会回落到 `DEFAULTS`,
|
||||||
|
于是**管理员改的值对别人完全不生效** —— 不报错、不进日志,是个纯粹的静默 bug。
|
||||||
|
所以实例级策略(调度、采集参数、注册策略)一律存 `user_id=0`,
|
||||||
|
并由 `db.set_setting()` 强制重定向(`slot:*` 这类**个人簿记键**除外,它们本来就该是个人级)。
|
||||||
|
12. **`user_id=0` 不是孤儿行。**(v1.3.0)
|
||||||
|
实例级配置挂在一个不对应任何真实账号的 `user_id=0` 上。任何「清理孤儿行」的语句
|
||||||
|
都必须排除它 —— `DELETE FROM settings WHERE user_id NOT IN (SELECT id FROM users)`
|
||||||
|
会把整片实例级配置删掉(历史缺陷:实测把 19 个实例级键清到只剩 1 行)。
|
||||||
|
现在 `tools/smoke.py` 里有「跑完整轮 smoke 后实例级配置一条不少」的防回归断言,别删。
|
||||||
|
13. **改主键的迁移必须「删索引 → 改名 → 建新表 → 回填 → 删旧表」。**
|
||||||
|
`ALTER TABLE … RENAME TO` 会**把索引一起带走**,后续 `CREATE INDEX IF NOT EXISTS`
|
||||||
|
就变成空操作,新表会零索引。本项目在迁移前先调 `_drop_all_user_indexes()`,
|
||||||
|
并把 `ALTER TABLE … ADD COLUMN` 放在 `executescript` 之前。
|
||||||
|
|
||||||
|
### 加密与验证码这两块(零依赖约束)
|
||||||
|
|
||||||
|
14. **`crypto.py` 与 `captcha.py` 只能用标准库。** 项目的硬约束是「只要 Flask / waitress / openpyxl」
|
||||||
|
—— 所以 ChaCha20、HMAC、PNG 编码、点阵字模都是手写的。想引 `cryptography` 或 `Pillow`
|
||||||
|
之前先想清楚:这会让「下载即跑」的卖点消失。
|
||||||
|
15. **解密失败必须显式报错,不能「失败就返回原值」。** `crypto.decrypt()` 对非 `v1.` 前缀
|
||||||
|
原样返回(兼容历史明文),但**校验不过就抛 `DecryptError`**。静默降级会让加密形同虚设。
|
||||||
|
|
||||||
|
## 五、代码风格
|
||||||
|
|
||||||
|
- 遵循 PEP 8;行宽 100。
|
||||||
|
- **注释与文档字符串用中文**,说明「为什么这么做」而不是「这行在做什么」。
|
||||||
|
- 提交前用 `python -m compileall` 与 `python tools/smoke.py` 自查;**动过文档再加一条 `python tools/check_docs.py`**。
|
||||||
|
- 不要引入新的第三方依赖,除非有充分理由并在 PR 里说明——这个项目的卖点之一就是依赖少。
|
||||||
|
如果确实新增了,请同步登记到 [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md)。
|
||||||
|
|
||||||
|
## 六、提交与 PR
|
||||||
|
|
||||||
|
提交信息用 **Conventional Commits**,一句话说清「改了什么」即可,正文可写动机:
|
||||||
|
|
||||||
|
```
|
||||||
|
fix(docker): 数据改用 Docker 命名卷,修容器打不开数据库的问题
|
||||||
|
feat(api): 新增 /api/export 支持按筛选条件导出 CSV
|
||||||
|
docs: 补充反向代理部署示例
|
||||||
|
```
|
||||||
|
|
||||||
|
PR 请包含:
|
||||||
|
|
||||||
|
- **动机**:解决什么问题,或复现步骤
|
||||||
|
- **改动**:涉及哪些文件、有没有破坏性变更
|
||||||
|
- **验证**:贴出 `smoke` / `check_live` 的 `RESULT` 行;改前端再贴截图脚本的输出
|
||||||
|
- 若改动影响数据口径(聚合、去重、时区),请**额外写一份独立聚合与之比对**,
|
||||||
|
不要只靠肉眼看页面
|
||||||
|
|
||||||
|
## 七、Windows 特有坑(若你在 Windows 上开发)
|
||||||
|
|
||||||
|
- 宿主 Windows 进程访问过 `data/usage.sqlite` 后,**容器内**会打不开同一个库。
|
||||||
|
用命名卷部署时,宿主侧跑 CLI 请一律走 `docker compose exec portal python manage.py …`。
|
||||||
|
- Git 的 `/tmp` 会被解析成 `C:\tmp`,`git commit -F /tmp/msg.txt` 会失败——用仓库内路径。
|
||||||
|
- 本机若开着 HTTP 代理,`curl http://127.0.0.1:…` 会被代理拦成 502,探测本地服务要加 `--noproxy '*'`。
|
||||||
|
- 行尾:仓库用 `.gitattributes` 锁死 `eol=lf`,`docker/*.sh` 若带 CRLF 会在容器里报
|
||||||
|
`exec format error`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
有任何不确定的地方,先在 Issue 里问,比写完再返工更省事。
|
||||||
@@ -26,7 +26,7 @@ FROM python:3.13-slim AS runtime
|
|||||||
|
|
||||||
LABEL org.opencontainers.image.title="WorkBuddy Portal" \
|
LABEL org.opencontainers.image.title="WorkBuddy Portal" \
|
||||||
org.opencontainers.image.description="WorkBuddy 积分用量采集 / 存储 / 呈现一体化门户" \
|
org.opencontainers.image.description="WorkBuddy 积分用量采集 / 存储 / 呈现一体化门户" \
|
||||||
org.opencontainers.image.version="1.1.0" \
|
org.opencontainers.image.version="1.5.0" \
|
||||||
org.opencontainers.image.source="https://git.iwali.top/wangchuanli/workbuddy-portal"
|
org.opencontainers.image.source="https://git.iwali.top/wangchuanli/workbuddy-portal"
|
||||||
|
|
||||||
ENV PYTHONUNBUFFERED=1 \
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
@@ -36,6 +36,7 @@ ENV PYTHONUNBUFFERED=1 \
|
|||||||
PATH="/opt/venv/bin:$PATH" \
|
PATH="/opt/venv/bin:$PATH" \
|
||||||
WB_DATA_DIR=/app/data \
|
WB_DATA_DIR=/app/data \
|
||||||
WB_LOG_DIR=/app/logs \
|
WB_LOG_DIR=/app/logs \
|
||||||
|
WB_BACKUP_DIR=/app/backups \
|
||||||
WB_HOST=0.0.0.0 \
|
WB_HOST=0.0.0.0 \
|
||||||
WB_PORT=8848
|
WB_PORT=8848
|
||||||
|
|
||||||
@@ -55,16 +56,26 @@ WORKDIR /app
|
|||||||
# --chown 让非 root 用户能读写挂载卷之外的文件;.dockerignore 已挡掉数据与日志
|
# --chown 让非 root 用户能读写挂载卷之外的文件;.dockerignore 已挡掉数据与日志
|
||||||
COPY --chown=app:app . .
|
COPY --chown=app:app . .
|
||||||
|
|
||||||
|
# 构建期断言:万一 .dockerignore 漏掉 backups/,在这里直接失败。
|
||||||
|
# 只靠「记得改 .dockerignore」不可靠 —— 备份归档里有 instance.json(含
|
||||||
|
# SECRET_KEY 与 cookie_key),进了镜像层就是整库密钥随镜像分发,且**无法**
|
||||||
|
# 用后续的 RUN rm 抹掉(只会多留一个含内容的只读层)。让构建自己拒绝。
|
||||||
|
RUN set -eux; \
|
||||||
|
if [ -n "$(ls -A /app/backups 2>/dev/null)" ]; then \
|
||||||
|
echo "ERROR: /app/backups 里有内容被打进镜像层 —— .dockerignore 漏了 backups/" >&2; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
RUN set -eux; \
|
RUN set -eux; \
|
||||||
chmod +x /app/docker/entrypoint.sh /app/docker/healthcheck.py; \
|
chmod +x /app/docker/entrypoint.sh /app/docker/healthcheck.py; \
|
||||||
mkdir -p /app/data /app/logs; \
|
mkdir -p /app/data /app/logs /app/backups; \
|
||||||
chown -R app:app /app/data /app/logs; \
|
chown -R app:app /app/data /app/logs /app/backups; \
|
||||||
python -c "import workbuddy_portal, flask, waitress; print('deps ok', flask.__version__)"
|
python -c "import workbuddy_portal, flask, waitress; print('deps ok', flask.__version__)"
|
||||||
|
|
||||||
USER app
|
USER app
|
||||||
|
|
||||||
EXPOSE 8848
|
EXPOSE 8848
|
||||||
VOLUME ["/app/data", "/app/logs"]
|
VOLUME ["/app/data", "/app/logs", "/app/backups"]
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=6s --start-period=20s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=6s --start-period=20s --retries=3 \
|
||||||
CMD ["python", "/app/docker/healthcheck.py"]
|
CMD ["python", "/app/docker/healthcheck.py"]
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Wang Chuanli
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -11,12 +11,18 @@
|
|||||||
| 语言 / 框架 | Python 3.11+ · Flask 3 · Jinja2 · 纯标准库 `urllib` 采集 |
|
| 语言 / 框架 | Python 3.11+ · Flask 3 · Jinja2 · 纯标准库 `urllib` 采集 |
|
||||||
| 存储 | SQLite(WAL),单文件正本 `data/usage.sqlite` |
|
| 存储 | SQLite(WAL),单文件正本 `data/usage.sqlite` |
|
||||||
| 前端 | 服务端渲染后台 + 独立 ECharts 大屏(离线自带的 `echarts.min.js`) |
|
| 前端 | 服务端渲染后台 + 独立 ECharts 大屏(离线自带的 `echarts.min.js`) |
|
||||||
| 部署 | Docker Compose / 裸机 `waitress`;镜像可推 Gitea 容器注册表 |
|
| 部署 | **三条路径**:裸机 / Docker 自打包 / compose 拉云端镜像;镜像可推 Gitea 容器注册表 |
|
||||||
| 鉴权 | 全站登录 + CSRF + 角色(管理员 / 普通用户),凭证存库、页面只回掩码 |
|
| 鉴权 | **多用户**(各自的数据与凭证严格隔离)+ 全站登录 + CSRF + 角色(管理员 / 普通) |
|
||||||
| 版本 | v1.1.0 |
|
| 权限 | 普通账号**只能维护本人的 Cookie / User-Agent**;调度频率、采集参数、日志、用户管理、备份都归管理员 |
|
||||||
|
| 凭证 | Cookie **ChaCha20 + HMAC 静态加密**入库,页面与接口只回掩码 |
|
||||||
|
| 防攻击 | 登录 / 注册**图形验证码**(服务端出题 + 一次性)、双维度失败限速、注册限额、口令黑名单 |
|
||||||
|
| 备份 | **自动 + 手动**一致性快照、按份数清理、在线下载、**一键恢复**(恢复前自动再存一份);用户可导出本人全部数据 |
|
||||||
|
| 资源上限 | 容器层 `cpus` / `mem_limit` / `pids_limit`;实例层采集最小间隔、采集跨度硬顶 **31 天**、**有任务在跑不允许新建任务** |
|
||||||
|
| 版本 | v1.5.0 |
|
||||||
|
| **许可证** | **MIT**(第三方组件与再分发资源见 [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md)) |
|
||||||
|
|
||||||
**目录**:[核心特性](#核心特性) · [架构](#架构一图) · [快速开始](#快速开始) · [命令一览](#命令一览) ·
|
**目录**:[核心特性](#核心特性) · [架构](#架构一图) · [快速开始](#快速开始) · [命令一览](#命令一览) ·
|
||||||
[页面一览](#页面一览) · [接口一览](#接口一览) · [文档导航](#文档导航) · [安全须知](#安全须知)
|
[页面一览](#页面一览) · [接口一览](#接口一览) · [目录结构](#目录结构) · [文档导航](#文档导航) · [安全须知](#安全须知) · [开源与许可](#开源与许可)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -24,13 +30,19 @@
|
|||||||
|
|
||||||
| 能力 | 说明 |
|
| 能力 | 说明 |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| **多用户隔离** | 每个账号只填**自己的** Cookie、收**自己的**数据、看**自己的**记录。`user_id` 是所有查询的第一个条件,且是**必填位置参数**(漏传直接 `TypeError`,不会静默返回全量) |
|
||||||
|
| **两级权限** | 注册出来的账号一律是**普通账号**,能改的只有本人凭证(`cookie` / `user_agent`)。写权限只有一条规则:`config.writable_by(key, is_admin)` —— 页面与接口共用同一个函数,避免「界面置灰但接口还能写」这类规则漂移 |
|
||||||
|
| **配置作用域对齐** | 「键存在哪一级」与「谁能改」是一件事:实例级键(调度、采集参数、注册策略…)一律存 `user_id=0` 且仅管理员可写。**不会出现「管理员改了只有自己生效」**(那样别人读时会回落到默认值,是个静默 bug) |
|
||||||
|
| **凭证加密** | Cookie 以 ChaCha20(RFC 8439)+ HMAC-SHA256 encrypt-then-MAC 密文入库;主密钥单独放在 `data/instance.json`,与 `SECRET_KEY` 分开。升级时会把历史明文自动加密 |
|
||||||
|
| **自助注册 + 验证码** | 开放注册(可关),登录/注册均带**图形验证码**。答案是服务端本地点阵渲染的 PNG,只存库、一次性、5 分钟过期——**不进会话**(Flask 会话是签名不加密的,放进去等于送答案) |
|
||||||
| **增量采集** | 按 `MAX(ts)` 断点续采 + 回退窗口;主键 `ON CONFLICT` 去重,冲突时以「更早的本地时间」为准 |
|
| **增量采集** | 按 `MAX(ts)` 断点续采 + 回退窗口;主键 `ON CONFLICT` 去重,冲突时以「更早的本地时间」为准 |
|
||||||
| **进程内调度** | 每天固定时刻(默认 `09:00,17:00`)由内置线程触发;支持**启动补跑**(程序没开时错过的时刻,开机后在宽限期内补上) |
|
| **进程内调度** | 每天固定时刻(默认 `09:00,17:00`)由内置线程逐个启用账号触发;支持**启动补跑**(程序没开时错过的时刻,开机后在宽限期内补上) |
|
||||||
| **单写者保证** | 文件锁 `data/collect.lock` 让「调度 / 页面手动触发 / CLI」三处不并发写 SQLite;僵尸锁 30 分钟可抢占 |
|
| **单写者保证** | 文件锁 `data/collect.lock` 让「调度 / 页面手动触发 / CLI」三处不并发写 SQLite;僵尸锁 30 分钟可抢占 |
|
||||||
| **全量存档** | 不随官网导出窗口过期而丢数据;官网 xlsx 丢失约 22% 的 `Prompt`,可用 `fill-prompt` 回补 |
|
| **全量存档** | 不随官网导出窗口过期而丢数据;官网 xlsx 丢失约 22% 的 `Prompt`,可用 `fill-prompt` 回补 |
|
||||||
| **大屏去中间层** | 大屏直接走 `/api`,按当前筛选窗口实时聚合;左侧多取等长一段用于算环比,窗口不变不重复请求 |
|
| **大屏去中间层** | 大屏直接走 `/api`,按当前筛选窗口实时聚合;左侧多取等长一段用于算环比,窗口不变不重复请求 |
|
||||||
| **可观测** | 每次采集落一条 `collect_runs`(含 `[warn]`/`[error]` 逐行原文);另有操作审计与登录审计 |
|
| **可观测** | 每次采集落一条 `collect_runs`(含 `[warn]`/`[error]` 逐行原文);另有操作审计与登录审计,均带账号归属;**访问日志**写进 `logs/app.log`(waitress 本身不记 access log) |
|
||||||
| **一键备份** | 正本就是宿主机上的一个 `.sqlite` 文件,拷走即可;`manage.py vacuum` 回收空闲页 |
|
| **备份与恢复** | 备份走 SQLite **在线备份 API**(不是 `cp`,采集正在写也能拿到一致快照),归档成含所有 `*.sqlite` + `instance.json` 的 zip;可设**周期与保留份数**、管理员下载与**一键恢复**(恢复前自动再存一份当前库)、普通用户**导出本人全部数据**。归档不在 `data/` 卷里,`down -v` 不会连备份一起删 |
|
||||||
|
| **可安全对外** | 容器层限 `cpus`/`mem_limit`/`memswap`/`pids_limit`/`nofile`;实例层限采集最小间隔、每日时刻数、**采集跨度硬顶 31 天**;**有任务在跑时新建任务直接 409**;默认不信任 `X-Forwarded-For`(否则三道 IP 防线全废) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -39,75 +51,128 @@
|
|||||||
```
|
```
|
||||||
┌──────────────── workbuddy-portal(单进程)────────────────┐
|
┌──────────────── workbuddy-portal(单进程)────────────────┐
|
||||||
云端用量接口 │ │
|
云端用量接口 │ │
|
||||||
/billing/meter/ │ scheduler.py ──┐ │
|
/billing/meter/ │ scheduler.py ──┐ 按实例级时刻表遍历启用账号 │
|
||||||
get-user-request- │ (20s 轮询槽位) │ │
|
get-user-request- │ (20s 轮询槽位) │ │
|
||||||
usage │ ▼ │
|
usage │ ▼ │
|
||||||
▲ │ collect.py ─ 文件锁 collect.lock ─ 去重 upsert ─▶ SQLite │
|
▲ │ collect.py ─ 文件锁 collect.lock ─ 去重 upsert ─▶ SQLite │
|
||||||
│ │ ▲ data/usage.sqlite(WAL) │
|
│ │ ▲ (全部带 user_id) data/usage.sqlite(WAL)│
|
||||||
└───────────┼──────┘ ▲ │
|
└───────────┼──────┘ ▲ │
|
||||||
client.py(urllib)│ │ │
|
client.py(urllib)│ ▲ crypto.py 解密本账号 Cookie │ │
|
||||||
│ query.py(聚合全部下推 SQL) │
|
│ └ captcha.py 出验证码图 │ │
|
||||||
|
│ query.py(uid 为第一个查询条件) │
|
||||||
│ ▲ ▲ │
|
│ ▲ ▲ │
|
||||||
│ web/views.py ──────┘ └──── web/api.py│
|
│ web/views.py ──────┘ └──── web/api.py│
|
||||||
│ (Jinja 后台) (JSON) │
|
│ (Jinja 后台) (JSON) │
|
||||||
└───────────────┬───────────────────────────┬──────────────┘
|
└───────────────┬───────────────────────────┬──────────────┘
|
||||||
▼ ▼
|
▼ ▼
|
||||||
/ /records /tasks /dashboard(ECharts 大屏)
|
/ /records /tasks /config /dashboard(ECharts 大屏)
|
||||||
/config /logs /users
|
/profile [/logs /users:仅管理员] + 未登录:/login /register
|
||||||
```
|
```
|
||||||
|
|
||||||
四层职责:
|
五层职责:
|
||||||
|
|
||||||
| 层 | 位置 | 说明 |
|
| 层 | 位置 | 说明 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 采集 | `workbuddy_portal/collect.py` + `scheduler.py` | 纯 `urllib` 调云端;断点、去重、锁、导入导出 |
|
| 采集 | `workbuddy_portal/collect.py` + `scheduler.py` | 纯 `urllib` 调云端;断点、去重、锁、导入导出,全部按 `uid` 隔离 |
|
||||||
| 存储 | `workbuddy_portal/db.py` + `schema.sql` | SQLite WAL,单写者,运行期配置也在库里(`settings` 表) |
|
| 存储 | `workbuddy_portal/db.py` + `schema.sql` | SQLite WAL,单写者;运行期配置也在库里(`settings` 表,主键 `(user_id, key)`) |
|
||||||
| 聚合 | `workbuddy_portal/query.py` | `daily / dims / top / records / summary / bundle`,全部下推 SQL |
|
| 加固 | `workbuddy_portal/crypto.py` + `captcha.py` | 凭证静态加密(零第三方依赖手写 ChaCha20);验证码用**自写 PNG 编码器**出图 |
|
||||||
|
| 备份 | `workbuddy_portal/backup.py` | 在线备份 API 出快照 → zip 归档 → 按份数清理 → 校验 → 整表恢复(含恢复前自动兜底) |
|
||||||
|
| 聚合 | `workbuddy_portal/query.py` | `daily / dims / top / records / summary / bundle`,全部下推 SQL,`uid` 是第一个条件 |
|
||||||
| 呈现 | `workbuddy_portal/web/` | Jinja 后台(`views.py`)+ JSON API(`api.py`)+ 静态大屏 |
|
| 呈现 | `workbuddy_portal/web/` | Jinja 后台(`views.py`)+ JSON API(`api.py`)+ 静态大屏 |
|
||||||
|
|
||||||
|
> **为什么验证码不用 SVG、也不用第三方库**:SVG 是文本,答案会明文出现在页面源码里;
|
||||||
|
> 而本项目坚持 `requirements.txt` 只有 Flask / waitress / openpyxl,所以 PNG 编码器
|
||||||
|
> (zlib 压缩 IDAT)与 5×7 点阵字模都是手写的,见 [架构说明](docs/ARCHITECTURE.md#凭证加密与验证码)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
### 方式一:Docker Compose(推荐)
|
三条路径产出的是**同一份代码、同一个数据库格式**,可以互相迁移。完整参数与排错见
|
||||||
|
[部署与运维指南](docs/DEPLOYMENT.md)。
|
||||||
|
|
||||||
|
### 路径 A:裸机 Python
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
python manage.py init --user admin --password '一个足够强的密码'
|
||||||
|
python manage.py import-creds # 可选:把编辑器设置里的 cookie/UA 接管进数据库
|
||||||
|
python manage.py migrate-csv # 可选:把旧版 CSV 存档全量导入
|
||||||
|
python manage.py serve # 启动,默认 0.0.0.0:8848
|
||||||
|
```
|
||||||
|
|
||||||
|
### 路径 B:Docker 自打包
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.iwali.top/wangchuanli/workbuddy-portal.git
|
git clone https://git.iwali.top/wangchuanli/workbuddy-portal.git
|
||||||
cd workbuddy-portal
|
cd workbuddy-portal
|
||||||
|
|
||||||
cp .env.example .env # 至少设好 WB_ADMIN_PASSWORD
|
cp .env.example .env # 至少设好 WB_ADMIN_PASSWORD
|
||||||
# 编辑 .env: WB_ADMIN_PASSWORD=一个足够强的密码
|
|
||||||
|
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
docker compose logs -f # Ctrl-C 退出日志跟踪,容器继续跑
|
docker compose logs -f # Ctrl-C 退出日志跟踪,容器继续跑
|
||||||
```
|
```
|
||||||
|
|
||||||
打开 `http://<本机IP>:8848` → 用 `.env` 里设的账号登录 → 去「配置管理」粘贴 Cookie。
|
### 路径 C:compose 拉云端镜像(不需要 clone 仓库)
|
||||||
|
|
||||||
> 数据与日志放在 Docker **命名卷**(`workbuddy-portal_wb_data` / `_wb_logs`)里,
|
在任意空目录写一个 `docker-compose.yml`(内容见
|
||||||
> `docker compose down` 不会删。要用 CLI 就 `docker compose exec portal python manage.py …`。
|
[部署指南第五节](docs/DEPLOYMENT.md#五路径-cdocker-compose-拉云端镜像部署))+ `.env`,然后:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker login git.iwali.top -u <用户名> # 密码填 Access Token(公开仓库可跳过)
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
三条路径跑起来后都是:打开 `http://<IP>:8848` → 用管理员登录 →
|
||||||
|
**先把密码改掉** → 去「配置管理」粘贴 Cookie。
|
||||||
|
|
||||||
|
> 数据、日志与备份放在 Docker **命名卷**(`workbuddy-portal_wb_data` / `_wb_logs` /
|
||||||
|
> `_wb_backups`)里,`docker compose down` 不会删。要用 CLI 就 `docker compose exec portal python manage.py …`。
|
||||||
|
> **备份刻意独立成一个卷**:与正本同卷时,一次 `down -v` 会把两者一起带走 ——
|
||||||
|
> 那正好是最需要备份的时刻。
|
||||||
> **不要在宿主机上跑 `manage.py` 去连容器的库**——Windows + Docker Desktop 的 9p 挂载下,
|
> **不要在宿主机上跑 `manage.py` 去连容器的库**——Windows + Docker Desktop 的 9p 挂载下,
|
||||||
> 宿主进程碰一次 WAL 库就会让容器打不开数据库(纯读也会触发,且不自愈)。
|
> 宿主进程碰一次 WAL 库就会让容器打不开数据库(纯读也会触发,且不自愈)。
|
||||||
> 想直接看到数据/日志,用 `docker-compose.hostdir.yml` 叠加层(**仅建议 Linux 宿主机**)。
|
> 想直接看到数据/日志,用 `docker-compose.hostdir.yml` 叠加层(**仅建议 Linux 宿主机**)。
|
||||||
> 详见 [部署与运维指南](docs/DEPLOYMENT.md#windows-绑定挂载的坑容器打不开数据库)。
|
> 详见 [部署与运维指南](docs/DEPLOYMENT.md#115-windows-绑定挂载的坑容器打不开数据库)。
|
||||||
|
|
||||||
### 方式二:裸机 Python
|
> **从旧版本升级不需要手工介入**:`init` 由 `PRAGMA user_version` 驱动,自动迁移且幂等。
|
||||||
|
> v1.1.0 → v1.2.0 是「单用户 → 多用户」(历史数据归到首个账号、明文 Cookie 就地加密);
|
||||||
|
> v1.2.0 → v1.3.0 是「配置作用域收敛」(调度与采集参数从个人级提升到实例级);
|
||||||
|
> v1.3.0 → v1.4.0 只加一列(`users.session_ver`)与一张新表(`backups`),
|
||||||
|
> **无数据搬运**;v1.4.0 → v1.5.0 **没有库结构变更**(纯界面与安全加固)。
|
||||||
|
> 四次都带审计留痕,可重复执行。见 [CHANGELOG](docs/CHANGELOG.md)。
|
||||||
|
|
||||||
```bash
|
### 第一次使用必做
|
||||||
pip install -r requirements.txt
|
|
||||||
|
|
||||||
python manage.py init # 建表 + 默认配置 + 管理员 admin/admin123
|
**管理员:**
|
||||||
python manage.py import-creds # 可选:把编辑器设置里的 cookie/UA 接管进数据库
|
|
||||||
python manage.py migrate-csv # 可选:把旧版 CSV 存档全量导入
|
|
||||||
python manage.py serve # 启动,默认 0.0.0.0:8848
|
|
||||||
```
|
|
||||||
|
|
||||||
### 第一次使用必做三件事
|
1. **拿到并改掉初始密码**——`WB_ADMIN_PASSWORD` 留空时程序会生成**随机**口令,
|
||||||
|
只在启动日志里打印一次(`docker compose logs portal | grep -A6 管理员初始口令`)。
|
||||||
|
**没有 `admin123` 这类默认口令了**,拿不到就去查看日志。
|
||||||
|
2. **填自己的 Cookie**——「配置管理 → 凭证」,否则采集只会记一条 `no_cookie`。
|
||||||
|
3. **确认调度与限制**——「任务管理」里把 `09:00,17:00` 改成你的习惯时刻;
|
||||||
|
同时确认「每日时刻上限」「采集最小间隔」「单次最长跨度」三个刹车(都是**实例级**的,
|
||||||
|
对全站账号生效)。
|
||||||
|
4. **确认自动备份**——「备份管理」页看状态与保留份数;建议点一次「立即备份」,
|
||||||
|
并**把一份归档下载到容器之外**(备份留在同一台机器上只防「改错了」,不防「机器没了」)。
|
||||||
|
|
||||||
1. **改密码**——局域网可访问,默认密码等于没锁门(「配置管理 → 修改密码」)。
|
**普通账号(注册进来的默认身份):**
|
||||||
2. **填 Cookie**——「配置管理 → 凭证」,否则采集只会记一条 `cookie_expired`。
|
|
||||||
获取方式见 [用户手册](docs/USER-GUIDE.md#三获取并填写-cookie)。
|
1. **改密码**——「个人中心 → 修改登录密码」(改完其他设备上的登录会立刻失效)。
|
||||||
3. **确认调度时刻**——「任务管理」里把 `09:00,17:00` 改成你的习惯时刻,保存即生效。
|
2. **填自己的 Cookie**——这是你**唯一**需要动手的配置。
|
||||||
|
3. 想立刻看数据?点「任务管理 → 立即采集一次」,不用等调度时刻。
|
||||||
|
4. 想把自己的数据带走?「个人中心 → 导出我的全部数据」。
|
||||||
|
|
||||||
|
### 想给同事开账号?
|
||||||
|
|
||||||
|
登录页底部有「**自助注册**」入口(管理员可在「配置管理 → 实例级设置」关掉)。
|
||||||
|
注册同样要过验证码,且同一来源每天最多注册 3 个账号(可改)。
|
||||||
|
**注册出来的都是普通账号**:只能维护自己的 Cookie、只看自己的数据,看不到日志与用户管理。
|
||||||
|
每个账号登录后填**自己的** Cookie——系统不会、也无法把某人的凭证给别人用。
|
||||||
|
|
||||||
|
> 只想内部开号、不开放注册?管理员在「用户管理」页直接新建即可;
|
||||||
|
> 命令行也行:`python manage.py passwd alice 强密码`(默认普通账号,加 `--role admin` 提权)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -115,82 +180,114 @@ python manage.py serve # 启动,默认 0.0.0.0:8848
|
|||||||
|
|
||||||
统一入口是 `manage.py`(Docker 里同样可用:`docker compose exec portal python manage.py stats`)。
|
统一入口是 `manage.py`(Docker 里同样可用:`docker compose exec portal python manage.py stats`)。
|
||||||
|
|
||||||
|
**所有涉及数据/凭证的子命令都作用于某一个账号**,用 `-u/--user <用户名>` 指定;
|
||||||
|
不指定则取「管理员优先、其次 id 最小」的那个(所以旧习惯的单账号用法仍然成立)。
|
||||||
|
唯独 `collect` 不带 `-u` 时会**逐个启用账号**跑一遍,与进程内调度线程的行为一致。
|
||||||
|
|
||||||
| 命令 | 作用 |
|
| 命令 | 作用 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `init` | 初始化数据库(幂等)。`--user` / `--password` 指定首个管理员 |
|
| `init` | 初始化 / 迁移数据库(幂等)。`--user` / `--password` 指定首个管理员 |
|
||||||
| `serve` | 启动 Web。`--host` `--port` `--debug` `--no-scheduler` |
|
| `serve` | 启动 Web。`--host` `--port` `--debug` `--no-scheduler` |
|
||||||
| `collect` | 执行一次增量采集后退出(不想开 Web 时可挂系统计划任务) |
|
| `collect [-u 账号]` | 执行一次增量采集后退出;**不带 `-u` 则所有启用账号各跑一次** |
|
||||||
| `migrate-csv [文件]` | 从旧版 CSV 存档导入(默认自动探测旧项目路径) |
|
| `migrate-csv [文件] [-u 账号]` | 从旧版 CSV 存档导入(默认自动探测 `legacy-v1/data/usage_records.csv` 等路径),必须说明「算谁的」 |
|
||||||
| `import-xlsx <文件>` | 合入官网「用量明细-导出」的 xlsx |
|
| `import-xlsx <文件> [-u 账号]` | 合入官网「用量明细-导出」的 xlsx |
|
||||||
| `import-creds` | 从 VSCode / Cursor / Trae 的 `settings.json` 读取 `codebuddyUsage.*` 写入数据库 |
|
| `import-creds [-u 账号]` | 从 VSCode / Cursor / Trae 的 `settings.json` 读取 `codebuddyUsage.*` 写入该账号 |
|
||||||
| `fill-prompt` | 回补缺失的 `User Prompt`(官网导出会丢约 22%) |
|
| `fill-prompt [-u 账号]` | 回补缺失的 `User Prompt`(官网导出会丢约 22%) |
|
||||||
| `export-csv [路径]` | 导出与官网 xlsx 同构的 CSV(默认 `data/exports/`) |
|
| `export-csv [路径] [-u 账号]` | 导出 CSV(默认 `data/exports/usage_records_<账号>.csv`,文件名带归属) |
|
||||||
| `vacuum` | `wal_checkpoint(TRUNCATE)` + `VACUUM`,回收空闲页、压缩 WAL |
|
| `vacuum` | `wal_checkpoint(TRUNCATE)` + `VACUUM`,回收空闲页、压缩 WAL |
|
||||||
| `stats` | 存档概况 + 模型维度表 + 最近采集(不联网) |
|
| `backup [--note 说明]` | 立即打一份备份(所有 `data/*.sqlite` + `instance.json` → 一个 zip),并按保留份数清理最旧的 |
|
||||||
| `status` | 调度开关 / 下次执行 / Cookie 状态 / 最近采集 |
|
| `backups [--prune] [--keep N]` | 列出备份(大小 / 条数 / 积分 / 来源 / 时间),`--prune` 顺便按保留份数清理 |
|
||||||
| `passwd <用户> [新密码]` | 重置或创建登录账号 |
|
| `restore <文件名> --yes` | **从备份恢复**(整表替换)。破坏性操作,必须显式 `--yes`;不加只打印将要发生什么。恢复前会自动把当前库另存一份 |
|
||||||
|
| `stats [-u 账号]` | 先全库概览(每账号多少条 / 多少积分 / Cookie 状态),再给指定账号的维度明细 |
|
||||||
|
| `users` | 列出所有账号:角色、状态、数据量、凭证状态、最近登录 IP |
|
||||||
|
| `status` | 逐账号显示调度开关 / 下次执行 / Cookie 状态 / 最近采集 |
|
||||||
|
| `passwd <用户> [新密码]` | 重置或创建账号;`--role admin` 提权,`--activate` 顺手启用 |
|
||||||
|
|
||||||
### 自检工具
|
### 自检工具
|
||||||
|
|
||||||
| 脚本 | 层 | 说明 |
|
| 脚本 | 层 | 说明 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `tools/smoke.py` | 离线回归 | `test_client` 对真实库全页面只读渲染,**99 项断言**(历史缺陷防回归 ①~⑭、CSV 列、class↔CSS 对账、静态资源逐个 200),**不需要先起服务** |
|
| `tools/smoke.py` | 离线回归 | `test_client` 对真实库全页面只读渲染,**215 项断言**:历史缺陷防回归、多用户隔离 / 凭证保密 / 注册与验证码全链路、**非管理员越权面全关死**、**全局键必须落在实例级**、CSV 列、class↔CSS 对账、静态资源逐个 200。**不需要先起服务** |
|
||||||
| `tools/check_live.py` | 真实 HTTP | 对运行中的服务走真实链路(登录 → CSRF → 各页面 → 各 API → 导出 → 安全项),**56 项断言**,基本只读 |
|
| `tools/check_live.py` | 真实 HTTP | 对运行中的服务走真实链路(登录 → CSRF → 各页面 → 各 API → 导出 → 安全项 → 验证码与响应头),**122 项断言**,基本只读。`--as 账号:密码` 追加普通账号越权验收 |
|
||||||
| `tools/shots.py` | 界面实检 | Playwright 登录后逐页截图并收集 console / pageerror,产物在 `data/shots/` |
|
| `tools/shots.py` | 界面实检 | Playwright 登录后逐页截图并收集 console / pageerror,**含普通账号只读视角**与越权面探测,产物在 `data/shots/` |
|
||||||
|
| `tools/check_docs.py` | 文档自检 | 内部链接与**跨文件锚点**、图片引用、**绝对路径泄漏**、版本一致性(`__init__` / `Dockerfile` / `README` / `CHANGELOG` 四处)、模板与 JS 里的产品名硬编码。文档互相引用后章节一重排,锚点会**静默失效**,这层把它变成可执行断言 |
|
||||||
|
| `tools/demo_data.py` | 示例数据 | 生成**完全合成**的示例库(管理员 + 普通账号各一份数据与假 Cookie),文档截图基于它 |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python tools/smoke.py # 离线,随时可跑
|
python tools/smoke.py # 离线,随时可跑
|
||||||
|
python tools/check_docs.py # 文档自检,有问题退出码 1
|
||||||
python manage.py serve --port 8849 --no-scheduler # 另开一个终端
|
python manage.py serve --port 8849 --no-scheduler # 另开一个终端
|
||||||
python tools/check_live.py --base http://127.0.0.1:8849 # 真实 HTTP
|
python tools/check_live.py --base http://127.0.0.1:8849 --as demo:admin123
|
||||||
python tools/shots.py --base http://127.0.0.1:8849 --full # 逐页截图
|
python tools/shots.py --base http://127.0.0.1:8849 --full # 逐页截图(含普通账号视角)
|
||||||
```
|
```
|
||||||
|
|
||||||
> `smoke.py` 会写少量 `audit_log` 审计行(被拒的配置写入也留痕),不动业务数据;
|
> `check_live.py` 的 `--db` **默认指向 `data/usage.sqlite`**。对示例实例跑时必须显式传
|
||||||
> `check_live.py` 只读,但登录成功会更新 `users.last_login_at` / `login_count`。
|
> `--db data/demo/usage.sqlite`,否则验证码答案从真实库取,会以「登录失败」的形式误导排查。
|
||||||
|
|
||||||
|
> `smoke.py` **会对实例级配置做写入测试**(管理员写 `schedule_times` 后还原),
|
||||||
|
> 并**临时**建两个普通账号用于验证权限边界与注册链路(无论成败都在 `finally` 里删掉),
|
||||||
|
> 不动任何用量数据;跑之前建议先备份,或在示例库上跑。
|
||||||
|
> `check_live.py` / `shots.py` 只读,但登录成功会更新 `users.last_login_at` / `login_count`。
|
||||||
|
> 两者在验证码策略为 `always` 时会**从本地库里取答案**以完成自动登录
|
||||||
|
> ——取的是会话里的 captcha id(答案本身只存在于服务端)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 页面一览
|
## 页面一览
|
||||||
|
|
||||||
| 路径 | 作用 |
|
| 路径 | 作用 | 普通账号 |
|
||||||
|---|---|
|
|---|---|---|
|
||||||
| `/` | **概览**:KPI(含今日 vs 昨日整日)、采集健康度、调度状态、模型 TOP、最近采集 |
|
| `/login` | **登录**(未登录时的落点):用户名 / 密码 / **图形验证码**,底部有自助注册入口 | ✅ |
|
||||||
| `/dashboard` | **ECharts 交互大屏**(独立静态页):日历热力图、趋势、维度分布、单笔 TOP,支持区间/维度/指标联动 |
|
| `/register` | **自助注册**:用户名、显示名、邮箱、密码 + 验证码 | ✅(可被管理员关闭) |
|
||||||
| `/records` | **数据明细**:快捷区间、日期/模型/客户端/关键词筛选、排序、分页、展开 Prompt、导出 CSV |
|
| `/` | **概览**:KPI(含今日 vs 昨日整日)、采集健康度、调度状态(只读)、模型 TOP、最近采集 | ✅ 只看自己的 |
|
||||||
| `/tasks` | **任务管理**:调度开关与时刻、启动补跑、按区间补采、运行历史 |
|
| `/dashboard` | **ECharts 交互大屏**(独立静态页):日历热力图、趋势、维度分布、单笔 TOP,支持区间/维度/指标联动 | ✅ |
|
||||||
| `/config` | **配置管理**:Cookie / UA、采集参数、TLS 校验、修改密码、维护动作(回补 Prompt / 导出 / 整理库) |
|
| `/records` | **数据明细**:快捷区间、日期/模型/客户端/关键词筛选、排序、分页、展开 Prompt、导出 CSV | ✅ |
|
||||||
| `/logs` | **日志管理**:逐次采集详情(含 `[warn]`/`[error]` 原文)、状态筛选、应用日志、操作审计 |
|
| `/tasks` | **任务管理**:手动采集与按区间补采、运行历史。**调度开关与时刻对普通账号是只读的** | ⚠️ 只读调度 |
|
||||||
| `/users` | **用户管理**(仅管理员):新建账号、改显示名/权限/密码、删除、用户操作审计 |
|
| `/config` | **配置管理**:自己的 Cookie / UA(**唯一可改**)+ 只读的采集参数 + 维护动作;底部实例级设置区 | ⚠️ 仅凭证可改 |
|
||||||
|
| `/profile` | **个人中心**:账号概况、我的凭证状态(密文入库)、改密码(其他设备会话立即失效)、**导出我的全部数据**;点右上角用户名进入 | ✅ |
|
||||||
|
| `/profile/export` | **导出本人全部数据**(zip:使用记录 / 采集历史 / 操作审计 / 我的账号与配置;**不含 Cookie 明文**),10 秒限速 | ✅ |
|
||||||
|
| `/logs` | **日志管理**(**仅管理员**):全实例采集详情(含 `[warn]`/`[error]` 原文)、应用日志尾部、操作审计 | ❌ 403 |
|
||||||
|
| `/users` | **用户管理**(**仅管理员**):新建账号、改显示名/权限/状态/密码、删除、账号操作审计 | ❌ 403 |
|
||||||
|
| `/backups` | **备份管理**(**仅管理员**):份数/占用/自动备份状态与下次时间、周期与保留份数设置、列表(下载 / 恢复 / 删除) | ❌ 403 |
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
> 其余页面截图见 [用户手册](docs/USER-GUIDE.md)。
|
> 其余页面截图见 [用户手册](docs/USER-GUIDE.md);普通账号的只读视角见
|
||||||
|
> `docs/images/03b-tasks-user.png` 与 `04b-config-user.png`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 接口一览
|
## 接口一览
|
||||||
|
|
||||||
全部需要登录(`/api/*` 未登录返回 `401` JSON);写接口另需 CSRF(请求头 `X-CSRF-Token`,
|
全部需要登录(`/api/*` 未登录返回 `401` JSON);写接口另需 CSRF(请求头 `X-CSRF-Token`,
|
||||||
页面已注入 `window.WB_CSRF`)。完整参数说明见 [docs/API.md](docs/API.md)。
|
页面已注入 `window.WB_CSRF`)。
|
||||||
|
**所有数据接口都只返回当前登录账号的数据** —— `user_id` 由会话决定,不接受客户端传入。
|
||||||
|
完整参数说明见 [docs/API.md](docs/API.md)。
|
||||||
|
|
||||||
| 方法 | 路径 | 作用 |
|
| 方法 | 路径 | 作用 | 权限 |
|
||||||
|---|---|---|
|
|---|---|---|---|
|
||||||
| GET | `/api/manifest` | 存档总量、日期区间、存活日清单、数据源、健康状态 |
|
| GET | `/api/manifest` | 存档总量、日期区间、存活日清单、数据源、健康状态(含 `cookieChars`/`cookieBroken`/`role`) | 登录 |
|
||||||
| GET | `/api/bundle` | 大屏一次取齐:全量 `daily` + 窗口 `dims`/`top`/`records`/`totals` |
|
| GET | `/api/bundle` | 大屏一次取齐:全量 `daily` + 窗口 `dims`/`top`/`records`/`totals` | 登录 |
|
||||||
| GET | `/api/summary` | KPI + 环比(前一段不在存档内则不给假数字) |
|
| GET | `/api/summary` | KPI + 环比(前一段不在存档内则不给假数字) | 登录 |
|
||||||
| GET | `/api/daily` | 逐日聚合(含每日分模型、24 时段) |
|
| GET | `/api/daily` · `/api/dims` · `/api/top` | 逐日聚合 / 模型·客户端·时段汇总 / 单笔消耗榜 | 登录 |
|
||||||
| GET | `/api/dims` | 模型 / 客户端 / 时段汇总 |
|
| GET | `/api/records` · `/api/records/<id>` | 明细分页 / 单条详情(`<id>` 也受 `user_id` 约束) | 登录 |
|
||||||
| GET | `/api/top` | 单笔消耗榜(唯一带 Prompt 摘要的接口) |
|
| GET | `/api/runs` · `/api/runs/<id>` | 采集运行历史 / 单次详情(含逐行日志) | 登录 |
|
||||||
| GET | `/api/records` · `/api/records/<id>` | 明细分页 / 单条详情 |
|
| GET | `/api/status` | 调度状态、下次执行、互斥锁、最近采集;含 `is_admin` / `can_edit_schedule` / `can_view_logs` | 登录 |
|
||||||
| GET | `/api/runs` · `/api/runs/<id>` | 采集运行历史 / 单次详情(含逐行日志) |
|
| GET | `/api/audit` | 操作审计分页 + 可选动作清单 | 管理员看全站,普通账号看自己 |
|
||||||
| GET | `/api/status` | 调度状态、下次执行、互斥锁、最近采集 |
|
| POST | `/api/collect` | 手动触发采集(可指定区间补采);未配 Cookie 回 `409 no_cookie`,密文解不开回 `409 cookie_broken` | 登录(只采自己) |
|
||||||
| GET | `/api/audit` | 操作审计分页 + 可选动作清单 |
|
| POST | `/api/maintenance/<action>` | `fill-prompt` \| `export-csv` \| `vacuum` \| `recount` | `vacuum` 仅管理员 |
|
||||||
| POST | `/api/collect` | 手动触发采集(可指定区间补采) |
|
| GET/POST | `/api/settings` | 读 / 写配置。读只回**掩码** `cookie_hint`,并回 `_globalKeys` / `_userKeys` / `_role` / `_canEditGlobal`;写非本人可写的键会整单拒绝(`400` + `denied` 清单) | 登录;写仅本人凭证或管理员 |
|
||||||
| POST | `/api/maintenance/<action>` | `fill-prompt` \| `export-csv` \| `vacuum` \| `recount` |
|
| POST | `/api/profile` · `/api/password` | 改自己的显示名 / 邮箱 / 密码 | 登录 |
|
||||||
| GET/POST | `/api/settings` | 读 / 写配置(非法值 `400` 并列出全部错误) |
|
| POST | `/api/captcha` | 验证码机制自述(策略、位数、TTL、图片地址),便于排障自检 | 登录 |
|
||||||
| POST | `/api/password` | 修改自己的登录密码 |
|
| GET/POST | `/api/users` · `/api/users/<id>` · `/api/users/<id>/delete` | 用户管理 | **仅管理员** |
|
||||||
| GET/POST | `/api/users` · `/api/users/<id>` | 用户管理(仅管理员) |
|
| GET | `/captcha.png?purpose=login\|register` | **图形验证码图片**(唯一无需登录的接口;每次都是新题,带 `no-store`) | 公开 |
|
||||||
| GET | `/logs/tail` · `/records/export` | 应用日志尾部 / 按筛选流式导出 CSV |
|
| GET | `/logs/tail` | 应用日志尾部 | **仅管理员** |
|
||||||
|
| GET | `/records/export` | 按筛选流式导出 CSV | 登录(只导自己) |
|
||||||
|
| GET/POST | `/api/backups` | 列出备份(含大小/条数/来源/是否存在) / 立即打一份 | **仅管理员** |
|
||||||
|
| GET | `/api/backups/<filename>` | **下载**一份归档(zip) | **仅管理员** |
|
||||||
|
| POST | `/api/backups/<filename>/restore` | **从该归档恢复**(整表替换;`include_instance` 决定是否连密钥一起回滚) | **仅管理员** |
|
||||||
|
| POST | `/api/backups/<filename>/delete` · `/api/backups/prune` | 删除一份 / 按保留份数清理 | **仅管理员** |
|
||||||
|
| GET | `/profile/export` | 导出**本人**全部数据(zip,不含 Cookie 明文) | 登录 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -199,33 +296,42 @@ python tools/shots.py --base http://127.0.0.1:8849 --full # 逐页截图
|
|||||||
```
|
```
|
||||||
workbuddy-portal/
|
workbuddy-portal/
|
||||||
├── manage.py 统一 CLI(唯一入口)
|
├── manage.py 统一 CLI(唯一入口)
|
||||||
├── requirements.txt
|
├── requirements.txt 仅 Flask / waitress / openpyxl(零多余依赖)
|
||||||
├── Dockerfile 多阶段构建(依赖层 / 运行层)
|
├── Dockerfile 多阶段构建(依赖层 / 运行层)
|
||||||
├── docker-compose.yml 单服务编排(数据放 Docker 命名卷)
|
├── docker-compose.yml 单服务编排(数据 / 日志 / 备份各一个命名卷 + 容器资源上限)
|
||||||
├── docker-compose.hostdir.yml 可选叠加层:改用宿主机目录(仅建议 Linux)
|
├── docker-compose.hostdir.yml 可选叠加层:改用宿主机目录(仅建议 Linux)
|
||||||
├── .env.example 环境变量样例
|
├── .env.example 环境变量样例(含资源上限与反代开关)
|
||||||
├── docker/
|
├── docker/
|
||||||
│ ├── entrypoint.sh 幂等初始化 → exec serve(LF 行尾)
|
│ ├── entrypoint.sh 幂等初始化 → exec serve(LF 行尾)
|
||||||
│ └── healthcheck.py 标准库健康检查(免登录页 /login)
|
│ └── healthcheck.py 标准库健康检查(免登录页 /login)
|
||||||
├── docs/ 文档(见下)
|
├── docs/ 文档 + images/(手册配图,合成数据)
|
||||||
|
├── data/ 【运行时】正本 usage.sqlite / instance.json / exports/ / demo/
|
||||||
|
├── logs/ 【运行时】app.log(滚动 2 MB × 3)
|
||||||
|
├── backups/ 归档落点(**刻意不在 data/ 里**,整目录被 git 与 docker 忽略)
|
||||||
├── tools/
|
├── tools/
|
||||||
│ ├── smoke.py 离线回归(99 项断言)
|
│ ├── smoke.py 离线回归(215 项断言)
|
||||||
│ ├── check_live.py 真实 HTTP 验收(56 项断言)
|
│ ├── check_live.py 真实 HTTP 验收(122 项断言,支持 --as 普通账号)
|
||||||
│ └── shots.py Playwright 逐页截图 + JS 报错收集
|
│ ├── shots.py Playwright 逐页截图 + JS 报错收集
|
||||||
|
│ ├── demo_data.py 生成合成示例库(管理员 + 普通账号)
|
||||||
|
│ └── push-all.sh 一条命令推代码 + 推镜像到 Gitea
|
||||||
└── workbuddy_portal/
|
└── workbuddy_portal/
|
||||||
├── __init__.py create_app:配置 / 日志 / 蓝图 / 错误页 / 启动调度
|
├── __init__.py create_app:配置 / 日志 / 蓝图 / 错误页 / 启动调度
|
||||||
├── config.py 路径、项目标识、默认值、写时校验
|
├── config.py 路径、项目标识、默认值、**配置作用域与写权限**、密钥管理
|
||||||
├── db.py SQLite 连接、schema、settings 读写、审计
|
├── db.py SQLite 连接、schema、按作用域读写配置、账号、审计
|
||||||
├── schema.sql 表结构
|
├── schema.sql 表结构(多用户布局)
|
||||||
├── security.py 密码哈希、session、CSRF、失败限速、safe_next、角色
|
├── crypto.py 凭证静态加密(手写 ChaCha20 + HMAC-SHA256)
|
||||||
|
├── captcha.py 图形验证码(手写 PNG 编码器 + 点阵字模)
|
||||||
|
├── security.py 密码哈希、会话、CSRF、失败限速、验证码策略、角色、响应头
|
||||||
├── client.py 云端接口(urllib)+ 编辑器凭证读取
|
├── client.py 云端接口(urllib)+ 编辑器凭证读取
|
||||||
├── collect.py 增量采集 / 去重入库 / 互斥锁 / xlsx 导入 / CSV 导出
|
├── collect.py 增量采集 / 去重入库 / 互斥锁 / xlsx 导入 / CSV 导出
|
||||||
├── scheduler.py 进程内调度线程(槽位去重 + 启动补跑)
|
├── scheduler.py 进程内调度线程(实例级时刻表 + 槽位去重 + 启动补跑 + 自动备份)
|
||||||
├── query.py SQL 聚合层
|
├── query.py SQL 聚合层(uid 必填)
|
||||||
|
├── backup.py 备份 / 恢复(在线快照 → zip → 份数清理 → 校验 → 整表恢复)
|
||||||
└── web/
|
└── web/
|
||||||
├── views.py 页面路由
|
├── views.py 页面路由(含 /login /register /captcha.png /profile /backups)
|
||||||
├── api.py JSON API
|
├── api.py JSON API
|
||||||
├── templates/ base / login / overview / tasks / config / logs / records / users / error
|
├── templates/ base / login / register / profile / overview / tasks /
|
||||||
|
│ config / logs / records / users / backups / error
|
||||||
└── static/
|
└── static/
|
||||||
├── css/app.css 统一设计令牌
|
├── css/app.css 统一设计令牌
|
||||||
├── js/app.js 带 CSRF 的请求、表单与维护动作绑定
|
├── js/app.js 带 CSRF 的请求、表单与维护动作绑定
|
||||||
@@ -233,29 +339,148 @@ workbuddy-portal/
|
|||||||
└── dashboard/index.html ECharts 大屏(独立页)
|
└── dashboard/index.html ECharts 大屏(独立页)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> `backups/` **刻意不放在 `data/`**:`data/` 在 Docker 部署下是命名卷,
|
||||||
|
> `docker compose down -v` 会把备份和正本一起删掉——那正好是最需要备份的时刻。
|
||||||
|
> 容器里它挂的是独立的 `wb_backups` 卷;**绝不用绑定挂载**(Windows 9p 下会让容器
|
||||||
|
> `unable to open database file`,且不自愈)。该目录同时被 `.gitignore` 与 `.dockerignore` 忽略:
|
||||||
|
> 归档里含 `instance.json`,进了镜像就等于把整库密钥分发给所有能拉镜像的人。
|
||||||
|
>
|
||||||
|
> 工作区根下另有一个 `legacy-v1/`(v1.0 单文件版归档,仅作 `migrate-csv` 的来源,可安全删除),
|
||||||
|
> 它**在仓库之外**,所以含真实数据的 CSV 不会被提交。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 文档导航
|
## 文档导航
|
||||||
|
|
||||||
| 文档 | 面向 | 内容 |
|
| 文档 | 面向 | 内容 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| [docs/USER-GUIDE.md](docs/USER-GUIDE.md) | **使用者** | 用户使用手册:登录、各页面操作、Cookie 获取、导出、常见操作 |
|
| [docs/USER-GUIDE.md](docs/USER-GUIDE.md) | **使用者** | 用户使用手册:注册、登录、配 Cookie、各页面操作、导出、**权限与数据边界**、**信息安全与隐私安全**、常见问题 |
|
||||||
| [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) | 运维 | 部署与运维:Docker、裸机、反向代理、备份恢复、升级回滚、推镜像到 Gitea、排错 |
|
| [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) | 运维 | 部署与运维:**三条部署路径(裸机 / Docker 自打包 / compose 拉云端镜像)**、反向代理与 HTTPS、推镜像到 Gitea、备份恢复、升级回滚、巡检、排错、配置项速查 |
|
||||||
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | 开发 | 架构与设计说明:数据模型、调度与锁、聚合边界、安全模型、设计取舍 |
|
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | 开发 | 架构与设计说明:数据模型、调度与锁、聚合边界、安全模型、设计取舍 |
|
||||||
| [docs/API.md](docs/API.md) | 开发 / 集成 | 接口参考:路径、参数、返回结构、错误码 |
|
| [docs/API.md](docs/API.md) | 开发 / 集成 | 接口参考:路径、参数、返回结构、错误码 |
|
||||||
| [docs/FAQ.md](docs/FAQ.md) | 所有人 | 常见问题:采集为空、Cookie 失效、时区、性能、权限 |
|
| [docs/FAQ.md](docs/FAQ.md) | 所有人 | 常见问题:采集为空、Cookie 失效、时区、性能、权限 |
|
||||||
| [docs/CHANGELOG.md](docs/CHANGELOG.md) | 所有人 | 变更日志 |
|
| [docs/CHANGELOG.md](docs/CHANGELOG.md) | 所有人 | 变更日志 |
|
||||||
|
| [CONTRIBUTING.md](CONTRIBUTING.md) | 贡献者 | 贡献指南:开发环境、验证分层、必须遵守的不变量、提交规范 |
|
||||||
|
| [SECURITY.md](SECURITY.md) | 运维 / 安全 | 安全策略:漏洞私有报告渠道、已有措施、已知非目标 |
|
||||||
|
| [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md) | 合规 | 第三方组件清单与许可证(含随仓库再分发的 ECharts) |
|
||||||
|
| [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) | 所有人 | 行为准则 |
|
||||||
|
| [LICENSE](LICENSE) | 所有人 | MIT 许可证全文 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 安全须知
|
## 安全须知
|
||||||
|
|
||||||
局域网可访问 ⇒ 以下每一条都必要:
|
局域网甚至公网可访问 ⇒ 以下每一条都必要:
|
||||||
|
|
||||||
- **必须改默认密码**;给只读同事发普通账号(`is_admin=0`),不要共用管理员。
|
- **初始口令不是固定的**(v1.4.0 起):`WB_ADMIN_PASSWORD` 留空时程序生成**随机**口令,
|
||||||
- **Cookie 就是账号凭证**:只以掩码回显,存库不外传;默认开启 TLS 证书校验(`ssl_verify=1`),
|
只在启动日志里打印一次、库里只存散列。**没有 `admin123` 这类默认口令可猜**,
|
||||||
仅在自签 / 企业代理场景临时关闭。
|
代价是「拿不到那行日志就进不去」——首次启动后请立刻 `docker compose logs portal | grep -A6 管理员初始口令`。
|
||||||
|
给同事发**普通账号**(注册出来的默认身份),不要共用管理员 —— 管理员是能停用别人账号的角色。
|
||||||
|
- **权限只有两档,且规则只有一条**:`config.writable_by(key, is_admin)`。
|
||||||
|
普通账号能写的只有 `USER_EDITABLE_KEYS = {cookie, user_agent}`(且只限本人这份);
|
||||||
|
调度频率、采集参数、注册策略、日志、用户管理、**备份**全部关死。
|
||||||
|
**页面上的置灰/隐藏只是「不给误导性按钮」,真正的闸门在 `@admin_required` 与
|
||||||
|
`writable_by()` 这两个服务端判断上**——所以直接敲 URL 或构造请求也过不去。
|
||||||
|
- **数据按账号隔离**:`uid` 是所有查询的必填位置参数(漏传直接报错,不会静默返回全量);
|
||||||
|
`/api/runs/<id>`、`/api/records/<id>` 这类按 id 取的单条接口也带 `user_id` 约束;
|
||||||
|
`/logs`、`/logs/tail`、`/users`、`/api/users*`、`/backups`、`/api/backups*` 仅管理员。
|
||||||
|
- **不信任 `X-Forwarded-For`(默认)**:全站只有 `security.client_ip()` 一个取客户端地址的
|
||||||
|
入口。默认直接用 `remote_addr`;`WB_TRUST_PROXY=1` 时取**最右侧**合法 IP。
|
||||||
|
这一条不改就是**三道 IP 防线同时失效**:实测每次换一个伪造的 XFF,45 次验证码请求全部放行。
|
||||||
|
- **Cookie 静态加密**:ChaCha20 + HMAC-SHA256(encrypt-then-MAC)密文入库,主密钥在
|
||||||
|
`data/instance.json` 的 `cookie_key`(**与 `SECRET_KEY` 分开**,轮换代价不同),
|
||||||
|
POSIX 上该文件权限收到 `0600`。`get_settings()` 把加密键一律置空,要明文只有
|
||||||
|
`db.get_secret()` 一条路——这样任何「顺手打印全部配置」的代码都带不出凭证。
|
||||||
|
- **Cookie 不跨账号回落**:`NO_FALLBACK_KEYS`(`cookie` / `user_agent`)不参与实例级回落,
|
||||||
|
否则新账号会「继承」管理员的凭证,属于最严重的串号越权。
|
||||||
|
- **实例级也不存凭证**:`init_db()` 灌默认值时会跳过 `USER_EDITABLE_KEYS` 并显式删除
|
||||||
|
实例级的 `cookie` / `user_agent` 行——实例级存凭证等于给所有账号发同一张身份。
|
||||||
|
- **验证码先于口令校验**:登录时先验验证码再比密码,避免攻击者拿「密码对不对」当信号,
|
||||||
|
在解验证码之前就把字典跑完。答案存服务端 `captchas` 表,**一次性、5 分钟过期、按用途隔离**,
|
||||||
|
下发到浏览器的只有随机 id(Flask 会话是签名不加密的,放答案等于送答案)。
|
||||||
|
字模本身带**逐字符随机旋转 / 切变 / 缩放抖动 / 波浪偏移**,同一验证码两次渲染字节差异 **76.9%**。
|
||||||
|
- **注册受双重限制**:验证码 + 同 IP 每日配额(默认 3 个,可改;`allow_register=0` 可整体关闭)。
|
||||||
|
- **停用账号立即失效**:`current_user()` 每个请求回查 `users.status` **与 `users.session_ver`**,
|
||||||
|
不必等 12 小时会话过期。改密码 / 管理员重置口令 / 停用 / 删除都会让该账号的**所有其他设备上的
|
||||||
|
会话立刻作废**(改自己密码时保留当前这次会话,否则改完立刻被自己踢下线);
|
||||||
|
**恢复备份**会让全站所有会话作废。
|
||||||
|
- **口令有黑名单**:`WEAK_PASSWORDS`(34 个自动撞库字典的头几页)在**设置/修改**口令时拒绝,
|
||||||
|
登录时不校验 —— 否则会把用老口令的存量用户挡在门外。
|
||||||
- **CSRF 全站校验**,退出登录也是 `POST`(GET 型退出能被 `<img src="/logout">` 静默触发)。
|
- **CSRF 全站校验**,退出登录也是 `POST`(GET 型退出能被 `<img src="/logout">` 静默触发)。
|
||||||
|
前端 `formData()` 会跳过 `disabled` 控件(含祖先 `fieldset[disabled]`)——
|
||||||
|
disabled 的 input 仍在 `form.elements` 里,一起提交会让服务端因「越权修改只读项」拒掉**整单**。
|
||||||
- **开放重定向防护**:登录跳转的 `next` 只接受站内相对路径,`//evil.com` 这类协议相对 URL 一律回落到 `/`。
|
- **开放重定向防护**:登录跳转的 `next` 只接受站内相对路径,`//evil.com` 这类协议相对 URL 一律回落到 `/`。
|
||||||
- **登录限速**:同 IP 连续失败 5 次锁定 10 分钟;失败计数表有上限与 TTL。
|
- **登录限速按两个维度、且强度刻意不同**:IP 维度连续失败 5 次**真锁** 10 分钟;
|
||||||
- **不进版本库的文件**:`data/instance.json`(含 `secret_key`)、`data/usage.sqlite`、`logs/`、`.env`(含明文密码)。
|
**用户名维度只做秒级递增退避**(封顶 60 秒)——因为「知道用户名就能把对方锁死 10 分钟」
|
||||||
|
本身就是攻击,而**管理员用户名在导航栏里是公开的**。另有**单来源登录尝试总量**
|
||||||
|
(40 次 / 5 分钟,**含成功**)挡住「慢慢撞、不触发失败阈值」的形态。
|
||||||
|
失败提示是「本来源连续失败 N 次」而不是「剩余 N 次」——不给攻击者倒计时。
|
||||||
|
验证码出图另有 60 秒 40 张的限速(不设限就是一条廉价的 CPU 放大路径)。
|
||||||
|
- **重操作都有最小间隔**:手动采集 60 秒、CSV 导出 15 秒、导出本人数据 10 秒、
|
||||||
|
`vacuum` 120 秒、补全 prompt 60 秒。**采集跨度硬顶 31 天**(代码层 `COLLECT_MAX_RANGE_DAYS_HARD`,
|
||||||
|
改配置也突破不了);**有采集任务在跑时新建任务直接 `409`**。
|
||||||
|
- **安全响应头**:CSP(`frame-ancestors 'none'`)、`X-Frame-Options: DENY`、`nosniff`、
|
||||||
|
`Referrer-Policy: same-origin`、COOP;`/api/*` 与 `/captcha*` 带 `Cache-Control: no-store`。
|
||||||
|
**HSTS 只在真的走 HTTPS 时下发**(`COOKIE_SECURE or FORCE_HTTPS`)——纯 HTTP 部署下发会让
|
||||||
|
浏览器强升 https,表现成白屏,是个很难归因的故障。
|
||||||
|
- **有访问日志**:waitress 自己不记 access log,本程序补上(写进 `logs/app.log`,
|
||||||
|
跳过 `/static/` 与验证码图片)。出事无据可查时这一条很值。
|
||||||
|
- **不进版本库、也绝不进镜像的文件**:`data/instance.json`(含 `secret_key` 与 `cookie_key`)、
|
||||||
|
`data/usage.sqlite`、`data/shots/`、`data/demo/`、`backups/`(归档里**含 `instance.json`**)、
|
||||||
|
`logs/`、`.env`(含明文密码)。`.dockerignore` 里 `backups/` 这条是**硬要求**,
|
||||||
|
Dockerfile 还有一条构建期断言兜底:万一漏了就直接构建失败 ——
|
||||||
|
镜像层是只读叠加的,靠 `RUN rm` 补救只会多留一个含内容的中间层。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 开源与许可
|
||||||
|
|
||||||
|
本项目以 **MIT 许可证**发布,全文见 [LICENSE](LICENSE)。你可以自由使用、修改、商用与再分发,
|
||||||
|
只需保留版权声明与许可声明。
|
||||||
|
|
||||||
|
### 第三方组件(务必看一眼)
|
||||||
|
|
||||||
|
用量大屏**随仓库再分发了 Apache ECharts 5.6.0**(`workbuddy_portal/web/static/dashboard/vendor/echarts.min.js`,
|
||||||
|
Apache-2.0 许可)——之所以内置而不走 CDN,是为了让大屏在局域网内离线可用。
|
||||||
|
按 Apache-2.0 第 4 条,再分发时需保留其许可证与版权声明(该文件头部已自带)。
|
||||||
|
其余运行期依赖(Flask / waitress / openpyxl)不在本仓库内,由使用方安装时获取。
|
||||||
|
|
||||||
|
完整的依赖清单、许可证对照表与**合规自查清单**见 [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md)。
|
||||||
|
|
||||||
|
### 参与贡献
|
||||||
|
|
||||||
|
- 想改代码?先读 [CONTRIBUTING.md](CONTRIBUTING.md) —— 里面有**必须遵守的几条不变量**
|
||||||
|
(SQLite 单写者、列表接口不回 `prompt` 全文、两种字段命名契约不要互相「统一」、
|
||||||
|
写权限只能走 `config.writable_by`……),以及从 `compileall` 到容器验证的五层自检该怎么跑。
|
||||||
|
- 有想法但手上没有真实数据?`python tools/demo_data.py` 会生成一份**完全合成**的示例库,
|
||||||
|
写到 `data/demo/`(已在 `.gitignore` 内),可直接拿来调试界面与截图。
|
||||||
|
- 发现安全漏洞?**请不要开公开 Issue**,按 [SECURITY.md](SECURITY.md) 走私有渠道。
|
||||||
|
- 参与本项目即表示你同意遵守 [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)。
|
||||||
|
|
||||||
|
### 文档里的数据都是合成的
|
||||||
|
|
||||||
|
`docs/images/` 的全部界面截图与 `docs/` 中的 JSON 示例**均为合成数据**:
|
||||||
|
模型名统一为 `demo-*`,客户端为 `vscode` / `webconsole` / `sdk`,Prompt 为通用示例文本,
|
||||||
|
Cookie 是 `deadbeef…` / `cafef00d…` 这类一眼可辨的假串,
|
||||||
|
审计 IP 取自 RFC 5737 的文档专用网段(`192.0.2.0/24`)。
|
||||||
|
生成方式是 `tools/demo_data.py`(会造 `admin` 与 `demo` 两个账号,各有自己的数据),
|
||||||
|
所以任何人不需要真实账号就能复现整套文档。截图由 `tools/shots.py` 逐页重出(**13 张**,
|
||||||
|
含注册页、个人中心,以及**普通账号视角的只读「任务管理 / 配置管理」**),
|
||||||
|
脚本会读示例库里的验证码答案自动过掉登录。
|
||||||
|
|
||||||
|
> ⚠️ **重出截图时务必用相对路径起示例服务**:
|
||||||
|
> ```bash
|
||||||
|
> cd workbuddy-portal
|
||||||
|
> WB_DATA_DIR=data/demo WB_LOG_DIR=data/demo/logs python manage.py serve --port 8849 --no-scheduler
|
||||||
|
> ```
|
||||||
|
> 用绝对路径(包括 Git Bash 里的 `$PWD`,它展开成 `/c/...`)会让启动日志印出
|
||||||
|
> `C:\Users\<用户名>\…`,而那一行正好会出现在「日志管理」页的截图上。
|
||||||
|
> 另外 Git Bash 下 `$PWD` 是 MSYS 风格路径,Python 会把它解析成 `C:\c\Users\…`
|
||||||
|
> —— 于是**服务连的是另一个空库**,界面上看不出异常,但截图数据全不对
|
||||||
|
> (症状:验证码取不到答案,`shots.py` 报「需要验证码但取不到答案」)。
|
||||||
|
|
||||||
|
### 致谢
|
||||||
|
|
||||||
|
- 交互大屏依赖 [Apache ECharts](https://echarts.apache.org/)
|
||||||
|
- Web 框架 [Flask](https://flask.palletsprojects.com/),生产服务器 [waitress](https://github.com/Pylons/waitress)
|
||||||
|
- 行为准则框架来自 [Contributor Covenant](https://www.contributor-covenant.org/)
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
# 安全策略(Security Policy)
|
||||||
|
|
||||||
|
## 支持范围
|
||||||
|
|
||||||
|
本项目按「自托管」定位开发,**1.4.0 起已按「可以暴露到公网」加固**(IP 来源、限速、
|
||||||
|
资源上限、采集跨度硬顶都补上了),**1.5.0 又按同一前提收口了一轮信息泄漏、注入与读接口滥用**。
|
||||||
|
即便如此,仍然建议置于反向代理之后。安全修复只针对当前主分支与最新发布版本。
|
||||||
|
|
||||||
|
| 版本 | 是否接受安全修复 |
|
||||||
|
|---|---|
|
||||||
|
| `1.5.x`(当前) | ✅ |
|
||||||
|
| `1.4.x` | ⚠️ 可用,但建议升级(1.5.0 收口了内部异常/表名外泄、响应头注入、导出路径穿越、读接口无刹车) |
|
||||||
|
| `1.3.x` | ⚠️ 可用,但**公网暴露前必须升级**(1.4.0 修掉了三道 IP 防线可被 XFF 伪造绕过的问题) |
|
||||||
|
| `< 1.3` | ⚠️ 可用,但建议升级(1.3.0 收紧了普通账号的越权面,见下) |
|
||||||
|
| `< 1.2` | ❌ 请先升级(1.2.0 修掉了单用户时代「Cookie 明文入库」与「人人都是管理员」两个根本问题) |
|
||||||
|
|
||||||
|
> **1.5.0 收口了什么**(都不是「可直接利用的高危漏洞」,而是**对外时迟早出事**的那一类):
|
||||||
|
>
|
||||||
|
> 1. **信息泄漏**:接口的兜底异常原本把 `str(e)` 原样回给客户端(含绝对路径与 SQL 片段),
|
||||||
|
> 现在只回一个 8 位事件号,细节进服务端日志;`/api/manifest` / `/api/bundle` 里的
|
||||||
|
> **表名与库文件路径**也只对管理员下发。另外补掉了登录接口的**用户名枚举时序侧信道**
|
||||||
|
> (账号不存在时也走一次同代价的哑哈希)。
|
||||||
|
> 2. **注入**:导出文件名含用户名,而用户名并不总是注册正则的产物
|
||||||
|
> (`manage.py passwd` 建号、老库升级上来的名字都可能带引号或 CR/LF)——
|
||||||
|
> 直接拼 `Content-Disposition` 就是**响应头注入**;`export_csv` 的账号名直拼路径则是
|
||||||
|
> **路径穿越**。两者都改为先收敛字符集,建号也补上了用户名校验。
|
||||||
|
> 3. **资源滥用**:读接口原本**没有任何刹车**,一个注册账号循环调 `/api/bundle`
|
||||||
|
> 就能持续吃满 CPU 与出口带宽。现在 `/api/*` 按账号(未登录按 IP)限速 240 次 / 60 秒。
|
||||||
|
> 4. **隐私落盘**:进程 `umask` 收到 `0077` —— 数据目录里是**对话正文**、凭证密文、主密钥
|
||||||
|
> 与它们的明文导出副本,默认 `0644` 等于同机任何用户可读。
|
||||||
|
> 5. **调试器**:`--debug` 不再允许绑定对外地址(Werkzeug 调试器 = 任意代码执行,
|
||||||
|
> 而 `--host` 的默认值恰好是 `0.0.0.0`)。
|
||||||
|
|
||||||
|
> **1.4.0 修掉了什么**(三条 P0):
|
||||||
|
> 1. **`X-Forwarded-For` 可伪造 ⇒ 三道 IP 防线(验证码限速 / 注册配额 / 登录锁定)同时失效**。
|
||||||
|
> 实测:每次换一个伪造的 XFF,45 次验证码请求**全部放行**。现在默认不信任 XFF,
|
||||||
|
> 全站只有 `security.client_ip()` 一个取客户端地址的入口。
|
||||||
|
> 2. **默认口令 `admin123` 硬编码兜底**:现在没有默认口令,留空则生成随机口令、
|
||||||
|
> 只在启动日志打印一次、不写进数据库。
|
||||||
|
> 3. **`.dockerignore` 漏了 `backups/` ⇒ 凭证密文被打进镜像**:`backups/` 里那份
|
||||||
|
> 4.5 MB 的真实库快照含 `settings` 凭证密文与 `users` 口令散列,
|
||||||
|
> `docker build` 会把它烤进镜像层,推一次镜像等于把整库密钥分发出去。
|
||||||
|
>
|
||||||
|
> 还收掉了一批 P1:采集/导出无跨度上限与频率限制、`WB_COOKIE_SECURE` 默认关且无 HSTS、
|
||||||
|
> 账号锁定可被当武器(管理员用户名公开)、撞库不受限、验证码强度不足(可模板匹配)、
|
||||||
|
> 改密码不失效其他会话、`instance.json` 未设 `0600`、无访问日志。
|
||||||
|
|
||||||
|
> **1.3.0 修掉了什么**:此前「调度时刻 / 采集参数」是**个人级**配置,普通账号可以
|
||||||
|
> 自己改(等于让普通账号决定这台服务器怎么发请求、关不关 TLS 校验);
|
||||||
|
> 且 `/logs` 对普通账号开放。现在这两块都收归管理员,普通账号只保留
|
||||||
|
> 「维护本人 Cookie / User-Agent」这一项写权限。
|
||||||
|
|
||||||
|
## 如何报告漏洞
|
||||||
|
|
||||||
|
**请不要在公开 Issue 里贴出可直接利用的细节**(含真实 Cookie、`secret_key` / `cookie_key`、可复现的绕过步骤)。
|
||||||
|
|
||||||
|
请通过以下任一私有渠道联系维护者:
|
||||||
|
|
||||||
|
<!-- TODO(维护者):首次公开发布前,把下面这行替换为真实可达的安全联系邮箱 -->
|
||||||
|
- 邮件:`<安全联系邮箱>`(占位,待维护者补全)
|
||||||
|
- 或通过代码托管平台(Gitea)的站内私信联系仓库管理员
|
||||||
|
|
||||||
|
请在报告里尽量包含:
|
||||||
|
|
||||||
|
1. 受影响的版本 / 提交号
|
||||||
|
2. 复现步骤与最小复现(可脱敏)
|
||||||
|
3. 影响范围(能读到什么、能改到什么)
|
||||||
|
4. 如果有,你建议的修复方向
|
||||||
|
|
||||||
|
我们会在 **7 天内**确认收到,并在修复发布后于 CHANGELOG 里致谢(除非你希望匿名)。
|
||||||
|
|
||||||
|
## 设计上已有的安全措施
|
||||||
|
|
||||||
|
理解这些边界,有助于你判断某个现象是「设计如此」还是「真的漏洞」:
|
||||||
|
|
||||||
|
### 身份、会话与权限
|
||||||
|
|
||||||
|
| 项 | 做法 | 位置 |
|
||||||
|
|---|---|---|
|
||||||
|
| 全站鉴权 | 每个页面都有 `@login_required`,每个 `/api/*` 未登录返回 401 JSON | `security.login_required`、`web/views.py` |
|
||||||
|
| 角色(两档) | 管理员 / 普通。**仅管理员**:`/users`、`/api/users*`、`/logs`、`/logs/tail`、`vacuum`、`/backups`、`/api/backups*`(含下载与**恢复**) | `security.admin_required` |
|
||||||
|
| **写权限只有一条规则** | `config.writable_by(key, is_admin)` —— 普通账号能写的只有 `USER_EDITABLE_KEYS = {cookie, user_agent}`(且只限本人那份)。页面与接口共用这一个判断,杜绝「界面置灰但接口还能写」 | `config.writable_by` |
|
||||||
|
| **界面置灰 ≠ 安全边界** | 页面上的 disabled / hidden 只是「不给误导性按钮」;真正的闸门是服务端 `@admin_required` 与 `writable_by()`,所以直接敲 URL 或构造请求也过不去 | `web/views.py`、`web/api.py` |
|
||||||
|
| 越权写整单拒绝 | 一次提交里只要含不可写的键,整个请求 `400` + `denied` 点名,不做「部分生效」 | `web/api.py:api_settings_post` |
|
||||||
|
| 停用即失效 | `current_user()` **每个请求**回查 `users.status` **与 `users.session_ver`**,不等 12 小时会话过期 | `security.current_user` |
|
||||||
|
| **会话版本号** | 改密码 / 管理员重置口令 / 停用 / **删除**账号 → `bump_session_ver()`,该账号所有其他设备上的会话**立刻作废**。改自己密码时把当前会话刷到新版本(否则改完立刻被自己踢下线)。**恢复备份**会让全站所有会话作废 | `db.session_ver_of` / `db.bump_session_ver` |
|
||||||
|
| 自锁保护 | 管理员不能停用 / 降权 / 删除自己;也不能删掉最后一个启用的管理员 | `web/api.py` |
|
||||||
|
| CSRF | 所有写请求必须带 `X-CSRF-Token`,页面注入 `window.WB_CSRF`,服务端统一拦截;退出登录也是 POST | `security.check_csrf` |
|
||||||
|
| 会话签名 | Flask `secret_key` 由 `data/instance.json` 持有,首次启动随机生成 | `workbuddy_portal/config.py` |
|
||||||
|
| 会话 cookie | `HttpOnly` + `SameSite=Lax` + `Path=/`;HTTPS 部署可设 `WB_COOKIE_SECURE=1` 打开 Secure | `workbuddy_portal/__init__.py` |
|
||||||
|
| **客户端 IP 唯一入口** | `security.client_ip()` —— 默认**不信任** `X-Forwarded-For`(直接用 `remote_addr`);`WB_TRUST_PROXY=1` 时取**最右侧**合法 IP(最近一跳由你自己的代理写入,客户端伪造不了)。含 `IPv4:port` 与 IPv6 处理。**所有** IP 相关判断(验证码限速 / 注册配额 / 登录限速 / 审计留痕)都走它 | `security.client_ip` |
|
||||||
|
| **强制 HTTPS** | `WB_FORCE_HTTPS=1` 时非 HTTPS 的 **GET/HEAD** 跳转(POST 不跳,否则会掉请求体);`WB_TRUST_PROXY` 关闭时不读 `X-Forwarded-Proto` | `security.needs_https_redirect` |
|
||||||
|
| 口令存储 | 加盐哈希(PBKDF2-SHA256),不存明文;强度校验(≥8 位、含两类字符、不得等于用户名)+ **黑名单** `WEAK_PASSWORDS`(34 个撞库字典头几页)。黑名单**只在设置/修改口令时生效**,登录不校验 —— 否则会把用老口令的存量用户挡在门外 | `security.hash_password` / `password_problem` |
|
||||||
|
| **初始口令不给默认值** | `WB_ADMIN_PASSWORD` 留空 → `secrets.token_urlsafe(12)` 随机生成,**只在启动日志打印一次、不写进数据库**(审计里出现口令等于永久留档) | `db._create_first_admin` |
|
||||||
|
| 开放重定向 | 登录后的 `next` 只允许站内相对路径,`//evil.com` 一律回落到 `/` | `security.safe_next` |
|
||||||
|
| 失败限速(双维度、**强度刻意不同**) | **IP 维度**:连续失败 5 次**真锁** 10 分钟。**用户名维度**:只做秒级递增退避(封顶 60 秒)—— 「知道用户名就能把对方锁死 10 分钟」本身是攻击,而**管理员用户名在导航里是公开的**。另有**单来源登录尝试总量** 40 次 / 5 分钟(**含成功**),挡「慢慢撞、不触发失败阈值」。计数表有上限与 TTL | `security.auth_block_reason` / `note_try` / `try_window_left` |
|
||||||
|
| 失败提示不泄漏信息 | 提示是「本来源连续失败 N 次」而不是「剩余 N 次」——不给攻击者倒计时 | `web/views.py:login` |
|
||||||
|
| **用户名枚举的时序侧信道** | 口令校验对**不存在的账号**也走一次同代价的哑哈希(`_DUMMY_HASH`)。否则「账号不存在」比「口令错误」快一到两个数量级,一个秒表就能枚举出哪些用户名真实存在 | `security.login_ok` / `_dummy_verify` |
|
||||||
|
| 响应头 | CSP(`frame-ancestors 'none'`)、`X-Frame-Options: DENY`、`nosniff`、`Referrer-Policy: same-origin`、COOP、**`Permissions-Policy`(显式关掉地理位置/麦克风/摄像头/支付/USB)**;`/api/*` 与 `/captcha*` 带 `no-store` | `security.apply_security_headers` |
|
||||||
|
| **HSTS 只在真 HTTPS 时下发** | `COOKIE_SECURE or FORCE_HTTPS` 才发 `Strict-Transport-Security`。纯 HTTP 部署下发会让浏览器强升 https,表现成白屏 —— 很难归因的一类故障 | `security.apply_security_headers` |
|
||||||
|
| **访问日志** | waitress 自己不记 access log;本程序补上(跳过 `/static/` 与 `/captcha.png`),写进 `logs/app.log`,`WB_ACCESS_LOG` 控制 | `security._access_log` |
|
||||||
|
|
||||||
|
### 多用户数据隔离
|
||||||
|
|
||||||
|
| 项 | 做法 | 位置 |
|
||||||
|
|---|---|---|
|
||||||
|
| 强隔离 | `uid` 是 `conn` 之后的**第一个位置参数且无默认值**;漏传直接 `TypeError`,不会退化成「返回全量」 | `query.py` / `collect.py` / `scheduler.py` |
|
||||||
|
| 按 id 取单条也隔离 | `/api/records/<id>`、`/api/runs/<id>` 的 `WHERE` 都带 `user_id` | `web/api.py` |
|
||||||
|
| **配置作用域与写权限对齐** | 「键存在哪一级」与「谁能改」是一件事:`GLOBAL_KEYS` 一律存**实例级 `user_id=0`** 且仅管理员可写;`USER_EDITABLE_KEYS` 是个人级且人人可写本人那份。`set_setting()` 强制把全局键重定向到 `user_id=0`,从结构上消除「管理员改了只有自己生效」 | `config.GLOBAL_KEYS` / `config.USER_EDITABLE_KEYS`、`db.set_setting` |
|
||||||
|
| 三级回落 | `个人 → 实例(user_id=0) → DEFAULTS` | `db.get_settings` |
|
||||||
|
| 凭证不回落 | `NO_FALLBACK_KEYS = {cookie, user_agent}` **不参与实例级回落** —— 回落等于新账号继承管理员凭证,是最严重的串号越权 | `db.get_setting` |
|
||||||
|
| 实例级也不存凭证 | `init_db()` 灌默认值时跳过 `USER_EDITABLE_KEYS`,并显式 `DELETE FROM settings WHERE user_id=0 AND key IN ('cookie','user_agent')` —— 实例级存凭证等于给所有账号发同一张身份 | `db.init_db` |
|
||||||
|
| 实例级不是孤儿 | `user_id=0` 不对应任何真实账号,**任何「清理孤儿行」的语句都必须排除它**(历史缺陷:`DELETE ... WHERE user_id NOT IN (SELECT id FROM users)` 曾把实例级配置整片删掉) | `tools/smoke.py` 防回归断言 |
|
||||||
|
| 日志隔离 | `/logs`、`/logs/tail` **仅管理员**;操作审计对普通账号只下发本人记录 | `web/views.py`、`web/api.py` |
|
||||||
|
| 导出不互相覆盖 | `/records/export` 与 CLI `export-csv` 的文件名带账号名 | `web/views.py`、`collect.export_csv` |
|
||||||
|
| 前端不误提交只读项 | `app.js:formData()` 跳过 `disabled` 控件(含祖先 `fieldset[disabled]`)—— disabled 的 input 仍在 `form.elements` 里,一起提交会让服务端按「越权修改只读项」拒掉**整单** | `web/static/js/app.js` |
|
||||||
|
|
||||||
|
### 云端凭证(Cookie)的保密
|
||||||
|
|
||||||
|
| 项 | 做法 | 位置 |
|
||||||
|
|---|---|---|
|
||||||
|
| **静态加密** | ChaCha20(RFC 8439 §2.3)+ HMAC-SHA256 **encrypt-then-MAC**,密文 `v1.<b64salt>.<b64nonce>.<b64ct>.<b64tag>`;手写实现,零第三方依赖 | `workbuddy_portal/crypto.py` |
|
||||||
|
| 密钥分离 | 主密钥 `cookie_key` 与 `SECRET_KEY` **分开键位**存放(两者轮换代价不同:换 `cookie_key` 会让所有已存 Cookie 失效) | `config.encryption_key` / `secret_key` |
|
||||||
|
| 唯一明文出口 | `db.get_secret()` 是取明文的**唯一**通道;`get_settings()` 把 `ENCRYPTED_KEYS` 一律置空,所以「顺手回传全部配置」的代码带不出凭证 | `db.py` |
|
||||||
|
| 只回掩码 | 页面与 `/api/settings` 只给「N 字符,结尾 …xxxx」与 `broken` 标志,`secret_state()` 不返回明文 | `db.secret_state` |
|
||||||
|
| 失败即报错 | `decrypt()` 校验失败**抛 `DecryptError`**,绝不「失败就返回原值」;非 `v1.` 前缀视为历史明文原样返回(下次写入自动升级) | `crypto.decrypt` |
|
||||||
|
| 历史明文清理 | 启动迁移时把 settings 里残留的明文凭证就地加密,并写一条 `encrypt_secrets` 审计 | `db._encrypt_legacy_secrets` |
|
||||||
|
| TLS 校验 | 默认开启;`ssl_verify` 是**实例级且仅管理员可改** —— 能让普通账号关掉它,等于允许把所有人的 Cookie 发往不校验证书的地址 | `settings.ssl_verify`、`config.GLOBAL_KEYS` |
|
||||||
|
|
||||||
|
### 防自动化攻击
|
||||||
|
|
||||||
|
| 项 | 做法 | 位置 |
|
||||||
|
|---|---|---|
|
||||||
|
| 图形验证码 | 手写 PNG 编码器 + 5×7 点阵字模 + 干扰线/噪点;**不用 SVG**(SVG 是文本,答案会明文出现在页面源码里) | `workbuddy_portal/captcha.py` |
|
||||||
|
| **抗模板匹配** | 让**同一字符两次渲染尽量不同**:逐字符随机旋转 ±22°、切变 ±0.32、缩放抖动、波浪偏移、粗刷笔画(旋转时不断裂)、两色斜向渐变背景、噪点 46 → 70、压线 2~3 条。实测同一验证码两次渲染**字节差异 76.9%**,字符仍可辨认 | `captcha._draw_char` / `captcha._brush_line` |
|
||||||
|
| 答案不进会话 | 答案只写服务端 `captchas` 表;会话里仅存随机 id —— Flask 会话是「签名不加密」的,放答案等于送答案 | `security.issue_captcha` |
|
||||||
|
| 一次性 | 校验后立即删除,且**先删后判**;5 分钟过期、按 `purpose` 隔离,不能拿注册的题去登登录 | `captcha.verify` |
|
||||||
|
| 先验码后验密 | 登录先校验验证码再比对口令,避免攻击者拿「密码对不对」当提前信号跑完字典 | `web/views.py` |
|
||||||
|
| 出图限速 | 每来源 60 秒最多 40 张(不设限就是一条廉价的 CPU/带宽放大路径)。**来源按 `client_ip()` 判定**,伪造 XFF 无效 | `security.captcha_fetch_allowed` |
|
||||||
|
| 注册配额 | 同 IP 每日最多注册 N 个(默认 3,可改);`allow_register=0` 可整体关闭。**同样按 `client_ip()` 判定** | `security.register_quota` |
|
||||||
|
|
||||||
|
### 信息泄漏收敛(v1.5.0)
|
||||||
|
|
||||||
|
不致命,但都是**踩点阶段最好用**的材料,所以统一收掉。
|
||||||
|
|
||||||
|
| 项 | 做法 | 位置 |
|
||||||
|
|---|---|---|
|
||||||
|
| **内部异常不外泄** | 接口的兜底 `except Exception` 不再回 `str(e)`(那会带绝对路径、SQL、`sqlite3` 报错),改为**完整堆栈进服务端日志 + 一个 8 位事件号**。面向用户写的业务异常(`BadParam` / `Busy` / `NotReady` / `ApiError` / `BackupError`)不受影响,它们本来就是给用户看的 | `web/api.py::_internal` |
|
||||||
|
| 内部实现清单只给管理员 | `/api/manifest`、`/api/bundle` 的 `sources`(表名)与 `archive`(库文件路径)对普通账号为空;大屏页「数据源」卡片随之收起 | `query.manifest(sources=…)` |
|
||||||
|
| **响应头注入收口** | 导出文件名一律先收敛成 ASCII 安全名(只留 `[A-Za-z0-9._-]`、打平 `..`),再按 RFC 5987 附上原名。含引号 / CR / LF 的用户名不可能再拼出畸形响应头 | `security.safe_filename` / `content_disposition` |
|
||||||
|
| **导出路径穿越收口** | `export_csv` 的账号名 tag 同样收敛,且结果固定落在 `EXPORT_DIR` 之下。根因也堵了:`manage.py passwd` **建号时补上用户名校验**(与注册页同一套) | `collect.export_csv`、`manage.py` |
|
||||||
|
| 上传体量反馈一致 | `413` 返回 JSON(与其它接口错误同形状)。不加这一层时 Flask 吐 HTML 页,前端 `r.json()` 会炸成「Unexpected token <」 | `workbuddy_portal/__init__.py` |
|
||||||
|
| **访问日志不带查询串** | `_access_log` 记的是 `request.path` —— 若记 `?q=<搜索词>`,prompt 片段会顺着搜索词进日志文件 | `security._access_log` |
|
||||||
|
|
||||||
|
### 资源与频率限制(对外提供服务时)
|
||||||
|
|
||||||
|
单写者架构下**不加副本扛负载**,所以限制必须落在「单实例资源」与「单账号频率」两处。
|
||||||
|
|
||||||
|
| 项 | 做法 | 位置 |
|
||||||
|
|---|---|---|
|
||||||
|
| **采集三道闸门** | `POST /api/collect`:① 采集锁存在 → `409`(**有任务在跑就不能新建任务**);② `action_allowed("collect:uid", gap)` → `429`(默认最小间隔 60 秒);③ 跨度超上限 → `400` | `web/api.py:api_collect` |
|
||||||
|
| **采集跨度硬顶** | `COLLECT_MAX_RANGE_DAYS_HARD = 31`(= 1 个月)。`collect_max_range_days` 只是更严的旋钮,**改大也突破不了** —— 上限由代码兜底,不依赖写入校验。历史脏数据、手工改库都过不去 | `config.COLLECT_MAX_RANGE_DAYS_HARD`、`collect.max_range_days` |
|
||||||
|
| 调度时刻数硬顶 | `SCHEDULE_SLOTS_HARD_MAX = 12`,另有可配置的更严上限 `max_schedule_slots_per_day`(默认 6)。时刻数量直接决定采集频次,是「一个账号能不能把云端打满」的开关 | `config.normalize_setting` |
|
||||||
|
| 重操作最小间隔 | 采集 60s / CSV 导出 15s / **导出本人数据 10s** / `vacuum` 120s / 补全 prompt 60s / 重算 5s;超限 `429` 并给出剩余等待秒数 | `security.action_allowed` |
|
||||||
|
| **读接口限速** | `/api/*` 按「账号」计数(未登录退化成来源 IP,**不做 DB 回查**)限速 **240 次 / 60 秒**,超限 `429`。重点挡的是 `/api/bundle` —— 它要算全量逐日聚合、还会下发最多 2 万条明细,把它放进 `for` 循环就能持续吃掉 CPU 与出口带宽(这不需要任何漏洞)。阈值刻意宽松:大屏切一次筛选只发 1~2 个请求 | `security.api_rate_ok` |
|
||||||
|
| 容器资源上限 | `cpus: 1.0` / `mem_limit: 512m` / `memswap_limit` **与 `mem_limit` 相等**(= 禁用 swap:超限会「被 OOM 杀掉」而不是「越来越慢」,后者更难查)/ `pids_limit: 256`(挡 fork 炸弹)/ `ulimits.nofile 4096:8192`(SQLite 除主库外还持有 `-wal` `-shm`,恢复期还要开临时库 + `ATTACH` 源库) | `docker-compose.yml` |
|
||||||
|
| 线程数受控 | `WB_THREADS`(容器默认 4,代码默认 8)决定单实例能同时吃进几个慢请求;1 核配 8 线程容易出现「都在等 CPU」的假并发 | `config.THREADS` |
|
||||||
|
| SSRF 收口 | `api_base` 拒绝云元数据地址(`169.254.169.254` / `metadata.google.internal` / `[fd00:ec2::254]`)—— SSRF 拿云上临时凭证最经典的一跳,且无任何合法采集场景需要它 | `config.normalize_setting` |
|
||||||
|
|
||||||
|
### 备份与数据导出
|
||||||
|
|
||||||
|
| 项 | 做法 | 位置 |
|
||||||
|
|---|---|---|
|
||||||
|
| **归档含密钥,这是有意的取舍** | 归档里的 `instance.json` 带 `cookie_key` —— 没有它就永远解不开 `settings` 里的凭证密文,那样的「恢复」等于把所有人的 Cookie 弄丢。代价是**归档本身是最高机密**,因此它**永不入库、永不进镜像** | `backup.INSTANCE_NAME` |
|
||||||
|
| **不进镜像** | `.dockerignore` 排除 `backups/`;Dockerfile 在 `COPY` 之后有一条**构建期断言**,`/app/backups` 非空即构建失败。不能靠 `RUN rm` 补救:镜像层是只读叠加的,删掉只会多留一个含内容的中间层 | `.dockerignore`、`Dockerfile` |
|
||||||
|
| 文件名收口 | `safe_name()` 对下载与恢复接口吃进来的文件名做 basename 收敛 + 后缀 + 字符白名单(防 `../../etc/passwd`、绝对路径、`sub/..`) | `backup.safe_name` |
|
||||||
|
| 防 zip slip | `_extract_safely()` 只按**基名**解压 —— 归档可能是**外部给的**,`extractall` 会因为条目名里的 `..` 写到目录之外 | `backup._extract_safely` |
|
||||||
|
| 恢复前护栏 | 自动先给**当前**库打一份 `pre-restore` 快照;恢复错了还能回去 | `backup.restore` |
|
||||||
|
| **恢复后全站会话作废** | 所有账号 `session_ver` +1。光靠「从归档里搬 `session_ver`」不够:在打备份**之前**登录的那个人,Cookie 里的 `sv` 正好等于归档里的值,会话会「合法地」活下来,而它描述的账号与权限可能已经被换掉了 | `backup.restore` |
|
||||||
|
| 导出不含凭证 | `/profile/export` 给 4 份 CSV/JSON 与说明,**不含 Cookie 明文** —— 导出自己的数据不等于把凭证交出去。10 秒限速 | `web/views.py:profile_export` |
|
||||||
|
| 快照用在线备份 API | `Connection.backup` 而不是 `cp`:按页复制并持有读事务,采集正在写也能拿到一致快照。手工 `cp` 一个 WAL 库可能缺最近一段数据,**而且不报错** | `backup._snapshot` |
|
||||||
|
|
||||||
|
### 其它
|
||||||
|
|
||||||
|
| 项 | 做法 | 位置 |
|
||||||
|
|---|---|---|
|
||||||
|
| 容器权限 | 运行层非 root(uid/gid 1000 `app`);`init: true` 让 tini 接管 PID 1,`docker stop` 能干净传到 python | `Dockerfile`、`docker-compose.yml` |
|
||||||
|
| **密钥文件权限** | `data/instance.json` 在 POSIX 上显式 `chmod 0600`(默认 umask 022 会留下 0644,同机其他用户可读)。恢复时写回也走同一处理,并先把原文件另存 `.pre-restore-<ts>` | `config._instance_init`、`backup._restore_instance` |
|
||||||
|
| **进程级落盘权限(v1.5.0)** | 启动时把 `umask` 收到 `0077`,此后**本进程新建的一切文件**天然是 owner-only:SQLite 的 `-wal` / `-shm`、导出 CSV、备份 zip、日志一并覆盖。刻意选「一条设置管全部」而不是逐个 `chmod` —— 逐点 `chmod` 一定会漏,而漏掉的那个文件里往往正好是最新的**对话正文**。配套 `harden_dir()` / `harden_file()` 收紧遗留的 0755 目录与已存在的文件 | `config.harden_process` / `harden_dir` / `harden_file` |
|
||||||
|
| **调试器不绑对外地址** | `manage.py serve --debug` 在 `--host` 非回环时**直接拒绝启动**(退出码 2)。Werkzeug 的交互式调试器等于任意代码执行,而 `--host` 默认就是 `0.0.0.0` —— 这条把「本机调试习惯」和「公网部署」之间的那个坑堵死 | `manage.py cmd_serve` |
|
||||||
|
| 上传体量 | `MAX_CONTENT_LENGTH = 4 MiB`(超限由 `413` 处理器转成 JSON) | `workbuddy_portal/__init__.py` |
|
||||||
|
| 不索引 | 页面带 `noindex, nofollow` | `web/templates/base.html` |
|
||||||
|
|
||||||
|
**绝不入库**:`data/instance.json`(含 `secret_key` 与 `cookie_key`)、`data/usage.sqlite`、
|
||||||
|
`data/shots/`(截图里可能有真实账号信息)、`data/demo/`、`backups/`(库快照 = 凭证密文 + 密码哈希)、
|
||||||
|
`logs/*`、`.env`。
|
||||||
|
|
||||||
|
`.gitignore` 已覆盖;改动忽略规则后请用 `git check-ignore -v <file>` 逐条复核。
|
||||||
|
注意 `.gitignore` **不支持行尾注释**(`path # 说明` 会让整行变成永不匹配的模式)。
|
||||||
|
|
||||||
|
**也绝不进镜像**:`.dockerignore` 里 `backups/` 是**硬要求**,理由见上表(P0-3)。
|
||||||
|
`Dockerfile` 里那条构建期断言就是防「以后有人又把它删了」。
|
||||||
|
|
||||||
|
> `backups/` 同时也是一个**刻意不放在 `data/`** 的目录:`data/` 在 Docker 部署下是命名卷,
|
||||||
|
> `docker compose down -v` 会把备份和正本一起删掉 —— 那正好是最需要备份的时刻。
|
||||||
|
> 容器里它挂独立卷 `wb_backups`;**绝不用绑定挂载**(Windows 9p 下容器会
|
||||||
|
> `unable to open database file`,且不自愈)。
|
||||||
|
|
||||||
|
## 已知的**非**目标(部署方需自行处理)
|
||||||
|
|
||||||
|
本项目刻意不做下面这些,请按你的环境补齐:
|
||||||
|
|
||||||
|
- **没有强制 HTTPS**:请由反向代理(nginx/Caddy)终止 TLS。纯 HTTP 部署时
|
||||||
|
**不要**设 `WB_COOKIE_SECURE=1`,否则浏览器不回传会话 cookie(表现为反复被弹回登录页)。
|
||||||
|
注意:纯 HTTP 下流量在网内是明文的,同一局域网内的中间人可以看到会话 Cookie 与 Prompt 内容。
|
||||||
|
**反代配置有一个要点**:开启 `WB_TRUST_PROXY=1` 后,程序取 `X-Forwarded-For` 里
|
||||||
|
**最右侧**的合法 IP —— 最右侧是离你最近的那一跳(由你自己的代理写入),客户端加不进去。
|
||||||
|
所以 nginx 写 `$proxy_add_x_forwarded_for`(保留链路,便于排查)或 `$remote_addr`
|
||||||
|
(覆盖)**都安全**;真正不能做的是去信最左边那一段(那是客户端自己填的)。
|
||||||
|
不设反代直接暴露时**必须保持 0**,否则三道 IP 防线全部失效(见 P0-1)。
|
||||||
|
- **没有 CSRF 之外的重放防护 / 没有 WAF**:公网暴露前请置于反向代理的 rate limit 之后。
|
||||||
|
应用层已有采集/导出/恢复等重操作的最小间隔,但那只是「别自己把自己打满」,
|
||||||
|
挡不住分布式来源。
|
||||||
|
- **备份策略只做到「本机自动 + 手动下载」**:程序会按周期打快照、按份数清理、支持下载与恢复,
|
||||||
|
但**不会**把归档推到异地。**备份留在同一台机器上只防「改错了」,不防「机器没了」** ——
|
||||||
|
请自行把归档同步到别处(那是 3-2-1 原则里属于你的那一半)。
|
||||||
|
- **没有邮件/短信找回**:邮箱只是联系信息,不参与认证;密码忘掉由管理员重置。
|
||||||
|
- **不建议在没有任何前置防护时直接暴露到公网**:1.4.0 起 IP 来源、限速、资源上限、
|
||||||
|
采集跨度硬顶都补上了,但设计前提仍是「局域网或 VPN 内使用 + 前面有反代」。
|
||||||
|
- **Cookie 的获取方式由使用者负责**:手动从浏览器复制、**粘贴给自己的账号**。
|
||||||
|
它的权限等同于你的账号,请勿分享给他人;轮换后记得在「配置管理」页更新。
|
||||||
|
- **`cookie_key` 泄露 = 所有 Cookie 泄露**:`data/instance.json` 的权限应与数据库同级看待。
|
||||||
|
**备份归档里也有一份 `instance.json`** —— 归档的保密等级与 `instance.json` 完全相同。
|
||||||
|
- **管理员在运维层面是可信角色**:能登录部署机器的人可以看到数据库文件、应用日志,
|
||||||
|
理论上也能改代码绕过界面限制。所以团队共用时请把「能登服务器」与「日常使用」分开 ——
|
||||||
|
界面层的隔离保护的是**使用者之间**,不是「使用者 vs 服务器管理员」。
|
||||||
|
**管理员还能下载备份与执行恢复** —— 这等于对全库数据的完整读写权,授管理员前请想清楚。
|
||||||
|
|
||||||
|
## 部署前的最小检查清单
|
||||||
|
|
||||||
|
- [ ] **未设 `WB_ADMIN_PASSWORD` 时已从启动日志抄下随机初始口令并改掉**
|
||||||
|
(`docker compose logs portal | grep -A6 管理员初始口令`)—— 现在**没有** `admin123` 兜底
|
||||||
|
- [ ] 已确认是否要开放自助注册;开放时按需调小 `register_max_per_ip`
|
||||||
|
- [ ] `data/`、`logs/`、`backups/` 的权限只对服务账号可读写(前两者与 `instance.json` 同级机密)
|
||||||
|
- [ ] 前面有反向代理并启用了 HTTPS;若是 HTTPS,已设 `WB_COOKIE_SECURE=1`
|
||||||
|
- [ ] **反代是否重写了 `X-Forwarded-For`**:是 → `WB_TRUST_PROXY=1`
|
||||||
|
(`$proxy_add_x_forwarded_for` 或 `$remote_addr` 均可,程序取最右侧);
|
||||||
|
否(含直接暴露)→ **保持 `WB_TRUST_PROXY=0`**。这条搞错会让三道 IP 防线同时失效
|
||||||
|
- [ ] 容器资源上限符合预期(`docker stats` 看 `MEM LIMIT` 是否为 512MiB;`docker inspect` 看 `PidsLimit`)
|
||||||
|
- [ ] 确认 `data/instance.json` 没有被提交到任何仓库
|
||||||
|
- [ ] `backups/` 也未被提交,且已确认 `.gitignore` 生效(`git check-ignore -v backups/x.zip`)
|
||||||
|
- [ ] **确认镜像里没有备份归档**:`docker run --rm <镜像> sh -c 'ls -A /app/backups'` 应为空
|
||||||
|
- [ ] 已规划**异地**备份:程序只负责本机打快照,归档需自行同步到别的机器/对象存储
|
||||||
|
- [ ] 新增的账号一律用**普通角色**;只有确实需要维护实例的人(含**下载备份与恢复**)才给管理员
|
||||||
|
- [ ] 已把「采集最小间隔 / 单次最长跨度 / 每日时刻上限」三个刹车调到符合你的预期
|
||||||
|
(「任务管理」页可改;跨度硬顶 31 天不可突破)
|
||||||
|
- [ ] 升级后登录一次「配置管理」,确认 Cookie 状态为「已配置」而不是
|
||||||
|
「已保存但无法解密」
|
||||||
|
- [ ] 升级到 1.4.0 后确认「任务管理」里调度时刻**对普通账号是只读的**,
|
||||||
|
且访问 `/backups` 返回 403(用普通账号各试一次)
|
||||||
|
- [ ] **升级到 1.5.0 后跑一次 `python tools/smoke.py`**:第 9 节专门验「对外暴露面」
|
||||||
|
(响应头收敛 / 异常不外泄 / 读接口限速 / 安全响应头 / 普通账号拿不到数据源清单)
|
||||||
|
- [ ] 确认容器里进程的落盘权限已收紧:`docker compose exec portal sh -c 'umask'` 应为 `0077`,
|
||||||
|
且 `ls -l /app/data` 的目录权限是 `drwx------`
|
||||||
|
- [ ] 确认从外部看到的响应头里有 `Permissions-Policy`(1.5.0 新增)
|
||||||
|
- [ ] 确认 `--debug` 没有被用在对外地址上:`python manage.py serve --debug` 应当**直接报错退出**
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# 第三方组件与许可声明(Third-Party Notices)
|
||||||
|
|
||||||
|
本项目(WorkBuddy Portal)自身以 [MIT 许可证](LICENSE) 发布。
|
||||||
|
但它**依赖**、并在个别位置**再分发**了若干第三方组件。这些组件的著作权归各自作者所有,
|
||||||
|
其许可条款独立于本项目的 MIT 条款。本文件汇总这些依赖,供合规审查与二次分发时参考。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、运行期依赖(`requirements.txt`)
|
||||||
|
|
||||||
|
这些包不在本仓库内,由使用方安装时获取。
|
||||||
|
|
||||||
|
| 组件 | 版本要求 | 许可证 | 用途 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| [Flask](https://flask.palletsprojects.com/) | `>=3.0` | BSD-3-Clause | Web 框架(路由、Jinja 模板、会话) |
|
||||||
|
| [waitress](https://github.com/Pylons/waitress) | `>=3.0` | ZPL-2.1 | 生产级纯 Python WSGI 服务器 |
|
||||||
|
| [openpyxl](https://openpyxl.readthedocs.io/) | `>=3.1` | MIT | 仅 `manage.py import-xlsx` 读 Excel |
|
||||||
|
|
||||||
|
Python 标准库(`sqlite3`、`urllib`、`http`、`threading` 等)按 PSF-2.0 许可,随 Python 分发。
|
||||||
|
|
||||||
|
> 项目**刻意不依赖** `APScheduler`(调度自实现)与 `requests`(用标准库 `urllib`),
|
||||||
|
> 因此这两者的许可证与本项目无关。
|
||||||
|
|
||||||
|
## 二、随仓库再分发的第三方资源
|
||||||
|
|
||||||
|
这是需要特别注意的一类:文件**物理存在于本仓库中**。
|
||||||
|
|
||||||
|
### Apache ECharts 5.6.0 — Apache License 2.0
|
||||||
|
|
||||||
|
- **位置**:`workbuddy_portal/web/static/dashboard/vendor/echarts.min.js`
|
||||||
|
- **著作权**:Copyright © 2017-2025 Apache Software Foundation 及 ECharts 贡献者
|
||||||
|
- **许可证**:Apache License, Version 2.0(全文见 <https://www.apache.org/licenses/LICENSE-2.0>)
|
||||||
|
- **为何内置**:用量大屏要在局域网内离线可用,不能依赖公网 CDN
|
||||||
|
- **未修改**:文件按官方发行版原样保留,其头部已包含 Apache 许可证声明与版权信息
|
||||||
|
|
||||||
|
按 Apache-2.0 第 4 条要求,再分发时需保留许可证与版权声明——该 `.min.js` 文件头部已自带,
|
||||||
|
本声明构成附加的显著声明。
|
||||||
|
|
||||||
|
> **替换说明**:如需升级,从 <https://echarts.apache.org/> 下载对应版本覆盖同名文件即可,
|
||||||
|
> 大屏页通过 `/static/dashboard/vendor/echarts.min.js` 引用,无需改代码。
|
||||||
|
|
||||||
|
### 项目自有资源
|
||||||
|
|
||||||
|
以下文件由本项目创作,同样按 MIT 发布,**不属第三方**:
|
||||||
|
|
||||||
|
- `workbuddy_portal/web/static/favicon.svg`
|
||||||
|
- `workbuddy_portal/web/static/css/app.css`
|
||||||
|
- `workbuddy_portal/web/static/js/app.js`
|
||||||
|
- `workbuddy_portal/web/static/dashboard/index.html`
|
||||||
|
- `docs/images/*.png`(界面截图,**使用合成示例数据**渲染,见下节)
|
||||||
|
|
||||||
|
## 三、开发期工具(非运行依赖)
|
||||||
|
|
||||||
|
| 组件 | 许可证 | 用途 |
|
||||||
|
|---|---|---|
|
||||||
|
| [Playwright for Python](https://playwright.dev/python/) | Apache-2.0 | `tools/shots.py` 登录后逐页截图 |
|
||||||
|
| [Pillow](https://python-pillow.org/) | MIT-CMU | 人工压缩文档配图时使用,未入库 |
|
||||||
|
|
||||||
|
这些工具**不会**被打进 Docker 运行镜像的依赖里,也不影响部署方的义务。
|
||||||
|
|
||||||
|
## 四、文档与截图中的数据
|
||||||
|
|
||||||
|
`docs/images/` 下的界面截图与 `docs/` 中的 JSON 示例**全部使用合成数据**,
|
||||||
|
由 `tools/demo_data.py` 生成:模型名统一为 `demo-*`,客户端为 `vscode`/`webconsole`/`sdk`,
|
||||||
|
Prompt 为通用示例文本,审计 IP 取自 RFC 5737 的文档专用网段(`192.0.2.0/24`)。
|
||||||
|
**不含任何真实账号、真实用量或第三方受版权保护的内容。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、合规自查清单
|
||||||
|
|
||||||
|
二次分发或商用前,建议逐项确认:
|
||||||
|
|
||||||
|
- [ ] `LICENSE` 与本文档随发行物一并提供
|
||||||
|
- [ ] `vendor/echarts.min.js` 的头部许可证声明未被剥离或压缩掉
|
||||||
|
- [ ] 若替换了 ECharts,同步更新本文档中的版本号
|
||||||
|
- [ ] 若新增了第三方文件到仓库,在此登记其许可证
|
||||||
|
- [ ] 若将本项目的界面截图用于宣传,确认其中不含真实业务数据
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# backups/ — 数据库备份存放处
|
||||||
|
|
||||||
|
人工或脚本产生的数据库快照统一放这里。**不要放进 `data/`**,原因见下。
|
||||||
|
|
||||||
|
## 为什么备份不放在 `data/`
|
||||||
|
|
||||||
|
`data/` 是运行时数据目录,在 Docker 部署下会被挂载成卷(`workbuddy-portal_wb_data`)。
|
||||||
|
|
||||||
|
- 备份放进 `data/` → 执行 `docker compose down -v` 或重建卷时,**备份会跟着正本一起被删掉**,
|
||||||
|
这正好是最需要备份的那一刻。
|
||||||
|
- 备份放在仓库目录下的 `backups/` → 卷重建不影响它,同时离源码够近、搬家不丢。
|
||||||
|
|
||||||
|
`backups/` 下的一切都被 `.gitignore` 忽略(见仓库根 `.gitignore` 的「数据库备份」段),
|
||||||
|
**绝不要提交**:快照里含 `settings` 表的加密凭证密文与 `users` 表的密码哈希。
|
||||||
|
|
||||||
|
## 目录内容
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `usage.sqlite.bak-pre-v13` | v1.2.0 → v1.3.0 迁移(`DB_SCHEMA_VERSION` 2 → 3)前的快照。保留用意:迁移同时做了「配置作用域收敛」(把调度与采集参数从个人级提升到实例级 `user_id=0`),万一收敛结果不符合预期,可回滚到这份 uv=2 的库重来。 |
|
||||||
|
|
||||||
|
### 校验记录(2026-09-16)
|
||||||
|
|
||||||
|
```
|
||||||
|
integrity_check : ok
|
||||||
|
user_version : 2
|
||||||
|
usage_records : 1665 条
|
||||||
|
SUM(credits) : 8513.36
|
||||||
|
```
|
||||||
|
|
||||||
|
迁移后正本 `data/usage.sqlite`(uv=3)同样是 **1665 条 / 8513.36 积分**,零丢失。
|
||||||
|
|
||||||
|
> **快照本身是自包含的**:全部数据都在主文件里(`-wal` 为 0 字节,没有未落盘的提交帧)。
|
||||||
|
>
|
||||||
|
> 但要注意一个会反复出现的现象:**只要有人以 WAL 模式打开过这份快照,
|
||||||
|
> SQLite 就会就地重建 `…-wal` / `…-shm` 两个侧车文件** —— 连只读打开也会
|
||||||
|
> (SQLite 需要 `-shm` 做锁表)。所以「移走一次」不是长久之计,
|
||||||
|
> 校验命令里加 `immutable=1` 才是根治(告诉 SQLite 这个文件不会变,不必建锁表)。
|
||||||
|
>
|
||||||
|
> 侧车只是运行时缓存,`backups/` 已在 `.gitignore` 里被整体忽略,
|
||||||
|
> 不会误入库。归档或搬运快照前把两个侧车移走即可,前提是先确认 `-wal` 是 0 字节。
|
||||||
|
|
||||||
|
## 怎么用
|
||||||
|
|
||||||
|
**校验一份备份是否可用**(`immutable=1` = 只读且不建锁表,**不会改动也不会污染快照**):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -c "
|
||||||
|
import sqlite3
|
||||||
|
c = sqlite3.connect('file:backups/usage.sqlite.bak-pre-v13?immutable=1', uri=True)
|
||||||
|
print('integrity:', c.execute('PRAGMA integrity_check').fetchone()[0])
|
||||||
|
print('user_version:', c.execute('PRAGMA user_version').fetchone()[0])
|
||||||
|
print('records:', c.execute('SELECT COUNT(*) FROM usage_records').fetchone()[0])
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
> 备份文件的**唯一权威判据**是 `integrity_check` 与行数,别拿 `sha256` 当验签 ——
|
||||||
|
> 快照落盘后再被 SQLite 干净关闭过一次,主文件字节可能变化而内容完全一致。
|
||||||
|
> 校验只需 `sqlite3` 标准库,用你跑服务的**同一个**解释器即可。
|
||||||
|
|
||||||
|
**回滚一份备份**:停掉服务 → 备份当前正本 → 把快照覆盖回 `data/usage.sqlite`
|
||||||
|
→ **同时删除 `data/usage.sqlite-wal` 与 `data/usage.sqlite-shm`**(残留的 WAL 会让 SQLite 读到旧状态)
|
||||||
|
→ 启动服务 → 跑 `python manage.py status` 与 `python manage.py stats` 确认账号与存档条数。
|
||||||
|
|
||||||
|
> 回滚前务必确认目标库的 `user_version` 与服务端 `workbuddy_portal/db.py` 的
|
||||||
|
> `DB_SCHEMA_VERSION` 兼容:回滚到更老的版本号时,服务会在下次启动时重跑迁移。
|
||||||
|
|
||||||
|
## 自动备份(可选)
|
||||||
|
|
||||||
|
容器部署下推荐用宿主机的 cron 做,**先落盘再压缩**,避免 SQLite 在线拷贝产生撕裂快照:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 每天 03:30,用 SQLite 自带的一致性备份命令(不是 cp!)
|
||||||
|
docker compose exec -T portal python -c "
|
||||||
|
import sqlite3
|
||||||
|
src = sqlite3.connect('/app/data/usage.sqlite')
|
||||||
|
dst = sqlite3.connect('/tmp/wb-backup.sqlite')
|
||||||
|
src.backup(dst); dst.close(); src.close()
|
||||||
|
"
|
||||||
|
docker compose cp portal:/tmp/wb-backup.sqlite \
|
||||||
|
"./backups/usage-$(date +%Y%m%d).sqlite"
|
||||||
|
docker compose exec -T portal rm -f /tmp/wb-backup.sqlite
|
||||||
|
```
|
||||||
|
|
||||||
|
`cp` 一个正在被写入的 SQLite 文件可能拿到半截事务;`sqlite3.Connection.backup()`
|
||||||
|
走的是官方的在线备份 API,能保证快照一致。手工离线拷贝时才可以直接 `cp`。
|
||||||
|
|
||||||
|
## 清理策略
|
||||||
|
|
||||||
|
保留最近 7 份日备 + 每份迁移前快照。旧的直接删,别在这里堆积——
|
||||||
|
`backups/` 是安全网,不是归档;数据正本永远只有 `data/usage.sqlite` 一个。
|
||||||
@@ -19,3 +19,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ${WB_HOST_DATA_DIR:-./data}:/app/data
|
- ${WB_HOST_DATA_DIR:-./data}:/app/data
|
||||||
- ${WB_HOST_LOG_DIR:-./logs}:/app/logs
|
- ${WB_HOST_LOG_DIR:-./logs}:/app/logs
|
||||||
|
# 备份同样落到宿主机目录(默认就是仓库里的 ./backups)。
|
||||||
|
# 叠加后 `wb_backups` 那个命名卷变成没人用的空卷,可以随手删掉:
|
||||||
|
# docker volume rm workbuddy-portal_wb_backups
|
||||||
|
- ${WB_HOST_BACKUP_DIR:-./backups}:/app/backups
|
||||||
|
|||||||
@@ -28,6 +28,28 @@ services:
|
|||||||
container_name: workbuddy-portal
|
container_name: workbuddy-portal
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
init: true # tini 接管 PID 1:docker stop 能干净地传到 python
|
init: true # tini 接管 PID 1:docker stop 能干净地传到 python
|
||||||
|
|
||||||
|
# ---- 容器资源上限(对外提供服务时的第一道闸门)----
|
||||||
|
# 应用层已经做了「采集频率 / 并发 / 跨度」三重限制,但那些是**业务**刹车;
|
||||||
|
# 这里限制的是**进程**本身能吃掉多少宿主机资源 —— 两者都要有:
|
||||||
|
# 业务刹车管「正常的重活别做太多」,容器上限管的是一次异常(内存泄漏、
|
||||||
|
# 正则回溯、某次超大导出)能不能把整台机器带下去。
|
||||||
|
#
|
||||||
|
# 单写者架构下**不要**靠加副本扛负载,所以「限制单实例资源 + 限制单账号
|
||||||
|
# 频率」才是正解,而不是横向扩展(见文件头的设计取舍说明)。
|
||||||
|
cpus: "${WB_CPUS:-1.0}" # 1 核:SQLite 单写者,多给核也并行不起来
|
||||||
|
mem_limit: "${WB_MEM_LIMIT:-512m}"
|
||||||
|
memswap_limit: "${WB_MEM_LIMIT:-512m}" # 与 mem_limit 相等 = 禁用 swap,
|
||||||
|
# 否则内存超限会悄悄滑进 swap,
|
||||||
|
# 表现为「越来越慢」而不是「被 OOM 杀掉」
|
||||||
|
pids_limit: ${WB_PIDS_LIMIT:-256} # 挡 fork 炸弹
|
||||||
|
# 文件句柄:SQLite 会在主库之外再持有 -wal / -shm,备份快照与恢复期间
|
||||||
|
# 还要同时开临时库 + ATTACH 源库,默认 1024 在高并发下偏紧。
|
||||||
|
ulimits:
|
||||||
|
nofile:
|
||||||
|
soft: 4096
|
||||||
|
hard: 8192
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- "${WB_BIND:-0.0.0.0}:${WB_PORT:-8848}:8848"
|
- "${WB_BIND:-0.0.0.0}:${WB_PORT:-8848}:8848"
|
||||||
environment:
|
environment:
|
||||||
@@ -35,13 +57,37 @@ services:
|
|||||||
WB_HOST: 0.0.0.0
|
WB_HOST: 0.0.0.0
|
||||||
WB_PORT: "8848"
|
WB_PORT: "8848"
|
||||||
WB_ADMIN_USER: ${WB_ADMIN_USER:-admin}
|
WB_ADMIN_USER: ${WB_ADMIN_USER:-admin}
|
||||||
|
# 留空时程序会生成**随机**口令并只在启动日志里打印一次(不再有 admin123 兜底),
|
||||||
|
# 所以首次部署要去 `docker compose logs portal | grep 口令` 抄一次。
|
||||||
WB_ADMIN_PASSWORD: ${WB_ADMIN_PASSWORD:-}
|
WB_ADMIN_PASSWORD: ${WB_ADMIN_PASSWORD:-}
|
||||||
WB_DISABLE_SCHEDULER: ${WB_DISABLE_SCHEDULER:-0}
|
WB_DISABLE_SCHEDULER: ${WB_DISABLE_SCHEDULER:-0}
|
||||||
|
# ---- 反向代理与传输安全(三个必须一起决定,别只改一个)----
|
||||||
|
# WB_TRUST_PROXY:默认 0 = 不信任 X-Forwarded-For。
|
||||||
|
# 直接暴露给公网时必须留 0 —— 否则攻击者每次换一个伪造的 XFF,
|
||||||
|
# 验证码限速 / 注册配额 / 登录锁定三道 IP 防线会同时失效。
|
||||||
|
# 置 1 的前提:**你自己的**反代会写这个头。开启后程序取 XFF 里
|
||||||
|
# **最右侧**的合法 IP(最近一跳由你的代理写入,客户端加不进去),
|
||||||
|
# 所以 nginx 写 $proxy_add_x_forwarded_for(保留链路,便于排查)或
|
||||||
|
# 写 $remote_addr(覆盖)都可以 —— 关键是别去信最左边那段。
|
||||||
|
WB_TRUST_PROXY: ${WB_TRUST_PROXY:-0}
|
||||||
|
# 前面挂了 HTTPS 反代时置 1:读到 X-Forwarded-Proto: https 就不跳转
|
||||||
|
WB_FORCE_HTTPS: ${WB_FORCE_HTTPS:-0}
|
||||||
|
# 会话 Cookie 是否只走 HTTPS。纯 HTTP 部署必须留 0:设成 1 时浏览器
|
||||||
|
# 不会回传会话 Cookie,表现为「登录成功又立刻跳回登录页」。改它要 up -d(环境变量)。
|
||||||
|
WB_COOKIE_SECURE: ${WB_COOKIE_SECURE:-0}
|
||||||
|
# 访问日志(waitress 自己不记 access log,出事无据可查时很要命)
|
||||||
|
WB_ACCESS_LOG: ${WB_ACCESS_LOG:-1}
|
||||||
|
# waitress 线程数 = 单实例能同时吃进几个慢请求(采集/导出/备份恢复)
|
||||||
|
WB_THREADS: ${WB_THREADS:-4}
|
||||||
|
WB_BACKUP_DIR: /app/backups
|
||||||
WB_IMPORT_CREDS: ${WB_IMPORT_CREDS:-0}
|
WB_IMPORT_CREDS: ${WB_IMPORT_CREDS:-0}
|
||||||
WB_IMPORT_XLSX: ${WB_IMPORT_XLSX:-}
|
WB_IMPORT_XLSX: ${WB_IMPORT_XLSX:-}
|
||||||
volumes:
|
volumes:
|
||||||
- wb_data:/app/data # 数据正本 + 导出 + secret_key
|
- wb_data:/app/data # 数据正本 + 导出 + secret_key
|
||||||
- wb_logs:/app/logs # 应用日志(滚动 2 MB × 3)
|
- wb_logs:/app/logs # 应用日志(滚动 2 MB × 3)
|
||||||
|
# 备份**单独一个卷**:备份与正本同卷时,一次 `down -v` 或卷损坏会把
|
||||||
|
# 两者一起带走 —— 那正是最需要备份的时刻。分开挂才有意义。
|
||||||
|
- wb_backups:/app/backups
|
||||||
# 可选:把编辑器配置挂进来,配合 WB_IMPORT_CREDS=1 自动接管 cookie
|
# 可选:把编辑器配置挂进来,配合 WB_IMPORT_CREDS=1 自动接管 cookie
|
||||||
# - ${WB_EDITOR_SETTINGS:-./nonexistent.json}:/mnt/editor-settings.json:ro
|
# - ${WB_EDITOR_SETTINGS:-./nonexistent.json}:/mnt/editor-settings.json:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -59,3 +105,8 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
wb_data:
|
wb_data:
|
||||||
wb_logs:
|
wb_logs:
|
||||||
|
# 备份卷。**必须与 wb_data 分开** —— 同卷时 `down -v` 会把正本与备份一起删。
|
||||||
|
# 想让它跟着正本走(比如整套搬到别的机器)就先 `docker run --rm -v
|
||||||
|
# workbuddy-portal_wb_backups:/b -v $PWD/backups:/o alpine cp -a /b/. /o/`
|
||||||
|
# 把归档捞到宿主机目录里,再一起搬。
|
||||||
|
wb_backups:
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
# Copyright (c) 2026 Wang Chuanli
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# workbuddy-portal 容器入口
|
# workbuddy-portal 容器入口
|
||||||
# 1) 幂等初始化数据库(建表 + 默认配置 + 首个管理员)
|
# 1) 幂等初始化数据库(建表 + 默认配置 + 首个管理员)
|
||||||
@@ -15,14 +18,19 @@ PORT="${WB_PORT:-8848}"
|
|||||||
|
|
||||||
log() { echo "[entrypoint] $*"; }
|
log() { echo "[entrypoint] $*"; }
|
||||||
|
|
||||||
log "workbuddy-portal 启动:data=${WB_DATA_DIR:-/app/data} logs=${WB_LOG_DIR:-/app/logs} 监听 ${HOST}:${PORT} TZ=${TZ:-未设置}"
|
log "workbuddy-portal 启动:data=${WB_DATA_DIR:-/app/data} logs=${WB_LOG_DIR:-/app/logs} backups=${WB_BACKUP_DIR:-/app/backups} 监听 ${HOST}:${PORT} TZ=${TZ:-未设置}"
|
||||||
|
|
||||||
# ---------- 1. 初始化(幂等:users 非空时不会重建管理员)----------
|
# ---------- 1. 初始化(幂等:users 非空时不会重建管理员)----------
|
||||||
|
# 口令策略(1.4.0 起):WB_ADMIN_PASSWORD 为空时**没有**默认口令,
|
||||||
|
# manage.py init 会生成一个随机口令并只打印一次 —— 抄下来,否则进不去。
|
||||||
if [ -n "${ADMIN_PASSWORD}" ]; then
|
if [ -n "${ADMIN_PASSWORD}" ]; then
|
||||||
python manage.py init --user "${ADMIN_USER}" --password "${ADMIN_PASSWORD}"
|
python manage.py init --user "${ADMIN_USER}" --password "${ADMIN_PASSWORD}"
|
||||||
else
|
else
|
||||||
|
log "未设置 WB_ADMIN_PASSWORD。若这是首次初始化,下面「管理员初始口令」一行就是"
|
||||||
|
log " 唯一的获取机会(程序不保存明文,库里只有散列,找不回来):"
|
||||||
|
log " docker compose logs portal | grep -A2 '管理员初始口令'"
|
||||||
|
log " 已在运行过一次的实例上,这一行不会再出现(管理员早就建好了)。"
|
||||||
python manage.py init --user "${ADMIN_USER}"
|
python manage.py init --user "${ADMIN_USER}"
|
||||||
log "未设置 WB_ADMIN_PASSWORD —— 首次部署的默认密码是 admin123,请登录后立刻修改"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ---------- 2. 可选:从挂载进来的 VSCode/Cursor/Trae settings.json 接管 cookie 与 UA ----------
|
# ---------- 2. 可选:从挂载进来的 VSCode/Cursor/Trae settings.json 接管 cookie 与 UA ----------
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
# Copyright (c) 2026 Wang Chuanli
|
||||||
|
|
||||||
"""容器健康检查。
|
"""容器健康检查。
|
||||||
|
|
||||||
只用标准库:slim 镜像里没有 curl。`/login` 是唯一免登录页面,拿到 200 即认为
|
只用标准库:slim 镜像里没有 curl。`/login` 是唯一免登录页面,拿到 200 即认为
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
|
|
||||||
| 项 | 说明 |
|
| 项 | 说明 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 认证 | 全部需要登录。`/api/*` 未登录返回 **401** JSON(页面则跳登录页) |
|
| 认证 | 全部需要登录。`/api/*` 未登录返回 **401** JSON(页面则跳登录页)。唯一例外是 `/captcha.png` |
|
||||||
| CSRF | **写接口**(`POST`)需带 `X-CSRF-Token` 头,或表单域 `_csrf`;缺失 / 错误返回 **400** |
|
| **数据作用域** | **所有数据接口只返回当前登录账号的数据**。`user_id` 由服务端会话决定,**不接受客户端传入** —— 传 `?user_id=1` 会被忽略 |
|
||||||
|
| CSRF | **写接口**(`POST`)需带 `X-CSRF-Token` 头,或表单域 `_csrf`;缺失 / 错误返回 **400**。未登录的 POST 也会先被 CSRF 拦成 400(这比先鉴权更保守) |
|
||||||
| 日期参数 | `from` / `to`,`YYYY-MM-DD`。也容忍 `YYYY/MM/DD`、带时间的写法;起止写反会自动交换 |
|
| 日期参数 | `from` / `to`,`YYYY-MM-DD`。也容忍 `YYYY/MM/DD`、带时间的写法;起止写反会自动交换 |
|
||||||
| 非法参数 | 无法识别时返回 **400** 且带人话说明(如 `参数 from 不是合法日期:abc(正确写法 2026-09-08)`),**不会 500** |
|
| 非法参数 | 无法识别时返回 **400** 且带人话说明(如 `参数 from 不是合法日期:abc(正确写法 2026-09-08)`),**不会 500** |
|
||||||
| 分页 | `page`(默认 1)+ `size`(默认 50,上限 500) |
|
| 分页 | `page`(默认 1)+ `size`(默认 50,上限 500) |
|
||||||
@@ -27,6 +28,8 @@
|
|||||||
| `forbidden` | 403 | 已登录但权限不足 |
|
| `forbidden` | 403 | 已登录但权限不足 |
|
||||||
| `not_found` | 404 | 接口或资源不存在 |
|
| `not_found` | 404 | 接口或资源不存在 |
|
||||||
| `busy` | 409 | 已有采集在跑(单写者约束) |
|
| `busy` | 409 | 已有采集在跑(单写者约束) |
|
||||||
|
| `no_cookie` | 409 | 本账号还没配 Cookie,无法采集 |
|
||||||
|
| `cookie_broken` | 409 | Cookie 密文解不开(`cookie_key` 换过),需重新粘贴 |
|
||||||
| `cookie_expired` | 401 | 云端 Cookie 失效,需去「配置管理」更新 |
|
| `cookie_expired` | 401 | 云端 Cookie 失效,需去「配置管理」更新 |
|
||||||
| `api` | 502 | 云端接口异常 |
|
| `api` | 502 | 云端接口异常 |
|
||||||
| `internal` | 500 | 服务端异常 |
|
| `internal` | 500 | 服务端异常 |
|
||||||
@@ -47,16 +50,16 @@
|
|||||||
"producer": "workbuddy-portal(Flask + SQLite)",
|
"producer": "workbuddy-portal(Flask + SQLite)",
|
||||||
"note": "...",
|
"note": "...",
|
||||||
"totals": {
|
"totals": {
|
||||||
"records": 1665, "credits": 8513.36, "calls": 1086,
|
"records": 944, "credits": 4961.63, "calls": 944,
|
||||||
"freeCalls": 579, "billableCalls": 507,
|
"freeCalls": 328, "billableCalls": 616,
|
||||||
"models": 12, "clients": 3,
|
"models": 7, "clients": 3,
|
||||||
"first": "2026-08-10 00:00:00", "last": "2026-09-14 14:51:00",
|
"first": "2026-08-16 09:12:00", "last": "2026-09-14 15:52:00",
|
||||||
"topCredits": 319.5
|
"topCredits": 412.8
|
||||||
},
|
},
|
||||||
"months": ["2026-08", "2026-09"],
|
"months": ["2026-08", "2026-09"],
|
||||||
"sources": [{ "path": "usage.sqlite", "role": "primary", "count": 1665, "bytes": 1234567 }],
|
"sources": [{ "path": "usage.sqlite", "role": "primary", "count": 944, "bytes": 434176 }],
|
||||||
"focusDay": "2026-09-14",
|
"focusDay": "2026-09-14",
|
||||||
"health": { "cookie": true, "lastRunAt": "2026-09-14 14:51:28", "lastRunStatus": "ok" }
|
"health": { "cookie": true, "lastRunAt": "2026-09-14 15:52:10", "lastRunStatus": "ok" }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -73,14 +76,14 @@
|
|||||||
{
|
{
|
||||||
"manifest": { ... 同上 ... },
|
"manifest": { ... 同上 ... },
|
||||||
"daily": [
|
"daily": [
|
||||||
{ "d": "2026-09-08", "c": 1423.5, "k": 88, "fc": 40, "bc": 48,
|
{ "d": "2026-09-08", "c": 168.4, "k": 31, "fc": 12, "bc": 19,
|
||||||
"m": { "deepseek-v4-flash": 800.2, "glm-5.3-flash": 623.3 },
|
"m": { "demo-flash": 62.1, "demo-pro": 41.7 },
|
||||||
"h": [0,0,0,0,0,0,0,0,0,12.5, ...] }
|
"h": [0,0,0,0,0,0,0,0,0,12.5, ...] }
|
||||||
],
|
],
|
||||||
"dims": { "model": [...], "client": [...], "hour": [...] },
|
"dims": { "model": [...], "client": [...], "hour": [...] },
|
||||||
"top": [ { "id": "...", "c": 319.5, "m": "kimi-k3-1", "cl": "VSCode", "t": "2026-09-12 15:04:00", "px": "摘要…" } ],
|
"top": [ { "id": "...", "c": 412.8, "m": "demo-reason", "cl": "vscode", "t": "2026-09-12 15:04:00", "px": "摘要…" } ],
|
||||||
"records": [ { "id": "...", "c": 5.78, "m": "...", "cl": "...", "t": "...", "px": "..." } ],
|
"records": [ { "id": "...", "c": 5.78, "m": "...", "cl": "...", "t": "...", "px": "..." } ],
|
||||||
"recordsTotal": 1665,
|
"recordsTotal": 944,
|
||||||
"recordsCap": 20000,
|
"recordsCap": 20000,
|
||||||
"recordsTruncated": false,
|
"recordsTruncated": false,
|
||||||
"totals": { ... },
|
"totals": { ... },
|
||||||
@@ -106,13 +109,13 @@ KPI + 环比。`from`/`to` 缺省时自动取全量区间。
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"records": 428, "credits": 2145.6, "calls": 300,
|
"records": 226, "credits": 1180.4, "calls": 226,
|
||||||
"freeCalls": 120, "billableCalls": 180,
|
"freeCalls": 78, "billableCalls": 148,
|
||||||
"firstDay": "2026-09-08", "lastDay": "2026-09-14", "days": 7,
|
"firstDay": "2026-09-08", "lastDay": "2026-09-14", "days": 7,
|
||||||
"models": 9, "clients": 2,
|
"models": 7, "clients": 3,
|
||||||
"first": "...", "last": "...",
|
"first": "...", "last": "...",
|
||||||
"window": { "from": "2026-09-08", "to": "2026-09-14", "days": 7 },
|
"window": { "from": "2026-09-08", "to": "2026-09-14", "days": 7 },
|
||||||
"avgPerCall": 7.15,
|
"avgPerCall": 5.22,
|
||||||
"prev": { ... 上一段等长窗口的同样结构 ... },
|
"prev": { ... 上一段等长窗口的同样结构 ... },
|
||||||
"delta": { "credits": 12.3, "calls": -4.1, "window": { "from": "...", "to": "..." } },
|
"delta": { "credits": 12.3, "calls": -4.1, "window": { "from": "...", "to": "..." } },
|
||||||
"partial": { "date": "2026-09-14", "hhmm": "14:52" }
|
"partial": { "date": "2026-09-14", "hhmm": "14:52" }
|
||||||
@@ -132,7 +135,7 @@ KPI + 环比。`from`/`to` 缺省时自动取全量区间。
|
|||||||
| `from` / `to` | 筛选窗口 |
|
| `from` / `to` | 筛选窗口 |
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "days": [ { "d": "2026-09-08", "c": 1423.5, "k": 88, "fc": 40, "bc": 48,
|
{ "days": [ { "d": "2026-09-08", "c": 168.4, "k": 31, "fc": 12, "bc": 19,
|
||||||
"m": {...}, "h": [24 个元素] } ] }
|
"m": {...}, "h": [24 个元素] } ] }
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -146,8 +149,8 @@ KPI + 环比。`from`/`to` 缺省时自动取全量区间。
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": [ { "name": "deepseek-v4-flash", "credits": 3784.86, "calls": 234, "avg": 16.17, "free": 0 } ],
|
"model": [ { "name": "demo-flash", "credits": 1286.4, "calls": 252, "avg": 5.1, "free": 0 } ],
|
||||||
"client": [ { "name": "VSCode", ... } ],
|
"client": [ { "name": "vscode", ... } ],
|
||||||
"hour": [ { "name": "14", ... } ]
|
"hour": [ { "name": "14", ... } ]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -162,7 +165,7 @@ KPI + 环比。`from`/`to` 缺省时自动取全量区间。
|
|||||||
| `n` | 50 | 返回条数(1~1000) |
|
| `n` | 50 | 返回条数(1~1000) |
|
||||||
|
|
||||||
```json
|
```json
|
||||||
[ { "id": "…", "c": 319.5, "m": "kimi-k3-1", "cl": "VSCode",
|
[ { "id": "…", "c": 412.8, "m": "demo-reason", "cl": "vscode",
|
||||||
"t": "2026-09-12 15:04:00", "px": "截断后的 Prompt 摘要" } ]
|
"t": "2026-09-12 15:04:00", "px": "截断后的 Prompt 摘要" } ]
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -184,7 +187,7 @@ KPI + 环比。`from`/`to` 缺省时自动取全量区间。
|
|||||||
{
|
{
|
||||||
"items": [ { "request_id": "…", "credits": 5.78, "model": "…",
|
"items": [ { "request_id": "…", "credits": 5.78, "model": "…",
|
||||||
"client": "…", "ts": "2026-09-14 14:20:00", "prompt": "…" } ],
|
"client": "…", "ts": "2026-09-14 14:20:00", "prompt": "…" } ],
|
||||||
"total": 1665, "page": 1, "size": 50, "pages": 34,
|
"total": 944, "page": 1, "size": 50, "pages": 19,
|
||||||
"window": { "from": "...", "to": "..." }
|
"window": { "from": "...", "to": "..." }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -211,10 +214,19 @@ KPI + 环比。`from`/`to` 缺省时自动取全量区间。
|
|||||||
"scheduler": { "enabled": true, "times": ["09:00","17:00"], "next": "2026-09-14 17:00:00" },
|
"scheduler": { "enabled": true, "times": ["09:00","17:00"], "next": "2026-09-14 17:00:00" },
|
||||||
"running_runs": 0,
|
"running_runs": 0,
|
||||||
"last_run": { "id": 8, "trigger": "startup", "status": "ok", "started_at": "...", "message": "..." },
|
"last_run": { "id": 8, "trigger": "startup", "status": "ok", "started_at": "...", "message": "..." },
|
||||||
"cookie_set": true
|
"cookie_set": true,
|
||||||
|
"is_admin": false,
|
||||||
|
"can_edit_schedule": false,
|
||||||
|
"can_view_logs": false
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 最后三个字段是**前端显隐的依据**(v1.3.0 起)。
|
||||||
|
> 调度时刻是**实例级**的 —— 普通账号拿到 `can_edit_schedule: false`,
|
||||||
|
> 页面据此把「采集调度」渲染成只读表格,而不是给一个点了会被拒的表单。
|
||||||
|
> 大屏是拿不到 Jinja 上下文的静态页,只能靠 `/api/manifest` 的 `role`
|
||||||
|
> 或这里的字段决定要不要显示「日志管理」入口。
|
||||||
|
|
||||||
### GET `/api/audit`
|
### GET `/api/audit`
|
||||||
|
|
||||||
操作审计分页。
|
操作审计分页。
|
||||||
@@ -235,29 +247,93 @@ KPI + 环比。`from`/`to` 缺省时自动取全量区间。
|
|||||||
|
|
||||||
### GET `/api/settings`
|
### GET `/api/settings`
|
||||||
|
|
||||||
读配置。**`cookie` 只回掩码**,绝不回明文;内部簿记键(`slot:*`)不返回。
|
读**当前登录账号的有效配置**。返回的是一个**扁平字典**:配置键 → 值。
|
||||||
|
|
||||||
|
> **凭证只回掩码**:`cookie` 这个键固定为空串,真正的状态在 `cookie_hint` / `cookie_broken`;
|
||||||
|
> 内部簿记键(`slot:*`)根本不返回。
|
||||||
|
> 下例中的数字与尾号都是合成示例数据,不是任何真实实例的值。
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"values": { "page_size": "200", "schedule_times": "09:00,17:00", ... },
|
"api_base": "https://www.workbuddy.cn",
|
||||||
"cookie_hint": "4054 字符,…09db9a660825",
|
"api_path": "/billing/meter/get-user-request-usage",
|
||||||
"num_settings": { "page_size": [20, 1000, "条/页"], ... },
|
"page_size": "200",
|
||||||
"bool_settings": ["schedule_enabled", "catch_up"]
|
"schedule_times": "09:00,17:00",
|
||||||
|
"ssl_verify": "1",
|
||||||
|
"cookie": "",
|
||||||
|
"cookie_hint": "92 字符,…c0ffee",
|
||||||
|
"cookie_broken": false,
|
||||||
|
"user_agent": "Mozilla/5.0 (...)",
|
||||||
|
"_globalKeys": ["allow_register", "api_base", "api_path", "captcha_length",
|
||||||
|
"captcha_policy", "catch_up", "catch_up_grace_hours",
|
||||||
|
"drift_tolerance_minutes", "max_prompt", "page_size",
|
||||||
|
"register_max_per_ip", "rewind_minutes", "schedule_enabled",
|
||||||
|
"schedule_times", "ssl_verify", "timeout", "verify_days"],
|
||||||
|
"_userKeys": ["cookie", "user_agent"],
|
||||||
|
"_canEditGlobal": true,
|
||||||
|
"_role": "user"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| 各配置键 | 有效值(`个人 → 实例 → DEFAULTS` 三级回落后的结果) |
|
||||||
|
| `cookie` | **恒为空串** —— `db.get_settings()` 统一置空,明文只能经 `db.get_secret()` 取 |
|
||||||
|
| `cookie_hint` | 「N 字符,…尾 4 位」;未配置时为空串 |
|
||||||
|
| `cookie_broken` | `true` 表示密文解不开(`cookie_key` 换过),需重新粘贴 Cookie |
|
||||||
|
| `_globalKeys` | **实例级**键清单(所有账号共用一份,只有管理员能改)。v1.3.0 起含调度与采集参数 |
|
||||||
|
| `_userKeys` | **个人级**键清单,即 `config.USER_EDITABLE_KEYS`;普通账号唯一能写的两个键 |
|
||||||
|
| `_canEditGlobal` | 当前账号能否改 `_globalKeys` 里的键 |
|
||||||
|
| `_role` | `"admin"` / `"user"` —— 前端据此决定显隐(大屏走 `/api/manifest` 的 `role`) |
|
||||||
|
|
||||||
|
> **写权限就一条规则**:`config.writable_by(key, is_admin)`。
|
||||||
|
> 页面上「哪些输入框可以改」与接口「哪些键能写」用的是同一个函数,
|
||||||
|
> 所以不会出现「界面置灰但接口还能写」的不一致。
|
||||||
|
|
||||||
### GET `/api/users`(管理员)
|
### GET `/api/users`(管理员)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "items": [ { "id": 1, "username": "admin", "display_name": "管理员",
|
{ "items": [ { "id": 1, "username": "admin", "display_name": "管理员", "email": null,
|
||||||
"is_admin": 1, "created_at": "...", "last_login_at": "...", "login_count": 12 } ] }
|
"is_admin": 1, "status": "active", "created_at": "...",
|
||||||
|
"last_login_at": "...", "last_login_ip": "192.0.2.10", "login_count": 12 } ] }
|
||||||
```
|
```
|
||||||
|
|
||||||
> **口令散列永不出现在响应里。**
|
> **口令散列永不出现在响应里**(`_user_public()` 只挑安全字段)。
|
||||||
|
|
||||||
### GET `/logs/tail`
|
### POST `/api/profile`
|
||||||
|
|
||||||
应用日志尾部。
|
改**自己**的显示名与邮箱。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "display_name": "新显示名", "email": "me@example.com" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST `/api/captcha`
|
||||||
|
|
||||||
|
验证码机制的**自述**,便于排障时自检(不需要猜当前策略是什么)。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "policy": "always", "length": 4, "ttl_seconds": 300,
|
||||||
|
"image_url": "/captcha.png",
|
||||||
|
"note": "答案只存在服务端 captchas 表;一次性使用,校验后立即删除。" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET `/captcha.png`
|
||||||
|
|
||||||
|
**唯一不需要登录的接口**,返回一张 PNG 图形验证码。
|
||||||
|
|
||||||
|
| 参数 | 默认 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `purpose` | `login` | `login` \| `register`;其它值一律收敛为 `login`(不会 500) |
|
||||||
|
|
||||||
|
- 响应头带 `Cache-Control: no-store`(缓存旧图会导致「图没变但怎么输都错」);
|
||||||
|
- 每来源 60 秒最多 40 张,超限返回 **429**;
|
||||||
|
- **答案不会出现在响应里,也不会出现在任何页面源码或会话中** ——
|
||||||
|
服务端只把随机 id 写进会话(`cap_login` / `cap_register`),答案留在 `captchas` 表。
|
||||||
|
|
||||||
|
### GET `/logs/tail`(**仅管理员**)
|
||||||
|
|
||||||
|
应用日志尾部。普通账号访问返回 **403**(账号自己的采集日志请用 `/api/runs`)。
|
||||||
|
|
||||||
| 参数 | 默认 | 说明 |
|
| 参数 | 默认 | 说明 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -292,22 +368,27 @@ KPI + 环比。`from`/`to` 缺省时自动取全量区间。
|
|||||||
|
|
||||||
| 场景 | 响应 |
|
| 场景 | 响应 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 成功 | `200 {"ok": true, "result": {"message": "新增 11 条,重复 6 条,存档共 1665 条", ...}}` |
|
| 成功 | `200 {"ok": true, "result": {"message": "新增 11 条,重复 6 条,存档共 944 条", ...}}` |
|
||||||
| 已有采集在跑 | `409 {"ok": false, "error": "busy", "message": "..."}` |
|
| 已有采集在跑 | `409 {"ok": false, "error": "busy", "message": "..."}` |
|
||||||
|
| 本账号未配 Cookie | `409 {"ok": false, "error": "no_cookie", "message": "..."}` |
|
||||||
|
| Cookie 解不开 | `409 {"ok": false, "error": "cookie_broken", "message": "..."}` |
|
||||||
| Cookie 失效 | `401 {"ok": false, "error": "cookie_expired", "message": "..."}` |
|
| Cookie 失效 | `401 {"ok": false, "error": "cookie_expired", "message": "..."}` |
|
||||||
| 云端异常 | `502 {"ok": false, "error": "api", "message": "..."}` |
|
| 云端异常 | `502 {"ok": false, "error": "api", "message": "..."}` |
|
||||||
| 日期不合法 | `400 {"ok": false, "error": "bad_request", "message": "起始日期不合法:..."}` |
|
| 日期不合法 | `400 {"ok": false, "error": "bad_request", "message": "起始日期不合法:..."}` |
|
||||||
|
|
||||||
|
> 采集只使用**当前账号自己的** Cookie(`collect.load_credentials(conn, uid)`),
|
||||||
|
> 且会写一条带 `user_id` 的 `collect_runs`。多用户下不要并发触发采集(单写者约束)。
|
||||||
|
|
||||||
### POST `/api/maintenance/<action>`
|
### POST `/api/maintenance/<action>`
|
||||||
|
|
||||||
把 CLI 维护动作搬到页面。
|
把 CLI 维护动作搬到页面。
|
||||||
|
|
||||||
| `action` | 作用 |
|
| `action` | 作用 | 权限 |
|
||||||
|---|---|
|
|---|---|---|
|
||||||
| `fill-prompt` | 从云端回补缺失的 Prompt |
|
| `fill-prompt` | 从云端回补缺失的 Prompt | 本人 |
|
||||||
| `export-csv` | 全量导出 CSV 到 `data/exports/` |
|
| `export-csv` | 全量导出 CSV 到 `data/exports/`(文件名带账号名) | 本人 |
|
||||||
| `vacuum` | `wal_checkpoint(TRUNCATE)` + `VACUUM` |
|
| `vacuum` | `wal_checkpoint(TRUNCATE)` + `VACUUM` | **仅管理员** |
|
||||||
| `recount` | 重新统计并返回当前条数 |
|
| `recount` | 重新统计并返回当前条数 | 本人 |
|
||||||
|
|
||||||
未知动作返回 **404**。成功返回 `{"ok": true, "message": "..."}`。
|
未知动作返回 **404**。成功返回 `{"ok": true, "message": "..."}`。
|
||||||
|
|
||||||
@@ -321,13 +402,24 @@ KPI + 环比。`from`/`to` 缺省时自动取全量区间。
|
|||||||
|---|---|
|
|---|---|
|
||||||
| 全部合法 | `200 {"ok": true, "changed": ["page_size"], "ignored": []}` |
|
| 全部合法 | `200 {"ok": true, "changed": ["page_size"], "ignored": []}` |
|
||||||
| 有非法值 | `400 {"ok": false, "error": "invalid", "errors": ["page_size 需在 20 ~ 1000 条/页 之间"]}` |
|
| 有非法值 | `400 {"ok": false, "error": "invalid", "errors": ["page_size 需在 20 ~ 1000 条/页 之间"]}` |
|
||||||
|
| 不可写的键(普通账号写实例级) | `400 {"ok": false, "error": "invalid", "errors": ["以下配置仅管理员可修改,本账号无法保存:api_base。普通账号可以维护的是本人凭证(Cookie / User-Agent)。"]}` |
|
||||||
|
|
||||||
要点:
|
要点:
|
||||||
|
|
||||||
- `cookie` **留空 = 不修改**(不会把已有 Cookie 清掉);
|
- **写权限判断只有一处**:`config.writable_by(key, is_admin)`。
|
||||||
|
普通账号能写的**只有** `cookie` 与 `user_agent`(且只限本人这份);
|
||||||
|
其余(云端接口、注册策略、调度、采集参数)全部仅管理员。
|
||||||
|
- **越权写是「整单拒绝」而不是「部分生效」**:请求里只要含一个不可写的键,
|
||||||
|
整个请求 `400`,并在 `errors` 里**点名**是哪些键。这样调用方不会误以为
|
||||||
|
「既然 `changed` 里没有它就说明写过了」。
|
||||||
|
- `cookie` **留空 = 不修改**(不会把已有 Cookie 清掉);写 `__clear__` 或 `-` 才是清空;
|
||||||
|
- `cookie` 落库前会**自动加密**(`db.set_secret`),写进去的永远不是明文;
|
||||||
- 未知键被忽略并在 `ignored` 里列出,**不会被写成任意键**;
|
- 未知键被忽略并在 `ignored` 里列出,**不会被写成任意键**;
|
||||||
- 内部键(`slot:*`)被忽略;
|
- 内部键(`slot:*`)被忽略 —— 它们是调度簿记,不属于用户可配置项;
|
||||||
- 每次拒绝都会写一条 `settings_rejected` 审计。
|
- 改 `schedule_times` 会清掉**已不存在时刻**对应的 `slot:*` 标记(所有账号一起清),
|
||||||
|
新时刻立即生效。刻意**不做全清**:全清会让所有账号在宽限期内一起重采。
|
||||||
|
- 每次拒绝都会写一条 `settings_rejected` 审计(管理员的「日志管理 → 操作审计」里能看到,
|
||||||
|
这也是排查「谁的账号在试越权」的入口)。
|
||||||
|
|
||||||
### POST `/api/password`
|
### POST `/api/password`
|
||||||
|
|
||||||
@@ -342,22 +434,36 @@ KPI + 环比。`from`/`to` 缺省时自动取全量区间。
|
|||||||
### POST `/api/users`(管理员)
|
### POST `/api/users`(管理员)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "username": "viewer", "display_name": "只读同事", "password": "…", "is_admin": false }
|
{ "username": "viewer", "display_name": "只读同事", "email": "viewer@example.com",
|
||||||
|
"password": "…", "password2": "…", "is_admin": false }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`is_admin` **默认 false**(多用户系统里「默认给管理员」是最常见的越权起点)。
|
||||||
|
用户名 / 口令强度与自助注册同一套校验。
|
||||||
|
|
||||||
### POST `/api/users/<id>`(管理员)
|
### POST `/api/users/<id>`(管理员)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "display_name": "新名字", "is_admin": true, "password": "可选,重置密码" }
|
{ "display_name": "新名字", "email": "…", "is_admin": true,
|
||||||
|
"status": "active", "password": "可选,重置密码" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**自锁护栏**(服务端强制,全部返回 400):
|
||||||
|
|
||||||
|
1. 不能取消自己的管理员身份;
|
||||||
|
2. 不能停用自己的账号;
|
||||||
|
3. 不能把最后一个**启用状态的**管理员降权或停用。
|
||||||
|
|
||||||
### POST `/api/users/<id>/delete`(管理员)
|
### POST `/api/users/<id>/delete`(管理员)
|
||||||
|
|
||||||
删除账号。**三重护栏**(服务端强制):
|
删除账号。护栏:
|
||||||
|
|
||||||
1. 不能取消自己的管理员身份;
|
1. 不能删除自己;
|
||||||
2. 不能删除自己;
|
2. 至少要保留一个账号;
|
||||||
3. 至少保留一个账号。
|
3. 不能删掉最后一个启用状态的管理员。
|
||||||
|
|
||||||
|
请求体可选 `{"keep_data": true}` 保留其用量数据;**默认连同数据与 Cookie 一起删除** ——
|
||||||
|
留下孤儿数据既占空间,也会在有人重新注册同名账号时被看到(`user_id` 复用风险)。
|
||||||
|
|
||||||
违反返回 `400`。
|
违反返回 `400`。
|
||||||
|
|
||||||
|
|||||||
@@ -24,15 +24,17 @@ client.py 纯 urllib 调云端;读编辑器 settings.json 取凭证
|
|||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
collect.py 断点续采 → 文件锁 → 规范化 → 分批 upsert;导入/导出也在这
|
collect.py 断点续采 → 文件锁 → 规范化 → 分批 upsert;导入/导出也在这
|
||||||
│ ▲
|
│ ▲ 所有函数都要求 uid
|
||||||
│ │ scheduler.py 只是「到点调 collect.sync()」
|
│ │ scheduler.py 只是「到点调 collect.sync(conn, uid, ...)」
|
||||||
▼
|
▼
|
||||||
db.py + schema.sql SQLite(WAL),单写者;settings 表兼作运行期配置
|
crypto.py 凭证静态加密(手写 ChaCha20 + HMAC);captcha.py 出验证码图
|
||||||
|
▼
|
||||||
|
db.py + schema.sql SQLite(WAL),单写者;settings 表兼作运行期配置(主键 (user_id,key))
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
query.py 全部聚合下推 SQL:daily / dims / top / records / summary / bundle / manifest
|
query.py 全部聚合下推 SQL:daily / dims / top / records / summary / bundle / manifest
|
||||||
│
|
│ uid 是 WHERE 的第一个条件
|
||||||
├──▶ web/views.py Jinja 后台(7 个页面)
|
├──▶ web/views.py Jinja 后台(含 /login /register /profile + 6 个数据页)
|
||||||
└──▶ web/api.py JSON(大屏 + 页面异步调用)
|
└──▶ web/api.py JSON(大屏 + 页面异步调用)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -40,38 +42,69 @@ query.py 全部聚合下推 SQL:daily / dims / top / records / summa
|
|||||||
任何一次查询变化都要重新跑生成器。现在聚合全部下推 SQL,页面与接口共享同一个 `query` 层,
|
任何一次查询变化都要重新跑生成器。现在聚合全部下推 SQL,页面与接口共享同一个 `query` 层,
|
||||||
口径不可能不一致。
|
口径不可能不一致。
|
||||||
|
|
||||||
|
**关键点:没有「当前用户」这种隐式全局。** `uid` 必须由调用方一路显式传下去
|
||||||
|
(见 [七、安全模型](#七安全模型)),所以「忘了过滤」在类型层面就写不出来。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 二、数据模型
|
## 二、数据模型
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
|
-- 多用户布局:所有按账号隔离的表都以 user_id 打头
|
||||||
usage_records(
|
usage_records(
|
||||||
request_id TEXT PRIMARY KEY, -- 云端请求 ID,去重靠它
|
user_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
request_id TEXT NOT NULL, -- 云端请求 ID,去重靠它
|
||||||
ts TEXT NOT NULL, -- 本地时间戳 'YYYY-MM-DD HH:MM:SS'
|
ts TEXT NOT NULL, -- 本地时间戳 'YYYY-MM-DD HH:MM:SS'
|
||||||
day TEXT NOT NULL, -- 派生字段:便于按天聚合与建索引
|
day TEXT NOT NULL, -- 派生字段:便于按天聚合与建索引
|
||||||
hour INTEGER NOT NULL, -- 派生字段:0-23,供时段分布
|
hour INTEGER NOT NULL, -- 派生字段:0-23,供时段分布
|
||||||
model TEXT, client TEXT,
|
model TEXT NOT NULL DEFAULT '-', client TEXT NOT NULL DEFAULT '-',
|
||||||
credits REAL NOT NULL,
|
credits REAL NOT NULL DEFAULT 0,
|
||||||
prompt TEXT, -- 可截断(max_prompt)
|
prompt TEXT, -- 可截断(max_prompt)
|
||||||
first_seen TEXT, last_seen TEXT,
|
first_seen TEXT NOT NULL, last_seen TEXT NOT NULL,
|
||||||
cloud_ts TEXT -- 云端原始时间,用于漂移检测
|
cloud_ts TEXT, -- 云端原始时间,用于漂移检测
|
||||||
|
PRIMARY KEY (user_id, request_id) -- 复合主键:去重是「按账号」去重
|
||||||
)
|
)
|
||||||
|
|
||||||
collect_runs(
|
collect_runs(
|
||||||
id INTEGER PRIMARY KEY, trigger TEXT, status TEXT,
|
id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
trigger TEXT, status TEXT,
|
||||||
started_at, finished_at, duration_ms,
|
started_at, finished_at, duration_ms,
|
||||||
win_from, win_to, -- 本次扫描窗口
|
win_from, win_to, -- 本次扫描窗口
|
||||||
fetched, added, dup, total, conflicts,
|
fetched, added, dup, total, conflicts,
|
||||||
exit_code, message, detail -- detail 存逐行日志原文
|
exit_code, message, detail -- detail 存逐行日志原文
|
||||||
)
|
)
|
||||||
|
|
||||||
settings(key PRIMARY KEY, value, updated_at) -- cookie / 调度 / 采集参数 / 与 slot:HH:MM 簿记
|
settings(user_id INTEGER NOT NULL DEFAULT 0, key TEXT NOT NULL, value, updated_at,
|
||||||
users(id, username UNIQUE, password_hash, display_name, is_admin, created_at, last_login_at, login_count)
|
PRIMARY KEY (user_id, key))
|
||||||
audit_log(id, at, actor, action, detail, ip)
|
|
||||||
|
users(id, username UNIQUE, password_hash, display_name, email,
|
||||||
|
is_admin, status, register_ip, last_login_ip,
|
||||||
|
created_at, last_login_at, login_count)
|
||||||
|
|
||||||
|
captchas(id TEXT PRIMARY KEY, answer TEXT, purpose TEXT,
|
||||||
|
created_at, expires_at, used_at)
|
||||||
|
|
||||||
|
audit_log(id, user_id, at, actor, action, detail, ip)
|
||||||
```
|
```
|
||||||
|
|
||||||
索引:`day`、`(day,hour)`、`(model,day)`、`(client,day)`、`credits DESC`、`ts`。
|
**`user_id = 0` 的含义**:在 `settings` / `collect_runs` / `audit_log` 里表示
|
||||||
覆盖了「按天」「按天+时段」「模型/客户端 × 天」「单笔 TOP」「时间排序」五类热点查询。
|
**实例级**(所有账号共用,例如 `api_base`、`schedule_times`、`page_size`、系统迁移审计);
|
||||||
|
在 `usage_records` 里是「尚未归属」的兜底值,正常不会出现。
|
||||||
|
|
||||||
|
> ⚠️ 实例级**不是**「孤儿行」。清理孤儿数据的 SQL 必须显式排除 `user_id = 0`,
|
||||||
|
> 例如 `DELETE FROM settings WHERE user_id <> 0 AND user_id NOT IN (SELECT id FROM users)`。
|
||||||
|
> 写成 `user_id NOT IN (SELECT id FROM users)` 会一次性删光整片实例级配置 ——
|
||||||
|
> 表现是「所有账号的调度、采集参数、注册策略突然全部回到默认值」。
|
||||||
|
|
||||||
|
索引全部以 `user_id` 打头,覆盖六类热点查询:
|
||||||
|
`(user_id, day)`、`(user_id, day, hour)`、`(user_id, model, day)`、`(user_id, client, day)`、
|
||||||
|
`(user_id, credits DESC)`、`(user_id, ts)`。
|
||||||
|
|
||||||
|
### 为什么要复合主键而不是「加一列 user_id」
|
||||||
|
|
||||||
|
`usage_records` 的主键从 `request_id` 变成 `(user_id, request_id)` 是**语义**变化:
|
||||||
|
两个人可能各自命中同一个云端 `request_id`(同一台机器上的多个浏览器 profile 就会),
|
||||||
|
单列主键会让后写入的人把前一个人的记录覆盖掉。去重必须按账号做。
|
||||||
|
|
||||||
### 为什么 `day`/`hour` 是冗余列
|
### 为什么 `day`/`hour` 是冗余列
|
||||||
|
|
||||||
@@ -84,6 +117,27 @@ audit_log(id, at, actor, action, detail, ip)
|
|||||||
`settings(key='slot:2026-09-14T09:00', value='done')`。这类键用前缀 `slot:` 标记为
|
`settings(key='slot:2026-09-14T09:00', value='done')`。这类键用前缀 `slot:` 标记为
|
||||||
**内部键**:`/api/settings` 读写两侧都过滤掉(`config.is_internal_key()`),
|
**内部键**:`/api/settings` 读写两侧都过滤掉(`config.is_internal_key()`),
|
||||||
用户不会在配置页看到它们,也无法通过接口写入任意键。
|
用户不会在配置页看到它们,也无法通过接口写入任意键。
|
||||||
|
多用户下槽位标记是**按账号**的(`(user_id, 'slot:09:00')`),所以谁改了自己的时刻
|
||||||
|
只影响自己。
|
||||||
|
|
||||||
|
### 升级路径:`PRAGMA user_version`
|
||||||
|
|
||||||
|
`db.init_db()` 用 `PRAGMA user_version` 判断库结构版本(0/1 = 单用户,2 = 多用户)。
|
||||||
|
**主键变了的表不能 `ALTER`**,只能重建,顺序不能变:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 删掉所有自建索引 ← 关键,见下
|
||||||
|
2. ALTER TABLE ... RENAME TO _v1_xxx
|
||||||
|
3. ALTER TABLE 加新列(只加列的表用这个,代价小得多)
|
||||||
|
4. executescript(schema.sql) ← 此时列齐了,表与索引一次建全
|
||||||
|
5. INSERT ... SELECT 回填(user_id) → DROP TABLE _v1_xxx
|
||||||
|
6. 把历史明文凭证加密 + 写一条 schema_migrate 审计
|
||||||
|
```
|
||||||
|
|
||||||
|
> **为什么第 1 步不能省**:`ALTER TABLE RENAME` 会把索引**一起带走且名字仍被占用**,
|
||||||
|
> 于是随后的 `CREATE INDEX IF NOT EXISTS` 被静默跳过 —— 新表一个索引都没有,
|
||||||
|
> 功能看起来完全正常,查询却慢几百倍。这是最阴的一类迁移 bug。
|
||||||
|
> 第 3 步放在第 4 步之前,则是为了让引用新列的索引一次就建成功。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -240,19 +294,90 @@ records: id c(credits) m(model) cl(client) t(ts) px(prompt)
|
|||||||
| 密码存储 | `pbkdf2:sha256:200000`(Werkzeug 实现) |
|
| 密码存储 | `pbkdf2:sha256:200000`(Werkzeug 实现) |
|
||||||
| 会话 | Flask 签名 cookie `workbuddy_portal_sid`,HttpOnly + SameSite=Lax,12 小时 |
|
| 会话 | Flask 签名 cookie `workbuddy_portal_sid`,HttpOnly + SameSite=Lax,12 小时 |
|
||||||
| 密钥持久化 | `data/instance.json` 的 `secret_key`,重启不踢人 |
|
| 密钥持久化 | `data/instance.json` 的 `secret_key`,重启不踢人 |
|
||||||
| 失败限速 | 同 IP 连续 5 次失败锁定 10 分钟;计数表有上限(4096 个 IP)与 TTL(1 小时) |
|
| 失败限速 | **IP 与用户名双维度**各连续 5 次失败锁定 10 分钟;计数表有上限(8192 key)与 TTL(1 小时) |
|
||||||
|
| 验证码 | 策略 `always`(默认)/ `adaptive` / `off`;**先验码再验密** |
|
||||||
|
| 注册 | 开关 `allow_register` + 同 IP 每日配额 `register_max_per_ip` + 强制验证码 |
|
||||||
|
| 停用即失效 | `current_user()` 每请求回查 `users.status`(缓存在 `flask.g`),不等会话过期 |
|
||||||
|
| 会话固定防护 | `login_session()` 先 `session.clear()`,顺带换掉 CSRF token 与验证码 id |
|
||||||
|
|
||||||
### 授权
|
### 授权与数据隔离
|
||||||
|
|
||||||
`@login_required`(`/api/*` 未登录返回 401 JSON,页面跳登录)+
|
`@login_required`(`/api/*` 未登录返回 401 JSON,页面跳登录)+
|
||||||
`@admin_required`(403)两层。`/users` 与 `/api/users*` 全部要管理员。
|
`@admin_required`(403)两层。**仅管理员**:`/users`、`/api/users*`、`/logs`、`/logs/tail`、`vacuum`。
|
||||||
|
|
||||||
内置护栏(服务端强制,前端只是提前提示):不能取消自己的管理员身份、不能删自己、至少留一个账号。
|
两者之间还有一层「同一个页面、两种形态」:`/tasks` 与 `/config` 对普通账号**仍然可达**,
|
||||||
|
但渲染成**只读形态**(不给表单,换成只读表格 + 一句说明为什么只读),
|
||||||
|
写接口也会拒绝。页面形态与接口判断**共用 `config.writable_by()`**,
|
||||||
|
所以不存在「界面上没按钮、构造请求却能改」的空隙 —— 这是本次权限收敛刻意保证的性质。
|
||||||
|
|
||||||
|
**多租户隔离靠「显式传参」而不是「隐式全局」**,这是本节最重要的一条设计:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 每个公开函数都把 uid 放在 conn 之后的第一个位置,且**不给默认值**
|
||||||
|
def daily(conn, uid, frm=None, to=None, with_maps=True): ...
|
||||||
|
def totals(conn, uid, frm=None, to=None): ...
|
||||||
|
collect.sync(conn, uid, trigger="manual", ...)
|
||||||
|
scheduler.slots(conn, uid=0)
|
||||||
|
```
|
||||||
|
|
||||||
|
为什么不给默认值?因为一旦写成 `uid=0`,忘记传参就会**静默返回实例级(≈全量)数据**——
|
||||||
|
这种越权不会报错、不会进日志,只会在某天被人发现。给成必填参数后,漏传是 `TypeError`,
|
||||||
|
在第一次跑测试时就炸掉。
|
||||||
|
|
||||||
|
配套的几条:
|
||||||
|
|
||||||
|
| 项 | 做法 |
|
||||||
|
|---|---|
|
||||||
|
| 单条读取也过滤 | `/api/records/<id>`、`/api/runs/<id>` 的 `WHERE` 都带 `user_id` |
|
||||||
|
| 配置作用域 | 三级回落 `个人 → 实例 → DEFAULTS`;`GLOBAL_KEYS`(云端接口 + 注册策略 + 调度 + 采集参数)一律存**实例级 `user_id=0`** 且仅管理员可写;普通账号可写的只有 `USER_EDITABLE_KEYS = {cookie, user_agent}` |
|
||||||
|
| **凭证不回落** | `NO_FALLBACK_KEYS = {cookie, user_agent}` 跳过实例级回落 —— 否则新账号会「继承」管理员的 Cookie,这是最严重的串号越权 |
|
||||||
|
| 导出隔离 | CSV 文件名带账号名(多用户下同目录同名会互相覆盖) |
|
||||||
|
| 审计归属 | `audit_log` / `collect_runs` 都带 `user_id`;`/api/audit` 普通账号只看自己 |
|
||||||
|
|
||||||
|
内置护栏(服务端强制,前端只是提前提示):不能取消自己的管理员身份、不能停用自己、
|
||||||
|
不能删自己、至少留一个账号、至少留一个**启用状态的**管理员。
|
||||||
|
|
||||||
|
### 凭证加密与验证码
|
||||||
|
|
||||||
|
这两件事都要求「零第三方依赖」(`requirements.txt` 只有 Flask / waitress / openpyxl),
|
||||||
|
所以都是手写标准库实现。
|
||||||
|
|
||||||
|
**凭证加密(`crypto.py`)** —— 手写 ChaCha20(RFC 8439 §2.3 的块函数)+ HMAC-SHA256
|
||||||
|
**encrypt-then-MAC**,所以不存在「先解密再验签」的填充预言类问题:
|
||||||
|
|
||||||
|
```
|
||||||
|
密文 = "v1." + b64(salt) + "." + b64(nonce) + "." + b64(ciphertext) + "." + b64(tag)
|
||||||
|
子密钥 = HMAC-SHA256(master, salt, "aead-key") / ("aead-mac") ← 密钥分离
|
||||||
|
```
|
||||||
|
|
||||||
|
实现上的三个决定:
|
||||||
|
|
||||||
|
1. **`decrypt()` 失败抛异常,绝不返回原值**。返回原值看似「容错」,
|
||||||
|
实际是把「密钥不匹配」伪装成「Cookie 是个奇怪字符串」,然后把这段垃圾发给云端。
|
||||||
|
现在会明确抛 `db.SecretUnreadable`,页面提示「密文解不开,请重新粘贴」。
|
||||||
|
2. **非 `v1.` 前缀原样返回** —— 专门用来兼容单用户时代存下来的明文;
|
||||||
|
下次写入时自动升级为密文。升级迁移还会主动扫一遍并就地加密。
|
||||||
|
3. **`get_settings()` 把加密键置空**,明文的唯一出口是 `db.get_secret()`。
|
||||||
|
这比「记得别回传 cookie」可靠:写新接口的人即使 `**settings` 一把梭也带不出凭证。
|
||||||
|
|
||||||
|
**图形验证码(`captcha.py`)** —— 手写 PNG 编码器(zlib 压缩 IDAT)+ 5×7 点阵字模
|
||||||
|
+ Bresenham 干扰线 + 逐字符抖动 + 噪点:
|
||||||
|
|
||||||
|
| 决定 | 原因 |
|
||||||
|
|---|---|
|
||||||
|
| 出 PNG 而不是 SVG | SVG 是文本,答案会**明文出现在页面源码里**,等于把答案发给机器人 |
|
||||||
|
| 不用第三方 captcha/Pillow | 保持零第三方依赖;图像只由点阵矩形构成,没必要引入整个图像栈 |
|
||||||
|
| 答案不进会话 | Flask 会话是「签名 + base64,**不加密**」的(客户端可解码读明文),放答案等于送答案。只写一个随机 id |
|
||||||
|
| 先删后判 | `verify()` 先 `DELETE` 再比对,避免并发下同一张图被用两次 |
|
||||||
|
| 按 `purpose` 隔离 | 拿注册的题去登录校验必然失败 |
|
||||||
|
| 出图限速 | 60 秒 40 张 —— 出图要做点阵渲染 + zlib 压缩,不设限就是一条廉价的 CPU/带宽放大路径 |
|
||||||
|
| 登录先验码 | 否则攻击者能拿「密码对不对」当信号,在解验证码之前就把字典跑完 |
|
||||||
|
|
||||||
### CSRF
|
### CSRF
|
||||||
|
|
||||||
`before_request` 统一校验:`X-CSRF-Token` 头或 `_csrf` 表单域。
|
`before_request` 统一校验:`X-CSRF-Token` 头或 `_csrf` 表单域。
|
||||||
**退出登录也走 POST**——GET 型退出能被 `<img src="/logout">` 静默触发。
|
**退出登录也走 POST**——GET 型退出能被 `<img src="/logout">` 静默触发。
|
||||||
|
未登录的 `POST` 也会先被 CSRF 拦成 400(先拦比先鉴权更保守)。
|
||||||
|
|
||||||
### 开放重定向
|
### 开放重定向
|
||||||
|
|
||||||
@@ -298,6 +423,44 @@ page_size = db.get_int(conn, "page_size", 200) # 任何异常都回落默认
|
|||||||
|
|
||||||
采集路径上**不允许出现裸 `int(s.get(...))`**。数值还会按 `NUM_SETTINGS` 的范围再钳一次。
|
采集路径上**不允许出现裸 `int(s.get(...))`**。数值还会按 `NUM_SETTINGS` 的范围再钳一次。
|
||||||
|
|
||||||
|
### 配置的作用域:三级回落与一个例外
|
||||||
|
|
||||||
|
```
|
||||||
|
个人(user_id=n) ──没有──▶ 实例(user_id=0) ──没有──▶ config.DEFAULTS
|
||||||
|
▲
|
||||||
|
└── NO_FALLBACK_KEYS(cookie / user_agent)到此为止,不回落到实例级
|
||||||
|
```
|
||||||
|
|
||||||
|
`GLOBAL_KEYS`(共 17 个键,分四组)在读写两侧都被强制折算到 `user_id = 0`,
|
||||||
|
所以它们天然只有一份,非管理员改不了:
|
||||||
|
|
||||||
|
| 分组 | 键 | 为什么归实例级 |
|
||||||
|
|---|---|---|
|
||||||
|
| 云端接口(2) | `api_base`、`api_path` | 一台部署连的就是那一个云端,逐账号配没有意义 |
|
||||||
|
| 注册策略(4) | `allow_register`、`register_max_per_ip`、`captcha_policy`、`captcha_length` | 敞开注册与否是运营决策,不能由任意账号开关 |
|
||||||
|
| 调度(4) | `schedule_enabled`、`schedule_times`、`catch_up`、`catch_up_grace_hours` | **普通账号不能设置定时任务频率**(本轮需求) |
|
||||||
|
| 采集参数(7) | `page_size`、`rewind_minutes`、`drift_tolerance_minutes`、`max_prompt`、`verify_days`、`timeout`、`ssl_verify` | 关掉 `ssl_verify` 就能把所有人的 Cookie 发到中间人手里 |
|
||||||
|
|
||||||
|
这三样东西必须**对齐**,缺一不可:
|
||||||
|
|
||||||
|
1. **键存在哪一级** —— `GLOBAL_KEYS` 一律 `user_id = 0`;
|
||||||
|
2. **谁能写** —— `config.writable_by(key, is_admin)`:只有 `USER_EDITABLE_KEYS` 对普通账号放行;
|
||||||
|
3. **读时落哪** —— 三级回落 `个人 → 实例 → DEFAULTS`。
|
||||||
|
|
||||||
|
> 🔴 最常见的静默 bug 是「只做到第 2 条」:管理员能改,但值写进了**管理员自己的 `user_id=n`**。
|
||||||
|
> 别的账号读同一个键时会**落回 `DEFAULTS`**,于是「管理员改了,但只有他自己那边生效」,
|
||||||
|
> 界面上完全看不出来。所以 `set_setting()` 内部会把全局键**强制重定向**到 `user_id = 0`,
|
||||||
|
> 从结构上消除这种可能,而不是靠调用方记得传 `0`。
|
||||||
|
|
||||||
|
有个 **`slot:*` 例外**:`slot:09:00` 这类簿记键表示「**本账号**今天这个槽位跑过没有」,
|
||||||
|
它是**个人级**(每个账号各记一份),而它所指向的时刻 `schedule_times` 是**实例级**。
|
||||||
|
这两者千万别一起改 —— 把 `slot:*` 也搬到实例级,会让两个账号互相以为对方已经跑过当天采集。
|
||||||
|
|
||||||
|
有个**容易误判**的细节:`NO_FALLBACK_KEYS` 只拦住「实例级那一行」,不拦 `DEFAULTS`。
|
||||||
|
所以一个全新账号读 `user_agent` 拿到的是 `DEFAULTS` 里的**通用 Chrome UA**(非空),
|
||||||
|
而不是空串 —— 这是刻意的(首次采集总得带个 UA)。测试断言要注意:
|
||||||
|
正确的断言是「新账号的 UA ≠ 实例级那一份」,而不是「新账号的 UA 为空」。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 九、已知坑与红线
|
## 九、已知坑与红线
|
||||||
@@ -321,6 +484,20 @@ Flask 在 `full_dispatch_request()` 返回 `app_iter` **之后**就 pop 请求
|
|||||||
现在 `tools/smoke.py` 有专门一节:抓页面里所有 `src`/`href` 资源引用逐个断言 200
|
现在 `tools/smoke.py` 有专门一节:抓页面里所有 `src`/`href` 资源引用逐个断言 200
|
||||||
(断言前先剥掉 HTML 注释,否则注释里的示例路径会被误判)。
|
(断言前先剥掉 HTML 注释,否则注释里的示例路径会被误判)。
|
||||||
|
|
||||||
|
### 母模板里的 `{% set %}` 会静默覆盖子模板的同名变量
|
||||||
|
|
||||||
|
`base.html` 顶层原本写 `{% set me = current_user() %}`,用来渲染右上角的用户名。
|
||||||
|
但 `current_user()` 只回 `{id, username, display_name, is_admin}` 四个键 ——
|
||||||
|
而 `profile.html` 自己也用 `me` 接视图传来的**完整用户行**。
|
||||||
|
|
||||||
|
结果:母模板的 `set` 把子模板的 `me` 顶掉了,`me.created_at` 取不到,
|
||||||
|
个人中心渲染成「账号 admin · 注册于 · 最近登录 未登录」——**不报错、不告警**,
|
||||||
|
页面看起来只是"少了个时间"。
|
||||||
|
|
||||||
|
**规则**:母模板里给全站用的局部变量要**起专门的名字**(现在叫 `cur`),
|
||||||
|
不要复用子模板可能用到的键。`smoke.py` 里有 3 条断言盯着这件事
|
||||||
|
(`注册于` 必须是真实日期、`最近登录` 不能是空占位、`base.html` 不得再出现 `set me = `)。
|
||||||
|
|
||||||
### Jinja 里避开 `dict` 的方法名
|
### Jinja 里避开 `dict` 的方法名
|
||||||
|
|
||||||
模板中 `a.items` / `a.keys` / `a.get` / `a.values` / `a.update` / `a.pop` / `a.copy`
|
模板中 `a.items` / `a.keys` / `a.get` / `a.values` / `a.update` / `a.pop` / `a.copy`
|
||||||
@@ -367,16 +544,29 @@ GMT+8 下 `new Date("2026-08-15T00:00:00")` 的 UTC 时刻是前一天 16:00,
|
|||||||
| 层 | 手段 | 抓什么 |
|
| 层 | 手段 | 抓什么 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 1 | 独立聚合对账(直读 CSV 不走 `query.py`) | 口径错、少算。热力图要**逐格**比,历史上出过「同格覆盖少算 84%」 |
|
| 1 | 独立聚合对账(直读 CSV 不走 `query.py`) | 口径错、少算。热力图要**逐格**比,历史上出过「同格覆盖少算 84%」 |
|
||||||
| 2 | `tools/smoke.py`(99 项断言,离线) | 模板残留、历史缺陷防回归 ①~⑭、静态资源 404、class↔CSS 对账 |
|
| 2 | `tools/smoke.py`(**215 项断言**,离线) | 模板残留、历史缺陷防回归 ①~⑭、**多用户隔离 / 凭证保密 / 注册与验证码全链路**、**非管理员越权面全关死**、**全局键必须落在实例级**、静态资源 404、class↔CSS 对账 |
|
||||||
| 3 | `tools/check_live.py`(56 项断言,真实 HTTP) | `test_client` 覆盖不到的:waitress、端口、cookie 往返、开放重定向、CSRF |
|
| 3 | `tools/check_live.py`(**122 项断言**,真实 HTTP) | `test_client` 覆盖不到的:waitress、端口、cookie 往返、开放重定向、CSRF、验证码、安全响应头;`--as 账号:密码` 追加普通账号越权验收 |
|
||||||
| 4 | Node DOM stub + `vm.runInContext` 跑大屏真实脚本 | 「页面聚合 == 独立算出的聚合」、切区间只发一次请求 |
|
| 4 | Node DOM stub + `vm.runInContext` 跑大屏真实脚本 | 「页面聚合 == 独立算出的聚合」、切区间只发一次请求 |
|
||||||
| 5 | `tools/shots.py`(Playwright 截图 + console/pageerror) | **界面层**。本轮最有价值的 bug(大屏全白)只有它抓到 |
|
| 5 | `tools/shots.py`(Playwright 截图 + console/pageerror) | **界面层**。本轮最有价值的 bug(大屏全白)只有它抓到 |
|
||||||
|
| 附 | `tools/check_docs.py`(**文档层**,不属于上面五层) | 内部链接 / 跨文件锚点 / 图片引用 / **绝对路径泄漏** / 版本一致性 / 产品名硬编码。**章节一重排,锚点就静默失效**,Markdown 自己不报错,只有它抓得到。文档清单自动发现,不写死文件名 |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python tools/smoke.py # 1~2 层,随时跑
|
python tools/smoke.py # 1~2 层,随时跑
|
||||||
|
python tools/check_docs.py # 文档层,改过 md 就跑
|
||||||
python manage.py serve --port 8849 --no-scheduler # 另开终端
|
python manage.py serve --port 8849 --no-scheduler # 另开终端
|
||||||
python tools/check_live.py --base http://127.0.0.1:8849 # 3 层
|
python tools/check_live.py --base http://127.0.0.1:8849 # 3 层
|
||||||
python tools/shots.py --base http://127.0.0.1:8849 --full # 5 层
|
python tools/shots.py --base http://127.0.0.1:8849 --full # 5 层
|
||||||
```
|
```
|
||||||
|
|
||||||
|
关于第 2 层在**多用户**下的两个约定:
|
||||||
|
|
||||||
|
1. `login(cli, uid)` 只注入 `uid` 不够「假装」成谁 —— `current_user()` 每请求回查 `users`
|
||||||
|
表(为的是停用立即失效),所以 `uname` / `adm` 这些会话键改不了权限。
|
||||||
|
要测非管理员行为,必须**真的**在库里有一个普通账号。
|
||||||
|
`smoke.py` 会临时建一个(随机用户名,`finally` 里删掉),并在注册链路里临时再建一个。
|
||||||
|
2. 「验证码答案没泄漏」这类断言要挑对判据:答案是 4 位随机大写串,
|
||||||
|
直接在页面里搜它只能说明「这次没撞上」。更可靠的是**结构断言** ——
|
||||||
|
会话里只有 id、库里才有答案、同 id 二次校验必失败、图必须由独立接口下发
|
||||||
|
(页面里不出现 `data:image`)。
|
||||||
|
|
||||||
**改动前先读 [九、已知坑与红线](#九已知坑与红线),改完先把第 2 层跑绿。**
|
**改动前先读 [九、已知坑与红线](#九已知坑与红线),改完先把第 2 层跑绿。**
|
||||||
|
|||||||
@@ -8,6 +8,470 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [1.5.0] — 2026-09-18
|
||||||
|
|
||||||
|
**主题:按「将会被公网访问」重新审一遍界面与暴露面**
|
||||||
|
|
||||||
|
两件事:① 清掉界面里的 AI 生成残留与「AI 味」,把视觉从「霓虹渐变」改回工程控制台;
|
||||||
|
② 按「能被公网访问」的前提逐条收口信息泄漏、注入与资源滥用。
|
||||||
|
**没有库结构变更**(`user_version` 仍是 4),升级不需要手工介入。
|
||||||
|
|
||||||
|
### 安全与隐私加固(对外提供服务)
|
||||||
|
|
||||||
|
**主题:把「能被公网访问」当成前提,逐条收口信息泄漏与资源滥用**
|
||||||
|
|
||||||
|
这一轮没有新增功能,全部是收口。原则:**能把信息少给一点就少给一点,
|
||||||
|
能把权限多收一层就多收一层**。
|
||||||
|
|
||||||
|
**信息泄漏(这类问题的共同点是:平时看不出问题,踩点阶段最好用)**
|
||||||
|
|
||||||
|
- **内部异常不再直出**。`/api/collect`、`/api/maintenance/*`、`/api/backups*` 的兜底
|
||||||
|
`except Exception` 原本把 `str(e)` 原样回给客户端 —— 那里面会带绝对路径
|
||||||
|
(`/app/workbuddy_portal/...`)、SQL 语句、`sqlite3` 的报错。现在统一走
|
||||||
|
`web/api.py::_internal`:完整堆栈写进服务端日志并打一个 **8 位事件号**,
|
||||||
|
客户端只拿到事件号。既不影响「出事了去查日志」,也不送材料。
|
||||||
|
(`BadParam` / `Busy` / `NotReady` / `ApiError` / `BackupError` 这些**面向用户写的**
|
||||||
|
业务异常不受影响,它们的文案本来就是给用户看的。)
|
||||||
|
- **普通账号拿不到内部实现清单**。`/api/manifest`、`/api/bundle` 里的 `sources`
|
||||||
|
(表名 `usage_records`、`daily 聚合视图` …)与 `archive`(`data/usage.sqlite`)
|
||||||
|
只对管理员下发;非管理员的大屏页「数据源」卡片自动收起,避免留一个只有表头的空壳。
|
||||||
|
- **响应头注入**。导出文件名里含用户名,而用户名**并不总是**注册正则的产物
|
||||||
|
(`manage.py passwd` 建号时不校验、老库升级上来的名字也可能带引号或 CR/LF)。
|
||||||
|
一旦名字里有引号或换行,直接拼 `Content-Disposition` 就是响应拆分。
|
||||||
|
新增 `security.safe_filename()` 与 `security.content_disposition()`:
|
||||||
|
收敛成 ASCII 安全名 + 按 RFC 5987 附上原名,`/records/export` 与
|
||||||
|
`/profile/export` 都改走它。
|
||||||
|
- **路径穿越**。`collect.export_csv()` 的账号名 tag 直接拼进文件路径 ——
|
||||||
|
一个叫 `..\..\x` 的账号能把 CSV 写到数据目录之外。现在只保留 `[A-Za-z0-9._-]`,
|
||||||
|
且结果固定落在 `EXPORT_DIR` 之下。同时 `manage.py passwd` **建号时补上用户名校验**
|
||||||
|
(与注册页同一套),从根上不再产生这类名字。
|
||||||
|
- **用户名枚举的时序侧信道**。`login_ok` 在账号不存在时不执行任何哈希计算,
|
||||||
|
比「口令错误」快一到两个数量级,一个秒表就能枚举出哪些用户名真实存在。
|
||||||
|
现在对不存在的账号也走一次**同代价的哑哈希**,两条路径耗时对齐。
|
||||||
|
|
||||||
|
**隐私:落盘权限(这条是对外部署最容易被忽略的)**
|
||||||
|
|
||||||
|
- 数据目录里躺着的是:`usage_records.prompt`(**用户与 AI 的完整对话正文**)、
|
||||||
|
凭证密文、`instance.json`(主密钥)、`exports/*.csv`(对话正文的**明文**副本)、
|
||||||
|
`backups/*.zip`(全库 + 主密钥)。默认 `umask 022` 会把它们留成 `0644 / 0755`,
|
||||||
|
也就是**同机任何用户都能读**。
|
||||||
|
- 新增 `config.harden_process()`:在进程启动时把 `umask` 收到 `0077`,
|
||||||
|
此后新建的一切文件(含 SQLite 的 `-wal` / `-shm`)天然是 owner-only。
|
||||||
|
刻意选「一条设置管全部」而不是逐个 `chmod` —— 逐点 `chmod` 一定会漏,
|
||||||
|
而漏掉的那个文件里往往正好是最新的对话正文。
|
||||||
|
- 配套 `harden_dir()` / `harden_file()` 收紧**已经存在**的目录(老版本留下的 0755)
|
||||||
|
以及导出 CSV、备份 zip 落盘后的权限。Windows 无此模型,函数直接跳过。
|
||||||
|
|
||||||
|
**资源滥用**
|
||||||
|
|
||||||
|
- **读接口限速**:登录有 IP 锁定、注册有配额、采集有最小间隔,但读接口原本**没有任何刹车** ——
|
||||||
|
一个注册账号把 F12 里的 `/api/bundle` 请求放进 `for` 循环就能持续吃 CPU 与出口带宽
|
||||||
|
(那个接口要算全量逐日聚合,还会下发最多 2 万条明细)。现在 `/api/*` 按
|
||||||
|
「账号(未登录时按来源 IP)」限速 240 次 / 60 秒,超出返回 `429`。
|
||||||
|
阈值给得宽松:大屏切一次筛选只发 1~2 个请求,正常用户碰不到。
|
||||||
|
|
||||||
|
**其它**
|
||||||
|
|
||||||
|
- **`--debug` 不再能绑到对外地址**。Werkzeug 的交互式调试器等于任意代码执行,
|
||||||
|
而 `--host` 默认就是 `0.0.0.0`;`python manage.py serve --debug` 会直接把 RCE 挂上公网。
|
||||||
|
现在绑非回环地址直接拒绝启动,并提示本机调试的正确写法。
|
||||||
|
- 新增 `Permissions-Policy` 响应头(显式关掉地理位置 / 麦克风 / 摄像头 / 支付 / USB)。
|
||||||
|
- 新增 `413` 处理器:请求体超限时接口返回 JSON 而不是 Flask 默认 HTML 页
|
||||||
|
(前端 `r.json()` 在 HTML 上会炸,表现成「Unexpected token <」)。
|
||||||
|
- 文档:新增 [部署指南第十三节「安全与隐私基线」](DEPLOYMENT.md#十三安全与隐私基线) ——
|
||||||
|
代码层已做到的、公网暴露前必须自己做的、存了哪些个人数据、保留期怎么定、上线自检命令。
|
||||||
|
|
||||||
|
验证:`python tools/smoke.py` **ok=264 / fail=0**(第 9 节专门覆盖上述各项:
|
||||||
|
`safe_filename` 边界、真实导出响应头不含引号/换行、每个 `except Exception` 都走 `_internal`、
|
||||||
|
读接口限速、安全响应头、大屏页已转义、普通账号拿不到数据源清单)。
|
||||||
|
|
||||||
|
### 界面去 AI 化
|
||||||
|
|
||||||
|
**主题:清掉界面里的 AI 生成残留与「AI 味」**
|
||||||
|
|
||||||
|
- **大屏页清理生成器残留**:`static/dashboard/index.html` 里 **114 处**
|
||||||
|
`data-page-node-id="…"`(页面生成工具给每个节点打的标记)全部删除,
|
||||||
|
文件从 64 KB 降到 58 KB。这类属性没有任何运行时作用,只是生成痕迹。
|
||||||
|
- **视觉系统重做**(`static/css/app.css` + 大屏内联样式):由「深色底 + 霓虹渐变 + 辉光」
|
||||||
|
改为中性的工程控制台风格 —— 灰阶打底、单一蓝色强调色。具体删掉了:
|
||||||
|
`radial-gradient` 页面背景、按钮/激活态/分页/徽标的 `linear-gradient`、
|
||||||
|
`box-shadow` 发光(品牌点、主按钮、日历格)、渐变文字(错误页大号状态码)、
|
||||||
|
以及标题前的彩色装饰条(`.pagehead h1::before`、`.card h2::before`、`h1::before`)。
|
||||||
|
- **KPI 改用灰度分层**:原来每张卡一条彩虹色左色条,现在 `--c` 只渲染成一个 8px
|
||||||
|
状态点(`采集健康`/`自动备份` 这类状态卡仍能一眼区分),其余靠灰度与字重。
|
||||||
|
- **图表配色同步降饱和**:ECharts `PALETTE`、日历 5 级色阶、热力图 `visualMap`
|
||||||
|
与后台共用同一套语义色;数据系列改用蓝色阶 + 1 个琥珀色对比项。
|
||||||
|
- **文案精简**:删掉说教式长段落(`配置管理` 页整张「为什么采集参数是只读的」说明卡)、
|
||||||
|
把只给结论即可的提示压成一句话;`任务管理`/`配置管理`/`备份管理`/`用户管理`
|
||||||
|
的长提示同步收敛。
|
||||||
|
- **修掉两个真实缺陷**(都在这次清理中暴露):
|
||||||
|
- `templates/profile.html` 的 Markdown 星号漏进 HTML —— `**任何时候都不回传明文**`
|
||||||
|
会在页面上原样渲染成星号;
|
||||||
|
- 登录页写着「首次部署默认账号 `admin` / `admin123`」,而 1.4.0 起已改用随机口令,
|
||||||
|
该提示会把人引向一个永远登不上的口令,已删除。
|
||||||
|
- **文档同步**:`docs/USER-GUIDE.md` 中引用上述被删文案的两处说明一并更新。
|
||||||
|
界面截图 `docs/images/*.png` 仍是旧观感,需要用 `python tools/shots.py` 重出。
|
||||||
|
|
||||||
|
验证:`python tools/smoke.py` 全绿(ok=233 / fail=0),其中
|
||||||
|
「页面 class 与 app.css 选择器对账」确认新版样式表没有漏掉任何模板在用的类名;
|
||||||
|
`python tools/check_docs.py` 0 处问题。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.4.0] — 2026-09-16
|
||||||
|
|
||||||
|
**主题:备份管理 · 对外提供服务的安全加固 · 资源与频率限制**
|
||||||
|
|
||||||
|
三件事:① 补齐「备份 / 恢复 / 导出本人数据」这条数据安全链路;② 修掉三类 P0 与
|
||||||
|
一批 P1;③ 让这个程序可以**安全地暴露到公网**——资源在容器层限制、任务频率在实例层
|
||||||
|
限制、采集跨度有硬上限。
|
||||||
|
|
||||||
|
**数据不会丢**:`manage.py init` 检测到 `PRAGMA user_version` 3 → 4 时自动迁移,
|
||||||
|
只做一件事(`ALTER TABLE users ADD session_ver`,非空、默认 0)+建一张新表
|
||||||
|
`backups`。**无数据搬运、无键位变动**,可重复执行。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 备份管理(新功能)
|
||||||
|
|
||||||
|
- **`workbuddy_portal/backup.py`(新模块)** —— 备份的全部逻辑
|
||||||
|
- **快照走 SQLite 在线备份 API**(`Connection.backup`),不是 `cp`。
|
||||||
|
它按页复制并持有读事务,所以**采集正在写的时候拿到的也是「某一时刻的完整库」**。
|
||||||
|
手工 `cp data/usage.sqlite` 做不到这点:`-wal` 里可能还有没落盘的帧,
|
||||||
|
拷出来的库会缺最近一段数据,而且**不报错**。
|
||||||
|
- **归档 = 一个 zip**:所有 `data/*.sqlite` + `manifest.json` + `instance.json`。
|
||||||
|
单文件、可校验、可搬到别的机器恢复。
|
||||||
|
- **`backup_keep` 保留份数**:超出后按时间删最旧的(只按**磁盘上真实存在**的算份数,
|
||||||
|
已经被手工删掉的条目不占名额)。
|
||||||
|
- **`backups` 表 + 磁盘双向对齐**:`sync_index()` 把磁盘上的归档登记进表、
|
||||||
|
把消失的标记 `missing=1`(不删行,保留「这里曾有过一份」的痕迹)。
|
||||||
|
**磁盘是事实来源**,表只是缓存 —— 手工拷进来/手工删掉都能被正确呈现。
|
||||||
|
- **管理员页面 `/backups`**(导航在「用户管理」前):KPI(份数 / 占用 / 自动备份状态 /
|
||||||
|
上次与下次)、自动备份设置表单、归档内容说明、备份列表(下载 / 恢复 / 删除)。
|
||||||
|
- **接口**(全部仅管理员):
|
||||||
|
`GET /api/backups`、`POST /api/backups`、`GET /api/backups/<filename>`(下载)、
|
||||||
|
`POST /api/backups/<filename>/restore`、`POST /api/backups/<filename>/delete`、
|
||||||
|
`POST /api/backups/prune`。
|
||||||
|
- **恢复的三条设计决定**(每条都对应一个会真的踩到的坑)
|
||||||
|
1. **SQL 级整表替换,不做文件 swap**。解包 → 临时库先迁移到当前 schema →
|
||||||
|
一个 `BEGIN IMMEDIATE` 事务里整表搬过去。好处:不需要停机、不依赖「没有别人
|
||||||
|
持有文件句柄」(Windows 上文件 swap 会因句柄占用直接失败)、备份是旧版本
|
||||||
|
(uv=2/3)也能恢复、中途失败就是一次回滚。
|
||||||
|
2. **列名交集而非 `SELECT *`**。老库的列是 `ALTER` 追加的,顺序与新库建表语句
|
||||||
|
不一定一致 —— `SELECT *` 会**静默错位**,字段整体串位而值都「合法」,
|
||||||
|
是最难查的一类数据损坏。
|
||||||
|
3. **恢复前自动打一份 `pre-restore` 快照**。恢复错了还能回到恢复之前。
|
||||||
|
- **恢复后让所有会话失效**:把所有账号的 `session_ver` +1。光靠「从归档里搬
|
||||||
|
`session_ver`」是不够的 —— 在打备份**之前**登录的那个人,他那张 Cookie 里的 `sv`
|
||||||
|
正好等于归档里的值,会话会「合法地」活下来,而它描述的账号与权限可能已经被
|
||||||
|
这次恢复整个换掉了。
|
||||||
|
- **`instance.json` 在归档里是必要的,不是顺手加的**:没有 `cookie_key` 就永远
|
||||||
|
解不开 `settings` 里的凭证密文,那样的「恢复」等于把所有人的 Cookie 弄丢。
|
||||||
|
代价是归档本身含密钥 ⇒ 它**永不入库、永不进镜像**(见下方 P0-3)。
|
||||||
|
恢复时可以 `include_instance=false` 只搬数据、保留本机当前密钥。
|
||||||
|
- **`safe_name()` 路径穿越收口**:下载与恢复接口都直接吃文件名,
|
||||||
|
这里做 basename 收敛 + 后缀 + 字符白名单(实测拦住 `../../etc/passwd`、
|
||||||
|
`x.txt`、`''`、`..`、`a b.zip`)。
|
||||||
|
- **`_extract_safely()` 防 zip slip**:归档可能是**外部给的**,
|
||||||
|
`extractall` 会因为条目名里的 `..` / 绝对路径写到目录之外。
|
||||||
|
- **普通用户导出本人全部数据**:`GET /profile/export`(任何登录用户,10 秒限速),
|
||||||
|
用 `SpooledTemporaryFile(max_size=16MB)` 边生成边下发,含 4 份 CSV/JSON
|
||||||
|
(使用记录 / 采集历史 / 操作审计 / 我的账号与配置)+ 说明。
|
||||||
|
**不含 Cookie 明文** —— 导出自己的数据不等于把凭证交出去。
|
||||||
|
- **CLI**:`manage.py backup [--note]` / `backups [--prune] [--keep N]` /
|
||||||
|
`restore <文件名> --yes`(破坏性操作必须显式 `--yes`,不加只打印将要发生什么)。
|
||||||
|
- **自动备份**:`backup_enabled` / `backup_interval_hours` / `backup_keep` 三个实例级键。
|
||||||
|
由 `scheduler.tick()` 每 20 秒检查一次(**有采集在跑就跳过**,不跟采集抢磁盘),
|
||||||
|
到期就打一份并按份数清理。首次部署无需等待 —— 库里没有自动备份记录时第一轮 tick 即触发。
|
||||||
|
|
||||||
|
### P0 修复
|
||||||
|
|
||||||
|
- **P0-1 `X-Forwarded-For` 可伪造 → 三道 IP 防线全废**
|
||||||
|
- 实测:修改前每次换一个伪造的 `X-Forwarded-For`,45 次验证码请求**全部放行**;
|
||||||
|
验证码限速、注册配额、登录锁定三道防线同时失效。
|
||||||
|
- 新增 **`security.client_ip()`** —— 全站**唯一**取客户端地址的入口。
|
||||||
|
默认**不信任** XFF,直接用 `remote_addr`;`WB_TRUST_PROXY=1` 时取**最右侧**合法 IP
|
||||||
|
(最近的一跳由你自己的代理写入,客户端伪造不了),含 `IPv4:port` 与 IPv6 处理。
|
||||||
|
- 原来散落在 `views.py` / `api.py` 的 6 处 `request.remote_addr` 全部改走它。
|
||||||
|
- **P0-2 默认口令 `admin123` 硬编码兜底**
|
||||||
|
- 现在 `WB_ADMIN_PASSWORD` 为空时,程序用 `secrets.token_urlsafe(12)` 生成随机口令,
|
||||||
|
**只在启动日志里打印一次**,且**不写进数据库**(审计日志里出现口令等于永久留档)。
|
||||||
|
`manage.py init` 会用醒目的方框打印它 —— 库里只有散列,日志一滚就再也拿不回来。
|
||||||
|
- `docker/entrypoint.sh` 里那句「默认密码是 admin123」的提示同时删除(它会给出一个
|
||||||
|
永远登录不上的口令)。
|
||||||
|
- **P0-3 `.dockerignore` 漏了 `backups/` → 凭证密文被打进镜像**
|
||||||
|
- `backups/` 里躺着 `usage.sqlite.bak-pre-v13`(4.5 MB 的**真实生产库**快照),
|
||||||
|
含 `settings` 的凭证密文与 `users` 的口令散列。而 `.dockerignore` 里没有任何
|
||||||
|
规则能匹配 `backups/` ⇒ `docker build` 会把它原样烤进镜像层,
|
||||||
|
**推一次镜像等于把整库密钥分发给所有能拉镜像的人**。
|
||||||
|
- 补 `backups/` 与 `data/demo/`;并在 Dockerfile 里加一条**构建期断言**:
|
||||||
|
`COPY` 之后若 `/app/backups` 非空就直接构建失败。
|
||||||
|
靠「记得改 .dockerignore」不可靠 —— 让构建自己拒绝。
|
||||||
|
注意这不能靠 `RUN rm` 补救:镜像层是只读叠加的,删掉只会多留一个含内容的中间层。
|
||||||
|
|
||||||
|
### P1 修复
|
||||||
|
|
||||||
|
- **采集与导出没有任何跨度上限与频率限制**:一个注册账号就能反复打 `/api/collect`
|
||||||
|
把云端与线程池占满。现在 `/api/collect` 有**三道闸门**:
|
||||||
|
① 采集锁存在 → `409`(**有任务在跑就不能新建任务**);
|
||||||
|
② `action_allowed("collect:uid", gap)` → `429`(默认最小间隔 60 秒);
|
||||||
|
③ 跨度超过上限 → `400`。
|
||||||
|
- **`COLLECT_MAX_RANGE_DAYS_HARD = 31` / `SCHEDULE_SLOTS_HARD_MAX = 12`**:
|
||||||
|
代码层**硬顶**。`collect_max_range_days` / `max_schedule_slots_per_day` 只是更严的
|
||||||
|
旋钮,**改大也突破不了** —— 上限必须由代码兜底,不能只靠写入校验。
|
||||||
|
历史脏数据、手工改库都过不去。(用户要求:「最长跨度的为 1 个月」)
|
||||||
|
- **`action_allowed(key, min_interval)` 通用重操作限速**:采集 / 导出 / 导出本人数据 /
|
||||||
|
`vacuum` / 补全 prompt / 重算 各有最小间隔(`{"fill-prompt":60, "export-csv":15,
|
||||||
|
"vacuum":120, "recount":5}`,采集与 CSV 导出见各自常量)。
|
||||||
|
- **`WB_COOKIE_SECURE` 默认 0、无 HSTS**:`_env_flag()` 统一解析布尔环境变量;
|
||||||
|
`apply_security_headers()` 在 **`COOKIE_SECURE or FORCE_HTTPS`** 时下发 HSTS
|
||||||
|
(**只在真正走 HTTPS 时下发** —— 纯 HTTP 部署下发会让浏览器强升 https,
|
||||||
|
表现成白屏,是个很难归因的故障)。
|
||||||
|
- **账号锁定可以被当武器**:知道用户名就能把对方锁死 10 分钟,而**管理员用户名在导航栏里
|
||||||
|
是公开的**。改成双维度、强度刻意不同:
|
||||||
|
IP 维度真锁(`MAX_LOGIN_FAILS` + `LOGIN_LOCK_MINUTES`),
|
||||||
|
用户名维度只做秒级递增退避(`USER_SOFT_THRESHOLD` / `USER_SOFT_CAP_SECONDS=60`)。
|
||||||
|
另外加**单 IP 登录尝试总量**(`LOGIN_ATTEMPTS_PER_IP=40` / `LOGIN_ATTEMPTS_WINDOW=300`,
|
||||||
|
**含成功**)挡住「慢慢撞、不触发失败阈值」的形态。
|
||||||
|
登录失败提示也从「剩余 N 次」改成「本来源连续失败 N 次」—— 不再给攻击者倒计时。
|
||||||
|
- **改密码不失效其他会话**:新增 `users.session_ver`(`DB_SCHEMA_VERSION` 3 → 4),
|
||||||
|
`current_user()` 每个请求把会话里的 `sv` 与库里比对,不等就丢会话。
|
||||||
|
改自己密码时会**把当前会话刷新到新版本**(否则改完立刻被自己踢下线)。
|
||||||
|
管理员重置口令 / 停用账号 / 删除账号同样 `bump_session_ver()` —— 停用立即生效,
|
||||||
|
不用等会话过期。
|
||||||
|
- **验证码强度不足**(字模在源码里、只整体放大 5 倍、无扭曲,容易被模板匹配):
|
||||||
|
改为让**同一字符两次渲染尽量不同** —— 逐字符随机旋转 ±22°、切变 ±0.32、缩放抖动、
|
||||||
|
波浪偏移、粗刷笔画(旋转时不断裂)、两色斜向渐变背景、噪点 46 → 70、压线 2~3 条。
|
||||||
|
实测同一验证码两次渲染字节差异 **76.9%**,字符仍可辨认。
|
||||||
|
- **`instance.json` 未设权限**:POSIX 上显式 `chmod 0600`(默认 umask 022 会留下 0644,
|
||||||
|
同机其他用户可读)。恢复时写回也走同一处理。
|
||||||
|
- **无访问日志**:waitress 自己不记 access log,出事无据可查。
|
||||||
|
新增 `security._access_log()`(跳过 `/static/` 与 `/captcha.png`,写进 `logs/app.log`),
|
||||||
|
由 `WB_ACCESS_LOG` 控制(默认开)。
|
||||||
|
- **`api_base` 可指向云元数据地址**:拦截 `169.254.169.254` /
|
||||||
|
`metadata.google.internal` / `[fd00:ec2::254]` —— 这是 SSRF 拿云上临时凭证最经典的一跳,
|
||||||
|
而没有任何合法采集场景需要它。
|
||||||
|
- **口令黑名单**:`WEAK_PASSWORDS`(34 个自动撞库字典的头几页)。
|
||||||
|
只在**设置/修改**口令时校验,登录不校验 —— 否则会把用老口令的存量用户挡在门外。
|
||||||
|
|
||||||
|
### 新增(配置项)
|
||||||
|
|
||||||
|
`GLOBAL_KEYS` 17 → **23** 键。新增的 6 个都是实例级:
|
||||||
|
|
||||||
|
| 键 | 默认 | 范围 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `max_schedule_slots_per_day` | 6 | 1~12 | 每日调度时刻数上限(挡住「填 200 个时刻」) |
|
||||||
|
| `collect_min_interval_seconds` | 60 | 0~3600 | 同一账号两次手动采集的最小间隔 |
|
||||||
|
| `collect_max_range_days` | 31 | 1~31 | 单次采集的最长跨度(硬顶 31 天 = 1 个月) |
|
||||||
|
| `backup_enabled` | 1 | 0/1 | 是否开启自动备份 |
|
||||||
|
| `backup_interval_hours` | 24 | 1~720 | 备份周期 |
|
||||||
|
| `backup_keep` | 7 | 1~100 | 保留最近几份 |
|
||||||
|
|
||||||
|
新增环境变量:`WB_TRUST_PROXY`、`WB_FORCE_HTTPS`、`WB_ACCESS_LOG`、`WB_THREADS`、
|
||||||
|
`WB_BACKUP_DIR`、`WB_CPUS` / `WB_MEM_LIMIT` / `WB_PIDS_LIMIT`(compose 用)、
|
||||||
|
`WB_HOST_BACKUP_DIR`(叠加层用)。
|
||||||
|
|
||||||
|
### 部署与外网暴露
|
||||||
|
|
||||||
|
- **`BACKUP_DIR` 刻意不在 `data/` 里面**:容器里 `data/` 是数据卷,
|
||||||
|
`docker compose down -v` 会把正本与副本一起删 —— 那正好是最需要备份的时刻。
|
||||||
|
新增 `WB_BACKUP_DIR`(容器里 `/app/backups`)+ compose **命名卷 `wb_backups`**。
|
||||||
|
**绝不退回绑定挂载**(Windows 9p 下容器会 `unable to open database file`)。
|
||||||
|
- **容器资源上限**(用户要求「资源从容器上限制」):
|
||||||
|
`cpus: 1.0` / `mem_limit: 512m` / `memswap_limit` 与 `mem_limit` **相等**(= 禁用 swap,
|
||||||
|
这样超限会「被 OOM 杀掉」而不是「越来越慢」,后者更难查)/
|
||||||
|
`pids_limit: 256`(挡 fork 炸弹)/ `ulimits.nofile 4096:8192`
|
||||||
|
(SQLite 除主库外还持有 `-wal` `-shm`,恢复期还要开临时库 + `ATTACH` 源库)。
|
||||||
|
- **`manage.py serve` 的线程数改为 `config.THREADS`**(原为硬编码 8):
|
||||||
|
它决定单实例能同时吃进几个慢请求(采集 / 导出 / 备份恢复),是资源上限的一部分。
|
||||||
|
1 核配 8 线程容易出现「都在等 CPU」的假并发,容器默认给 4。
|
||||||
|
- 新增 `docker-compose.yml` 里 `WB_TRUST_PROXY` / `WB_FORCE_HTTPS` /
|
||||||
|
`WB_COOKIE_SECURE` 三者相邻并写明「必须一起决定」—— 拆开写很容易出现
|
||||||
|
「开了强制 HTTPS 却忘了 Secure」这类半截配置。
|
||||||
|
|
||||||
|
### 其它
|
||||||
|
|
||||||
|
- `scheduler.py` 的 `tick()` 末尾增加自动备份检查(在账号循环**之外**,因为它是
|
||||||
|
实例级、与具体账号无关)。
|
||||||
|
- `collect.py` 新增 `max_range_days(conn)` / `min_interval_seconds(conn)`:
|
||||||
|
接口、页面、`sync()` 三处共用**唯一口径**(此前会出现「页面显示的限值」与
|
||||||
|
「接口实际校验的限值」不是同一个数的情况)。
|
||||||
|
- `sync()` 因跨度上限收窄起点时打一条 `[warn] 请求跨度超过上限 %d 天,已自动收窄起点`。
|
||||||
|
- `backup.sync_index()` 现在从 manifest 里读真实的 `trigger` / `actor` / `note`,
|
||||||
|
不再一律标成 `external` —— 恢复前最需要判断的恰恰是「这份是自动备份、
|
||||||
|
还是我手工留的、还是恢复前系统自动存的那一份」。
|
||||||
|
- **新增页**:`/backups`(仅管理员)、`/profile/export`(任何登录用户)。
|
||||||
|
- **测试**:`tools/smoke.py` 与 `tools/check_live.py` 补备份链路、越权、
|
||||||
|
跨度上限、并发拒绝、会话失效断言。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.3.0] — 2026-09-16
|
||||||
|
|
||||||
|
**主题:权限收敛 · 配置作用域统一 · 目录规范化**
|
||||||
|
|
||||||
|
把「配置存在哪一级」与「谁能改它」对齐成一条规则,并收紧普通账号的越权面。
|
||||||
|
**数据不会丢**:`manage.py init` 检测到 `PRAGMA user_version` 2 → 3 时自动迁移,
|
||||||
|
把管理员个人名下的调度与采集参数**提升到实例级**(`user_id=0`)后清掉个人残留,
|
||||||
|
全程写一条 `promote_global_settings` 审计,且可重复执行。
|
||||||
|
|
||||||
|
### 变更(**不兼容**)
|
||||||
|
|
||||||
|
- **调度与采集参数改为实例级,普通账号只读**
|
||||||
|
- `GLOBAL_KEYS` 扩容:`api_base` / `api_path` / 注册策略 4 键
|
||||||
|
+ `schedule_enabled` / `schedule_times` / `catch_up` / `catch_up_grace_hours`
|
||||||
|
+ `page_size` / `rewind_minutes` / `drift_tolerance_minutes` / `max_prompt` /
|
||||||
|
`verify_days` / `timeout` / `ssl_verify`
|
||||||
|
- 新增 `config.USER_EDITABLE_KEYS = {cookie, user_agent}` —— 普通账号**唯一**可写的两个键
|
||||||
|
- 新增 `config.writable_by(key, is_admin)`:前后端与测试共用的**唯一**判断入口,
|
||||||
|
避免「页面置灰但接口还能写」这类规则漂移
|
||||||
|
- 为什么必须放实例级(而不是「个人级但只有管理员能写」):若只写在管理员自己的
|
||||||
|
`user_id` 下,其它账号读取时会回落到 `DEFAULTS`,**管理员改的值对别人完全不生效**
|
||||||
|
—— 那是个静默 bug。统一放实例级,语义是「一台部署一套采集与调度策略」。
|
||||||
|
- **`set_setting()` 强制把全局键重定向到 `user_id=0`**:从结构上消除
|
||||||
|
「管理员改了只有自己生效」的可能
|
||||||
|
- **`/logs` 与 `/logs/tail` 改为仅管理员**(原先普通账号能看到自己的采集历史 +
|
||||||
|
整机应用日志尾部)。普通账号访问返回 403,导航里不显示入口
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- **`manage.py init` 自动迁移(uv 2 → 3)**:`_promote_personal_to_global(conn)`
|
||||||
|
取首个管理员的个人级全局键值提升到 `user_id=0`,再清除 `user_id<>0` 的残留;
|
||||||
|
幂等,可反复执行
|
||||||
|
- **凭证类键刻意不灌实例级**:`init_db()` 灌默认值时 `continue` 掉
|
||||||
|
`USER_EDITABLE_KEYS`,并显式 `DELETE FROM settings WHERE user_id=0 AND key IN ('cookie','user_agent')`
|
||||||
|
—— 实例级存凭证等于给所有账号发同一张身份
|
||||||
|
- **`/api/settings` 回传 `_userKeys` / `_role`**,`/api/manifest` 回传 `role`,
|
||||||
|
`/api/status` 新增 `is_admin` / `can_edit_schedule` / `can_view_logs`
|
||||||
|
—— 大屏是静态页,拿不到 Jinja 上下文,只能靠这几个字段决定显隐
|
||||||
|
- **`app.js:formData()` 跳过 disabled 控件**(含祖先 `fieldset[disabled]`):
|
||||||
|
disabled 的 input 仍在 `form.elements` 里,一起提交会让服务端因「越权修改只读项」
|
||||||
|
拒掉**整单**;现在只读项既不显示也不参与提交
|
||||||
|
- **测试**:`tools/smoke.py` 162 → 215 项断言(新增「非管理员越权面必须全部关死」
|
||||||
|
与「全局键必须落在实例级」两节);`tools/check_live.py` 83 → 122 项,
|
||||||
|
新增 `--as USER:PASS` 参数与第 12 节「普通账号真实 HTTP 越权验收」
|
||||||
|
- **新增 `tools/check_docs.py`(文档自检)**:内部链接与**跨文件锚点**、图片引用、
|
||||||
|
**绝对路径泄漏**(连带会泄漏用户名)、版本一致性(`__init__` / `Dockerfile` /
|
||||||
|
`README` / `CHANGELOG` 四处)、模板与 JS 里的产品名硬编码。
|
||||||
|
文档互相引用后章节一重排,锚点会**静默失效** —— Markdown 自己不报错、CI 也不管,
|
||||||
|
只能靠这一层。**有问题即退出码 1**(只想看报告不失败用 `--no-fail`)。
|
||||||
|
文档清单**自动发现**,不写死文件名 —— 写死列表的那版曾漏掉
|
||||||
|
`THIRD-PARTY-NOTICES.md` / `CODE_OF_CONDUCT.md`(覆盖面 13 → 9 个文件且毫无提示)。
|
||||||
|
锚点比对**刻意不逐字复刻 GitHub/Gitea 的 slug 算法**(各家对 `+`/`:`/连续空格的
|
||||||
|
处理并不一致,写死一个实现换个托管平台就批量误报),改为只比较「有效字符」
|
||||||
|
(小写字母 / 数字 / 汉字),既不受标点差异干扰,章节真被改名时又照抓不误
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- **`api_status` 引用了未定义的变量 `u`** ⇒ `/api/status` 稳定 500。
|
||||||
|
该缺陷由 `check_live.py` 新增的真实 HTTP 验收抓到,此前 smoke 完全没有覆盖这个接口
|
||||||
|
- **`tools/smoke.py` 的清理语句会把实例级配置当孤儿删掉**
|
||||||
|
(`DELETE FROM settings WHERE user_id NOT IN (SELECT id FROM users)`,
|
||||||
|
而 `user_id=0` 不是任何真实账号)⇒ 每跑一轮 smoke 就清空一次实例级配置。
|
||||||
|
实测曾把 19 个实例级键清到只剩 1 行。已加 `user_id<>0 AND` 并补防回归断言
|
||||||
|
- **`tools/smoke.py` 哨兵 UA 还原会留下多余行**:原本实例级无 `user_agent` 行时
|
||||||
|
`set_setting(..., "")` 会插一行空串。改为「原本无则 DELETE」,断言也改成比行为而非比行
|
||||||
|
|
||||||
|
### 目录规范化
|
||||||
|
|
||||||
|
- 工作区根目录的 v1.0 单文件版(`fetch_usage.py`、`dashboard/`、`data/usage_records.csv`)
|
||||||
|
收进工作区级 `legacy-v1/`,附带 README 说明「已被取代、可安全删除」;
|
||||||
|
`config.LEGACY_CSV_CANDIDATES` 第一候选同步指向新位置
|
||||||
|
- 新增 `backups/` 作为数据库快照的统一落点(刻意**不放在 `data/`**——
|
||||||
|
`data/` 是 Docker 卷,`down -v` 会把备份和正本一起删掉)
|
||||||
|
- `.gitignore` 补 `backups/*`、`data/*.bak*`、`legacy-v1/` 三条兜底规则
|
||||||
|
- 清理 `data/shots/`(已被 `docs/images/` 取代)与全部 `__pycache__`
|
||||||
|
|
||||||
|
### 文档
|
||||||
|
|
||||||
|
- `docs/DEPLOYMENT.md` 重写:三条并列的部署路径(裸机 / Docker 自打包 /
|
||||||
|
docker-compose 拉云端镜像),配置项速查表按新作用域重排
|
||||||
|
- `docs/USER-GUIDE.md` 重写:新增「权限与数据边界」「信息安全与隐私安全」两章,
|
||||||
|
截图重出为普通账号视角
|
||||||
|
- `README.md` / `SECURITY.md` / `CONTRIBUTING.md` / `docs/ARCHITECTURE.md` /
|
||||||
|
`docs/API.md` / `docs/FAQ.md` 同步权限模型、配置作用域与验证层变化;
|
||||||
|
订正了 CONTRIBUTING / ARCHITECTURE / FAQ 里残留的旧断言数(165/83 → 215/122)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.2.0] — 2026-09-15
|
||||||
|
|
||||||
|
**主题:多用户化 · Cookie 加密 · 开放注册与验证码**
|
||||||
|
|
||||||
|
从单用户版升级到多用户版。**数据不会丢**:`manage.py init` 会自动检测旧表结构并迁移
|
||||||
|
(`PRAGMA user_version` 0 → 2),历史用量归到首个账号、明文 Cookie 就地加密,
|
||||||
|
全程写一条 `schema_migrate` / `encrypt_secrets` 审计,且可重复执行。
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- **多用户与数据隔离**
|
||||||
|
- `users` 表补齐 `email` / `status` / `register_ip` / `last_login_ip`;
|
||||||
|
`settings` 主键改为 `(user_id, key)`,`usage_records` 改为 `(user_id, request_id)`,
|
||||||
|
全部索引以 `user_id` 打头;`collect_runs` / `audit_log` 增加 `user_id`
|
||||||
|
- `query.py` / `collect.py` / `scheduler.py` 全链路把 `uid` 作为 `conn` 之后的
|
||||||
|
**第一个位置参数且无默认值** —— 漏传直接 `TypeError`,不会退化成「返回全量」
|
||||||
|
- 配置三级回落:`个人 → 实例(user_id=0) → config.DEFAULTS`;
|
||||||
|
新增 `NO_FALLBACK_KEYS = {cookie, user_agent}`,凭证**永不回落**(回落即串号越权)
|
||||||
|
- `scheduler.tick()` 遍历启用账号逐个判断槽位;未配 Cookie 的账号自动跳过
|
||||||
|
- 新增 `manage.py users` / `stats -u` / `status`(逐账号)/ 各子命令的 `-u/--user`
|
||||||
|
- **Cookie 静态加密**(`workbuddy_portal/crypto.py`,约 190 行,**零第三方依赖**)
|
||||||
|
- 手写 ChaCha20 块函数(RFC 8439 §2.3)+ HMAC-SHA256 **encrypt-then-MAC**,
|
||||||
|
密文格式 `v1.<b64salt>.<b64nonce>.<b64ct>.<b64tag>`,已用官方测试向量逐字节验证
|
||||||
|
- 主密钥 `cookie_key` 独立存放在 `data/instance.json`(与 `SECRET_KEY` 分开键位)
|
||||||
|
- `db.get_secret()` 是取明文的**唯一**通道;`get_settings()` 把加密键一律置空;
|
||||||
|
`db.secret_state()` 只回 `{set, chars, tail, broken}`,绝不含明文
|
||||||
|
- `decrypt()` 对非 `v1.` 前缀原样返回(兼容历史明文,下次写入自动升级),
|
||||||
|
校验失败**抛异常**而不是「失败就返回原值」;升级时自动把历史明文加密
|
||||||
|
- **开放注册**:`/register` 页 + `POST /api/users`(管理员);开关 `allow_register`、
|
||||||
|
同 IP 每日配额 `register_max_per_ip`;用户名/密码强度校验(保留字黑名单、≥8 位且两类字符)
|
||||||
|
- **图形验证码**(`workbuddy_portal/captcha.py`,约 250 行,零第三方依赖)
|
||||||
|
- **手写 PNG 编码器**(zlib 压缩 IDAT)+ 5×7 点阵字模 + Bresenham 干扰线与噪点。
|
||||||
|
刻意不用 SVG —— SVG 是文本,答案会明文出现在页面源码里
|
||||||
|
- 答案只写服务端 `captchas` 表;会话里仅存随机 id;**一次性、5 分钟过期、按用途隔离**
|
||||||
|
- 策略 `captcha_policy`:`always`(默认)/ `adaptive`(同来源失败 2 次后要求)/ `off`
|
||||||
|
- 登录**先验验证码再比口令**(否则攻击者能拿「密码对不对」当信号提前跑完字典)
|
||||||
|
- **安全加固**
|
||||||
|
- 失败限速改为 **IP + 用户名双维度**,任一超限即锁;新增验证码出图限速(60s/40 张)
|
||||||
|
- `current_user()` 每请求回查 `users.status` ⇒ 停用账号**立即**失效,不必等会话过期
|
||||||
|
- 安全响应头:CSP / `X-Frame-Options` / `nosniff` / `Referrer-Policy` / COOP;
|
||||||
|
`/api/*` 与 `/captcha*` 带 `no-store`
|
||||||
|
- 会话 cookie 显式 `HttpOnly` + `SameSite=Lax` + `Path=/`;`WB_COOKIE_SECURE=1` 可开 Secure
|
||||||
|
- `/logs/tail` 改为**仅管理员**;管理员不能停用/降权/删除自己
|
||||||
|
- **页面**:新增 `/login` 验证码、`/register`、`/profile`(个人中心,点右上角用户名进入);
|
||||||
|
`/config` 增加凭证状态与 `cookie_broken` 告警、实例级设置区;`/users` 增加邮箱/状态列与启停
|
||||||
|
|
||||||
|
### 变更
|
||||||
|
|
||||||
|
- 接口新增:`GET/POST /api/profile`、`POST /api/captcha`(机制自述)、
|
||||||
|
`POST /api/users/<id>/delete`;`GET /api/settings` 回传 `_globalKeys` / `_canEditGlobal`
|
||||||
|
- `/api/collect` 未配 Cookie 回 `409 no_cookie`,密文解不开回 `409 cookie_broken`
|
||||||
|
(不再静默当成「未配置」)
|
||||||
|
- `/records/export` 与 CLI `export-csv` 的默认文件名带账号名(多用户下同名会互相覆盖)
|
||||||
|
- `WB_COOKIE` 环境变量兜底**已移除** —— 它会导致串号
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- `db.get_db()` 在流式响应里被复用导致 `Cannot operate on a closed database`
|
||||||
|
(生成器内部改为自建连接)
|
||||||
|
- `.dockerignore` 的 `__pycache__/` 只匹配上下文根目录,嵌套目录会被打进镜像
|
||||||
|
- 注册成功提示与注册页说明里的 `**强调**` 字面量(HTML 不解析 Markdown)
|
||||||
|
- **`base.html` 顶层的 `{% set me = current_user() %}` 会覆盖子模板传入的同名变量**
|
||||||
|
—— 而 `current_user()` 只含 `id/username/display_name/is_admin`,于是个人中心把
|
||||||
|
`me.created_at` 渲染成空(「注册于 ·」)。局部变量改名 `cur`,`smoke.py` 加 3 条防回归断言
|
||||||
|
- `WB_COOKIE_SECURE` 没有写进 `docker-compose.yml` 的 `environment:`
|
||||||
|
—— 在 `.env` 里设了也不生效,文档里的开关实际是哑的(已补上,并加进 `.env.example`)
|
||||||
|
- 「配置管理 → 修改登录密码」提示写「至少 6 位」,与实际策略(≥8 位 + 两类字符)不符
|
||||||
|
- 「用户管理」删除说明写「可勾选保留」,而页面只有确认框、必删数据,措辞改为与实际一致
|
||||||
|
|
||||||
|
### 升级提示
|
||||||
|
|
||||||
|
- 纯 HTTP 局域网部署**不要**设 `WB_COOKIE_SECURE=1`,否则浏览器不回传会话 cookie,
|
||||||
|
表现为「刚登录完又被弹回登录页」
|
||||||
|
- 迁移后请到「配置管理」确认 Cookie 状态;`secret_state.broken = true` 说明
|
||||||
|
`data/instance.json` 里的 `cookie_key` 与写入时不一致,重新粘贴一次即可
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.1.0] — 2026-09-14
|
## [1.1.0] — 2026-09-14
|
||||||
|
|
||||||
**主题:项目定名 `workbuddy-portal` · 容器化 · 文档体系**
|
**主题:项目定名 `workbuddy-portal` · 容器化 · 文档体系**
|
||||||
@@ -22,16 +486,31 @@
|
|||||||
- **容器环境变量**:`WB_HOST` `WB_PORT` `WB_DATA_DIR` `WB_LOG_DIR` `WB_DB`
|
- **容器环境变量**:`WB_HOST` `WB_PORT` `WB_DATA_DIR` `WB_LOG_DIR` `WB_DB`
|
||||||
`WB_ADMIN_USER` `WB_ADMIN_PASSWORD` `WB_DISABLE_SCHEDULER` `WB_IMPORT_CREDS` `WB_IMPORT_XLSX`
|
`WB_ADMIN_USER` `WB_ADMIN_PASSWORD` `WB_DISABLE_SCHEDULER` `WB_IMPORT_CREDS` `WB_IMPORT_XLSX`
|
||||||
- **文档体系** `docs/`:
|
- **文档体系** `docs/`:
|
||||||
[用户使用手册](USER-GUIDE.md)(含 9 张界面截图)、
|
[用户使用手册](USER-GUIDE.md)(含 9 张界面截图,**全部用合成示例数据渲染**)、
|
||||||
[部署与运维指南](DEPLOYMENT.md)(含推镜像到 Gitea 注册表的完整流程)、
|
[部署与运维指南](DEPLOYMENT.md)(含推镜像到 Gitea 注册表的完整流程)、
|
||||||
[架构与设计说明](ARCHITECTURE.md)、
|
[架构与设计说明](ARCHITECTURE.md)、
|
||||||
[接口参考](API.md)、
|
[接口参考](API.md)、
|
||||||
[常见问题](FAQ.md)
|
[常见问题](FAQ.md)
|
||||||
|
- **开源声明体系**(仓库根目录):[LICENSE](../LICENSE)(MIT)、
|
||||||
|
[THIRD-PARTY-NOTICES.md](../THIRD-PARTY-NOTICES.md)(依赖清单与再分发合规自查)、
|
||||||
|
[CONTRIBUTING.md](../CONTRIBUTING.md)(含「必须遵守的不变量」与五层自检方法)、
|
||||||
|
[SECURITY.md](../SECURITY.md)、[CODE_OF_CONDUCT.md](../CODE_OF_CONDUCT.md)、
|
||||||
|
`.github/` 下的 Issue 表单与 PR 模板、`.editorconfig`,
|
||||||
|
以及给全部 Python / Shell 源文件加 `SPDX-License-Identifier: MIT` 头
|
||||||
|
- **`tools/demo_data.py`**:生成**完全合成**的示例库(假模型名 / 假 Prompt / 偏斜的积分分布),
|
||||||
|
写入 `data/demo/`(已在 `.gitignore` 内)。文档截图与本地调试都基于它,
|
||||||
|
任何人不需要真实账号就能复现整套界面
|
||||||
- **`.gitattributes`**:强制 `*.sh` / `Dockerfile` / 各类源码为 LF
|
- **`.gitattributes`**:强制 `*.sh` / `Dockerfile` / 各类源码为 LF
|
||||||
(带 CRLF 的 `.sh` 在容器里会报 `exec format error`,极难定位)
|
(带 CRLF 的 `.sh` 在容器里会报 `exec format error`,极难定位)
|
||||||
|
|
||||||
### 变更
|
### 变更
|
||||||
|
|
||||||
|
- **文档数据脱敏**:9 张界面截图全部改用合成示例数据重拍;
|
||||||
|
`docs/API.md`、`docs/DEPLOYMENT.md` 示例响应里的真实模型名与真实统计数字一并替换为示例口径。
|
||||||
|
此前截图中含**真实 Prompt 全文**、本机路径与本机用户名,属于不该公开的内容
|
||||||
|
- **`.gitignore` 补强**:只写 `data/*.sqlite` 会漏掉子目录,改为同时保留 `data/**/*.sqlite`
|
||||||
|
等规则,并新增 `data/demo/` 忽略——否则 `tools/demo_data.py` 的产物会被误提交
|
||||||
|
|
||||||
- **项目定名**:`wb_usage_portal` → **`workbuddy-portal`**;
|
- **项目定名**:`wb_usage_portal` → **`workbuddy-portal`**;
|
||||||
Python 包 `wb_usage` → **`workbuddy_portal`**;会话 cookie
|
Python 包 `wb_usage` → **`workbuddy_portal`**;会话 cookie
|
||||||
`wb_usage_sid` → `workbuddy_portal_sid`(升级后需要重新登录)
|
`wb_usage_sid` → `workbuddy_portal_sid`(升级后需要重新登录)
|
||||||
@@ -66,7 +545,7 @@
|
|||||||
| 容器跑着跑着页面全 500,日志里 `sqlite3.OperationalError: unable to open database file` | 数据原本用**绑定挂载**;Windows + Docker Desktop 走 **9p**,宿主的 Windows 进程只要访问过这个 WAL 库(**纯读也会触发**),容器侧下一次连接就重建不了 `-shm`,且**不会自愈** | 改为 **Docker 命名卷**(容器独占数据目录);需要宿主目录时叠加 `docker-compose.hostdir.yml`(仅建议 Linux) |
|
| 容器跑着跑着页面全 500,日志里 `sqlite3.OperationalError: unable to open database file` | 数据原本用**绑定挂载**;Windows + Docker Desktop 走 **9p**,宿主的 Windows 进程只要访问过这个 WAL 库(**纯读也会触发**),容器侧下一次连接就重建不了 `-shm`,且**不会自愈** | 改为 **Docker 命名卷**(容器独占数据目录);需要宿主目录时叠加 `docker-compose.hostdir.yml`(仅建议 Linux) |
|
||||||
|
|
||||||
最小复现:容器正常 → 宿主跑一次 `manage.py stats` → 容器立刻打不开库、且重启前不再恢复。
|
最小复现:容器正常 → 宿主跑一次 `manage.py stats` → 容器立刻打不开库、且重启前不再恢复。
|
||||||
已写进 [DEPLOYMENT.md 第九节](DEPLOYMENT.md#windows-绑定挂载的坑容器打不开数据库)。
|
已写进 [DEPLOYMENT.md 第十一节](DEPLOYMENT.md#115-windows-绑定挂载的坑容器打不开数据库)。
|
||||||
|
|
||||||
### 安全
|
### 安全
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,22 @@ Error response from daemon: Ports are not available: exposing port TCP 0.0.0.0:8
|
|||||||
2. 服务器防火墙有没有放行该端口;
|
2. 服务器防火墙有没有放行该端口;
|
||||||
3. `docker compose ps` 的 `PORTS` 是不是 `0.0.0.0:8848->8848/tcp`。
|
3. `docker compose ps` 的 `PORTS` 是不是 `0.0.0.0:8848->8848/tcp`。
|
||||||
|
|
||||||
|
### Q:登录成功却立刻又跳回登录页(循环)
|
||||||
|
|
||||||
|
99% 是 `WB_COOKIE_SECURE` 被开成了 `1`,而你在用 **HTTP** 访问。
|
||||||
|
|
||||||
|
会话 Cookie 加了 `Secure` 属性后,浏览器**只在 HTTPS 下才回传它**——于是服务端每次收到
|
||||||
|
请求都看不到会话,判定未登录,再把你送回登录页。日志里看起来是「一直在登录」。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env 里改回 0(纯局域网 HTTP 部署的正确值),然后重建容器
|
||||||
|
WB_COOKIE_SECURE=0
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
> 只有在前面真的挂了 HTTPS 反向代理、并且用域名访问时,才把它设为 `1`。
|
||||||
|
> 注意 `TZ`、`WB_COOKIE_SECURE` 都是**环境变量**,`docker compose restart` 不生效,要 `up -d`。
|
||||||
|
|
||||||
### Q:容器 `unhealthy` 但 `Up`
|
### Q:容器 `unhealthy` 但 `Up`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -58,7 +74,7 @@ docker compose exec portal python manage.py stats
|
|||||||
```
|
```
|
||||||
|
|
||||||
完整复现步骤与原理见
|
完整复现步骤与原理见
|
||||||
[部署与运维指南](DEPLOYMENT.md#windows-绑定挂载的坑容器打不开数据库)。
|
[部署与运维指南](DEPLOYMENT.md#115-windows-绑定挂载的坑容器打不开数据库)。
|
||||||
|
|
||||||
### Q:`database is locked` / `disk I/O error`
|
### Q:`database is locked` / `disk I/O error`
|
||||||
|
|
||||||
@@ -88,11 +104,27 @@ python -c "p='docker/entrypoint.sh';d=open(p,'rb').read();open(p,'wb').write(d.r
|
|||||||
|
|
||||||
### Q:采集报 `cookie_expired` / `unauthorized`
|
### Q:采集报 `cookie_expired` / `unauthorized`
|
||||||
|
|
||||||
Cookie 过期。重新获取(见 [用户手册 3.2](USER-GUIDE.md#32-拿-cookie-的两种办法)),
|
Cookie 过期。重新获取(见 [用户手册 4.2](USER-GUIDE.md#42-拿-cookie-的两种办法)),
|
||||||
填进「配置管理 → 凭证」,保存后按区间补采。
|
填进「配置管理 → **我的云端凭证**」,保存后按区间补采。
|
||||||
|
|
||||||
> Cookie 通常是浏览器会话级,**关掉浏览器可能就失效**。从已登录浏览器复制时勾选「保持登录」。
|
> Cookie 通常是浏览器会话级,**关掉浏览器可能就失效**。从已登录浏览器复制时勾选「保持登录」。
|
||||||
|
|
||||||
|
### Q:采集被跳过,日志写 `no_cookie`
|
||||||
|
|
||||||
|
这个账号**还没配 Cookie**。多用户下每个账号要各自配一次——系统**不会**拿别人的 Cookie
|
||||||
|
替你采集(那会把两个人的数据混在一起)。到「配置管理 → 我的云端凭证」粘贴一份即可。
|
||||||
|
|
||||||
|
### Q:日志写 `cookie_broken` / 页面显示「无法解密」
|
||||||
|
|
||||||
|
数据库里的 Cookie 密文,用当前实例主密钥解不开了。通常是 `data/instance.json`
|
||||||
|
(存着 `cookie_key`)被删、被替换,或从别的机器拷了库过来。
|
||||||
|
|
||||||
|
**动作**:重新粘贴一次该账号的 Cookie,历史数据不受影响。
|
||||||
|
**预防**:备份时把 `data/instance.json` 和数据一起备份,别在容器之间混用。
|
||||||
|
|
||||||
|
> 这是**静态加密的正确行为**——密钥换了就该解不开;如果它「解不开也照样能用」,
|
||||||
|
> 那说明根本没加密。
|
||||||
|
|
||||||
### Q:采集成功但「新增 0 条」
|
### Q:采集成功但「新增 0 条」
|
||||||
|
|
||||||
大概率正常。看那一次的 `抓取` 条数:
|
大概率正常。看那一次的 `抓取` 条数:
|
||||||
@@ -195,6 +227,39 @@ docker compose exec portal python -c "from workbuddy_portal import db; print(db.
|
|||||||
|
|
||||||
## 五、账号与权限
|
## 五、账号与权限
|
||||||
|
|
||||||
|
### Q:怎么开放/关闭自助注册
|
||||||
|
|
||||||
|
「配置管理 → 实例级设置 → 开放自助注册」(**仅管理员可见**)。
|
||||||
|
打开后登录页会出现「自助注册」链接,任何人填表即可建号;关掉后只能由管理员在
|
||||||
|
「用户管理 → 新建账号」里建。默认是**开放**。
|
||||||
|
|
||||||
|
### Q:注册被拒 / 提示来源已达上限
|
||||||
|
|
||||||
|
同一个 IP 每天默认只能注册 3 个账号(`register_max_per_ip`,范围 1 ~ 50)。
|
||||||
|
这条限制是防批量刷号用的;换个来源,或由管理员调大。
|
||||||
|
|
||||||
|
### Q:验证码一直不对
|
||||||
|
|
||||||
|
按这个顺序排查:
|
||||||
|
|
||||||
|
| 现象 | 原因 |
|
||||||
|
|---|---|
|
||||||
|
| 刚才还能用,第二次就错 | 验证码**一次性**,用一次即废;输错也要重新取图 |
|
||||||
|
| 输得慢一点就错 | 有效期 **5 分钟**,过期即失效 |
|
||||||
|
| 拿登录页的码去注册 | 两个 `purpose` 的验证码**互不通用** |
|
||||||
|
| 明明对了还是被拒 | 该来源已被锁定(连续失败 5 次 → 锁 10 分钟) |
|
||||||
|
|
||||||
|
**动作**:点验证码图片换一张重来。想少费眼力,让管理员把「验证码位数」保持在 4 位。
|
||||||
|
|
||||||
|
> 验证码答案只存在服务端 `captchas` 表:下发到浏览器的是一个随机 `captcha_id`,
|
||||||
|
> 校验后无论成败都立刻删除。所以**在网页源码里搜不到答案**,抓图也拿不到复用的码。
|
||||||
|
|
||||||
|
### Q:验证码能关掉吗
|
||||||
|
|
||||||
|
「配置管理 → 实例级设置 → 验证码策略」有 `always` / `adaptive` / `off` 三档。
|
||||||
|
`adaptive` 只在同一来源**连续失败 2 次后**才要验证码,对天天登录的人更友好。
|
||||||
|
`off` 会显著放大撞库与批量注册的风险,**只有在前面已有可信网关时才考虑**。
|
||||||
|
|
||||||
### Q:忘记管理员密码
|
### Q:忘记管理员密码
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -207,20 +272,58 @@ docker compose exec portal python manage.py passwd admin 新密码
|
|||||||
|
|
||||||
不传新密码时会用默认的 `admin123`——**别这么干**。
|
不传新密码时会用默认的 `admin123`——**别这么干**。
|
||||||
|
|
||||||
### Q:怎么给同事开只读账号
|
账号被停用了要顺便恢复启用,加 `--activate`:
|
||||||
|
|
||||||
「用户管理 → 新建账号」,**不要勾**「管理员」。
|
```bash
|
||||||
普通用户能看所有页面、能导出、能触发采集,但看不到「用户管理」且访问 `/users` 返回 403。
|
python manage.py passwd admin 新密码 --activate
|
||||||
|
```
|
||||||
|
|
||||||
|
想看现在有哪些账号、各自角色/状态/数据量/凭证状态:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python manage.py users
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q:怎么给同事开账号
|
||||||
|
|
||||||
|
两种都行:
|
||||||
|
|
||||||
|
1. 让同事**自助注册**(需管理员开放注册);
|
||||||
|
2. 「用户管理 → 新建账号」,权限选**普通**(默认就是普通,管理员要显式选)。
|
||||||
|
|
||||||
|
普通用户能看概览/大屏/明细/任务/配置/日志,能改**自己的**凭证与采集参数、能触发采集与导出;
|
||||||
|
但看不到「用户管理」(访问 `/users` 返回 403),也改不了实例级设置(输入框置灰,接口也会拒)。
|
||||||
|
|
||||||
|
> 建完账号记得告诉同事:**要自己配一份自己的 Cookie**,否则采集不会跑(日志里是 `no_cookie`)。
|
||||||
|
|
||||||
|
### Q:管理员能看到别人的数据吗
|
||||||
|
|
||||||
|
**看不到。** 用户管理页只显示每个账号的记录条数与积分合计,点不进内容;
|
||||||
|
任何页面上 Cookie 都只回显「长度 + 结尾 4 位」。采集也只用本人凭证。
|
||||||
|
|
||||||
|
所以「把两个人的数据合起来看」要各自导出 CSV 再到外部合并——这是刻意的边界,不是缺陷。
|
||||||
|
|
||||||
|
### Q:误操作了别人账号 / 删错了人
|
||||||
|
|
||||||
|
- **停用**是可逆的:数据与 Cookie 都保留,随时可以再启用;
|
||||||
|
- **删除**不可逆:会连同该账号的用量数据与 Cookie 一起删。只能靠备份恢复
|
||||||
|
(见 [六、运维 · 备份](#q备份怎么做最稳))。
|
||||||
|
|
||||||
|
每次账号操作都会写 `audit_log`,在「用户管理 → 账号操作审计」里能查到谁在什么时候动的。
|
||||||
|
|
||||||
### Q:不小心把自己降级 / 删掉自己了
|
### Q:不小心把自己降级 / 删掉自己了
|
||||||
|
|
||||||
做不到。服务端有三条护栏:不能取消自己的管理员身份、不能删除自己、至少保留一个账号。
|
做不到。服务端有四条护栏:不能取消自己的管理员身份、不能停用自己、不能删除自己、
|
||||||
|
不能删掉最后一个启用的管理员。
|
||||||
|
|
||||||
### Q:所有人被踢下线了
|
### Q:所有人被踢下线了
|
||||||
|
|
||||||
`SECRET_KEY` 变了。它存在 `data/instance.json`。这个文件丢了/被删了就会重新生成,
|
`SECRET_KEY` 变了。它存在 `data/instance.json`。这个文件丢了/被删了就会重新生成,
|
||||||
所有会话失效(**数据不受影响**)。恢复办法:从备份里找回 `instance.json`,或让大家重新登录。
|
所有会话失效(**数据不受影响**)。恢复办法:从备份里找回 `instance.json`,或让大家重新登录。
|
||||||
|
|
||||||
|
> 同一个文件里还有 `cookie_key`(凭证加密主密钥),它变了会让**所有账号的 Cookie 都要重填**。
|
||||||
|
> 备份数据库时务必把 `instance.json` 一起备份。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 六、运维
|
## 六、运维
|
||||||
@@ -247,7 +350,7 @@ docker run --rm -v workbuddy-portal_wb_data:/data:ro -v "$PWD/backup":/backup \
|
|||||||
alpine:3.20 tar czf /backup/wb-data-$(date +%F).tar.gz -C /data .
|
alpine:3.20 tar czf /backup/wb-data-$(date +%F).tar.gz -C /data .
|
||||||
```
|
```
|
||||||
|
|
||||||
恢复见 [部署指南 6.3](DEPLOYMENT.md#63-恢复)。
|
恢复见 [部署指南 8.3](DEPLOYMENT.md#83-恢复)。
|
||||||
**别把新库配旧 WAL 用**——会损坏数据(用上面已 checkpoint 的单文件备份最省心)。
|
**别把新库配旧 WAL 用**——会损坏数据(用上面已 checkpoint 的单文件备份最省心)。
|
||||||
|
|
||||||
### Q:数据库文件越来越大
|
### Q:数据库文件越来越大
|
||||||
@@ -256,27 +359,86 @@ docker run --rm -v workbuddy-portal_wb_data:/data:ro -v "$PWD/backup":/backup \
|
|||||||
docker compose exec portal python manage.py vacuum
|
docker compose exec portal python manage.py vacuum
|
||||||
```
|
```
|
||||||
|
|
||||||
或在「配置管理 → 维护动作 → 整理数据库」点一下。
|
或在「配置管理 → 维护动作 → 整理数据库」点一下(**这个按钮仅管理员可见**,
|
||||||
|
因为它动的是整库,不只你的数据)。
|
||||||
作用是 `wal_checkpoint(TRUNCATE)` + `VACUUM`,回收删除后的空闲页并压缩 WAL。
|
作用是 `wal_checkpoint(TRUNCATE)` + `VACUUM`,回收删除后的空闲页并压缩 WAL。
|
||||||
|
|
||||||
|
### Q:从 1.1.0 升级到 1.2.0 要做什么
|
||||||
|
|
||||||
|
**手工动作:零。** 首次启动新版本时会自动迁移:
|
||||||
|
|
||||||
|
1. 老数据整体归到**第一个账号**(也就是原来的那个唯一账号);
|
||||||
|
2. 原来明文存的 Cookie **就地加密**,日志里会记一条
|
||||||
|
`明文凭证已加密:settings[uid=1].cookie`;
|
||||||
|
3. 建 `captchas` 表、给各表补 `user_id` 列与索引。
|
||||||
|
|
||||||
|
### Q:从 1.2.0 升级到 1.3.0 要做什么
|
||||||
|
|
||||||
|
**同样零手工动作**,但会做一次**配置作用域收敛**,值得知道它改了什么:
|
||||||
|
|
||||||
|
| 做了什么 | 效果 |
|
||||||
|
|---|---|
|
||||||
|
| 把管理员个人名下的**调度与采集参数**提升到实例级(`user_id=0`) | 这些策略从此「一台部署一套」,对所有账号统一生效 |
|
||||||
|
| 清掉这些键在 `user_id<>0` 下的残留 | 不会再有「某个账号偷偷带着一份自己的旧调度时刻」 |
|
||||||
|
| 实例级不再保留 `cookie` / `user_agent` | 凭证只属于个人;实例级存凭证等于给所有账号发同一张身份 |
|
||||||
|
|
||||||
|
审计里会留一条 `promote_global_settings`,日志里能看到「配置作用域收敛」。
|
||||||
|
迁移**幂等**,可重复启动。
|
||||||
|
|
||||||
|
**升级后建议确认两件事:**
|
||||||
|
|
||||||
|
1. 各账号的 Cookie 仍然「已配置」(能解密)——「配置管理 → 我的云端凭证」;
|
||||||
|
2. 用一个普通账号登录,「任务管理」里的调度时刻应当是**只读**的,
|
||||||
|
点保存会被拒并点名越权项。
|
||||||
|
|
||||||
|
> 如果你的旧部署里**不同账号原本设了不同的采集时刻**,升级后会统一成管理员那一刻。
|
||||||
|
> 这是刻意的(一台部署一个时刻表,避免多个采集抢同一把写锁),
|
||||||
|
> 但需要提前知会使用者:他们之后要改时刻得找管理员。
|
||||||
|
|
||||||
|
看迁移结果:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs portal | grep -iE "迁移|migrat|收敛|promote"
|
||||||
|
docker compose exec portal python manage.py users
|
||||||
|
docker compose exec portal python manage.py stats
|
||||||
|
```
|
||||||
|
|
||||||
|
**升级前务必备份**(含 `instance.json`):迁移会改主键与索引,虽然实现了回滚失败即中止,
|
||||||
|
但备份永远是第一道保险。
|
||||||
|
|
||||||
### Q:升级会不会丢数据
|
### Q:升级会不会丢数据
|
||||||
|
|
||||||
不会。数据在 Docker 命名卷 `workbuddy-portal_wb_data` 里(对应容器内 `/app/data`),
|
不会。数据在 Docker 命名卷 `workbuddy-portal_wb_data` 里(对应容器内 `/app/data`),
|
||||||
`docker compose up -d --build` 只重建容器,不碰卷。
|
`docker compose up -d --build` 只重建容器,不碰卷。
|
||||||
但**升级前依然要备份**(见上一条):`schema.sql` 用 `CREATE TABLE IF NOT EXISTS`,
|
但**升级前依然要备份**(见上一条)。
|
||||||
加表加索引安全,**改列需要手工迁移**。
|
|
||||||
|
> 迁移用 `PRAGMA user_version` 记录版本,**幂等**:重复启动不会重复迁移。
|
||||||
|
> 改列这种操作现在也由 `init_db()` 自动完成,不再是「需要手工迁移」。
|
||||||
|
> 想确认当前库结构版本:
|
||||||
|
> ```bash
|
||||||
|
> docker compose exec portal python -c "
|
||||||
|
> from workbuddy_portal import db; c = db.connect()
|
||||||
|
> print('user_version =', c.execute('PRAGMA user_version').fetchone()[0])"
|
||||||
|
> ```
|
||||||
|
> 1.2.0 是 `2`,1.3.0 是 `3`。
|
||||||
|
|
||||||
### Q:日志在哪、怎么滚动
|
### Q:日志在哪、怎么滚动
|
||||||
|
|
||||||
| 位置 | 内容 |
|
| 位置 | 内容 | 谁能看 |
|
||||||
|---|---|
|
|---|---|---|
|
||||||
| `docker compose logs portal` | 容器 stdout(entrypoint + waitress) |
|
| `docker compose logs portal` | 容器 stdout(entrypoint + waitress) | 能登服务器的人 |
|
||||||
| 命名卷 `workbuddy-portal_wb_logs` 里的 `app.log` | 应用日志,滚动 2 MB × 3 |
|
| 命名卷 `workbuddy-portal_wb_logs` 里的 `app.log` | 应用日志,滚动 2 MB × 3 | 能登服务器的人 |
|
||||||
| 页面「日志管理」 | 采集逐行日志 + 应用日志尾部 + 操作审计 |
|
| 页面「日志管理」 | 全实例采集逐行日志 + 应用日志尾部 + 操作审计 | **仅管理员** |
|
||||||
|
| 页面「任务管理 → 运行历史」 | **你自己**的采集记录(触发方式、耗时、条数) | 所有登录用户 |
|
||||||
|
|
||||||
|
> 普通账号看不到「日志管理」页(导航里不显示,直接敲 `/logs` 返回 403)。
|
||||||
|
> 自己排错先用「任务管理 → 运行历史」,需要逐行日志时找管理员。
|
||||||
|
|
||||||
### Q:想改采集的接口地址(走镜像/代理)
|
### Q:想改采集的接口地址(走镜像/代理)
|
||||||
|
|
||||||
「配置管理」里改 `api_base` 与 `api_path`。改了之后记得同步确认 Cookie 是该域下的有效凭证。
|
「配置管理 → 采集参数」里改 `api_base` 与 `api_path`。这两个是**实例级**键,
|
||||||
|
只有管理员能改(普通账号看到的是置灰的输入框,接口层面也会拒绝)。
|
||||||
|
改了之后记得同步确认 Cookie 是该域下的有效凭证。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -287,14 +449,37 @@ docker compose exec portal python manage.py vacuum
|
|||||||
**五层,前两层必须跑绿**:
|
**五层,前两层必须跑绿**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python tools/smoke.py # 离线回归 99 项
|
python tools/smoke.py # 离线回归 215 项(不需要起服务)
|
||||||
|
python tools/check_docs.py # 文档自检(改过任何 md 就跑)
|
||||||
|
python tools/demo_data.py # 可选:造一份合成示例库
|
||||||
python manage.py serve --port 8849 --no-scheduler # 另开终端
|
python manage.py serve --port 8849 --no-scheduler # 另开终端
|
||||||
python tools/check_live.py --base http://127.0.0.1:8849 # 真实 HTTP 56 项
|
python tools/check_live.py --base http://127.0.0.1:8849 # 真实 HTTP 122 项
|
||||||
python tools/shots.py --base http://127.0.0.1:8849 --full # 界面截图 + JS 报错
|
python tools/shots.py --base http://127.0.0.1:8849 --full # 界面截图 + JS 报错
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 对**示例库**跑 `check_live.py` 时记得加 `--db data/demo/usage.sqlite` ——
|
||||||
|
> 它的 `--db` 默认值是真实的 `data/usage.sqlite`,不传会从错误库里取验证码答案,
|
||||||
|
> 症状是「登录失败」加一串看不懂的断言失败。
|
||||||
|
|
||||||
|
两个新增参数值得一提:
|
||||||
|
|
||||||
|
- `check_live.py --db <路径>`:让它直接读库里的验证码答案,从而**自动过验证码**登录;
|
||||||
|
- `shots.py --db <路径>`:同上,截图脚本自动解开登录页与注册页。
|
||||||
|
|
||||||
详见 [架构说明 · 验证体系](ARCHITECTURE.md#十验证体系)。
|
详见 [架构说明 · 验证体系](ARCHITECTURE.md#十验证体系)。
|
||||||
|
|
||||||
|
### Q:改了多用户的代码,怎么确认没越权
|
||||||
|
|
||||||
|
三个低成本自查:
|
||||||
|
|
||||||
|
1. 在 `smoke.py` 的隔离小节里加一条断言 —— 调用 `query.*` 时**故意漏掉 `uid`**,
|
||||||
|
期望它抛 `TypeError`(本项目把 `uid` 设计成「`conn` 之后的第一个位置参数、无默认值」,
|
||||||
|
漏传就炸,不会静默返回全量);
|
||||||
|
2. 临时建一个普通账号,把 `WB_DISABLE_SCHEDULER` 之类放一边,直接访问 `/users` 与
|
||||||
|
`POST /api/settings` 写实例级键,都应该是 403;
|
||||||
|
3. 写一个**哨兵值**(如 `SMOKE-SENTINEL-UA`)到实例级 `user_agent`,
|
||||||
|
断言新账号读不到它——这一招能抓到「落回落到别人配置上」的越权。
|
||||||
|
|
||||||
### Q:`ModuleNotFoundError: No module named 'flask'`
|
### Q:`ModuleNotFoundError: No module named 'flask'`
|
||||||
|
|
||||||
选错解释器了。依赖装在项目的 venv 或托管环境里:
|
选错解释器了。依赖装在项目的 venv 或托管环境里:
|
||||||
|
|||||||
|
之前 宽度: | 高度: | 大小: 93 KiB 之后 宽度: | 高度: | 大小: 334 KiB |
|
之前 宽度: | 高度: | 大小: 170 KiB 之后 宽度: | 高度: | 大小: 643 KiB |
|
之前 宽度: | 高度: | 大小: 616 KiB 之后 宽度: | 高度: | 大小: 1.5 MiB |
|
之前 宽度: | 高度: | 大小: 164 KiB 之后 宽度: | 高度: | 大小: 810 KiB |
|
之后 宽度: | 高度: | 大小: 509 KiB |
|
之前 宽度: | 高度: | 大小: 140 KiB 之后 宽度: | 高度: | 大小: 708 KiB |
|
之后 宽度: | 高度: | 大小: 638 KiB |
|
之前 宽度: | 高度: | 大小: 218 KiB 之后 宽度: | 高度: | 大小: 833 KiB |
|
之前 宽度: | 高度: | 大小: 98 KiB 之后 宽度: | 高度: | 大小: 498 KiB |
|
之前 宽度: | 高度: | 大小: 397 KiB 之后 宽度: | 高度: | 大小: 1.5 MiB |
|
之前 宽度: | 高度: | 大小: 397 KiB 之后 宽度: | 高度: | 大小: 1.5 MiB |
|
之后 宽度: | 高度: | 大小: 383 KiB |
|
之后 宽度: | 高度: | 大小: 469 KiB |
|
之后 宽度: | 高度: | 大小: 689 KiB |
@@ -1,78 +1,211 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
# Copyright (c) 2026 Wang Chuanli
|
||||||
|
|
||||||
"""WorkBuddy Portal —— 统一命令行入口。
|
"""WorkBuddy Portal —— 统一命令行入口。
|
||||||
|
|
||||||
采集 / 存储 / 呈现三件事都由本项目承担,不再依赖外部计划任务或自动化。
|
采集 / 存储 / 呈现三件事都由本项目承担,不再依赖外部计划任务或自动化。
|
||||||
|
|
||||||
|
多用户说明:所有涉及「数据」或「凭证」的子命令都作用于**某一个账号**。
|
||||||
|
用 `-u/--user <用户名>` 指定;不指定时取「管理员优先、其次 id 最小」的那个
|
||||||
|
(老库升级后数据都在首个账号名下,所以不指定也能沿用旧习惯)。
|
||||||
|
唯独 `collect` 不带 `-u` 时会**逐个账号**跑一遍,与进程内调度线程的行为一致。
|
||||||
|
|
||||||
常用:
|
常用:
|
||||||
python manage.py init 初始化数据库(建表 + 默认配置 + 管理员)
|
python manage.py init 初始化数据库(建表 + 默认配置 + 管理员)
|
||||||
python manage.py serve 启动 Web(0.0.0.0:8848,进程内含调度线程)
|
python manage.py serve 启动 Web(0.0.0.0:8848,进程内含调度线程)
|
||||||
python manage.py serve --port 9000 --debug 开发模式(reloader 下调度只启动一份)
|
python manage.py serve --debug --host 127.0.0.1 开发模式(--debug 只允许绑回环地址)
|
||||||
python manage.py collect 执行一次增量采集并退出(可用于外部计划任务)
|
python manage.py collect 为**所有已启用账号**各跑一次增量采集
|
||||||
|
python manage.py collect -u alice 只为 alice 采集
|
||||||
python manage.py migrate-csv [文件] 从旧版 CSV 存档导入(默认自动探测路径)
|
python manage.py migrate-csv [文件] 从旧版 CSV 存档导入(默认自动探测路径)
|
||||||
python manage.py import-xlsx <文件> 从官网导出的 xlsx 合入
|
python manage.py import-xlsx <文件> 从官网导出的 xlsx 合入
|
||||||
python manage.py fill-prompt 补全缺失的 User Prompt
|
python manage.py fill-prompt 补全缺失的 User Prompt
|
||||||
python manage.py export-csv [路径] 导出与官网同构的 CSV
|
python manage.py export-csv [路径] 导出与官网同构的 CSV(文件名带账号名)
|
||||||
python manage.py stats 只看存档概况,不联网
|
python manage.py stats [-u 账号] 看存档概况,不联网
|
||||||
python manage.py passwd <用户名> [新密码] 重置登录密码
|
python manage.py users 列出所有账号及其数据量 / 凭证状态
|
||||||
python manage.py status 查看调度与最近采集状态
|
python manage.py passwd <用户名> [新密码] 重置密码;账号不存在则创建
|
||||||
|
python manage.py passwd <用户名> --role admin 新建或提权为管理员
|
||||||
|
python manage.py status 查看各账号的调度与最近采集状态
|
||||||
|
python manage.py vacuum 整理数据库(checkpoint + VACUUM)
|
||||||
|
python manage.py backup 立即打一份备份(所有 *.sqlite + instance.json)
|
||||||
|
python manage.py backups 列出备份;--prune 按保留份数清理最旧的
|
||||||
|
python manage.py restore <文件名> --yes 从备份恢复(恢复前会自动再备份一份当前库)
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
from workbuddy_portal import client, collect, config, db, query, scheduler # noqa: E402
|
from workbuddy_portal import backup, client, collect, config, db, query, scheduler # noqa: E402
|
||||||
|
from workbuddy_portal import security # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
def _p(*a):
|
def _p(*a):
|
||||||
print(*a)
|
print(*a)
|
||||||
|
|
||||||
|
|
||||||
|
def _warn(*a):
|
||||||
|
print(*a)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 账号解析 ----------------
|
||||||
|
def _default_uid(conn):
|
||||||
|
"""没显式指定 `-u` 时的目标账号:管理员优先,其次 id 最小。
|
||||||
|
|
||||||
|
老库升级后全部数据都归到首个账号,所以这个默认值正好等价于旧行为;
|
||||||
|
全空库(只有实例级配置)返回 0,即「实例作用域」。
|
||||||
|
"""
|
||||||
|
row = conn.execute("SELECT id FROM users ORDER BY is_admin DESC, id LIMIT 1").fetchone()
|
||||||
|
return row["id"] if row else 0
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_uid(conn, name):
|
||||||
|
"""把 `-u` 的取值(用户名或数字 id)解析成 uid;解析不到返回 None。"""
|
||||||
|
if name is None or name == "":
|
||||||
|
return _default_uid(conn)
|
||||||
|
row = db.user_by_name(conn, str(name))
|
||||||
|
if row is None and str(name).isdigit():
|
||||||
|
row = db.user_by_id(conn, int(name))
|
||||||
|
if row is None:
|
||||||
|
_p("[error] 没有这个账号:%s(用 manage.py users 查看)" % name)
|
||||||
|
return None
|
||||||
|
return row["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def _uid_or_fail(conn, name):
|
||||||
|
"""解析失败时返回 None,调用方自行 return 2。"""
|
||||||
|
uid = _resolve_uid(conn, name)
|
||||||
|
if uid is None:
|
||||||
|
return None
|
||||||
|
if uid == 0:
|
||||||
|
_warn("[warn] 库里还没有任何账号,本次按“实例作用域”执行(先跑 manage.py init)")
|
||||||
|
return uid
|
||||||
|
|
||||||
|
|
||||||
|
def _ua_of(conn, uid):
|
||||||
|
"""人话描述某个账号的 Cookie 状态(只看密文可解性,不碰明文)。"""
|
||||||
|
st = db.secret_state(conn, "cookie", uid)
|
||||||
|
if st["broken"]:
|
||||||
|
return "损坏(密钥换过,需重新粘贴)"
|
||||||
|
if not st["set"]:
|
||||||
|
return "未配置"
|
||||||
|
return "已配置 %d 字符" % st["chars"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 初始化 / 服务 ----------------
|
||||||
def cmd_init(args):
|
def cmd_init(args):
|
||||||
db.init_db(admin_user=args.user, admin_password=args.password)
|
db.init_db(admin_user=args.user, admin_password=args.password)
|
||||||
conn = db.connect()
|
conn = db.connect()
|
||||||
try:
|
try:
|
||||||
n = query.totals(conn)
|
uid = _default_uid(conn)
|
||||||
|
n = query.totals(conn, uid)
|
||||||
_p("数据库已就绪:%s" % config.SQLITE_PATH)
|
_p("数据库已就绪:%s" % config.SQLITE_PATH)
|
||||||
_p(" 存档 %d 条 / %.2f 积分 / %d 个活跃日" % (n["records"], n["credits"], n["days"]))
|
_p(" 账号数:%d" % db.user_count(conn))
|
||||||
_p(" 管理员:%s" % args.user)
|
_p(" 主账号存档 %d 条 / %.2f 积分 / %d 个活跃日" % (n["records"], n["credits"], n["days"]))
|
||||||
|
_p(" 操作账号:%s" % args.user)
|
||||||
|
# 随机口令只在「本次进程刚生成」时返回 —— 重跑 init 时管理员已存在,这里是 None。
|
||||||
|
# 必须**明着打印**:库里只有散列,日志一旦滚掉就再也拿不回来了。
|
||||||
|
pwd = db.generated_admin_password()
|
||||||
|
if pwd:
|
||||||
|
_p("")
|
||||||
|
_p(" ================= 管理员初始口令 =================")
|
||||||
|
_p(" 用 户 名:%s" % args.user)
|
||||||
|
_p(" 初 始 口 令:%s" % pwd)
|
||||||
|
_p("")
|
||||||
|
_p(" 这是随机生成的(1.4.0 起不再有 admin123 这类默认口令)。")
|
||||||
|
_p(" 程序只打印这一次、数据库里只存散列 —— 找不回来,")
|
||||||
|
_p(" 请立刻抄走,并在登录后到「个人设置」改掉。")
|
||||||
|
_p(" ==================================================")
|
||||||
|
st = db.secret_state(conn, "cookie", uid)
|
||||||
|
if st["broken"]:
|
||||||
|
_p(" [warn] Cookie 密文无法解开(cookie_key 与写入时不一致),请登录后重新粘贴")
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def cmd_serve(args):
|
def cmd_serve(args):
|
||||||
from workbuddy_portal import create_app
|
from workbuddy_portal import create_app
|
||||||
app = create_app(start_scheduler=not args.no_scheduler)
|
|
||||||
host = args.host or config.DEFAULT_HOST
|
host = args.host or config.DEFAULT_HOST
|
||||||
port = args.port or config.DEFAULT_PORT
|
port = args.port or config.DEFAULT_PORT
|
||||||
|
# --debug 会打开 Werkzeug 的交互式调试器:**任意 Python 代码执行**。
|
||||||
|
# 它虽然有 PIN,但 PIN 会打印在控制台、且在某些部署下可由机器信息推导出来,
|
||||||
|
# 把它绑到 0.0.0.0(默认值就是这个)等于把 RCE 直接挂上公网。
|
||||||
|
# 这里直接拒绝:调试要么在本机回环地址上做,要么就别开调试器。
|
||||||
|
if args.debug and host not in ("127.0.0.1", "localhost", "::1"):
|
||||||
|
_p("[error] --debug 只能绑定回环地址(当前 --host %s)。" % host)
|
||||||
|
_p(" Werkzeug 调试器可执行任意 Python 代码,绑到对外地址等于开放 RCE。")
|
||||||
|
_p(" 本机调试请用:python manage.py serve --debug --host 127.0.0.1")
|
||||||
|
return 2
|
||||||
|
app = create_app(start_scheduler=not args.no_scheduler)
|
||||||
if args.debug:
|
if args.debug:
|
||||||
app.run(host=host, port=port, debug=True)
|
app.run(host=host, port=port, debug=True)
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
from waitress import serve
|
from waitress import serve
|
||||||
_p("生产模式(waitress)监听 http://%s:%d" % (host, port))
|
# 线程数是资源上限的一部分(见 config.THREADS 的注释):
|
||||||
serve(app, host=host, port=port, threads=8, ident="workbuddy-portal")
|
# 它决定单实例能同时吃进几个慢请求(采集 / 导出 / 备份恢复)。
|
||||||
|
_p("生产模式(waitress)监听 http://%s:%d,线程数 %d"
|
||||||
|
% (host, port, config.THREADS))
|
||||||
|
serve(app, host=host, port=port, threads=config.THREADS, ident="workbuddy-portal")
|
||||||
except ImportError:
|
except ImportError:
|
||||||
_p("[warn] 未安装 waitress,回退到 Flask 内置服务器(生产建议 pip install waitress)")
|
_p("[warn] 未安装 waitress,回退到 Flask 内置服务器(生产建议 pip install waitress)")
|
||||||
app.run(host=host, port=port, threaded=True)
|
app.run(host=host, port=port, threaded=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 采集 / 导入 / 导出 ----------------
|
||||||
def cmd_collect(args):
|
def cmd_collect(args):
|
||||||
|
"""不带 -u 时逐个已启用账号采集;带 -u 时只采一个。"""
|
||||||
db.init_db(create_admin=False)
|
db.init_db(create_admin=False)
|
||||||
|
conn = db.connect()
|
||||||
try:
|
try:
|
||||||
r = collect.run_sync(trigger="cli")
|
if args.user:
|
||||||
|
uid = _resolve_uid(conn, args.user)
|
||||||
|
if uid is None:
|
||||||
|
return 2
|
||||||
|
targets = [uid]
|
||||||
|
else:
|
||||||
|
targets = [r["id"] for r in db.active_users(conn)]
|
||||||
|
if not targets:
|
||||||
|
_p("[error] 没有任何启用中的账号")
|
||||||
|
return 2
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
rc = 0
|
||||||
|
for uid in targets:
|
||||||
|
conn = db.connect()
|
||||||
|
try:
|
||||||
|
row = db.user_by_id(conn, uid)
|
||||||
|
who = row["username"] if row else "uid=%s" % uid
|
||||||
|
st = db.secret_state(conn, "cookie", uid)
|
||||||
|
if not st["set"] or st["broken"]:
|
||||||
|
_p("— %s:跳过(Cookie %s)" % (who, _ua_of(conn, uid)))
|
||||||
|
continue
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
_p("— %s:" % who)
|
||||||
|
try:
|
||||||
|
r = collect.run_sync(trigger="cli", uid=uid)
|
||||||
except collect.Busy as e:
|
except collect.Busy as e:
|
||||||
_p("[busy] %s" % e)
|
_p(" [busy] %s" % e)
|
||||||
return 1
|
rc = rc or 1
|
||||||
|
continue
|
||||||
|
except collect.NotReady as e:
|
||||||
|
_p(" [skip] %s" % e)
|
||||||
|
continue
|
||||||
|
except db.SecretUnreadable as e:
|
||||||
|
_p(" [error] %s(请重新粘贴 Cookie)" % e)
|
||||||
|
rc = rc or 4
|
||||||
|
continue
|
||||||
except collect.ApiError as e:
|
except collect.ApiError as e:
|
||||||
_p("[error] %s" % e)
|
_p(" [error] %s" % e)
|
||||||
return 3 if e.cookie_expired else 5
|
rc = rc or (3 if e.cookie_expired else 5)
|
||||||
|
continue
|
||||||
for line in r["lines"]:
|
for line in r["lines"]:
|
||||||
_p(line)
|
_p(" " + line)
|
||||||
return 0
|
return rc
|
||||||
|
|
||||||
|
|
||||||
def cmd_migrate_csv(args):
|
def cmd_migrate_csv(args):
|
||||||
@@ -88,10 +221,13 @@ def cmd_migrate_csv(args):
|
|||||||
return 2
|
return 2
|
||||||
conn = db.connect()
|
conn = db.connect()
|
||||||
try:
|
try:
|
||||||
collect.migrate_from_csv(conn, path, log=_p)
|
uid = _uid_or_fail(conn, args.user)
|
||||||
n = query.totals(conn)
|
if uid is None:
|
||||||
_p("当前存档:%d 条 / %.2f 积分 / %s ~ %s" % (n["records"], n["credits"],
|
return 2
|
||||||
n["firstDay"], n["lastDay"]))
|
collect.migrate_from_csv(conn, uid, path, log=_p)
|
||||||
|
n = query.totals(conn, uid)
|
||||||
|
_p("账号 uid=%s 当前存档:%d 条 / %.2f 积分 / %s ~ %s"
|
||||||
|
% (uid, n["records"], n["credits"], n["firstDay"], n["lastDay"]))
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
return 0
|
return 0
|
||||||
@@ -101,49 +237,91 @@ def cmd_import_xlsx(args):
|
|||||||
db.init_db(create_admin=False)
|
db.init_db(create_admin=False)
|
||||||
conn = db.connect()
|
conn = db.connect()
|
||||||
try:
|
try:
|
||||||
collect.import_xlsx(conn, args.path, log=_p)
|
uid = _uid_or_fail(conn, args.user)
|
||||||
|
if uid is None:
|
||||||
|
return 2
|
||||||
|
collect.import_xlsx(conn, uid, args.path, log=_p)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def cmd_fill_prompt(args):
|
def cmd_fill_prompt(args):
|
||||||
db.init_db(create_admin=False)
|
db.init_db(create_admin=False)
|
||||||
conn = db.connect()
|
conn = db.connect()
|
||||||
try:
|
try:
|
||||||
collect.fill_prompt(conn, log=_p)
|
uid = _uid_or_fail(conn, args.user)
|
||||||
|
if uid is None:
|
||||||
|
return 2
|
||||||
|
collect.fill_prompt(conn, uid, log=_p)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def cmd_export_csv(args):
|
def cmd_export_csv(args):
|
||||||
db.init_db(create_admin=False)
|
db.init_db(create_admin=False)
|
||||||
conn = db.connect()
|
conn = db.connect()
|
||||||
try:
|
try:
|
||||||
path, n = collect.export_csv(conn, args.path)
|
uid = _uid_or_fail(conn, args.user)
|
||||||
|
if uid is None:
|
||||||
|
return 2
|
||||||
|
row = db.user_by_id(conn, uid)
|
||||||
|
path, n = collect.export_csv(conn, uid, args.path,
|
||||||
|
username=(row["username"] if row else None))
|
||||||
_p("已导出 %d 条 -> %s" % (n, path))
|
_p("已导出 %d 条 -> %s" % (n, path))
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 统计 ----------------
|
||||||
def cmd_stats(args):
|
def cmd_stats(args):
|
||||||
db.init_db(create_admin=False)
|
db.init_db(create_admin=False)
|
||||||
conn = db.connect()
|
conn = db.connect()
|
||||||
try:
|
try:
|
||||||
t = query.totals(conn)
|
# 先给一张全局概览:多用户下最常问的就是「一共多少、谁占多少」
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT u.id, u.username, u.display_name, u.is_admin, u.status,"
|
||||||
|
" COUNT(r.request_id) AS records, COALESCE(SUM(r.credits),0) AS credits"
|
||||||
|
" FROM users u LEFT JOIN usage_records r ON r.user_id=u.id"
|
||||||
|
" GROUP BY u.id ORDER BY records DESC, u.id").fetchall()
|
||||||
|
total = conn.execute("SELECT COUNT(*) AS c, COALESCE(SUM(credits),0) AS s"
|
||||||
|
" FROM usage_records").fetchone()
|
||||||
|
_p("全库存档:%d 条 / %.2f 积分 / %d 个账号"
|
||||||
|
% (total["c"], total["s"], db.user_count(conn)))
|
||||||
|
if rows:
|
||||||
|
_p("")
|
||||||
|
_p("%-4s %-16s %-10s %-6s %-8s %8s %12s" %
|
||||||
|
("id", "用户名", "角色", "状态", "Cookie", "调用", "积分"))
|
||||||
|
for r in rows:
|
||||||
|
_p("%-4d %-16s %-10s %-6s %-8s %8d %12.2f" %
|
||||||
|
(r["id"], r["username"], "管理员" if r["is_admin"] else "普通",
|
||||||
|
"启用" if r["status"] == "active" else "停用",
|
||||||
|
_ua_of(conn, r["id"]), r["records"], r["credits"]))
|
||||||
|
|
||||||
|
uid = _resolve_uid(conn, args.user)
|
||||||
|
if uid is None:
|
||||||
|
return 2
|
||||||
|
t = query.totals(conn, uid)
|
||||||
if not t["records"]:
|
if not t["records"]:
|
||||||
_p("存档为空,先跑 python manage.py migrate-csv 或 manage.py collect")
|
_p("")
|
||||||
return
|
_p("(uid=%s 没有数据;换个 -u,或先跑 manage.py migrate-csv / collect)" % uid)
|
||||||
|
return 0
|
||||||
|
_p("")
|
||||||
|
_p("== uid=%s 明细 ==" % uid)
|
||||||
_p("存档:%d 条 / %.2f 积分 / %d 个活跃日(%s ~ %s)"
|
_p("存档:%d 条 / %.2f 积分 / %d 个活跃日(%s ~ %s)"
|
||||||
% (t["records"], t["credits"], t["days"], t["firstDay"], t["lastDay"]))
|
% (t["records"], t["credits"], t["days"], t["firstDay"], t["lastDay"]))
|
||||||
_p("计费调用 %d · 免费调用 %d · 模型 %d · 客户端 %d"
|
_p("计费调用 %d · 免费调用 %d · 模型 %d · 客户端 %d"
|
||||||
% (t["billableCalls"], t["freeCalls"], t["models"], t["clients"]))
|
% (t["billableCalls"], t["freeCalls"], t["models"], t["clients"]))
|
||||||
_p("")
|
_p("")
|
||||||
_p("%-24s %8s %12s %10s %8s" % ("模型", "调用", "积分", "单次均价", "免费占比"))
|
_p("%-24s %8s %12s %10s %8s" % ("模型", "调用", "积分", "单次均价", "免费占比"))
|
||||||
for m in query.dims(conn)["model"]:
|
for m in query.dims(conn, uid)["model"]:
|
||||||
_p("%-24s %8d %12.2f %10.2f %7.0f%%"
|
_p("%-24s %8d %12.2f %10.2f %7.0f%%"
|
||||||
% (m["name"], m["calls"], m["credits"], m["avgPerCall"], m["freeRate"] * 100))
|
% (m["name"], m["calls"], m["credits"], m["avgPerCall"], m["freeRate"] * 100))
|
||||||
runs = conn.execute("SELECT id,trigger,status,started_at,added,dup,total,message"
|
runs = conn.execute("SELECT id,trigger,status,started_at,added,dup,total,message"
|
||||||
" FROM collect_runs ORDER BY id DESC LIMIT 5").fetchall()
|
" FROM collect_runs WHERE user_id=? ORDER BY id DESC LIMIT 5",
|
||||||
|
(uid,)).fetchall()
|
||||||
if runs:
|
if runs:
|
||||||
_p("")
|
_p("")
|
||||||
_p("最近采集:")
|
_p("最近采集:")
|
||||||
@@ -153,13 +331,49 @@ def cmd_stats(args):
|
|||||||
r["total"], r["message"] or ""))
|
r["total"], r["message"] or ""))
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_users(args):
|
||||||
|
"""列出账号:角色 / 状态 / 数据量 / 凭证状态 / 最近登录。"""
|
||||||
|
db.init_db(create_admin=False)
|
||||||
|
conn = db.connect()
|
||||||
|
try:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT u.*, COUNT(r.request_id) AS records,"
|
||||||
|
" COALESCE(SUM(r.credits),0) AS credits,"
|
||||||
|
" MAX(r.day) AS last_day"
|
||||||
|
" FROM users u LEFT JOIN usage_records r ON r.user_id=u.id"
|
||||||
|
" GROUP BY u.id ORDER BY u.id").fetchall()
|
||||||
|
if not rows:
|
||||||
|
_p("还没有任何账号。跑 manage.py init 建管理员,或让用户自助注册。")
|
||||||
|
return 0
|
||||||
|
_p("%-4s %-16s %-12s %-6s %-6s %10s %8s %12s %s" %
|
||||||
|
("id", "用户名", "显示名", "角色", "状态", "Cookie", "调用", "积分", "最近登录 IP"))
|
||||||
|
for r in rows:
|
||||||
|
_p("%-4d %-16s %-12s %-6s %-6s %10s %8d %12.2f %s" %
|
||||||
|
(r["id"], r["username"], r["display_name"] or "",
|
||||||
|
"管理员" if r["is_admin"] else "普通",
|
||||||
|
"启用" if r["status"] == "active" else "停用",
|
||||||
|
_ua_of(conn, r["id"]), r["records"], r["credits"],
|
||||||
|
r["last_login_ip"] or "—"))
|
||||||
|
_p("")
|
||||||
|
_p("提示:cookie_key 或 secret_key 可在 data/instance.json 里找到,"
|
||||||
|
"二者权限等同管理员口令,切勿随仓库分发。")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 凭证 / 口令 ----------------
|
||||||
def cmd_import_creds(args):
|
def cmd_import_creds(args):
|
||||||
"""把 VSCode 设置里的 cookie / userAgent 接管进数据库(一次性迁移用)。"""
|
"""把 VSCode 设置里的 cookie / userAgent 接管进数据库(一次性迁移用)。"""
|
||||||
db.init_db(create_admin=False)
|
db.init_db(create_admin=False)
|
||||||
conn = db.connect()
|
conn = db.connect()
|
||||||
try:
|
try:
|
||||||
|
uid = _uid_or_fail(conn, args.user)
|
||||||
|
if uid is None:
|
||||||
|
return 2
|
||||||
found = client.read_vscode_creds()
|
found = client.read_vscode_creds()
|
||||||
if not found:
|
if not found:
|
||||||
_p("[error] 没找到 VSCode 系编辑器的 settings.json")
|
_p("[error] 没找到 VSCode 系编辑器的 settings.json")
|
||||||
@@ -173,11 +387,15 @@ def cmd_import_creds(args):
|
|||||||
_p("[error] 这些文件里都没有 codebuddyUsage.cookie,请到「配置管理」页手工粘贴")
|
_p("[error] 这些文件里都没有 codebuddyUsage.cookie,请到「配置管理」页手工粘贴")
|
||||||
return 2
|
return 2
|
||||||
cookie, ua = hit
|
cookie, ua = hit
|
||||||
db.set_setting(conn, "cookie", cookie)
|
# 一律走 set_secret(内部就是 set_setting),值在落库前完成加密
|
||||||
|
db.set_secret(conn, "cookie", cookie, uid)
|
||||||
if ua:
|
if ua:
|
||||||
db.set_setting(conn, "user_agent", ua)
|
db.set_setting(conn, "user_agent", ua, uid)
|
||||||
db.audit(conn, "import_creds", "cli", "从 VSCode 设置导入凭证(%d 字符)" % len(cookie), "127.0.0.1")
|
row = db.user_by_id(conn, uid)
|
||||||
_p("已导入 Cookie(%d 字符)与 User-Agent(%s)" % (len(cookie), "有" if ua else "无"))
|
db.audit(conn, "import_creds", (row["username"] if row else "cli"),
|
||||||
|
"从 VSCode 设置导入凭证(%d 字符)" % len(cookie), "127.0.0.1", uid)
|
||||||
|
_p("已把 Cookie(%d 字符)与 User-Agent(%s)写入账号 uid=%s"
|
||||||
|
% (len(cookie), "有" if ua else "无", uid))
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
return 0
|
return 0
|
||||||
@@ -185,41 +403,90 @@ def cmd_import_creds(args):
|
|||||||
|
|
||||||
def cmd_passwd(args):
|
def cmd_passwd(args):
|
||||||
db.init_db(create_admin=False)
|
db.init_db(create_admin=False)
|
||||||
from workbuddy_portal.security import hash_password
|
|
||||||
conn = db.connect()
|
conn = db.connect()
|
||||||
try:
|
try:
|
||||||
row = conn.execute("SELECT id FROM users WHERE username=?", (args.user,)).fetchone()
|
row = db.user_by_name(conn, args.user)
|
||||||
pwd = args.password or "admin123"
|
pwd = args.password
|
||||||
|
is_admin = 1 if args.role == "admin" else 0
|
||||||
if row:
|
if row:
|
||||||
conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(pwd), row["id"]))
|
sets, vals = [], []
|
||||||
_p("已重置 %s 的密码" % args.user)
|
if pwd:
|
||||||
|
sets.append("password_hash=?")
|
||||||
|
vals.append(security.hash_password(pwd))
|
||||||
|
if args.role:
|
||||||
|
sets.append("is_admin=?")
|
||||||
|
vals.append(is_admin)
|
||||||
|
if args.activate:
|
||||||
|
sets.append("status='active'")
|
||||||
|
if not sets:
|
||||||
|
_p("没给新密码也没给 --role,什么都没改")
|
||||||
|
return 0
|
||||||
|
vals.append(row["id"])
|
||||||
|
conn.execute("UPDATE users SET %s WHERE id=?" % ",".join(sets), vals)
|
||||||
|
_p("已更新账号 %s(%s)" % (args.user, ",".join(
|
||||||
|
x.split("=")[0] for x in sets)))
|
||||||
else:
|
else:
|
||||||
conn.execute("INSERT INTO users(username,password_hash,display_name,is_admin,created_at)"
|
if not pwd:
|
||||||
" VALUES(?,?,?,1,?)", (args.user, hash_password(pwd), args.user, db.now_str()))
|
_p("[error] 新账号必须给出密码")
|
||||||
_p("已创建用户 %s" % args.user)
|
return 2
|
||||||
_p("新密码:%s" % pwd)
|
# 建号必须走与注册页同一套用户名校验。绕开它的后果不是「名字不好看」:
|
||||||
|
# 导出文件名与下载响应头都直接拼账号名,一个带 `/`、`..`、引号或 CR/LF
|
||||||
|
# 的名字会变成路径穿越 / 响应头注入。这里从根上堵掉。
|
||||||
|
problem = security.username_problem(args.user)
|
||||||
|
if problem:
|
||||||
|
_p("[error] 用户名不合规:%s" % problem)
|
||||||
|
return 2
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO users(username,password_hash,display_name,is_admin,status,"
|
||||||
|
" created_at) VALUES(?,?,?,?,'active',?)",
|
||||||
|
(args.user, security.hash_password(pwd), args.user, is_admin, db.now_str()))
|
||||||
|
uid = conn.execute("SELECT id FROM users WHERE username=?",
|
||||||
|
(args.user,)).fetchone()["id"]
|
||||||
|
db.audit(conn, "user_create", "cli", "命令行创建账号 %s" % args.user,
|
||||||
|
"127.0.0.1", uid)
|
||||||
|
_p("已创建账号 %s(uid=%s,%s)"
|
||||||
|
% (args.user, uid, "管理员" if is_admin else "普通"))
|
||||||
|
if pwd:
|
||||||
|
_p("密码:%s" % pwd)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 状态 / 维护 ----------------
|
||||||
def cmd_status(args):
|
def cmd_status(args):
|
||||||
db.init_db(create_admin=False)
|
db.init_db(create_admin=False)
|
||||||
conn = db.connect()
|
conn = db.connect()
|
||||||
try:
|
try:
|
||||||
_p("服务器时间:%s" % db.now_str())
|
_p("服务器时间:%s" % db.now_str())
|
||||||
_p("调度开关:%s" % ("启用" if db.get_bool(conn, "schedule_enabled", True) else "停用"))
|
|
||||||
_p("每日时刻:%s" % (", ".join(scheduler.slots(conn)) or "—"))
|
|
||||||
nxt = scheduler.next_run_at(conn)
|
|
||||||
_p("下次执行:%s" % (nxt.strftime("%Y-%m-%d %H:%M:%S") if nxt else "—"))
|
|
||||||
_p("Cookie:%s" % ("已配置" if (db.get_setting(conn, "cookie") or "").strip() else "未配置"))
|
|
||||||
_p("互斥锁:%s" % ("存在(有采集在跑)" if os.path.exists(collect.LOCK_PATH) else "不存在"))
|
_p("互斥锁:%s" % ("存在(有采集在跑)" if os.path.exists(collect.LOCK_PATH) else "不存在"))
|
||||||
last = conn.execute("SELECT * FROM collect_runs ORDER BY id DESC LIMIT 1").fetchone()
|
_p("调度总开关(实例级):%s"
|
||||||
|
% ("启用" if db.get_bool(conn, "schedule_enabled", True) else "停用"))
|
||||||
|
users = db.active_users(conn)
|
||||||
|
if not users:
|
||||||
|
_p("(没有任何启用中的账号。先 manage.py init 建管理员,"
|
||||||
|
"或在「用户管理」页启用一个账号)")
|
||||||
|
for u in users:
|
||||||
|
uid = u["id"]
|
||||||
|
_p("")
|
||||||
|
_p("== uid=%d %s%s ==" % (uid, u["username"],
|
||||||
|
"(管理员)" if u["is_admin"] else ""))
|
||||||
|
_p(" 调度:%s / 每日 %s" %
|
||||||
|
("启用" if db.get_bool(conn, "schedule_enabled", True, uid) else "停用",
|
||||||
|
", ".join(scheduler.slots(conn, uid)) or "—"))
|
||||||
|
nxt = scheduler.next_run_at(conn, uid)
|
||||||
|
_p(" 下次执行:%s" % (nxt.strftime("%Y-%m-%d %H:%M:%S") if nxt else "—"))
|
||||||
|
_p(" Cookie:%s" % _ua_of(conn, uid))
|
||||||
|
last = conn.execute("SELECT * FROM collect_runs WHERE user_id=?"
|
||||||
|
" ORDER BY id DESC LIMIT 1", (uid,)).fetchone()
|
||||||
if last:
|
if last:
|
||||||
_p("最近采集:#%d %s %s %s" % (last["id"], last["started_at"], last["status"],
|
_p(" 最近采集:#%d %s %s %s" % (last["id"], last["started_at"],
|
||||||
last["message"] or ""))
|
last["status"], last["message"] or ""))
|
||||||
else:
|
else:
|
||||||
_p("最近采集:无")
|
_p(" 最近采集:无")
|
||||||
_p("(注意:调度线程只在 manage.py serve 进程内运行)")
|
_p("")
|
||||||
|
_p("(注意:调度线程只在 manage.py serve 进程内运行;"
|
||||||
|
"多实例部署时其余实例要设 WB_DISABLE_SCHEDULER=1)")
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -235,7 +502,122 @@ def cmd_vacuum(args):
|
|||||||
conn.execute("PRAGMA optimize")
|
conn.execute("PRAGMA optimize")
|
||||||
after = os.path.getsize(config.SQLITE_PATH) if os.path.exists(config.SQLITE_PATH) else 0
|
after = os.path.getsize(config.SQLITE_PATH) if os.path.exists(config.SQLITE_PATH) else 0
|
||||||
_p("数据库整理完成:%s → %s(%+d 字节)" % (_human(before), _human(after), after - before))
|
_p("数据库整理完成:%s → %s(%+d 字节)" % (_human(before), _human(after), after - before))
|
||||||
_p("存档 %d 条记录" % collect.record_count(conn))
|
n = conn.execute("SELECT COUNT(*) FROM usage_records").fetchone()[0]
|
||||||
|
_p("全库存档 %d 条记录" % n)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 备份 / 恢复 ----------------
|
||||||
|
def cmd_backup(args):
|
||||||
|
"""立即打一份备份。走 SQLite 在线备份 API,采集正在写也安全。"""
|
||||||
|
db.init_db(create_admin=False)
|
||||||
|
conn = db.connect()
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
r = backup.create(conn, trigger="cli", actor="cli", note=args.note or "")
|
||||||
|
except backup.BackupError as e:
|
||||||
|
_p("[error] %s" % e)
|
||||||
|
return 2
|
||||||
|
_p(r["message"])
|
||||||
|
_p(" 归档:%s" % backup.path_of(r["filename"]))
|
||||||
|
_p(" 大小:%s" % backup.human(r["bytes"]))
|
||||||
|
_p(" 校验:sha256 %s…" % r["sha256"][:16])
|
||||||
|
_p(" 内容:%d 条记录 / %.2f 积分 / %d 个账号 / 库结构 uv=%s"
|
||||||
|
% (r["stats"]["records"], r["stats"]["credits"],
|
||||||
|
r["stats"]["users"], r["stats"]["schema_ver"]))
|
||||||
|
keep = db.get_int(conn, "backup_keep", 7)
|
||||||
|
removed = backup.prune(conn, keep=keep, actor="cli")
|
||||||
|
if removed:
|
||||||
|
_p(" 已按「保留 %d 份」清理 %d 份最旧的:%s"
|
||||||
|
% (keep, len(removed), ", ".join(removed)))
|
||||||
|
_p("")
|
||||||
|
_p("提示:归档里含 instance.json(SECRET_KEY 与 cookie_key),")
|
||||||
|
_p(" 权限等同管理员口令 —— 别随镜像 / 仓库分发,也别放进公开网盘。")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_backups(args):
|
||||||
|
"""列出备份(可选清理)。磁盘是事实来源,每次先重建索引。"""
|
||||||
|
db.init_db(create_admin=False)
|
||||||
|
conn = db.connect()
|
||||||
|
try:
|
||||||
|
n = backup.sync_index(conn)
|
||||||
|
rows = backup.listing(conn)
|
||||||
|
_p("备份目录:%s" % backup.backup_dir())
|
||||||
|
if not rows:
|
||||||
|
_p("磁盘上还没有任何归档(%d 份 zip)。" % n)
|
||||||
|
_p("跑 manage.py backup 打一份;容器里这个目录挂的是独立的 wb_backups 卷。")
|
||||||
|
return 0
|
||||||
|
_p("磁盘 %d 份 · 合计 %s" % (n, backup.human(backup.total_bytes(conn))))
|
||||||
|
_p("")
|
||||||
|
_p("%-3s %-30s %10s %7s %11s %5s %-11s %s"
|
||||||
|
% ("#", "文件名", "大小", "条数", "积分", "账号", "来源", "生成时间"))
|
||||||
|
for i, r in enumerate(rows, 1):
|
||||||
|
_p("%-3d %-30s %10s %7d %11.2f %5d %-11s %s%s"
|
||||||
|
% (i, r["filename"], r["size_h"], r["records"], r["credits"],
|
||||||
|
r["users"], r["trigger"] or "-", r["created_at"],
|
||||||
|
"" if r["exists"] else " [文件已不存在]"))
|
||||||
|
enabled = db.get_bool(conn, "backup_enabled", True)
|
||||||
|
every = db.get_int(conn, "backup_interval_hours", 24)
|
||||||
|
keep = db.get_int(conn, "backup_keep", 7)
|
||||||
|
nxt = backup.next_auto_at(conn)
|
||||||
|
tail = (",下次约 %s" % nxt.strftime("%Y-%m-%d %H:%M")) if nxt else ""
|
||||||
|
_p("")
|
||||||
|
_p("自动备份:%s(周期 %d 小时,保留 %d 份)%s"
|
||||||
|
% ("启用" if enabled else "停用", every, keep, tail))
|
||||||
|
if args.prune:
|
||||||
|
want = args.keep or keep
|
||||||
|
removed = backup.prune(conn, keep=want, actor="cli")
|
||||||
|
_p("已按保留 %d 份清理 %d 份:%s"
|
||||||
|
% (want, len(removed), ", ".join(removed) if removed else "无(本来就不够多)"))
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_restore(args):
|
||||||
|
"""从归档恢复。**破坏性操作**:必须显式加 --yes。"""
|
||||||
|
db.init_db(create_admin=False)
|
||||||
|
conn = db.connect()
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
info = backup.verify(backup.path_of(args.filename))
|
||||||
|
except backup.BackupError as e:
|
||||||
|
_p("[error] %s" % e)
|
||||||
|
return 2
|
||||||
|
cn, cc = conn.execute("SELECT COUNT(*), COALESCE(SUM(credits),0)"
|
||||||
|
" FROM usage_records").fetchone()
|
||||||
|
cu = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||||
|
_p("待恢复:%s" % args.filename)
|
||||||
|
_p(" 归档生成于 %s(程序 v%s,库结构 uv=%d)"
|
||||||
|
% (info["created_at"], info["version"], info["schema_ver"]))
|
||||||
|
_p(" 归档内容:%d 条 / %.2f 积分 / %d 个账号"
|
||||||
|
% (info["records"], info["credits"], info["users"]))
|
||||||
|
_p(" 当前正本:%d 条 / %.2f 积分 / %d 个账号" % (cn, cc, cu))
|
||||||
|
if not args.yes:
|
||||||
|
_p("")
|
||||||
|
_p("这会**整表替换** usage_records / settings / users / collect_runs / "
|
||||||
|
"audit_log / captchas,")
|
||||||
|
_p("并且所有既有登录会话会立即失效(所有人需要重新登录)。")
|
||||||
|
_p("恢复前系统会自动把当前库另存一份备份,所以恢复错了还能回来。")
|
||||||
|
_p("")
|
||||||
|
_p("确认无误后,重跑并加上 --yes。")
|
||||||
|
return 1
|
||||||
|
try:
|
||||||
|
r = backup.restore(conn, args.filename,
|
||||||
|
include_instance=not args.no_instance, actor="cli")
|
||||||
|
except backup.BackupError as e:
|
||||||
|
_p("[error] %s" % e)
|
||||||
|
return 2
|
||||||
|
_p(r["message"])
|
||||||
|
_p(" 搬运的表:%s" % ", ".join(r["moved"]))
|
||||||
|
_p(" instance.json:%s"
|
||||||
|
% ("已一并恢复(cookie_key 换成了归档里那把)" if r["restored_instance"]
|
||||||
|
else "未动(保留本机当前的密钥)"))
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
return 0
|
return 0
|
||||||
@@ -248,6 +630,11 @@ def _human(n):
|
|||||||
n /= 1024.0
|
n /= 1024.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 参数表 ----------------
|
||||||
|
def _add_user_opt(p, help_text="作用账号(用户名或 uid),默认取管理员 / 最小 id"):
|
||||||
|
p.add_argument("-u", "--user", default=None, help=help_text)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
ap = argparse.ArgumentParser(description="WorkBuddy Portal(workbuddy-portal)",
|
ap = argparse.ArgumentParser(description="WorkBuddy Portal(workbuddy-portal)",
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
@@ -255,7 +642,7 @@ def main():
|
|||||||
sub = ap.add_subparsers(dest="cmd")
|
sub = ap.add_subparsers(dest="cmd")
|
||||||
|
|
||||||
s = sub.add_parser("init", help="初始化数据库")
|
s = sub.add_parser("init", help="初始化数据库")
|
||||||
s.add_argument("--user", default="admin")
|
s.add_argument("--user", default="admin", help="首个管理员用户名(仅库为空时生效)")
|
||||||
s.add_argument("--password", default=None)
|
s.add_argument("--password", default=None)
|
||||||
s.set_defaults(func=cmd_init)
|
s.set_defaults(func=cmd_init)
|
||||||
|
|
||||||
@@ -266,45 +653,77 @@ def main():
|
|||||||
s.add_argument("--no-scheduler", action="store_true", help="不启动进程内调度线程")
|
s.add_argument("--no-scheduler", action="store_true", help="不启动进程内调度线程")
|
||||||
s.set_defaults(func=cmd_serve)
|
s.set_defaults(func=cmd_serve)
|
||||||
|
|
||||||
s = sub.add_parser("collect", help="执行一次增量采集")
|
s = sub.add_parser("collect", help="执行一次增量采集(默认所有启用账号)")
|
||||||
|
_add_user_opt(s, "只采这一个账号;不传则逐个启用账号采集")
|
||||||
s.set_defaults(func=cmd_collect)
|
s.set_defaults(func=cmd_collect)
|
||||||
|
|
||||||
s = sub.add_parser("migrate-csv", help="从旧版 CSV 导入")
|
s = sub.add_parser("migrate-csv", help="从旧版 CSV 导入")
|
||||||
s.add_argument("path", nargs="?")
|
s.add_argument("path", nargs="?")
|
||||||
|
_add_user_opt(s, "这份老存档算谁的")
|
||||||
s.set_defaults(func=cmd_migrate_csv)
|
s.set_defaults(func=cmd_migrate_csv)
|
||||||
|
|
||||||
s = sub.add_parser("import-xlsx", help="从官网 xlsx 导入")
|
s = sub.add_parser("import-xlsx", help="从官网 xlsx 导入")
|
||||||
s.add_argument("path")
|
s.add_argument("path")
|
||||||
|
_add_user_opt(s)
|
||||||
s.set_defaults(func=cmd_import_xlsx)
|
s.set_defaults(func=cmd_import_xlsx)
|
||||||
|
|
||||||
s = sub.add_parser("fill-prompt", help="补全缺失的 User Prompt")
|
s = sub.add_parser("fill-prompt", help="补全缺失的 User Prompt")
|
||||||
|
_add_user_opt(s)
|
||||||
s.set_defaults(func=cmd_fill_prompt)
|
s.set_defaults(func=cmd_fill_prompt)
|
||||||
|
|
||||||
s = sub.add_parser("export-csv", help="导出 CSV")
|
s = sub.add_parser("export-csv", help="导出 CSV")
|
||||||
s.add_argument("path", nargs="?")
|
s.add_argument("path", nargs="?")
|
||||||
|
_add_user_opt(s)
|
||||||
s.set_defaults(func=cmd_export_csv)
|
s.set_defaults(func=cmd_export_csv)
|
||||||
|
|
||||||
s = sub.add_parser("stats", help="存档概况")
|
s = sub.add_parser("stats", help="存档概况(先全库概览,再给指定账号明细)")
|
||||||
|
_add_user_opt(s)
|
||||||
s.set_defaults(func=cmd_stats)
|
s.set_defaults(func=cmd_stats)
|
||||||
|
|
||||||
|
s = sub.add_parser("users", help="列出所有账号及其数据量 / 凭证状态")
|
||||||
|
s.set_defaults(func=cmd_users)
|
||||||
|
|
||||||
s = sub.add_parser("import-creds", help="从 VSCode 设置导入 cookie / UA 到数据库")
|
s = sub.add_parser("import-creds", help="从 VSCode 设置导入 cookie / UA 到数据库")
|
||||||
|
_add_user_opt(s)
|
||||||
s.set_defaults(func=cmd_import_creds)
|
s.set_defaults(func=cmd_import_creds)
|
||||||
|
|
||||||
s = sub.add_parser("passwd", help="重置 / 创建登录账号")
|
s = sub.add_parser("passwd", help="重置 / 创建登录账号")
|
||||||
s.add_argument("user")
|
s.add_argument("user")
|
||||||
s.add_argument("password", nargs="?")
|
s.add_argument("password", nargs="?")
|
||||||
|
s.add_argument("--role", choices=["admin", "user"], default=None,
|
||||||
|
help="不提则保持原角色;新建时默认普通账号")
|
||||||
|
s.add_argument("--activate", action="store_true", help="顺便把状态改回启用")
|
||||||
s.set_defaults(func=cmd_passwd)
|
s.set_defaults(func=cmd_passwd)
|
||||||
|
|
||||||
s = sub.add_parser("status", help="调度与最近采集状态")
|
s = sub.add_parser("status", help="各账号的调度与最近采集状态")
|
||||||
s.set_defaults(func=cmd_status)
|
s.set_defaults(func=cmd_status)
|
||||||
|
|
||||||
s = sub.add_parser("vacuum", help="整理数据库(checkpoint + VACUUM)")
|
s = sub.add_parser("vacuum", help="整理数据库(checkpoint + VACUUM)")
|
||||||
s.set_defaults(func=cmd_vacuum)
|
s.set_defaults(func=cmd_vacuum)
|
||||||
|
|
||||||
|
s = sub.add_parser("backup", help="立即打一份备份(所有 *.sqlite + instance.json)")
|
||||||
|
s.add_argument("--note", default="", help="给这份备份写一句备注(记进 manifest 与审计)")
|
||||||
|
s.set_defaults(func=cmd_backup)
|
||||||
|
|
||||||
|
s = sub.add_parser("backups", help="列出备份;--prune 按保留份数清理最旧的")
|
||||||
|
s.add_argument("--prune", action="store_true", help="顺便清理超出保留份数的旧备份")
|
||||||
|
s.add_argument("--keep", type=int, default=None, help="保留几份(默认取配置里的值)")
|
||||||
|
s.set_defaults(func=cmd_backups)
|
||||||
|
|
||||||
|
s = sub.add_parser("restore", help="从备份恢复(破坏性操作,必须加 --yes)")
|
||||||
|
s.add_argument("filename", help="备份文件名(如 usage-20260916-151043.zip,用 backups 查看)")
|
||||||
|
s.add_argument("--no-instance", action="store_true",
|
||||||
|
help="不恢复 instance.json(保留本机当前的 secret_key / cookie_key)")
|
||||||
|
s.add_argument("--yes", action="store_true", help="确认执行(不加只打印将要发生什么)")
|
||||||
|
s.set_defaults(func=cmd_restore)
|
||||||
|
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
if not getattr(args, "func", None):
|
if not getattr(args, "func", None):
|
||||||
ap.print_help()
|
ap.print_help()
|
||||||
return 0
|
return 0
|
||||||
|
# 先收紧 umask 再建目录:这样 SQLite 库、-wal/-shm、导出 CSV、备份 zip
|
||||||
|
# 从「被创建的那一刻」就是 owner-only,不必逐个事后 chmod。
|
||||||
|
config.harden_process()
|
||||||
config.ensure_dirs()
|
config.ensure_dirs()
|
||||||
return args.func(args) or 0
|
return args.func(args) or 0
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,338 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
"""文档自检:内部链接 / 跨文件锚点 / 图片引用 / 绝对路径泄漏 / 版本一致性 / 产品名硬编码。
|
||||||
|
|
||||||
|
文档一旦互相引用(README → docs/DEPLOYMENT.md#某节),章节重排就会让锚点**静默失效** ——
|
||||||
|
Markdown 不会报错,页面只是不跳转、图片只是显示裂图。这个脚本把这类问题变成可执行的断言。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python tools/check_docs.py # 有问题则退出码 1
|
||||||
|
python tools/check_docs.py --no-fail # 只看报告,不因问题而失败
|
||||||
|
|
||||||
|
退出码:0 通过,1 发现问题。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 常量
|
||||||
|
|
||||||
|
# 递归扫描时跳过的目录。**自动发现所有 .md**,而不是写死文件名列表 ——
|
||||||
|
# 写死列表的版本曾漏掉 THIRD-PARTY-NOTICES.md 与 CODE_OF_CONDUCT.md
|
||||||
|
# (新增文档时必然漏,而且没人会发现)。
|
||||||
|
SKIP_DIRS = {
|
||||||
|
".git", ".hg", ".svn", "node_modules", "vendor", "dist", "build",
|
||||||
|
".venv", "venv", "__pycache__", ".mypy_cache", ".pytest_cache", ".idea", ".vscode",
|
||||||
|
# AI 助手的工作记忆与技能缓存:是开发过程产物,不是项目文档。
|
||||||
|
# 它已在 .gitignore 里,这里一并跳过,免得自检报告被一堆无关 .md 淹没
|
||||||
|
# (而且那些笔记里的链接是给助手看的,不该按项目文档的规则去校验)。
|
||||||
|
".codebuddy",
|
||||||
|
}
|
||||||
|
|
||||||
|
# markdown 链接:[文本](目标)
|
||||||
|
LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
|
||||||
|
# markdown 图片:
|
||||||
|
IMG_RE = re.compile(r"!\[[^\]]*\]\(([^)\s]+)\)")
|
||||||
|
# markdown 标题:# / ## / ... (用于生成锚点)
|
||||||
|
HEADING_RE = re.compile(r"^(#{1,6})\s+(.*?)\s*$")
|
||||||
|
# 显式 HTML 锚点:<a name="x"></a> 或 <a id="x"></a>
|
||||||
|
HTML_ANCHOR_RE = re.compile(r"<a\s+(?:name|id)=[\"']([^\"']+)[\"']")
|
||||||
|
# 代码块围栏(三反引号或三波浪线)
|
||||||
|
FENCE_RE = re.compile(r"^\s*(```|~~~)")
|
||||||
|
|
||||||
|
# 绝对路径泄漏:正向白名单写不出来,反着匹配已知模式足够有效。
|
||||||
|
# 每个模式的**第 1 个捕获组**是「用户名那一段」,用来判占位符。
|
||||||
|
LEAK_PATTERNS = [
|
||||||
|
(re.compile(r"[A-Za-z]:[\\/](?:Users|users)[\\/]([^\\/\s\"'`)]+)"),
|
||||||
|
"用户目录绝对路径"),
|
||||||
|
(re.compile(r"[A-Za-z]:[\\/]Documents and Settings[\\/]([^\\/\s\"'`)]+)"),
|
||||||
|
"用户目录绝对路径"),
|
||||||
|
(re.compile(r"/(?:home|Users)/([A-Za-z0-9._-]+)/"), "用户目录绝对路径"),
|
||||||
|
]
|
||||||
|
# 占位符豁免:`C:\Users\<用户名>\…` 是**良好实践**,不是泄漏。
|
||||||
|
PLACEHOLDER_RE = re.compile(
|
||||||
|
r"[<>%*{}]|^\.{2,}$|^[-_]+$"
|
||||||
|
r"|^(?:user|users|username|user-?name|your-?name|youruser|"
|
||||||
|
r"用户名|你的用户名|xxx+|yyy+|zzz+|aaa+|example|placeholder|"
|
||||||
|
r"me|someone|nobody)$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 版本一致性:这四处必须互相一致
|
||||||
|
VERSION_INIT = os.path.join("workbuddy_portal", "__init__.py")
|
||||||
|
INIT_VER_RE = re.compile(r'^__version__\s*=\s*["\']([^"\']+)["\']', re.M)
|
||||||
|
DOCKERFILE = "Dockerfile"
|
||||||
|
OCI_VER_RE = re.compile(r'org\.opencontainers\.image\.version\s*=\s*"([^"]+)"')
|
||||||
|
README_VER_RE = re.compile(r"^\|\s*版本\s*\|\s*v?([0-9][^\s|]*)\s*\|", re.M)
|
||||||
|
CHANGELOG_VER_RE = re.compile(r"^##\s*\[?v?([0-9][^\s\]—-]*)", re.M)
|
||||||
|
|
||||||
|
# 产品名硬编码:应走 config.PROJECT_NAME 等上下文变量,不写进模板/JS
|
||||||
|
HARDCODE_NEEDLES = ["WorkBuddy Portal", "WorkBuddy 用量"]
|
||||||
|
HARDCODE_DIRS = [
|
||||||
|
os.path.join("workbuddy_portal", "web", "templates"),
|
||||||
|
os.path.join("workbuddy_portal", "web", "static", "js"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 基础工具
|
||||||
|
|
||||||
|
def read(path: str) -> str:
|
||||||
|
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
||||||
|
return fh.read()
|
||||||
|
|
||||||
|
|
||||||
|
def strip_code_blocks(text: str) -> str:
|
||||||
|
"""去掉围栏代码块内容 —— 里面的 `#` 不是标题,里面的链接不该被当链接。
|
||||||
|
|
||||||
|
用空串占位(保留换行),这样**行号不会错位**,报错才能定位到真实位置。
|
||||||
|
"""
|
||||||
|
out, in_fence = [], False
|
||||||
|
for line in text.splitlines():
|
||||||
|
if FENCE_RE.match(line):
|
||||||
|
in_fence = not in_fence
|
||||||
|
out.append("")
|
||||||
|
continue
|
||||||
|
out.append("" if in_fence else line)
|
||||||
|
return "\n".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def slugify(text: str) -> str:
|
||||||
|
"""把标题/锚点文本转成可比对的 key。
|
||||||
|
|
||||||
|
**刻意不逐字复刻 GitHub/Gitea 的 slug 算法。** 各家的标点处理规则并不一致
|
||||||
|
(GitHub 会删 `+`/`:` 却保留 `、`/`:`,而「连续空格是折叠成一个连字符
|
||||||
|
还是每个空格一个连字符」也随实现而变)。照着某个实现写死,换个托管平台
|
||||||
|
就会批量误报,反而让真问题淹掉。
|
||||||
|
|
||||||
|
这里只保留「有效字符」:小写字母、数字、汉字。标点、空格、连字符**全部丢弃**。
|
||||||
|
于是 `八、配置系统:写时校验 + 读时兜底` 与链接里的
|
||||||
|
`八配置系统写时校验--读时兜底` 归一后相等 —— 章节被重命名时,
|
||||||
|
有效字符会变,锚点仍然照抓不误。
|
||||||
|
|
||||||
|
代价:仅标点不同的两个标题会被视为同一锚点(极端罕见,可接受)。
|
||||||
|
"""
|
||||||
|
return re.sub(r"[^0-9a-z\u4e00-\u9fff]", "", text.lower())
|
||||||
|
|
||||||
|
|
||||||
|
def is_external(target: str) -> bool:
|
||||||
|
"""外部链接(http: / mailto: 等)不检查。"""
|
||||||
|
return bool(re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*:", target))
|
||||||
|
|
||||||
|
|
||||||
|
def looks_like_placeholder(segment: str) -> bool:
|
||||||
|
return bool(PLACEHOLDER_RE.search(segment.strip()))
|
||||||
|
|
||||||
|
|
||||||
|
def discover(root: str) -> list[str]:
|
||||||
|
"""递归找出所有 .md,返回相对 root 的 posix 路径。"""
|
||||||
|
found: list[str] = []
|
||||||
|
for dirpath, dirnames, filenames in os.walk(root):
|
||||||
|
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
|
||||||
|
for fn in filenames:
|
||||||
|
if fn.lower().endswith((".md", ".markdown")):
|
||||||
|
rel = os.path.relpath(os.path.join(dirpath, fn), root)
|
||||||
|
found.append(rel.replace("\\", "/"))
|
||||||
|
return sorted(found)
|
||||||
|
|
||||||
|
|
||||||
|
_anchor_cache: dict[str, set[str]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def anchors_of(abs_path: str) -> set[str]:
|
||||||
|
"""一个 md 文件里所有可跳转的锚点(标题 + 显式 HTML 锚点)。"""
|
||||||
|
if abs_path not in _anchor_cache:
|
||||||
|
text = strip_code_blocks(read(abs_path))
|
||||||
|
got: set[str] = set()
|
||||||
|
for line in text.splitlines():
|
||||||
|
m = HEADING_RE.match(line)
|
||||||
|
if m:
|
||||||
|
got.add(slugify(m.group(2)))
|
||||||
|
for m in HTML_ANCHOR_RE.finditer(text):
|
||||||
|
got.add(slugify(m.group(1)))
|
||||||
|
_anchor_cache[abs_path] = got
|
||||||
|
return _anchor_cache[abs_path]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 各检查项
|
||||||
|
|
||||||
|
def check_links(root: str, files: list[str]) -> list[str]:
|
||||||
|
problems: list[str] = []
|
||||||
|
for rel in files:
|
||||||
|
abs_path = os.path.join(root, rel.replace("/", os.sep))
|
||||||
|
base_dir = os.path.dirname(abs_path)
|
||||||
|
text = strip_code_blocks(read(abs_path))
|
||||||
|
for lineno, line in enumerate(text.splitlines(), 1):
|
||||||
|
for m in LINK_RE.finditer(line):
|
||||||
|
target = m.group(2)
|
||||||
|
if is_external(target):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 纯页内锚点:指回本文件
|
||||||
|
if target.startswith("#"):
|
||||||
|
frag = target[1:]
|
||||||
|
if frag and slugify(frag) not in anchors_of(abs_path):
|
||||||
|
problems.append(f"{rel}:{lineno} 页内锚点失效 {target}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
path_part, _, frag = target.partition("#")
|
||||||
|
if not path_part:
|
||||||
|
continue
|
||||||
|
resolved = os.path.normpath(os.path.join(base_dir, path_part))
|
||||||
|
if not os.path.exists(resolved):
|
||||||
|
problems.append(f"{rel}:{lineno} 链接目标不存在 {target}")
|
||||||
|
continue
|
||||||
|
if frag and resolved.lower().endswith((".md", ".markdown")):
|
||||||
|
if slugify(frag) not in anchors_of(resolved):
|
||||||
|
problems.append(f"{rel}:{lineno} 跨文件锚点失效 {target}")
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
def check_images(root: str, files: list[str]) -> list[str]:
|
||||||
|
problems: list[str] = []
|
||||||
|
for rel in files:
|
||||||
|
abs_path = os.path.join(root, rel.replace("/", os.sep))
|
||||||
|
base_dir = os.path.dirname(abs_path)
|
||||||
|
text = strip_code_blocks(read(abs_path))
|
||||||
|
for lineno, line in enumerate(text.splitlines(), 1):
|
||||||
|
for m in IMG_RE.finditer(line):
|
||||||
|
src = m.group(1)
|
||||||
|
if is_external(src):
|
||||||
|
continue
|
||||||
|
resolved = os.path.normpath(os.path.join(base_dir, src))
|
||||||
|
if not os.path.exists(resolved):
|
||||||
|
problems.append(f"{rel}:{lineno} 图片不存在 {src}")
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
def check_path_leaks(root: str, files: list[str]) -> list[str]:
|
||||||
|
"""扫绝对路径 —— 文档里出现多半是从本机命令里抄进来的(会连带泄漏用户名)。
|
||||||
|
|
||||||
|
命中后请**逐条人工判断**:占位符(`C:\\Users\\<用户名>`)已豁免,
|
||||||
|
但 `os.path.join(home, "AppData", ...)` 这类合法的路径发现代码不会命中
|
||||||
|
(它不含盘符或 `/home/` 前缀)。这里只报告,不自动修改。
|
||||||
|
"""
|
||||||
|
problems: list[str] = []
|
||||||
|
for rel in files:
|
||||||
|
abs_path = os.path.join(root, rel.replace("/", os.sep))
|
||||||
|
for lineno, line in enumerate(read(abs_path).splitlines(), 1):
|
||||||
|
for pat, label in LEAK_PATTERNS:
|
||||||
|
for m in pat.finditer(line):
|
||||||
|
seg = m.group(1) if m.groups() else ""
|
||||||
|
if seg and looks_like_placeholder(seg):
|
||||||
|
continue
|
||||||
|
problems.append(f"{rel}:{lineno} {label} {m.group(0)}")
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
def check_versions(root: str) -> list[str]:
|
||||||
|
"""版本号四处(__init__ / Dockerfile / README / CHANGELOG)必须一致。"""
|
||||||
|
found: dict[str, str] = {}
|
||||||
|
|
||||||
|
sources = [
|
||||||
|
(VERSION_INIT, INIT_VER_RE),
|
||||||
|
(DOCKERFILE, OCI_VER_RE),
|
||||||
|
("README.md", README_VER_RE),
|
||||||
|
(os.path.join("docs", "CHANGELOG.md"), CHANGELOG_VER_RE),
|
||||||
|
]
|
||||||
|
for rel, pat in sources:
|
||||||
|
p = os.path.join(root, rel)
|
||||||
|
if os.path.isfile(p):
|
||||||
|
m = pat.search(read(p))
|
||||||
|
if m:
|
||||||
|
found[rel.replace("\\", "/")] = m.group(1)
|
||||||
|
|
||||||
|
if not found:
|
||||||
|
return ["版本一致性: 一个版本号都没找到,检查脚本本身"]
|
||||||
|
|
||||||
|
if len(set(found.values())) > 1:
|
||||||
|
detail = "、".join("%s=%s" % (k, v) for k, v in sorted(found.items()))
|
||||||
|
return ["版本不一致: %s" % detail]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def check_name_hardcode(root: str) -> list[str]:
|
||||||
|
"""产品名应走上下文变量(config.PROJECT_NAME),不该硬编码进模板/JS。"""
|
||||||
|
problems: list[str] = []
|
||||||
|
for d in HARDCODE_DIRS:
|
||||||
|
full = os.path.join(root, d)
|
||||||
|
if not os.path.isdir(full):
|
||||||
|
continue
|
||||||
|
for dirpath, _dirnames, filenames in os.walk(full):
|
||||||
|
for fn in filenames:
|
||||||
|
if not fn.lower().endswith((".html", ".js")):
|
||||||
|
continue
|
||||||
|
fp = os.path.join(dirpath, fn)
|
||||||
|
rel = os.path.relpath(fp, root).replace("\\", "/")
|
||||||
|
for i, line in enumerate(read(fp).splitlines(), 1):
|
||||||
|
for n in HARDCODE_NEEDLES:
|
||||||
|
if n in line:
|
||||||
|
problems.append(
|
||||||
|
f"{rel}:{i} 疑似硬编码产品名「{n}」(应走上下文变量)")
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- main
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description="文档自检")
|
||||||
|
ap.add_argument("--root", default=".", help="仓库根(默认当前目录)")
|
||||||
|
ap.add_argument("--no-fail", action="store_true",
|
||||||
|
help="即使发现问题也返回 0(仅用于人工查看报告)")
|
||||||
|
ap.add_argument("--strict", action="store_true", help=argparse.SUPPRESS)
|
||||||
|
ap.add_argument("--quiet", action="store_true", help="只打印问题")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
root = os.path.abspath(args.root)
|
||||||
|
if not os.path.isdir(root):
|
||||||
|
print("--root 不是目录: %s" % root)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
files = discover(root)
|
||||||
|
if not files:
|
||||||
|
print("没找到任何 .md 文件。--root 是否正确?"
|
||||||
|
"(注意:Git Bash 的 /tmp/x 交给原生 Python 会变成 C:\\tmp\\x)")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
sections = [
|
||||||
|
("内部链接与锚点", check_links(root, files)),
|
||||||
|
("图片引用", check_images(root, files)),
|
||||||
|
("绝对路径泄漏", check_path_leaks(root, files)),
|
||||||
|
("版本一致性", check_versions(root)),
|
||||||
|
("产品名硬编码", check_name_hardcode(root)),
|
||||||
|
]
|
||||||
|
|
||||||
|
total = sum(len(p) for _, p in sections)
|
||||||
|
|
||||||
|
if not args.quiet:
|
||||||
|
print("扫描 %d 个 Markdown 文件:" % len(files))
|
||||||
|
for rel in files:
|
||||||
|
print(" - %s" % rel)
|
||||||
|
print()
|
||||||
|
|
||||||
|
for title, problems in sections:
|
||||||
|
if args.quiet and not problems:
|
||||||
|
continue
|
||||||
|
print("=== %s ===" % title)
|
||||||
|
if problems:
|
||||||
|
for p in problems:
|
||||||
|
print(" [!!] %s" % p)
|
||||||
|
else:
|
||||||
|
print(" OK")
|
||||||
|
print()
|
||||||
|
|
||||||
|
print("RESULT: %d 处问题" % total)
|
||||||
|
if total == 0:
|
||||||
|
print("文档自检全部通过。")
|
||||||
|
# 默认「有问题就失败」——「报出 7 处问题却返回 0」是个静默无用的陷阱,
|
||||||
|
# 想只看报告请显式加 --no-fail。
|
||||||
|
if total and not args.no_fail:
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -1,30 +1,42 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
# Copyright (c) 2026 Wang Chuanli
|
||||||
|
|
||||||
"""端到端验收:对**运行中的**服务发真实 HTTP 请求,走完整登录/CSRF/API 链路。
|
"""端到端验收:对**运行中的**服务发真实 HTTP 请求,走完整登录/CSRF/API 链路。
|
||||||
|
|
||||||
与 tests 里用 Flask test_client 的冒烟测试互补——这里验证的是「真的起起来了、
|
与 tools/smoke.py 的分工:smoke 用 Flask test_client 直接渲染模板、不发网络请求;
|
||||||
真的能登录、真的能取到数」,适合部署到局域网后随手跑一遍。
|
本脚本确认真的是「起起来了、能登录、能取到数」,适合部署到局域网后随手跑一遍。
|
||||||
|
|
||||||
用法:
|
用法:
|
||||||
python tools/check_live.py # 默认 http://127.0.0.1:8848
|
python tools/check_live.py # 默认 http://127.0.0.1:8848
|
||||||
python tools/check_live.py --base http://10.0.0.5:8848
|
python tools/check_live.py --base http://192.168.1.50:8848 # 换成你的部署主机
|
||||||
python tools/check_live.py -u admin -p 你的密码
|
python tools/check_live.py -u admin -p 你的密码
|
||||||
|
python tools/check_live.py --as alice:她的密码 # 额外跑一遍**普通账号**的越权面
|
||||||
python tools/check_live.py --from 2026-09-08 --to 2026-09-14
|
python tools/check_live.py --from 2026-09-08 --to 2026-09-14
|
||||||
|
|
||||||
|
`--as` 那一节会真的发越权请求(改调度 / 改采集参数 / 读日志),
|
||||||
|
期望全部被拒;不会创建或删除任何账号,所以请自己先准备一个普通账号。
|
||||||
|
|
||||||
退出码:0 全通过;1 有失败项(会打印失败清单)。
|
退出码:0 全通过;1 有失败项(会打印失败清单)。
|
||||||
|
|
||||||
注意:脚本会读取窗口数据但**不写库**(不触发采集、不改配置),可安全反复运行。
|
注意:脚本会读窗口数据、会走登录(登录本身会更新 last_login_at),
|
||||||
|
但**不触发采集、不改任何配置**,可安全反复运行。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import base64
|
||||||
import http.cookiejar
|
import http.cookiejar
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
import sqlite3
|
||||||
import sys
|
import sys
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
import zlib
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
OK = 0
|
OK = 0
|
||||||
@@ -37,6 +49,31 @@ def _d(s: str):
|
|||||||
return datetime.strptime(s, "%Y-%m-%d")
|
return datetime.strptime(s, "%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
|
def decode_session(cj) -> dict:
|
||||||
|
"""从 Flask 会话 cookie 里解出那份**未加密**的载荷。
|
||||||
|
|
||||||
|
Flask 的会话是「签名 + base64,**不加密**」的 —— 也就是说持有 cookie 的人
|
||||||
|
就能读到里面的内容。本项目因此把验证码答案放在服务端 captchas 表里,
|
||||||
|
会话里只留一个随机 id;本函数存在的意义就是取出那个 id,
|
||||||
|
好让自动化验收能跨过验证码这一关(顺便也验证了「答案不在会话里」)。
|
||||||
|
"""
|
||||||
|
for c in cj:
|
||||||
|
if not c.name.startswith("workbuddy_portal_sid"):
|
||||||
|
continue
|
||||||
|
seg = urllib.parse.unquote(c.value).split(".")[0]
|
||||||
|
seg += "=" * (-len(seg) % 4)
|
||||||
|
try:
|
||||||
|
raw = base64.urlsafe_b64decode(seg)
|
||||||
|
try:
|
||||||
|
raw = zlib.decompress(raw) # 某些版本的 itsdangerous 会压
|
||||||
|
except zlib.error:
|
||||||
|
pass
|
||||||
|
return json.loads(raw.decode("utf-8"))
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return {}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def chk(name: str, cond: bool, extra: str = "") -> None:
|
def chk(name: str, cond: bool, extra: str = "") -> None:
|
||||||
global OK, FAIL
|
global OK, FAIL
|
||||||
if cond:
|
if cond:
|
||||||
@@ -56,9 +93,10 @@ class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|||||||
|
|
||||||
|
|
||||||
class Live:
|
class Live:
|
||||||
def __init__(self, base: str, timeout: int = 20):
|
def __init__(self, base: str, timeout: int = 20, db_path: str | None = None):
|
||||||
self.base = base.rstrip("/")
|
self.base = base.rstrip("/")
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
|
self.db_path = db_path
|
||||||
# 关键:显式清空代理,否则本机代理会把 127.0.0.1 也拦成 502
|
# 关键:显式清空代理,否则本机代理会把 127.0.0.1 也拦成 502
|
||||||
self.cj = http.cookiejar.CookieJar()
|
self.cj = http.cookiejar.CookieJar()
|
||||||
self.op = urllib.request.build_opener(
|
self.op = urllib.request.build_opener(
|
||||||
@@ -111,6 +149,62 @@ class Live:
|
|||||||
st, body = self.get(path)
|
st, body = self.get(path)
|
||||||
return json.loads(body) if st == 200 else {}
|
return json.loads(body) if st == 200 else {}
|
||||||
|
|
||||||
|
def raw(self, path: str):
|
||||||
|
"""返回 (status, headers, bytes)——验证码/响应头这类要原始字节的场景用。"""
|
||||||
|
try:
|
||||||
|
r = self.op.open(urllib.request.Request(self.base + path), timeout=self.timeout)
|
||||||
|
return r.status, r.headers, r.read()
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code, e.headers, e.read()
|
||||||
|
|
||||||
|
def form_csrf(self, path: str) -> str:
|
||||||
|
"""取某个页面里的 CSRF 隐藏域(该页面必须与当前会话同源)。"""
|
||||||
|
_, html = self.get(path)
|
||||||
|
m = re.search(r'name="_csrf"\s+value="([^"]+)"', html)
|
||||||
|
return m.group(1) if m else ""
|
||||||
|
|
||||||
|
# ---- 验证码辅助(仅验收脚本用)----
|
||||||
|
def solve_captcha(self, purpose: str):
|
||||||
|
"""取一张图 -> 从会话里读 id -> 从本地库里取答案。返回 (答案, 会话载荷)。"""
|
||||||
|
self.raw("/captcha.png?purpose=" + purpose)
|
||||||
|
sess = decode_session(self.cj)
|
||||||
|
cid = sess.get("cap_" + purpose)
|
||||||
|
if not cid or not self.db_path or not os.path.exists(self.db_path):
|
||||||
|
return None, sess
|
||||||
|
try:
|
||||||
|
con = sqlite3.connect(self.db_path)
|
||||||
|
try:
|
||||||
|
row = con.execute("SELECT answer FROM captchas WHERE id=?", (cid,)).fetchone()
|
||||||
|
finally:
|
||||||
|
con.close()
|
||||||
|
except sqlite3.Error:
|
||||||
|
return None, sess
|
||||||
|
return (row[0] if row else None), sess
|
||||||
|
|
||||||
|
def login(self, user: str, pwd: str, nxt: str = "", follow: bool = True):
|
||||||
|
"""完整登录(验证码策略为 always 时自动解)。
|
||||||
|
|
||||||
|
follow=False 时返回原始 (status, Location),用于验证跳转目标是否安全。
|
||||||
|
返回 (status, location, need_captcha, session_payload)。
|
||||||
|
"""
|
||||||
|
html = self.get("/login")[1]
|
||||||
|
need_cap = 'name="captcha"' in html
|
||||||
|
m = re.search(r'name="_csrf"\s+value="([^"]+)"', html)
|
||||||
|
data = {"username": user, "password": pwd, "_csrf": m.group(1) if m else ""}
|
||||||
|
if nxt:
|
||||||
|
data["next"] = nxt
|
||||||
|
sess = {}
|
||||||
|
if need_cap:
|
||||||
|
ans, sess = self.solve_captcha("login")
|
||||||
|
if ans is None:
|
||||||
|
return None, None, True, sess
|
||||||
|
data["captcha"] = ans
|
||||||
|
if follow:
|
||||||
|
st, _ = self.post("/login", data)
|
||||||
|
return st, None, need_cap, sess
|
||||||
|
st, loc = self.post_raw("/login", data)
|
||||||
|
return st, loc, need_cap, sess
|
||||||
|
|
||||||
|
|
||||||
def run(L: Live, user: str, pwd: str, frm: str, to: str) -> None:
|
def run(L: Live, user: str, pwd: str, frm: str, to: str) -> None:
|
||||||
print("== 1. 未登录访问受保护资源 ==")
|
print("== 1. 未登录访问受保护资源 ==")
|
||||||
@@ -120,13 +214,15 @@ def run(L: Live, user: str, pwd: str, frm: str, to: str) -> None:
|
|||||||
st, _ = L.get(p)
|
st, _ = L.get(p)
|
||||||
chk("GET %-14s 未登录=401" % p, st == 401, "status=%s" % st)
|
chk("GET %-14s 未登录=401" % p, st == 401, "status=%s" % st)
|
||||||
|
|
||||||
print("== 2. 登录(含 CSRF) ==")
|
print("== 2. 登录(含 CSRF;验证码策略为 always 时自动解) ==")
|
||||||
st, html = L.get("/login")
|
st, html = L.get("/login")
|
||||||
m = re.search(r'name="_csrf"\s+value="([^"]+)"', html)
|
chk("登录页含 CSRF 隐藏域", bool(re.search(r'name="_csrf"\s+value="([^"]+)"', html)))
|
||||||
chk("登录页含 CSRF 隐藏域", bool(m))
|
st, _, need_cap, sess = L.login(user, pwd)
|
||||||
st, _ = L.post("/login", {"username": user, "password": pwd,
|
|
||||||
"_csrf": m.group(1) if m else ""})
|
|
||||||
chk("登录成功", st in (200, 302), "status=%s" % st)
|
chk("登录成功", st in (200, 302), "status=%s" % st)
|
||||||
|
if need_cap:
|
||||||
|
# 会话里只应有 id,不该有答案本身
|
||||||
|
chk("会话里只存验证码 id(不是答案)", bool(sess.get("cap_login")),
|
||||||
|
"cap_login=%s" % (sess.get("cap_login") or "无"))
|
||||||
st, html = L.get("/")
|
st, html = L.get("/")
|
||||||
chk("登录后 GET / 到概览", st == 200 and "概览" in html, "len=%d" % len(html))
|
chk("登录后 GET / 到概览", st == 200 and "概览" in html, "len=%d" % len(html))
|
||||||
|
|
||||||
@@ -209,11 +305,29 @@ def run(L: Live, user: str, pwd: str, frm: str, to: str) -> None:
|
|||||||
|
|
||||||
print("== 7. 凭据不外泄 ==")
|
print("== 7. 凭据不外泄 ==")
|
||||||
stj = L.jget("/api/settings")
|
stj = L.jget("/api/settings")
|
||||||
chk("settings 无 cookie 明文字段", "cookie" not in stj, "keys=%s" % list(stj.keys()))
|
# 契约:settings 里 cookie 这个键**必须为空**(db.get_settings 统一置空),
|
||||||
|
# 真正的状态只通过 cookie_hint / cookie_broken 这两个派生字段暴露。
|
||||||
|
chk("settings 里 cookie 字段为空串",
|
||||||
|
"cookie" in stj and not str(stj.get("cookie") or "").strip(),
|
||||||
|
"cookie=%r" % stj.get("cookie"))
|
||||||
|
chk("settings 用 cookie_hint/cookie_broken 代替明文",
|
||||||
|
"cookie_hint" in stj and "cookie_broken" in stj)
|
||||||
chk("settings 仅回 cookie_hint 掩码",
|
chk("settings 仅回 cookie_hint 掩码",
|
||||||
bool(stj.get("cookie_hint")) and len(str(stj.get("cookie_hint"))) < 200,
|
bool(stj.get("cookie_hint")) and len(str(stj.get("cookie_hint"))) < 200,
|
||||||
"hint=%s" % stj.get("cookie_hint"))
|
"hint=%s" % stj.get("cookie_hint"))
|
||||||
|
chk("settings 回传实例级键清单", isinstance(stj.get("_globalKeys"), list)
|
||||||
|
and bool(stj.get("_globalKeys")), "%s" % stj.get("_globalKeys"))
|
||||||
|
chk("settings 标明能否改实例级配置", stj.get("_canEditGlobal") is True)
|
||||||
|
chk("settings 回传个人可写键清单(应为 cookie/user_agent)",
|
||||||
|
set(stj.get("_userKeys") or []) == {"cookie", "user_agent"},
|
||||||
|
"%s" % stj.get("_userKeys"))
|
||||||
|
chk("settings 标明角色", stj.get("_role") == "admin", "%s" % stj.get("_role"))
|
||||||
chk("配置页 HTML 不含 cookie 明文", "eyJ" not in L.get("/config")[1])
|
chk("配置页 HTML 不含 cookie 明文", "eyJ" not in L.get("/config")[1])
|
||||||
|
# 密文形态:v1.<b64salt>.<b64nonce>.<b64ct>.<b64tag>,恰好用正则判定,
|
||||||
|
# 免得把版本号 "v1.2.0" 当成泄漏(这两者前缀撞车)
|
||||||
|
cipher_re = re.compile(r"v1\.[A-Za-z0-9+/=]{8,}\.[A-Za-z0-9+/=]{8,}\.")
|
||||||
|
for p in ("/config", "/profile", "/"):
|
||||||
|
chk("%-9s HTML 里没有 Cookie 密文" % p, not cipher_re.search(L.get(p)[1]))
|
||||||
|
|
||||||
print("== 8. 错误处理 ==")
|
print("== 8. 错误处理 ==")
|
||||||
for p in ("/api/nope", "/nope"):
|
for p in ("/api/nope", "/nope"):
|
||||||
@@ -251,20 +365,13 @@ def run(L: Live, user: str, pwd: str, frm: str, to: str) -> None:
|
|||||||
"api=%s csv=%s" % (first_id, (lines[1][:40] if len(lines) > 1 else None)))
|
"api=%s csv=%s" % (first_id, (lines[1][:40] if len(lines) > 1 else None)))
|
||||||
|
|
||||||
print("== 10. 安全:开放重定向与凭证外泄 ==")
|
print("== 10. 安全:开放重定向与凭证外泄 ==")
|
||||||
L2 = Live(L.base) # 全新会话,避免已登录被直跳
|
L2 = Live(L.base, L.timeout, L.db_path) # 全新会话,避免已登录被直跳
|
||||||
st, html = L2.get("/login")
|
st, loc, _, _ = L2.login(user, pwd, nxt="//evil.com", follow=False)
|
||||||
m = re.search(r'name="_csrf"\s+value="([^"]+)"', html)
|
|
||||||
csrf = m.group(1) if m else ""
|
|
||||||
st, loc = L2.post_raw("/login", {"username": user, "password": pwd,
|
|
||||||
"_csrf": csrf, "next": "//evil.com"})
|
|
||||||
chk("next=//evil.com 被拒(不出现协议相对跳转)",
|
chk("next=//evil.com 被拒(不出现协议相对跳转)",
|
||||||
st == 302 and "evil.com" not in (loc or "") and not (loc or "").startswith("//"),
|
st == 302 and "evil.com" not in (loc or "") and not (loc or "").startswith("//"),
|
||||||
"status=%s Location=%s" % (st, loc))
|
"status=%s Location=%s" % (st, loc))
|
||||||
L3 = Live(L.base)
|
L3 = Live(L.base, L.timeout, L.db_path)
|
||||||
st, html = L3.get("/login")
|
st, loc, _, _ = L3.login(user, pwd, nxt="/records", follow=False)
|
||||||
m = re.search(r'name="_csrf"\s+value="([^"]+)"', html)
|
|
||||||
st, loc = L3.post_raw("/login", {"username": user, "password": pwd,
|
|
||||||
"_csrf": m.group(1) if m else "", "next": "/records"})
|
|
||||||
chk("next=/records 站内路径正常放行", st == 302 and loc == "/records",
|
chk("next=/records 站内路径正常放行", st == 302 and loc == "/records",
|
||||||
"status=%s Location=%s" % (st, loc))
|
"status=%s Location=%s" % (st, loc))
|
||||||
st, loc = L3.post_raw("/login", {"username": user, "password": pwd, "_csrf": "wrong"})
|
st, loc = L3.post_raw("/login", {"username": user, "password": pwd, "_csrf": "wrong"})
|
||||||
@@ -274,6 +381,118 @@ def run(L: Live, user: str, pwd: str, frm: str, to: str) -> None:
|
|||||||
st, html = L.get("/")
|
st, html = L.get("/")
|
||||||
chk("GET /logout 后仍处于登录态", st == 200 and "概览" in html, "status=%s" % st)
|
chk("GET /logout 后仍处于登录态", st == 200 and "概览" in html, "status=%s" % st)
|
||||||
|
|
||||||
|
print("== 11. 多用户:注册入口 / 验证码 / 安全响应头 ==")
|
||||||
|
L4 = Live(L.base, L.timeout, L.db_path) # 全新未登录会话
|
||||||
|
st, html = L4.get("/register")
|
||||||
|
chk("GET /register 可达", st == 200 and "注册" in html, "status=%s" % st)
|
||||||
|
chk("注册页带验证码图", "capimg" in html and "/captcha.png" in html)
|
||||||
|
chk("注册页带 CSRF 隐藏域", bool(L4.form_csrf("/register")))
|
||||||
|
st, html = L4.get("/login")
|
||||||
|
chk("登录页带验证码图", "capimg" in html and 'name="captcha"' in html)
|
||||||
|
chk("登录页带自助注册链接", "/register" in html)
|
||||||
|
|
||||||
|
# 出图:真实字节 + 禁缓存 + 确实每次都不一样
|
||||||
|
shots = {}
|
||||||
|
for purpose in ("login", "register"):
|
||||||
|
code, hdr, data = L4.raw("/captcha.png?purpose=" + purpose)
|
||||||
|
chk("GET /captcha.png?purpose=%-8s 出 PNG" % purpose,
|
||||||
|
code == 200 and data[:4] == b"\x89PNG" and len(data) > 200,
|
||||||
|
"status=%s len=%d" % (code, len(data)))
|
||||||
|
chk(" └ 禁缓存 no-store", "no-store" in (hdr.get("Cache-Control") or ""))
|
||||||
|
chk(" └ 类型 image/png", (hdr.get("Content-Type") or "").startswith("image/png"))
|
||||||
|
shots[purpose] = data
|
||||||
|
_, _, again = L4.raw("/captcha.png?purpose=login")
|
||||||
|
chk("两次取图内容不同(不是一张静态图)", again != shots["login"])
|
||||||
|
chk("login 与 register 的图互不相同", shots["login"] != shots["register"])
|
||||||
|
# 图必须由服务端单独下发,不能把答案内联进页面
|
||||||
|
st, html = L4.get("/login")
|
||||||
|
chk("登录页没有内联 data: 图片(答案不走页面源码)",
|
||||||
|
"data:image" not in html and "base64," not in html)
|
||||||
|
|
||||||
|
# 安全响应头
|
||||||
|
code, hdr, _ = L4.raw("/login")
|
||||||
|
for name, want in (("X-Content-Type-Options", "nosniff"),
|
||||||
|
("X-Frame-Options", "DENY"),
|
||||||
|
("Referrer-Policy", "same-origin")):
|
||||||
|
chk("响应头 %-24s" % name, (hdr.get(name) or "") == want, "=%s" % hdr.get(name))
|
||||||
|
chk("响应头含 CSP 且 frame-ancestors 'none'",
|
||||||
|
"frame-ancestors 'none'" in (hdr.get("Content-Security-Policy") or ""))
|
||||||
|
code, hdr, _ = L4.raw("/captcha.png?purpose=login")
|
||||||
|
chk("/captcha 路径带 no-store", "no-store" in (hdr.get("Cache-Control") or ""))
|
||||||
|
|
||||||
|
# 路径穿越式 purpose 必须被收敛到已知用途,而不是 500
|
||||||
|
code, _, data = L4.raw("/captcha.png?purpose=../../etc/passwd")
|
||||||
|
chk("非法 purpose 不报 500", code == 200 and data[:4] == b"\x89PNG",
|
||||||
|
"status=%s" % code)
|
||||||
|
|
||||||
|
|
||||||
|
def run_nonadmin(base: str, timeout: int, db_path: str, user: str, pwd: str) -> None:
|
||||||
|
"""普通账号的越权面(真实 HTTP 链路,--as 才跑)。
|
||||||
|
|
||||||
|
规则只有一条:普通账号**只能维护本人凭证**,其余配置 / 日志 / 用户管理
|
||||||
|
全部不可达。期望值是 403(页面)与 400(写配置)—— 不是「看得到但改不了」,
|
||||||
|
更不是「写进去但只对自己生效」。
|
||||||
|
"""
|
||||||
|
print("== 12. 普通账号越权面(--as %s) ==" % user)
|
||||||
|
L = Live(base, timeout, db_path)
|
||||||
|
st, _, _, _ = L.login(user, pwd)
|
||||||
|
chk("普通账号登录成功", st in (200, 302), "status=%s" % st)
|
||||||
|
st, html = L.get("/")
|
||||||
|
if st != 200 or "概览" not in html:
|
||||||
|
chk("普通账号登录后能看到概览", False, "status=%s(后续断言已跳过)" % st)
|
||||||
|
return
|
||||||
|
chk("普通账号登录后能看到概览", True)
|
||||||
|
chk("导航不出现「日志管理」", "日志管理" not in html)
|
||||||
|
chk("导航不出现「用户管理」", "用户管理" not in html)
|
||||||
|
|
||||||
|
# 可达页面(都只渲染本人数据)
|
||||||
|
for p, kw in [("/records", "记录"), ("/tasks", "任务"),
|
||||||
|
("/config", "配置"), ("/profile", "个人")]:
|
||||||
|
st, body = L.get(p)
|
||||||
|
chk("GET %-9s 普通账号=200" % p, st == 200 and kw in body, "status=%s" % st)
|
||||||
|
st, _ = L.get("/dashboard")
|
||||||
|
chk("GET /dashboard 普通账号=200", st == 200, "status=%s" % st)
|
||||||
|
|
||||||
|
# 不可达:日志与用户管理
|
||||||
|
for p in ("/logs", "/logs/tail?lines=10", "/users", "/api/users"):
|
||||||
|
st, _ = L.get(p)
|
||||||
|
chk("GET %-21s 普通账号=403" % p, st == 403, "status=%s" % st)
|
||||||
|
|
||||||
|
# 角色标记:页面之外还有静态页(大屏)与前端要靠它决定显隐
|
||||||
|
stj = L.jget("/api/settings")
|
||||||
|
chk("/api/settings _role=user", stj.get("_role") == "user", "%s" % stj.get("_role"))
|
||||||
|
chk("/api/settings _canEditGlobal=False", stj.get("_canEditGlobal") is False)
|
||||||
|
mf = L.jget("/api/manifest")
|
||||||
|
chk("/api/manifest role=user(大屏据此隐掉日志入口)",
|
||||||
|
mf.get("role") == "user", "%s" % mf.get("role"))
|
||||||
|
sta = L.jget("/api/status")
|
||||||
|
chk("/api/status is_admin=False", sta.get("is_admin") is False, "%s" % sta.get("is_admin"))
|
||||||
|
chk("/api/status can_edit_schedule=False", sta.get("can_edit_schedule") is False)
|
||||||
|
|
||||||
|
# 越权写:调度 / 采集参数 / 实例级键 -> 400
|
||||||
|
csrf = L.form_csrf("/config")
|
||||||
|
chk("拿到普通账号自己的 CSRF", bool(csrf))
|
||||||
|
for key, val in (("schedule_times", "23:59"), ("schedule_enabled", "0"),
|
||||||
|
("page_size", "1000"), ("ssl_verify", "0"),
|
||||||
|
("api_base", "http://evil.invalid"), ("allow_register", "0")):
|
||||||
|
st, body = L.post("/api/settings", {key: val}, csrf=csrf, as_json=True)
|
||||||
|
chk("越权 POST %-16s =400" % key, st == 400, "status=%s" % st)
|
||||||
|
chk(" └ 且点名 %s" % key, key in body)
|
||||||
|
|
||||||
|
# 本人 UA 必须写得进去;写回原值,不给对方留副作用
|
||||||
|
cur_ua = str(stj.get("user_agent") or "")
|
||||||
|
st, body = L.post("/api/settings", {"user_agent": cur_ua}, csrf=csrf, as_json=True)
|
||||||
|
chk("本人 user_agent 可写=200", st == 200, "status=%s %s" % (st, body[:100]))
|
||||||
|
|
||||||
|
# 页面只给凭证表单,采集参数与调度都渲染成只读
|
||||||
|
st, cf = L.get("/config")
|
||||||
|
chk("配置页有凭证表单", 'id="formCred"' in cf)
|
||||||
|
chk("配置页无采集参数表单", 'id="formCollect"' not in cf)
|
||||||
|
chk("配置页无实例级设置表单", 'id="formGlobal"' not in cf)
|
||||||
|
st, tk = L.get("/tasks")
|
||||||
|
chk("任务页调度只读(没有保存按钮)", "保存调度配置" not in tk)
|
||||||
|
chk("任务页标注调度仅管理员可改", "仅管理员可改" in tk)
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
ap = argparse.ArgumentParser(description="对运行中的用量门户做端到端验收")
|
ap = argparse.ArgumentParser(description="对运行中的用量门户做端到端验收")
|
||||||
@@ -282,11 +501,30 @@ def main() -> int:
|
|||||||
ap.add_argument("-p", "--password", default="admin123", help="登录密码")
|
ap.add_argument("-p", "--password", default="admin123", help="登录密码")
|
||||||
ap.add_argument("--from", dest="frm", default="2026-09-08", help="验收窗口起")
|
ap.add_argument("--from", dest="frm", default="2026-09-08", help="验收窗口起")
|
||||||
ap.add_argument("--to", dest="to", default="2026-09-14", help="验收窗口止")
|
ap.add_argument("--to", dest="to", default="2026-09-14", help="验收窗口止")
|
||||||
|
ap.add_argument("--db", default=None,
|
||||||
|
help="SQLite 路径(默认 <repo>/data/usage.sqlite)。"
|
||||||
|
"验证码策略为 always 时用它取答案以完成自动登录;"
|
||||||
|
"指向不存在的文件则跳过需要验证码的登录")
|
||||||
ap.add_argument("--timeout", type=int, default=20)
|
ap.add_argument("--timeout", type=int, default=20)
|
||||||
|
ap.add_argument("--as", dest="as_user", default=None, metavar="USER:PASS",
|
||||||
|
help="额外用一个**普通账号**跑一遍越权验收(第 12 节)。"
|
||||||
|
"不会创建/删除账号,请自己先备好一个普通账号")
|
||||||
a = ap.parse_args()
|
a = ap.parse_args()
|
||||||
|
|
||||||
print("目标:%s 窗口:%s ~ %s\n" % (a.base, a.frm, a.to))
|
db_path = a.db or os.path.join(
|
||||||
run(Live(a.base, a.timeout), a.user, a.password, a.frm, a.to)
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data", "usage.sqlite")
|
||||||
|
print("目标:%s 窗口:%s ~ %s\n验证码答案源:%s\n" % (a.base, a.frm, a.to, db_path))
|
||||||
|
run(Live(a.base, a.timeout, db_path), a.user, a.password, a.frm, a.to)
|
||||||
|
if a.as_user:
|
||||||
|
if ":" not in a.as_user:
|
||||||
|
print(" [FAIL] --as 需要写成 用户名:密码")
|
||||||
|
FAILS.append("--as 参数格式")
|
||||||
|
else:
|
||||||
|
nu, np_ = a.as_user.split(":", 1)
|
||||||
|
try:
|
||||||
|
run_nonadmin(a.base, a.timeout, db_path, nu, np_)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
chk("第 12 节执行未抛异常", False, "%s: %s" % (type(e).__name__, e))
|
||||||
print("\nRESULT: ok=%d fail=%d" % (OK, FAIL))
|
print("\nRESULT: ok=%d fail=%d" % (OK, FAIL))
|
||||||
if FAILS:
|
if FAILS:
|
||||||
print("失败项:%s" % "、".join(FAILS))
|
print("失败项:%s" % "、".join(FAILS))
|
||||||
|
|||||||
@@ -0,0 +1,344 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
# Copyright (c) 2026 Wang Chuanli
|
||||||
|
"""生成**脱敏示例数据**,用于本地体验、界面截图与文档配图。
|
||||||
|
|
||||||
|
为什么需要它
|
||||||
|
------------
|
||||||
|
`docs/images/` 里的界面截图必须是可公开的,但真实库里的 Prompt 全文、
|
||||||
|
请求 ID、用量分布与本机路径都属于私有信息。与其手工打码,不如用一份
|
||||||
|
**完全合成**的数据集重新截图——顺便也让后来者能一键把界面跑起来看。
|
||||||
|
|
||||||
|
生成内容
|
||||||
|
--------
|
||||||
|
| 表 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `users` | 明示例两个账号:`admin`(管理员)与 `demo`(普通账号),各有自己的数据 |
|
||||||
|
| `usage_records` | 约 900 条合成记录,跨 30 天,含假模型名 / 假 Prompt / 偏斜的积分分布 |
|
||||||
|
| `collect_runs` | 采集历史,含 ok / warn / error 三种状态 |
|
||||||
|
| `settings` | 走项目默认值(`config.DEFAULTS`),凭证只写**一眼可辨的假值** |
|
||||||
|
| `audit_log` | 操作审计(按账号归属) |
|
||||||
|
|
||||||
|
刻意造两个账号,是因为「数据按账号隔离」在多用户版里是最该被截进文档的性质:
|
||||||
|
只有一个账号的话,用户管理页和「我的数据」列都看不出区别。
|
||||||
|
|
||||||
|
用法
|
||||||
|
----
|
||||||
|
# 默认写到 data/demo/(该目录在 .gitignore 内,不会误提交)
|
||||||
|
python tools/demo_data.py
|
||||||
|
|
||||||
|
# 指定目录与口令,然后起服务看效果
|
||||||
|
python tools/demo_data.py --out data/demo --admin-password demo123
|
||||||
|
WB_DATA_DIR=$PWD/data/demo python manage.py serve --port 8849 --no-scheduler
|
||||||
|
|
||||||
|
注意
|
||||||
|
----
|
||||||
|
本脚本**只写 `--out` 指定的目录**,不会读取也不会修改 `data/usage.sqlite`。
|
||||||
|
已存在的目标库会被拒绝覆盖,除非显式加 `--force`。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
sys.path.insert(0, BASE)
|
||||||
|
|
||||||
|
# ---- 合成素材:刻意保持通用,不含任何真实的产品名、Prompt 或业务信息 ----
|
||||||
|
MODELS = [
|
||||||
|
# (模型名, 权重, 积分中位数)
|
||||||
|
("demo-flash", 26, 0.35),
|
||||||
|
("demo-lite", 20, 0.60),
|
||||||
|
("demo-pro", 18, 2.20),
|
||||||
|
("demo-reason", 14, 4.80),
|
||||||
|
("demo-mini", 12, 0.22),
|
||||||
|
("demo-vision", 7, 6.50),
|
||||||
|
("demo-nano", 3, 0.12),
|
||||||
|
]
|
||||||
|
|
||||||
|
CLIENTS = [("vscode", 74), ("webconsole", 18), ("sdk", 8)]
|
||||||
|
|
||||||
|
# 写进 settings 的假 Cookie。刻意用重复串,一眼就能看出不是真凭据;
|
||||||
|
# 作用只是让概览页的健康指示灯是绿的(首装状态是「缺 Cookie」告警)。
|
||||||
|
# 两个账号给不同的值,这样「各自持有自己的凭证」在截图里看得出来。
|
||||||
|
DEMO_COOKIE = "wb_demo_session=" + "deadbeef" * 15
|
||||||
|
DEMO_COOKIE_2 = "wb_demo_session=" + "cafef00d" * 15
|
||||||
|
|
||||||
|
# 第二个账号(普通用户)用的模型:刻意与管理员**不重名**,
|
||||||
|
# 这样「按账号隔离」在按模型的图上立刻可见。
|
||||||
|
MODELS_2 = [
|
||||||
|
("demo-lite", 30, 0.55),
|
||||||
|
("demo-nano", 26, 0.14),
|
||||||
|
("demo-flash", 22, 0.32),
|
||||||
|
("demo-vision", 12, 5.90),
|
||||||
|
("demo-pro", 10, 2.05),
|
||||||
|
]
|
||||||
|
|
||||||
|
PROMPTS_SHORT = [
|
||||||
|
"帮我解释一下这段代码的作用",
|
||||||
|
"把这段 SQL 优化一下,避免全表扫描",
|
||||||
|
"写一个 Python 脚本,把目录里的 CSV 批量转成 JSON",
|
||||||
|
"这个报错是什么意思:connection refused",
|
||||||
|
"帮我 review 一下这个接口设计,有什么问题",
|
||||||
|
"解释一下 JWT 和 Session 的区别",
|
||||||
|
"生成一份周报模板",
|
||||||
|
"把下面的需求整理成技术方案",
|
||||||
|
"这个正则怎么写:匹配 11 位手机号",
|
||||||
|
"Explain the difference between processes and threads",
|
||||||
|
"Refactor this function to be more readable",
|
||||||
|
"What is the time complexity of this algorithm?",
|
||||||
|
"Write unit tests for this module",
|
||||||
|
"How do I make this loop faster?",
|
||||||
|
"Summarize the key points of this document",
|
||||||
|
]
|
||||||
|
|
||||||
|
PROMPTS_LONG = [
|
||||||
|
"你是资深后端工程师。请审查下面的接口实现,重点看:\n"
|
||||||
|
"1) 并发写入是否安全;\n2) 异常分支是否都有兜底;\n3) 有没有可以合并的重复查询。\n"
|
||||||
|
"请按「问题 / 影响 / 建议」三栏输出。",
|
||||||
|
|
||||||
|
"下面的表结构要支持按天和按模型两个维度聚合,数据量大约千万级。\n"
|
||||||
|
"请给出索引设计,并说明每个索引命中的查询模式。",
|
||||||
|
|
||||||
|
"把这段代码从回调风格改成 async/await,保持对外行为不变,"
|
||||||
|
"并补充必要的错误处理。改完给出前后对比。",
|
||||||
|
|
||||||
|
"Review the provided module and suggest improvements.\n"
|
||||||
|
"Focus on readability, error handling, and testability.\n"
|
||||||
|
"Reply as a short bullet list.",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _weighted(rng: random.Random, pairs):
|
||||||
|
"""按权重取一项(pairs 为 (值, 权重) 列表)。"""
|
||||||
|
total = sum(w for _, w in pairs)
|
||||||
|
pick = rng.uniform(0, total)
|
||||||
|
acc = 0.0
|
||||||
|
for val, w in pairs:
|
||||||
|
acc += w
|
||||||
|
if pick <= acc:
|
||||||
|
return val
|
||||||
|
return pairs[-1][0]
|
||||||
|
|
||||||
|
|
||||||
|
def _credits(rng: random.Random, median: float) -> float:
|
||||||
|
"""对数正态分布的积分值,偶尔出现大额。"""
|
||||||
|
val = rng.lognormvariate(0.0, 0.85) * median
|
||||||
|
if rng.random() < 0.02: # 2% 的大额长任务
|
||||||
|
val *= rng.uniform(12, 60)
|
||||||
|
return round(min(val, 900.0), 2)
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt(rng: random.Random) -> str | None:
|
||||||
|
r = rng.random()
|
||||||
|
if r < 0.12: # 一部分请求不带 Prompt
|
||||||
|
return None
|
||||||
|
if r < 0.55:
|
||||||
|
return rng.choice(PROMPTS_SHORT)
|
||||||
|
if r < 0.75:
|
||||||
|
return rng.choice(PROMPTS_LONG)
|
||||||
|
# 其余用短句拼接,避免重复得过于整齐
|
||||||
|
return rng.choice(PROMPTS_SHORT) + ":" + rng.choice(PROMPTS_SHORT)
|
||||||
|
|
||||||
|
|
||||||
|
def build(out_dir: str, days: int, seed: int, admin_password: str,
|
||||||
|
admin_user: str) -> str:
|
||||||
|
"""建库并写入合成数据,返回数据库文件路径。"""
|
||||||
|
out_dir = os.path.abspath(out_dir)
|
||||||
|
os.makedirs(out_dir, exist_ok=True)
|
||||||
|
db_file = os.path.join(out_dir, "usage.sqlite")
|
||||||
|
|
||||||
|
# 必须在 import 项目模块之前设好环境变量:config 在导入时读取它们。
|
||||||
|
# 日志目录用 setdefault —— 容器里 WB_LOG_DIR 已由镜像 ENV 指定,
|
||||||
|
# 不该在数据卷下再凭空建一个 logs/。
|
||||||
|
os.environ["WB_DATA_DIR"] = out_dir
|
||||||
|
os.environ.setdefault("WB_LOG_DIR", os.path.join(out_dir, "logs"))
|
||||||
|
os.environ["WB_DB"] = db_file
|
||||||
|
|
||||||
|
from workbuddy_portal import db, security # noqa: E402
|
||||||
|
|
||||||
|
conn = db.connect()
|
||||||
|
try:
|
||||||
|
db.init_db(conn, create_admin=True, admin_user=admin_user,
|
||||||
|
admin_password=admin_password)
|
||||||
|
|
||||||
|
# ---------- 账号 ----------
|
||||||
|
admin_row = db.user_by_name(conn, admin_user)
|
||||||
|
admin_uid = admin_row["id"]
|
||||||
|
demo_user = "demo"
|
||||||
|
if not db.user_by_name(conn, demo_user):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO users(username,password_hash,display_name,email,is_admin,"
|
||||||
|
"status,created_at) VALUES(?,?,?,?,0,'active',?)",
|
||||||
|
(demo_user, security.hash_password(admin_password), "演示账号",
|
||||||
|
"demo@example.invalid", db.now_str()))
|
||||||
|
db_row = db.user_by_name(conn, demo_user)
|
||||||
|
demo_uid = db_row["id"]
|
||||||
|
|
||||||
|
# 写入**明显是假值**的 Cookie:让概览页的健康状态显示为「正常」
|
||||||
|
# 而不是首装的「缺 Cookie」告警——演示与截图应当呈现「配置完成」后的样子。
|
||||||
|
# 经 set_secret 落库 = 真的走一遍加密,所以示例库里也是密文。
|
||||||
|
db.set_secret(conn, "cookie", DEMO_COOKIE, admin_uid)
|
||||||
|
db.set_secret(conn, "cookie", DEMO_COOKIE_2, demo_uid)
|
||||||
|
# 调度与采集参数是**实例级**的(v1.3.0 起普通账号只读),所以只写一次;
|
||||||
|
# 这里刻意不按账号各写一份 —— set_setting 会把全局键归到 user_id=0,
|
||||||
|
# 写两次只会后一次覆盖前一次,看起来「每人一套」其实没有。
|
||||||
|
db.set_setting(conn, "schedule_times", "09:00,17:00", 0)
|
||||||
|
|
||||||
|
rng = random.Random(seed)
|
||||||
|
now = datetime.now().replace(second=0, microsecond=0)
|
||||||
|
today0 = now.replace(hour=0, minute=0, second=0)
|
||||||
|
model_pairs = [(m, w) for m, w, _ in MODELS]
|
||||||
|
medians = {m: md for m, _, md in MODELS}
|
||||||
|
model_pairs_2 = [(m, w) for m, w, _ in MODELS_2]
|
||||||
|
medians_2 = {m: md for m, _, md in MODELS_2}
|
||||||
|
client_pairs = list(CLIENTS)
|
||||||
|
|
||||||
|
# ---------- usage_records(两个账号各生成一份)----------
|
||||||
|
def _gen_records(uid, pairs, med, lo=14, hi=46):
|
||||||
|
rows = []
|
||||||
|
for d in range(days - 1, -1, -1):
|
||||||
|
day0 = today0 - timedelta(days=d)
|
||||||
|
for _ in range(rng.randint(lo, hi)):
|
||||||
|
# 工作时间加权:9-19 点更密
|
||||||
|
hour = _weighted(rng, [(h, 6 if 9 <= h <= 19 else 1) for h in range(24)])
|
||||||
|
ts = day0 + timedelta(hours=hour, minutes=rng.randint(0, 59),
|
||||||
|
seconds=rng.randint(0, 59))
|
||||||
|
if ts > now:
|
||||||
|
continue
|
||||||
|
model = _weighted(rng, pairs)
|
||||||
|
client = _weighted(rng, client_pairs)
|
||||||
|
rid = "req-%s" % "".join(rng.choice("0123456789abcdef") for _ in range(16))
|
||||||
|
stamp = ts.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
rows.append((
|
||||||
|
uid, rid, stamp, ts.strftime("%Y-%m-%d"), hour, model, client,
|
||||||
|
_credits(rng, med[model]), _prompt(rng), stamp, stamp, stamp,
|
||||||
|
))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
rows = _gen_records(admin_uid, model_pairs, medians)
|
||||||
|
# 普通账号只给大约三分之二的量:列表里一眼能分出主次
|
||||||
|
rows += _gen_records(demo_uid, model_pairs_2, medians_2, lo=9, hi=31)
|
||||||
|
conn.execute("BEGIN")
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT OR REPLACE INTO usage_records"
|
||||||
|
"(user_id,request_id,ts,day,hour,model,client,credits,prompt,"
|
||||||
|
" first_seen,last_seen,cloud_ts) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)", rows)
|
||||||
|
conn.execute("COMMIT")
|
||||||
|
|
||||||
|
# ---------- collect_runs ----------
|
||||||
|
runs = []
|
||||||
|
total = 0
|
||||||
|
for i in range(14, 0, -1):
|
||||||
|
started = now - timedelta(minutes=i * 37 + rng.randint(0, 9))
|
||||||
|
fetched = rng.randint(3, 22)
|
||||||
|
dup = rng.randint(0, max(1, fetched - 2))
|
||||||
|
added = max(0, fetched - dup)
|
||||||
|
total += added
|
||||||
|
status = "ok"
|
||||||
|
msg = "新增 %d 条,重复 %d 条,存档共 %d 条" % (added, dup, total)
|
||||||
|
exit_code = 0
|
||||||
|
if i == 9:
|
||||||
|
status, exit_code = "warn", 1
|
||||||
|
msg = "参数错误:from 不是合法日期;abc(正确写法 2026-09-01)"
|
||||||
|
if i == 5:
|
||||||
|
status, exit_code = "error", 2
|
||||||
|
msg = "云端返回 401 Unauthorized:Cookie 可能已过期,请重新粘贴"
|
||||||
|
fetched = dup = added = 0
|
||||||
|
trigger = "schedule" if i % 3 else "manual"
|
||||||
|
runs.append((
|
||||||
|
admin_uid, trigger, status, started.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
(started + timedelta(milliseconds=rng.randint(180, 1400))
|
||||||
|
).strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
rng.randint(180, 1400),
|
||||||
|
(started - timedelta(days=1)).strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
started.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
fetched, added, dup, total, 0, exit_code, msg,
|
||||||
|
"[%s] %s" % (status, msg),
|
||||||
|
))
|
||||||
|
# 普通账号也给两条,让「日志只显示自己的」在截图里成立
|
||||||
|
for k, (st, msg) in enumerate((("ok", "新增 6 条,重复 4 条,存档共 192 条"),
|
||||||
|
("ok", "新增 3 条,重复 5 条,存档共 186 条"))):
|
||||||
|
at = now - timedelta(hours=5 + k * 9)
|
||||||
|
runs.append((demo_uid, "schedule", st, at.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
at.strftime("%Y-%m-%d %H:%M:%S"), 420,
|
||||||
|
(at - timedelta(days=1)).strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
at.strftime("%Y-%m-%d %H:%M:%S"), 10 - k, 6 - k * 3, 4, 192, 0, 0,
|
||||||
|
msg, "[%s] %s" % (st, msg)))
|
||||||
|
conn.execute("BEGIN")
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT INTO collect_runs(user_id,trigger,status,started_at,finished_at,duration_ms,"
|
||||||
|
"win_from,win_to,fetched,added,dup,total,conflicts,exit_code,message,detail)"
|
||||||
|
" VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", runs)
|
||||||
|
conn.execute("COMMIT")
|
||||||
|
|
||||||
|
# ---------- audit_log ----------
|
||||||
|
audits = []
|
||||||
|
# IP 用 RFC 5737 的文档专用网段(TEST-NET-1),
|
||||||
|
# 保证示例里出现的地址永远不可能是真实主机
|
||||||
|
actions = [
|
||||||
|
("login", "登录成功", "192.0.2.10"),
|
||||||
|
("login", "登录成功", "192.0.2.10"),
|
||||||
|
("settings", "修改:schedule_times", "192.0.2.10"),
|
||||||
|
("settings", "修改:page_size", "192.0.2.10"),
|
||||||
|
("maintenance.count", "存档当前 %d 条记录" % total, "192.0.2.10"),
|
||||||
|
("settings_rejected", "参数错误:page_size 必须是数字(条/页)", "192.0.2.10"),
|
||||||
|
]
|
||||||
|
for i in range(30):
|
||||||
|
act, detail, ip = actions[i % len(actions)]
|
||||||
|
at = now - timedelta(hours=i * 3 + rng.randint(0, 2))
|
||||||
|
uid = admin_uid if i % 3 else demo_uid
|
||||||
|
actor = admin_user if i % 3 else demo_user
|
||||||
|
audits.append((uid, at.strftime("%Y-%m-%d %H:%M:%S"), actor, act, detail, ip))
|
||||||
|
conn.execute("BEGIN")
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT INTO audit_log(user_id,at,actor,action,detail,ip)"
|
||||||
|
" VALUES(?,?,?,?,?,?)", audits)
|
||||||
|
conn.execute("COMMIT")
|
||||||
|
|
||||||
|
# 统计一下,便于打印
|
||||||
|
n = conn.execute("SELECT COUNT(*) FROM usage_records").fetchone()[0]
|
||||||
|
c = conn.execute("SELECT ROUND(SUM(credits),2) FROM usage_records").fetchone()[0]
|
||||||
|
d = conn.execute("SELECT COUNT(DISTINCT day) FROM usage_records").fetchone()[0]
|
||||||
|
print("示例库:%s" % db_file)
|
||||||
|
print(" 记录 %d 条 / 积分 %s / 覆盖 %d 天" % (n, c, d))
|
||||||
|
for r in conn.execute(
|
||||||
|
"SELECT u.id,u.username,u.is_admin,"
|
||||||
|
" (SELECT COUNT(*) FROM usage_records x WHERE x.user_id=u.id) AS n"
|
||||||
|
" FROM users u ORDER BY u.id"):
|
||||||
|
print(" 账号 #%s %-8s %-6s %d 条"
|
||||||
|
% (r["id"], r["username"], "管理员" if r["is_admin"] else "普通", r["n"]))
|
||||||
|
print(" 口令都是 %s(仅供本地演示)" % admin_password)
|
||||||
|
print(" 该目录在 .gitignore 内,不会被提交")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return db_file
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description="生成脱敏示例数据(完全合成,不碰真实库)")
|
||||||
|
ap.add_argument("--out", default=os.path.join(BASE, "data", "demo"),
|
||||||
|
help="输出目录,默认 data/demo")
|
||||||
|
ap.add_argument("--days", type=int, default=30, help="覆盖天数,默认 30")
|
||||||
|
ap.add_argument("--seed", type=int, default=20260914, help="随机种子,保证可复现")
|
||||||
|
ap.add_argument("--admin-user", default="admin")
|
||||||
|
ap.add_argument("--admin-password", default="admin123",
|
||||||
|
help="示例管理员口令,默认 admin123(仅供本地演示)")
|
||||||
|
ap.add_argument("--force", action="store_true", help="目标库已存在时覆盖")
|
||||||
|
a = ap.parse_args()
|
||||||
|
|
||||||
|
db_file = os.path.join(os.path.abspath(a.out), "usage.sqlite")
|
||||||
|
if os.path.exists(db_file) and not a.force:
|
||||||
|
print("目标库已存在:%s" % db_file)
|
||||||
|
print("如需重建请加 --force")
|
||||||
|
return 1
|
||||||
|
build(a.out, a.days, a.seed, a.admin_password, a.admin_user)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
# Copyright (c) 2026 Wang Chuanli
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# 用 Gitea Access Token 一次性完成:推代码 + 推镜像
|
# 用 Gitea Access Token 一次性完成:推代码 + 推镜像
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -1,35 +1,61 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
# Copyright (c) 2026 Wang Chuanli
|
||||||
|
|
||||||
"""界面实检:登录后逐页截图,用来目视确认「统一美化」是否真的落地。
|
"""界面实检:登录后逐页截图,用来目视确认「统一美化」是否真的落地。
|
||||||
|
|
||||||
用法:
|
用法:
|
||||||
python manage.py serve --port 8849 --no-scheduler # 另开一个终端
|
WB_DATA_DIR=$PWD/data/demo python manage.py serve --port 8849 --no-scheduler
|
||||||
python tools/shots.py --base http://127.0.0.1:8849
|
python tools/shots.py --base http://127.0.0.1:8849 --db data/demo/usage.sqlite
|
||||||
python tools/shots.py --full # 整页长图(默认只截首屏)
|
python tools/shots.py --full # 整页长图(默认只截首屏)
|
||||||
|
|
||||||
产物:data/shots/*.png(已被 .gitignore 之外的目录,可直接删)。
|
产物:data/shots/*.png(该目录在 .gitignore 内,可直接删)。
|
||||||
|
|
||||||
为什么不用 headless chrome 直出:本项目的页面都要登录态,
|
为什么不用 headless chrome 直出:本项目的页面都要登录态,
|
||||||
`--screenshot` 无法注入会话 Cookie,所以必须用 Playwright 走一次真实登录。
|
`--screenshot` 无法注入会话 Cookie,所以必须用 Playwright 走一次真实登录。
|
||||||
|
|
||||||
|
验证码:默认策略是 always,所以本脚本会**从本地库里取答案**(取的是会话里的
|
||||||
|
captcha id,答案只存在于服务端),这样自动化能跨过验证码这一关。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
|
import sqlite3
|
||||||
import sys
|
import sys
|
||||||
|
import urllib.parse
|
||||||
|
import zlib
|
||||||
|
|
||||||
BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
sys.path.insert(0, BASE)
|
sys.path.insert(0, BASE)
|
||||||
|
|
||||||
PAGES = [
|
# 文件名刻意与原有编号保持一致(docs/USER-GUIDE.md 里就是按这些名字引用的),
|
||||||
("login", "/login", "登录页"),
|
# 新增页面排在后面,避免为了两张新图去改一堆文档链接。
|
||||||
("overview", "/", "概览"),
|
CAPTURES = [
|
||||||
("records", "/records", "数据明细"),
|
# (文件名, 路径, 标签, 是否需登录)
|
||||||
("tasks", "/tasks", "任务管理"),
|
("00-login.png", "/login", "登录页 (含图形验证码)", False),
|
||||||
("config", "/config", "配置管理"),
|
("09-register.png", "/register", "注册页", False),
|
||||||
("logs", "/logs", "日志管理"),
|
("01-overview.png", "/", "概览", True),
|
||||||
("users", "/users", "用户管理"),
|
("02-records.png", "/records", "数据明细", True),
|
||||||
("dashboard", "/dashboard", "用量大屏"),
|
("03-tasks.png", "/tasks", "任务管理", True),
|
||||||
|
("04-config.png", "/config", "配置管理", True),
|
||||||
|
("05-logs.png", "/logs", "日志管理", True),
|
||||||
|
("06-users.png", "/users", "用户管理", True),
|
||||||
|
("07-dashboard.png", "/dashboard", "用量大屏", True),
|
||||||
|
("10-profile.png", "/profile", "个人中心", True),
|
||||||
|
("11-backups.png", "/backups", "备份管理(仅管理员)", True),
|
||||||
|
]
|
||||||
|
|
||||||
|
# v1.3.0 起「任务管理 / 配置管理」在普通账号下是**只读**形态,
|
||||||
|
# 与管理员看到的表单不是同一个页面。文档要同时展示两种视角,
|
||||||
|
# 所以单独跑一趟普通账号的登录会话(换个 context = 干净 Cookie)。
|
||||||
|
USER_CAPTURES = [
|
||||||
|
("03b-tasks-user.png", "/tasks", "任务管理(普通账号:调度只读)"),
|
||||||
|
("04b-config-user.png", "/config", "配置管理(普通账号:仅凭证可改)"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -58,18 +84,62 @@ def _find_browser() -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _session_payload(ctx) -> dict:
|
||||||
|
"""解出 Flask 会话 cookie 里的载荷,只为拿 captcha 的 id。
|
||||||
|
|
||||||
|
Flask 会话是「签名 + base64,不加密」的,所以这里面能读出内容 ——
|
||||||
|
正因如此,验证码答案绝不能放进去(本项目只放一个随机 id)。
|
||||||
|
"""
|
||||||
|
for c in ctx.cookies():
|
||||||
|
if not c.get("name", "").startswith("workbuddy_portal_sid"):
|
||||||
|
continue
|
||||||
|
seg = urllib.parse.unquote(c.get("value", "")).split(".")[0]
|
||||||
|
seg += "=" * (-len(seg) % 4)
|
||||||
|
try:
|
||||||
|
raw = base64.urlsafe_b64decode(seg)
|
||||||
|
try:
|
||||||
|
raw = zlib.decompress(raw)
|
||||||
|
except zlib.error:
|
||||||
|
pass
|
||||||
|
return json.loads(raw.decode("utf-8"))
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return {}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _captcha_answer(ctx, db_path: str, purpose: str) -> str | None:
|
||||||
|
cid = _session_payload(ctx).get("cap_" + purpose)
|
||||||
|
if not cid or not db_path or not os.path.exists(db_path):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
con = sqlite3.connect(db_path)
|
||||||
|
try:
|
||||||
|
row = con.execute("SELECT answer FROM captchas WHERE id=?", (cid,)).fetchone()
|
||||||
|
finally:
|
||||||
|
con.close()
|
||||||
|
except sqlite3.Error:
|
||||||
|
return None
|
||||||
|
return row[0] if row else None
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
ap = argparse.ArgumentParser()
|
ap = argparse.ArgumentParser()
|
||||||
ap.add_argument("--base", default="http://127.0.0.1:8849")
|
ap.add_argument("--base", default="http://127.0.0.1:8849")
|
||||||
ap.add_argument("-u", "--user", default="admin")
|
ap.add_argument("-u", "--user", default="admin")
|
||||||
ap.add_argument("-p", "--password", default="admin123")
|
ap.add_argument("-p", "--password", default="admin123")
|
||||||
|
ap.add_argument("--user2", default="demo",
|
||||||
|
help="普通账号用户名(截只读视角用;设为空则跳过)")
|
||||||
|
ap.add_argument("--password2", default="admin123")
|
||||||
ap.add_argument("--out", default=os.path.join(BASE, "data", "shots"))
|
ap.add_argument("--out", default=os.path.join(BASE, "data", "shots"))
|
||||||
|
ap.add_argument("--db", default=None,
|
||||||
|
help="SQLite 路径(默认 <repo>/data/usage.sqlite),用于取验证码答案")
|
||||||
ap.add_argument("--full", action="store_true", help="截整页长图")
|
ap.add_argument("--full", action="store_true", help="截整页长图")
|
||||||
ap.add_argument("--browser", default="", help="显式指定 chrome/msedge 可执行文件")
|
ap.add_argument("--browser", default="", help="显式指定 chrome/msedge 可执行文件")
|
||||||
ap.add_argument("--width", type=int, default=1440)
|
ap.add_argument("--width", type=int, default=1440)
|
||||||
ap.add_argument("--height", type=int, default=900)
|
ap.add_argument("--height", type=int, default=900)
|
||||||
a = ap.parse_args()
|
a = ap.parse_args()
|
||||||
|
|
||||||
|
db_path = a.db or os.path.join(BASE, "data", "usage.sqlite")
|
||||||
from playwright.sync_api import sync_playwright
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
exe = a.browser or _find_browser()
|
exe = a.browser or _find_browser()
|
||||||
@@ -90,33 +160,51 @@ def main() -> int:
|
|||||||
page.on("console", lambda m: errors.append(m.text) if m.type == "error" else None)
|
page.on("console", lambda m: errors.append(m.text) if m.type == "error" else None)
|
||||||
page.on("pageerror", lambda e: errors.append(str(e)))
|
page.on("pageerror", lambda e: errors.append(str(e)))
|
||||||
|
|
||||||
# 1) 先截未登录的登录页
|
def shoot(name, path, label, pg=None):
|
||||||
page.goto(a.base + "/login", wait_until="networkidle")
|
pg = pg or page
|
||||||
page.screenshot(path=os.path.join(a.out, "00-login.png"), full_page=a.full)
|
errors.clear()
|
||||||
print("[ok] 00-login.png")
|
pg.goto(a.base + path, wait_until="networkidle")
|
||||||
|
pg.wait_for_timeout(900) # 等 ECharts / 表格渲染稳下来
|
||||||
|
pg.screenshot(path=os.path.join(a.out, name), full_page=a.full)
|
||||||
|
js_err = [e for e in errors if "favicon" not in e.lower()]
|
||||||
|
if js_err:
|
||||||
|
problems.append("%s: %s" % (label, js_err[:3]))
|
||||||
|
print("[ok] %-22s %s%s" % (name, label,
|
||||||
|
"" if not js_err else " [JS错误] " + " | ".join(js_err[:3])))
|
||||||
|
|
||||||
# 2) 登录
|
def login(pg, ctx, user, password):
|
||||||
page.fill('input[name=username]', a.user)
|
"""登录并跨过验证码(策略为 always 时从本地库取答案)。"""
|
||||||
page.fill('input[name=password]', a.password)
|
pg.goto(a.base + "/login", wait_until="networkidle")
|
||||||
page.click('button[type=submit]')
|
pg.fill('input[name=username]', user)
|
||||||
page.wait_for_load_state("networkidle")
|
pg.fill('input[name=password]', password)
|
||||||
if "/login" in page.url:
|
if pg.query_selector('input[name=captcha]'):
|
||||||
|
ans = _captcha_answer(ctx, db_path, "login")
|
||||||
|
if not ans:
|
||||||
|
print("[FAIL] 需要验证码但取不到答案(--db 是否指向本实例的库?):%s" % db_path)
|
||||||
|
return False
|
||||||
|
pg.fill('input[name=captcha]', ans)
|
||||||
|
print("[ok] 已用库里的答案通过验证码(%s)" % user)
|
||||||
|
pg.click('button[type=submit]')
|
||||||
|
pg.wait_for_load_state("networkidle")
|
||||||
|
return "/login" not in pg.url
|
||||||
|
|
||||||
|
# 1) 未登录的两页
|
||||||
|
for name, path, label, need_auth in CAPTURES:
|
||||||
|
if need_auth:
|
||||||
|
break
|
||||||
|
shoot(name, path, label)
|
||||||
|
|
||||||
|
# 2) 以管理员登录(策略为 always 时自动解验证码)
|
||||||
|
if not login(page, ctx, a.user, a.password):
|
||||||
print("[FAIL] 登录失败,后续截图无意义")
|
print("[FAIL] 登录失败,后续截图无意义")
|
||||||
br.close()
|
br.close()
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
# 3) 逐页截图
|
# 3) 登录后的页面
|
||||||
for i, (slug, path, label) in enumerate(PAGES[1:], start=1):
|
for name, path, label, need_auth in CAPTURES:
|
||||||
errors.clear()
|
if not need_auth:
|
||||||
page.goto(a.base + path, wait_until="networkidle")
|
continue
|
||||||
page.wait_for_timeout(900) # 等 ECharts / 表格渲染稳下来
|
shoot(name, path, label)
|
||||||
page.screenshot(path=os.path.join(a.out, "%02d-%s.png" % (i, slug)),
|
|
||||||
full_page=a.full)
|
|
||||||
js_err = [e for e in errors if "favicon" not in e.lower()]
|
|
||||||
flag = "" if not js_err else " [JS错误] " + " | ".join(js_err[:3])
|
|
||||||
if js_err:
|
|
||||||
problems.append("%s: %s" % (label, js_err[:3]))
|
|
||||||
print("[ok] %02d-%s.png %s%s" % (i, slug, label, flag))
|
|
||||||
|
|
||||||
# 4) 大屏页再点几个交互,确认控件联动不炸
|
# 4) 大屏页再点几个交互,确认控件联动不炸
|
||||||
page.goto(a.base + "/dashboard", wait_until="networkidle")
|
page.goto(a.base + "/dashboard", wait_until="networkidle")
|
||||||
@@ -136,6 +224,31 @@ def main() -> int:
|
|||||||
problems.append("大屏交互: %s" % js_err[:2])
|
problems.append("大屏交互: %s" % js_err[:2])
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# 5) 换一个干净 context,用普通账号再跑一趟只读视角
|
||||||
|
if a.user2:
|
||||||
|
ctx2 = br.new_context(viewport={"width": a.width, "height": a.height},
|
||||||
|
device_scale_factor=2, locale="zh-CN")
|
||||||
|
page2 = ctx2.new_page()
|
||||||
|
page2.on("console", lambda m: errors.append(m.text) if m.type == "error" else None)
|
||||||
|
page2.on("pageerror", lambda e: errors.append(str(e)))
|
||||||
|
if not login(page2, ctx2, a.user2, a.password2):
|
||||||
|
print("[warn] 普通账号 %s 登录失败,跳过只读视角截图" % a.user2)
|
||||||
|
problems.append("普通账号 %s 登录失败" % a.user2)
|
||||||
|
else:
|
||||||
|
for name, path, label in USER_CAPTURES:
|
||||||
|
shoot(name, path, label, pg=page2)
|
||||||
|
# 顺带把越权面再验一次:普通账号访问这些必须不是 200
|
||||||
|
# /backups 与 /api/backups 是 v1.4.0 新增的:能下载或恢复整个数据库
|
||||||
|
# 等于对全库数据有完整读写权,所以必须只给管理员。
|
||||||
|
for probe in ("/logs", "/logs/tail", "/users", "/backups", "/api/backups"):
|
||||||
|
r = page2.goto(a.base + probe, wait_until="domcontentloaded")
|
||||||
|
code = r.status if r else 0
|
||||||
|
ok = code in (403, 401)
|
||||||
|
print("[%s] 越权面 %-12s -> %s" % ("ok" if ok else "!!", probe, code))
|
||||||
|
if not ok:
|
||||||
|
problems.append("越权面未关死:%s 返回 %s" % (probe, code))
|
||||||
|
ctx2.close()
|
||||||
|
|
||||||
br.close()
|
br.close()
|
||||||
|
|
||||||
print("\n截图目录:%s" % a.out)
|
print("\n截图目录:%s" % a.out)
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
# Copyright (c) 2026 Wang Chuanli
|
||||||
|
|
||||||
"""离线回归:用 Flask test_client 对**真实库**做全页面只读渲染 + 缺陷防回归断言。
|
"""离线回归:用 Flask test_client 对**真实库**做全页面只读渲染 + 缺陷防回归断言。
|
||||||
|
|
||||||
与 tools/check_live.py 的分工:
|
与 tools/check_live.py 的分工:
|
||||||
@@ -8,11 +11,22 @@
|
|||||||
因此能覆盖到「页面模板渲染是否正确」,且不需要先起服务、不需要密码。
|
因此能覆盖到「页面模板渲染是否正确」,且不需要先起服务、不需要密码。
|
||||||
|
|
||||||
覆盖内容:
|
覆盖内容:
|
||||||
1. 全页面渲染(含 /users,需管理员身份)——模板报错会直接暴露成 500
|
1. 全页面渲染(含 /users 与 /logs,均需管理员身份)——模板报错会直接暴露成 500
|
||||||
2. 模板未渲染残留(HTML 里出现 {{ / {% 说明有变量名写错)
|
2. 模板未渲染残留(HTML 里出现 {{ / {% 说明有变量名写错)
|
||||||
3. 历史缺陷防回归(见下 REGRESSIONS)
|
3. 历史缺陷防回归(见 5. 的 ①~⑭)
|
||||||
4. CSV 导出可被标准 csv 解析、列数一致
|
4. 多用户:数据隔离 / 凭证保密 / 注册与验证码 / **普通账号的越权面**(4. 与 4b.)
|
||||||
5. 页面 HTML 里的 class 与 app.css 的选择器做差集(抓类名拼写错误)
|
5. CSV 导出可被标准 csv 解析、列数一致
|
||||||
|
6. 页面 HTML 里的 class 与 app.css 的选择器做差集(抓类名拼写错误)
|
||||||
|
9. 对外暴露面:下载响应头收敛 / 内部异常不外泄 / 读接口限速 / 安全响应头
|
||||||
|
|
||||||
|
权限模型(改断言前先读这一行):
|
||||||
|
普通账号**只能写** `config.USER_EDITABLE_KEYS`(本人的 cookie / user_agent);
|
||||||
|
调度、采集参数、接口地址、注册策略全部只有管理员能写,且一律存在实例级
|
||||||
|
`user_id=0`。所以「越权写」的期望结果是 **400**,而不是「写进去但看不到」。
|
||||||
|
|
||||||
|
写库说明:会写少量 audit_log 行;另外会**临时**建两个普通账号
|
||||||
|
(一个用来验权限边界,一个用来走完整注册链路),无论成功失败都在 finally 里删掉。
|
||||||
|
不会改动任何用量数据。
|
||||||
|
|
||||||
用法:
|
用法:
|
||||||
cd workbuddy-portal
|
cd workbuddy-portal
|
||||||
@@ -25,7 +39,9 @@ import csv
|
|||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import random
|
||||||
import re
|
import re
|
||||||
|
import string
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
@@ -54,14 +70,19 @@ def note(msg: str) -> None:
|
|||||||
print(" [note] %s" % msg)
|
print(" [note] %s" % msg)
|
||||||
|
|
||||||
|
|
||||||
def login(cli, admin=True):
|
def login(cli, uid: int, csrf: str = "smoke-csrf-token"):
|
||||||
"""注入会话绕过登录:GET 不触发 CSRF,因此可直接测页面渲染。"""
|
"""注入会话绕过登录:GET 不触发 CSRF,因此可直接测页面渲染。
|
||||||
|
|
||||||
|
只有 `uid` 是真正生效的键 —— `security.current_user()` 每请求回查
|
||||||
|
users 表(这样做是为了「停用账号立即失效」),所以 `uname/dname/adm`
|
||||||
|
只是写给自己看的标记,改不了权限。
|
||||||
|
"""
|
||||||
with cli.session_transaction() as s:
|
with cli.session_transaction() as s:
|
||||||
s["uid"] = 1
|
s["uid"] = uid
|
||||||
s["uname"] = "admin" if admin else "viewer"
|
s["uname"] = "smoke-%s" % uid
|
||||||
s["dname"] = "管理员" if admin else "只读账号"
|
s["dname"] = "smoke"
|
||||||
s["adm"] = 1 if admin else 0
|
s["adm"] = 0
|
||||||
s["_csrf"] = "smoke-csrf-token"
|
s["_csrf"] = csrf
|
||||||
|
|
||||||
|
|
||||||
def page(cli, path, method="GET", **kw):
|
def page(cli, path, method="GET", **kw):
|
||||||
@@ -69,36 +90,78 @@ def page(cli, path, method="GET", **kw):
|
|||||||
return r.status_code, r.get_data(as_text=True)
|
return r.status_code, r.get_data(as_text=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _rand(n=8):
|
||||||
|
return "".join(random.choice(string.ascii_lowercase) for _ in range(n))
|
||||||
|
|
||||||
|
|
||||||
def run() -> None:
|
def run() -> None:
|
||||||
from workbuddy_portal import create_app, db, query
|
from workbuddy_portal import captcha, config, create_app, crypto, db, query, security
|
||||||
|
|
||||||
print("== 0. 构建应用 ==")
|
print("== 0. 构建应用 ==")
|
||||||
|
# 先把真实库迁到与代码同版本。init_db 是幂等的,顺带把迁移路径也验一遍。
|
||||||
|
# 少了这一步,「库还停在旧 schema」会被 current_user() 判成「未登录」,
|
||||||
|
# 现场表现是下面所有页面断言集体变 302 —— 看起来像产品坏了,
|
||||||
|
# 其实只是前置条件没满足。
|
||||||
|
db.init_db()
|
||||||
app = create_app(start_scheduler=False, do_init_db=False)
|
app = create_app(start_scheduler=False, do_init_db=False)
|
||||||
app.config["WTF_CSRF_ENABLED"] = False
|
app.config["WTF_CSRF_ENABLED"] = False
|
||||||
n_routes = len([r for r in app.url_map.iter_rules()])
|
n_routes = len([r for r in app.url_map.iter_rules()])
|
||||||
chk("create_app 成功", app is not None)
|
chk("create_app 成功", app is not None)
|
||||||
chk("路由数量 >= 35", n_routes >= 35, "routes=%d" % n_routes)
|
chk("路由数量 >= 40", n_routes >= 40, "routes=%d" % n_routes)
|
||||||
|
|
||||||
|
# 真实库里的账号:管理员必须有,普通账号按需临时造
|
||||||
|
conn = db.connect()
|
||||||
|
uv = conn.execute("PRAGMA user_version").fetchone()[0]
|
||||||
|
chk("库 schema 已与代码同版本", uv == db.DB_SCHEMA_VERSION,
|
||||||
|
"uv=%d 期望=%d" % (uv, db.DB_SCHEMA_VERSION))
|
||||||
|
admin_row = conn.execute("SELECT id FROM users WHERE is_admin=1 AND status='active'"
|
||||||
|
" ORDER BY id LIMIT 1").fetchone()
|
||||||
|
if admin_row is None:
|
||||||
|
print("\n[FATAL] 库里没有启用的管理员账号,先跑 python manage.py init")
|
||||||
|
return
|
||||||
|
ADMIN = admin_row["id"]
|
||||||
|
|
||||||
|
# 实例级配置快照:整轮跑完必须一条不少。
|
||||||
|
# 历史缺陷:清理语句写成 `user_id NOT IN (SELECT id FROM users)`,会把
|
||||||
|
# user_id=0(实例级)当成孤儿一起删掉 —— 表现为「跑一次 smoke,全实例的
|
||||||
|
# 调度/采集参数被重置」,而且不报任何错。所以这里前后各取一次快照。
|
||||||
|
inst_before = {r["key"] for r in conn.execute("SELECT key FROM settings WHERE user_id=0")}
|
||||||
|
chk("实例级配置非空(否则下面那条断言会空转)", len(inst_before) > 0,
|
||||||
|
"keys=%d" % len(inst_before))
|
||||||
|
chk("实例级不含凭证类键(cookie/user_agent 恒为个人级)",
|
||||||
|
not (inst_before & config.USER_EDITABLE_KEYS),
|
||||||
|
"混入=%s" % sorted(inst_before & config.USER_EDITABLE_KEYS))
|
||||||
|
|
||||||
# ---------------- 1. 未登录 ----------------
|
# ---------------- 1. 未登录 ----------------
|
||||||
print("== 1. 未登录:受保护页应跳登录、API 应 401 ==")
|
print("== 1. 未登录:受保护页应跳登录、API 应 401 ==")
|
||||||
with app.test_client() as cli:
|
with app.test_client() as cli:
|
||||||
for p in ("/", "/records", "/tasks", "/config", "/logs", "/users"):
|
for p in ("/", "/records", "/tasks", "/config", "/logs", "/users", "/profile"):
|
||||||
st, _ = page(cli, p)
|
st, _ = page(cli, p)
|
||||||
chk("GET %-10s 未登录=302" % p, st == 302, "status=%s" % st)
|
chk("GET %-10s 未登录=302" % p, st == 302, "status=%s" % st)
|
||||||
for p in ("/api/summary", "/api/users", "/api/settings", "/api/audit"):
|
for p in ("/api/summary", "/api/users", "/api/settings", "/api/audit"):
|
||||||
st, _ = page(cli, p)
|
st, _ = page(cli, p)
|
||||||
chk("GET %-14s 未登录=401" % p, st == 401, "status=%s" % st)
|
chk("GET %-14s 未登录=401" % p, st == 401, "status=%s" % st)
|
||||||
|
# 只认 POST 的接口:GET 应当 405(而不是落到 401 或被 GET 直接执行)
|
||||||
|
# 未登录的 POST 会被 before_request 里的 CSRF 先拦下(400)——
|
||||||
|
# 这比先鉴权更好:没有会话就不该被允许碰任何写接口。
|
||||||
|
for p in ("/api/profile", "/api/captcha"):
|
||||||
|
st, _ = page(cli, p)
|
||||||
|
chk("GET %-14s 未登录=405" % p, st == 405, "status=%s" % st)
|
||||||
|
st, _ = page(cli, p, method="POST")
|
||||||
|
chk("POST %-13s 无 CSRF=400" % p, st == 400, "status=%s" % st)
|
||||||
st, html = page(cli, "/login")
|
st, html = page(cli, "/login")
|
||||||
chk("登录页含 CSRF 隐藏域", 'name="_csrf"' in html)
|
chk("登录页含 CSRF 隐藏域", 'name="_csrf"' in html)
|
||||||
|
chk("登录页含验证码图", "capimg" in html and 'name="captcha"' in html)
|
||||||
|
chk("登录页含自助注册入口", "/register" in html)
|
||||||
|
|
||||||
# ---------------- 2. 管理员:全页面渲染 ----------------
|
# ---------------- 2. 管理员:全页面渲染 ----------------
|
||||||
print("== 2. 管理员:全页面渲染 ==")
|
print("== 2. 管理员:全页面渲染 ==")
|
||||||
with app.test_client() as cli:
|
with app.test_client() as cli:
|
||||||
login(cli, admin=True)
|
login(cli, ADMIN)
|
||||||
pages = [
|
pages = [
|
||||||
("/", "概览"), ("/records", "数据明细"), ("/tasks", "任务管理"),
|
("/", "概览"), ("/records", "数据明细"), ("/tasks", "任务管理"),
|
||||||
("/config", "配置管理"), ("/logs", "日志管理"), ("/users", "用户管理"),
|
("/config", "配置管理"), ("/logs", "日志管理"), ("/users", "用户管理"),
|
||||||
("/dashboard", "<html"),
|
("/profile", "个人中心"), ("/dashboard", "<html"),
|
||||||
]
|
]
|
||||||
for p, kw in pages:
|
for p, kw in pages:
|
||||||
st, html = page(cli, p)
|
st, html = page(cli, p)
|
||||||
@@ -109,24 +172,44 @@ def run() -> None:
|
|||||||
chk(" └ 含导航栏", "topbar" in html or p == "/dashboard")
|
chk(" └ 含导航栏", "topbar" in html or p == "/dashboard")
|
||||||
|
|
||||||
st, html = page(cli, "/users")
|
st, html = page(cli, "/users")
|
||||||
chk("用户管理页列出账号", 'data-uid=' in html, "含行内编辑按钮")
|
chk("用户管理页列出账号", 'data-uid=' in html)
|
||||||
chk("用户管理页含新建表单", 'id="formNewUser"' in html)
|
chk("用户管理页含新建表单", 'id="formNewUser"' in html)
|
||||||
chk("用户管理页含审计表", "用户操作审计" in html)
|
chk("用户管理页含账号操作审计", "账号操作审计" in html)
|
||||||
|
chk("用户管理页含状态列", "status" in html and "停用" in html)
|
||||||
|
chk("用户管理页含邮箱列", "邮箱" in html)
|
||||||
|
|
||||||
# 配置页的维护按钮 + 大屏回后台入口
|
|
||||||
st, cfg = page(cli, "/config")
|
st, cfg = page(cli, "/config")
|
||||||
chk("配置页含维护按钮组", cfg.count("data-maint=") >= 3, "n=%d" % cfg.count("data-maint="))
|
chk("配置页含维护按钮组", cfg.count("data-maint=") >= 3, "n=%d" % cfg.count("data-maint="))
|
||||||
chk("配置页含 TLS 校验下拉", 'name="ssl_verify"' in cfg)
|
chk("配置页含 TLS 校验下拉", 'name="ssl_verify"' in cfg)
|
||||||
|
chk("配置页含实例级设置区", "仅管理员可改" in cfg)
|
||||||
|
chk("配置页显示凭证状态而非明文", "cookie_hint" in cfg or "字符" in cfg)
|
||||||
st, rec = page(cli, "/records")
|
st, rec = page(cli, "/records")
|
||||||
chk("明细页含快捷区间", 'data-range="today"' in rec and 'data-range="30d"' in rec)
|
chk("明细页含快捷区间", 'data-range="today"' in rec and 'data-range="30d"' in rec)
|
||||||
chk("明细页表格包在 .tablewrap", "tablewrap" in rec)
|
chk("明细页表格包在 .tablewrap", "tablewrap" in rec)
|
||||||
|
st, pf = page(cli, "/profile")
|
||||||
|
chk("个人中心含改密表单", "/api/password" in pf or "password" in pf)
|
||||||
|
chk("个人中心说明凭证归属本人", "本人凭证" in pf or "我的 Cookie" in pf)
|
||||||
|
# 防回归:base.html 里曾用 {% set me = current_user() %},把子模板的 me
|
||||||
|
# 覆盖掉了 —— current_user() 只有 id/username/display_name/is_admin,
|
||||||
|
# 于是「注册于」渲染成空。变量已改名 cur,这里把两处都盯住。
|
||||||
|
lead = re.search(r'<p class="lead">(.*?)</p>', pf, re.S)
|
||||||
|
lead_txt = " ".join(lead.group(1).split()) if lead else ""
|
||||||
|
chk("个人中心「注册于」有真实时间",
|
||||||
|
bool(re.search(r"注册于\s+\d{4}-\d{2}-\d{2}", lead_txt)), lead_txt[:80])
|
||||||
|
chk("个人中心「最近登录」不是空占位",
|
||||||
|
bool(re.search(r"最近登录\s+\S", lead_txt)), lead_txt[:80])
|
||||||
|
chk("base.html 未再用 me 作局部变量(防覆盖子模板)",
|
||||||
|
"set me = " not in open(os.path.join(
|
||||||
|
BASE, "workbuddy_portal", "web", "templates", "base.html"),
|
||||||
|
encoding="utf-8").read())
|
||||||
|
|
||||||
# ---------------- 2b. 静态资源引用可解析 ----------------
|
# ---------------- 2b. 静态资源引用可解析 ----------------
|
||||||
print("== 2b. 页面引用的静态资源全部可达 ==")
|
print("== 2b. 页面引用的静态资源全部可达 ==")
|
||||||
asset_re = re.compile(r"\.(?:js|css|svg|png|jpe?g|gif|webp|ico|woff2?)(?:\?|$)", re.I)
|
asset_re = re.compile(r"\.(?:js|css|svg|png|jpe?g|gif|webp|ico|woff2?)(?:\?|$)", re.I)
|
||||||
with app.test_client() as cli:
|
with app.test_client() as cli:
|
||||||
login(cli, admin=True)
|
login(cli, ADMIN)
|
||||||
for p in ("/", "/records", "/tasks", "/config", "/logs", "/users", "/dashboard"):
|
for p in ("/", "/records", "/tasks", "/config", "/logs", "/users",
|
||||||
|
"/profile", "/dashboard"):
|
||||||
_, html = page(cli, p)
|
_, html = page(cli, p)
|
||||||
# 先剥掉 HTML 注释:注释里常写示例路径(src="vendor/x.js"),
|
# 先剥掉 HTML 注释:注释里常写示例路径(src="vendor/x.js"),
|
||||||
# 不剥会把示例当真实引用误报。
|
# 不剥会把示例当真实引用误报。
|
||||||
@@ -147,25 +230,327 @@ def run() -> None:
|
|||||||
chk("%-11s 资源引用全部 200" % p, not bad,
|
chk("%-11s 资源引用全部 200" % p, not bad,
|
||||||
("坏引用=%s" % bad) if bad else "%d 个引用" % n)
|
("坏引用=%s" % bad) if bad else "%d 个引用" % n)
|
||||||
|
|
||||||
# ---------------- 3. 非管理员:权限边界 ----------------
|
# ---------------- 3. 多用户:隔离 / 保密 / 注册与验证码 ----------------
|
||||||
print("== 3. 非管理员:/users 必须 403,导航不出现该入口 ==")
|
print("== 3. 多用户:数据隔离 / 凭证保密 / 注册与验证码 ==")
|
||||||
|
viewer = "smoke_v_%s" % _rand()
|
||||||
|
regged = "smoke_r_%s" % _rand()
|
||||||
|
created: list[str] = [viewer]
|
||||||
|
saved_ua_inst = None
|
||||||
|
|
||||||
|
def _mk_user(name):
|
||||||
|
conn.execute("INSERT INTO users(username,password_hash,display_name,is_admin,"
|
||||||
|
"status,created_at) VALUES(?,?,?,0,'active',?)",
|
||||||
|
(name, security.hash_password("Smoke-Pass1"), "冒烟账号", db.now_str()))
|
||||||
|
return conn.execute("SELECT id FROM users WHERE username=?", (name,)).fetchone()["id"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
VIEWER = _mk_user(viewer)
|
||||||
|
|
||||||
|
# ① 凭证密文入库
|
||||||
|
row = conn.execute("SELECT value FROM settings WHERE key='cookie' AND user_id=?",
|
||||||
|
(ADMIN,)).fetchone()
|
||||||
|
if row and row["value"]:
|
||||||
|
chk("① Cookie 以密文入库(v1. 前缀)", crypto.is_encrypted(row["value"]),
|
||||||
|
"head=%s" % row["value"][:12])
|
||||||
|
chk("① 密文不含明文片段",
|
||||||
|
crypto.is_encrypted(row["value"]) and ";" not in row["value"][:4])
|
||||||
|
plain = db.get_secret(conn, "cookie", ADMIN)
|
||||||
|
chk("① 解回来长度合理(>100 字符)", len(plain) > 100, "chars=%d" % len(plain))
|
||||||
|
else:
|
||||||
|
note("库里没有 Cookie,跳过密文断言")
|
||||||
|
|
||||||
|
# ② 凭证绝不跨账号回落
|
||||||
|
chk("② NO_FALLBACK_KEYS 含 cookie/user_agent",
|
||||||
|
{"cookie", "user_agent"} <= db.NO_FALLBACK_KEYS)
|
||||||
|
chk("② 新账号读不到别人的 Cookie", db.get_secret(conn, "cookie", VIEWER) == "")
|
||||||
|
# User-Agent 本身不是秘密,新账号拿到 DEFAULTS 里的**通用** UA 是对的;
|
||||||
|
# 要守住的是「不能继承别人存下来的那一份」。用一个哨兵值把这点钉死:
|
||||||
|
sentinel = "SMOKE-SENTINEL-UA/%s" % _rand()
|
||||||
|
# 注意:get_setting 在没有行时会回落到 DEFAULTS,所以「还原是否成功」
|
||||||
|
# 要拿**行为**比(读出来一样),而不是拿「行在不在」比 —— 这两件事
|
||||||
|
# 在实例级是分开的,混起来会让断言永远失败。
|
||||||
|
before_ua = db.get_setting(conn, "user_agent", "", 0)
|
||||||
|
had_row = conn.execute("SELECT 1 FROM settings WHERE user_id=0 AND key='user_agent'"
|
||||||
|
).fetchone() is not None
|
||||||
|
saved_ua_inst = before_ua if had_row else None
|
||||||
|
db.set_setting(conn, "user_agent", sentinel, 0) # 写实例级
|
||||||
|
chk("② 实例级放哨兵后,新账号仍看不到它",
|
||||||
|
db.get_setting(conn, "user_agent", "", VIEWER) != sentinel,
|
||||||
|
"new=%s…" % (db.get_setting(conn, "user_agent", "", VIEWER) or "")[:22])
|
||||||
|
chk("② 哨兵在实例级确实生效(证明上面的断言不是在空跑)",
|
||||||
|
db.get_setting(conn, "user_agent", "", 0) == sentinel)
|
||||||
|
# 还原:**原本没有这一行就删掉**。实例级本不该存在凭证类键
|
||||||
|
# (v1.3.0 起 cookie / user_agent 恒为个人级),写回空串只会留下
|
||||||
|
# 一个多余行,下次跑就会让「实例级不含凭证键」的断言失败。
|
||||||
|
if had_row:
|
||||||
|
db.set_setting(conn, "user_agent", before_ua, 0)
|
||||||
|
else:
|
||||||
|
conn.execute("DELETE FROM settings WHERE user_id=0 AND key='user_agent'")
|
||||||
|
chk("② 已还原实例级 UA(读出来与放哨兵前一致)",
|
||||||
|
db.get_setting(conn, "user_agent", "", 0) == before_ua)
|
||||||
|
chk("② 还原后实例级不留 user_agent 行(原本有则保留)",
|
||||||
|
(conn.execute("SELECT 1 FROM settings WHERE user_id=0 AND key='user_agent'"
|
||||||
|
).fetchone() is not None) == had_row)
|
||||||
|
# 普通配置应当能回落到实例级(否则每个新账号都拿到空配置)
|
||||||
|
chk("② 普通配置仍回落实例级",
|
||||||
|
db.get_setting(conn, "page_size", None, VIEWER) ==
|
||||||
|
db.get_setting(conn, "page_size", None, 0))
|
||||||
|
|
||||||
|
# ③ /api/settings 只给掩码,绝不给明文
|
||||||
with app.test_client() as cli:
|
with app.test_client() as cli:
|
||||||
login(cli, admin=False)
|
login(cli, ADMIN)
|
||||||
|
st, body = page(cli, "/api/settings")
|
||||||
|
j = json.loads(body)
|
||||||
|
chk("③ /api/settings 200", st == 200, "status=%s" % st)
|
||||||
|
plain = db.get_secret(conn, "cookie", ADMIN)
|
||||||
|
chk("③ 响应体不含 Cookie 明文", not plain or plain not in body)
|
||||||
|
chk("③ cookie 字段被置空", not (j.get("cookie") or "").strip())
|
||||||
|
chk("③ 只给 cookie_hint 掩码", "cookie_hint" in j and "cookie_broken" in j)
|
||||||
|
chk("③ 标注实例级键清单", isinstance(j.get("_globalKeys"), list) and j["_globalKeys"])
|
||||||
|
chk("③ 管理员 _canEditGlobal=True", j.get("_canEditGlobal") is True)
|
||||||
|
chk("③ 无 slot:* 内部键", "slot:" not in body)
|
||||||
|
|
||||||
|
# ④ 验证码:不落 session、一次性、出图禁缓存
|
||||||
|
with app.test_client() as cli:
|
||||||
|
r = cli.get("/captcha.png?purpose=login")
|
||||||
|
chk("④ /captcha.png=200", r.status_code == 200, "status=%s" % r.status_code)
|
||||||
|
chk("④ 是 PNG 字节流",
|
||||||
|
r.headers.get("Content-Type", "").startswith("image/png")
|
||||||
|
and r.get_data()[:8] == b"\x89PNG\r\n\x1a\n")
|
||||||
|
chk("④ 出图禁缓存", "no-store" in r.headers.get("Cache-Control", ""))
|
||||||
|
with cli.session_transaction() as s:
|
||||||
|
cid = s.get("cap_login")
|
||||||
|
chk("④ 会话里只存验证码 id", bool(cid), "id=%s" % (cid or "无"))
|
||||||
|
ans = conn.execute("SELECT answer,purpose FROM captchas WHERE id=?",
|
||||||
|
(cid,)).fetchone() if cid else None
|
||||||
|
chk("④ 答案只存在服务端 captchas 表",
|
||||||
|
ans is not None and len(ans["answer"]) >= 4 and ans["purpose"] == "login")
|
||||||
|
if ans:
|
||||||
|
st, html = page(cli, "/login")
|
||||||
|
chk("④ 页面 HTML 里搜不到答案", ans["answer"] not in html)
|
||||||
|
chk("④ 页面 JS 里也搜不到会话密钥", cid not in html)
|
||||||
|
# 一次性:同一个 id 用两次,第二次必须失败
|
||||||
|
if ans:
|
||||||
|
chk("④ 首次校验通过",
|
||||||
|
captcha.verify(conn, cid, ans["answer"], "login"))
|
||||||
|
chk("④ 同 id 二次校验失败(已消费)",
|
||||||
|
not captcha.verify(conn, cid, ans["answer"], "login"))
|
||||||
|
chk("④ 消费后记录已删除",
|
||||||
|
conn.execute("SELECT COUNT(*) FROM captchas WHERE id=?",
|
||||||
|
(cid,)).fetchone()[0] == 0)
|
||||||
|
|
||||||
|
# ⑤ 自助注册全链路(取答案 -> POST /register -> 账号可用)
|
||||||
|
with app.test_client() as cli:
|
||||||
|
cli.get("/register")
|
||||||
|
# 验证码图是浏览器去取的,test_client 不会自动加载 <img>,
|
||||||
|
# 所以这里显式打一次 —— 这一步正是「注册页有没有发挑战」的验证
|
||||||
|
r = cli.get("/captcha.png?purpose=register")
|
||||||
|
chk("⑤ 注册页的验证码接口可用", r.status_code == 200
|
||||||
|
and r.get_data()[:4] == b"\x89PNG", "status=%s" % r.status_code)
|
||||||
|
with cli.session_transaction() as s:
|
||||||
|
cid = s.get("cap_register")
|
||||||
|
# 这个客户端没有走 login() 注入固定 token,所以要取真实值;
|
||||||
|
# 顺手也证明了 /register 的 CSRF 校验确实在生效
|
||||||
|
csrf = s.get("_csrf")
|
||||||
|
a2 = conn.execute("SELECT answer,purpose FROM captchas WHERE id=?",
|
||||||
|
(cid,)).fetchone() if cid else None
|
||||||
|
chk("⑤ 注册用的挑战落在 register 用途下",
|
||||||
|
a2 is not None and a2["purpose"] == "register")
|
||||||
|
if a2:
|
||||||
|
st, _ = page(cli, "/register", method="POST", data={
|
||||||
|
"username": regged, "display_name": "冒烟注册", "email": "",
|
||||||
|
"password": "Smoke-Pass1", "password2": "Smoke-Pass1",
|
||||||
|
"captcha": a2["answer"]},
|
||||||
|
headers={"X-CSRF-Token": csrf or ""})
|
||||||
|
chk("⑤ 注册成功=302", st == 302, "status=%s" % st)
|
||||||
|
created.append(regged)
|
||||||
|
u = conn.execute("SELECT id,is_admin,status,display_name,last_login_ip"
|
||||||
|
" FROM users WHERE username=?", (regged,)).fetchone()
|
||||||
|
chk("⑤ 建出的是普通账号",
|
||||||
|
u is not None and u["is_admin"] == 0 and u["status"] == "active")
|
||||||
|
chk("⑤ 注册即登录(会话已建立)",
|
||||||
|
u is not None and u["id"] == db.user_by_name(conn, regged)["id"])
|
||||||
|
# 缺 CSRF 必须 400
|
||||||
|
st, _ = page(cli, "/register", method="POST", data={"username": "x" * 3})
|
||||||
|
chk("⑤ 注册缺 CSRF=400", st == 400, "status=%s" % st)
|
||||||
|
|
||||||
|
# ⑥ 权限边界:普通账号改不了实例级配置
|
||||||
|
with app.test_client() as cli:
|
||||||
|
login(cli, VIEWER)
|
||||||
|
st, j = page(cli, "/api/settings")
|
||||||
|
chk("⑥ 非管理员 _canEditGlobal=False", json.loads(j).get("_canEditGlobal") is False)
|
||||||
|
evil = "http://evil.invalid"
|
||||||
|
st, _ = page(cli, "/api/settings", method="POST", json={"api_base": evil},
|
||||||
|
headers={"X-CSRF-Token": "smoke-csrf-token"})
|
||||||
|
chk("⑥ 非管理员改实例级配置=400", st == 400, "status=%s" % st)
|
||||||
|
chk("⑥ 且确实没写进去",
|
||||||
|
db.get_setting(conn, "api_base", "", VIEWER) != evil
|
||||||
|
and db.get_setting(conn, "api_base", "", 0) != evil)
|
||||||
|
|
||||||
|
# ⑦ 数据隔离:所有查询函数都必须显式带 uid
|
||||||
|
try:
|
||||||
|
query.daily(conn)
|
||||||
|
chk("⑦ query.daily 漏传 uid 会报错", False, "居然没报错")
|
||||||
|
except TypeError:
|
||||||
|
chk("⑦ query.daily 漏传 uid 会报错", True)
|
||||||
|
d_admin = query.daily(conn, ADMIN)
|
||||||
|
d_viewer = query.daily(conn, VIEWER)
|
||||||
|
chk("⑦ 不同账号的 daily 互不相同",
|
||||||
|
not d_admin or d_viewer != d_admin or len(d_viewer) == 0)
|
||||||
|
chk("⑦ 新账号 totals 为空", query.totals(conn, VIEWER)["records"] == 0)
|
||||||
|
t_admin = query.totals(conn, ADMIN)
|
||||||
|
chk("⑦ 管理员 totals 有数据", t_admin["records"] > 0, "records=%d" % t_admin["records"])
|
||||||
|
chk("⑦ totals(uid=0) 不含任何人的数据",
|
||||||
|
query.totals(conn, 0)["records"] == 0)
|
||||||
|
finally:
|
||||||
|
# 哨兵 UA 一定要还原(否则下次真采集会带着测试字符串发出去)。
|
||||||
|
# 原本实例级没有这一行时,**删掉**而不是写回空串 —— 见第 ② 条断言。
|
||||||
|
if saved_ua_inst is not None:
|
||||||
|
db.set_setting(conn, "user_agent", saved_ua_inst, 0)
|
||||||
|
else:
|
||||||
|
conn.execute("DELETE FROM settings WHERE user_id=0 AND key='user_agent'")
|
||||||
|
for name in created:
|
||||||
|
conn.execute("DELETE FROM users WHERE username=?", (name,))
|
||||||
|
# 注意 `user_id<>0` 不能省:user_id=0 是**实例级配置**(调度、采集参数、
|
||||||
|
# 接口地址、注册策略都在那里),它不属于任何账号,所以
|
||||||
|
# `NOT IN (SELECT id FROM users)` 会把它当孤儿一起删掉 ——
|
||||||
|
# 表现成「跑一次 smoke,全实例的配置被重置」,且不报任何错。
|
||||||
|
conn.execute("DELETE FROM settings WHERE user_id<>0"
|
||||||
|
" AND user_id NOT IN (SELECT id FROM users)")
|
||||||
|
conn.execute("DELETE FROM usage_records WHERE user_id<>0"
|
||||||
|
" AND user_id NOT IN (SELECT id FROM users)")
|
||||||
|
|
||||||
|
# 确认清理干净
|
||||||
|
left = conn.execute("SELECT COUNT(*) FROM users WHERE username LIKE 'smoke\\_%' ESCAPE '\\'"
|
||||||
|
).fetchone()[0]
|
||||||
|
chk("3. 临时账号已清理", left == 0, "残留=%d" % left)
|
||||||
|
|
||||||
|
# ---------------- 4. 普通账号的权限边界 ----------------
|
||||||
|
# 规则只有一条(config.writable_by):普通账号只能写本人的 cookie / user_agent,
|
||||||
|
# 其余(调度、采集参数、接口地址、注册策略)一律 400。页面隐藏 / disabled
|
||||||
|
# 只是「不给出误导性按钮」,真正的闸门在服务端,所以这里全部走真实请求。
|
||||||
|
print("== 4. 非管理员:越权面必须全部关死 ==")
|
||||||
|
viewer2 = "smoke_w_%s" % _rand()
|
||||||
|
try:
|
||||||
|
V2 = _mk_user(viewer2)
|
||||||
|
with app.test_client() as cli:
|
||||||
|
login(cli, V2)
|
||||||
st, html = page(cli, "/users")
|
st, html = page(cli, "/users")
|
||||||
chk("GET /users 非管理员=403", st == 403, "status=%s" % st)
|
chk("GET /users 非管理员=403", st == 403, "status=%s" % st)
|
||||||
st, _ = page(cli, "/api/users")
|
st, _ = page(cli, "/api/users")
|
||||||
chk("GET /api/users 非管理员=403", st == 403, "status=%s" % st)
|
chk("GET /api/users 非管理员=403", st == 403, "status=%s" % st)
|
||||||
st, _ = page(cli, "/api/users")
|
# ④ 日志是**实例级**运行信息(含数据库路径 / 账号名 / 来源 IP),
|
||||||
|
# 普通账号整页 403 —— 不是「只看到自己那份」。
|
||||||
|
st, _ = page(cli, "/logs")
|
||||||
|
chk("GET /logs 非管理员=403", st == 403, "status=%s" % st)
|
||||||
|
st, _ = page(cli, "/logs/tail?lines=10")
|
||||||
|
chk("GET /logs/tail 非管理员=403", st == 403, "status=%s" % st)
|
||||||
|
# ⑤ 其余页面(都只渲染本人数据)必须照常能开
|
||||||
|
for p in ("/", "/dashboard", "/records", "/tasks", "/config", "/profile"):
|
||||||
|
st, _ = page(cli, p)
|
||||||
|
chk("GET %-11s 非管理员=200" % p, st == 200, "status=%s" % st)
|
||||||
st, html = page(cli, "/")
|
st, html = page(cli, "/")
|
||||||
chk("概览导航不含「用户管理」", "用户管理" not in html)
|
chk("概览导航不含「用户管理」", "用户管理" not in html)
|
||||||
for p in ("/", "/records", "/tasks", "/logs"):
|
chk("概览导航不含「日志管理」", "日志管理" not in html)
|
||||||
st, _ = page(cli, p)
|
chk("普通账号导航含「个人中心」入口", 'class="who"' in html)
|
||||||
chk("GET %-10s 非管理员=200" % p, st == 200, "status=%s" % st)
|
# ⑥ 越权写:调度 / 采集参数 / 实例级键,逐个试,全部必须 400
|
||||||
|
keep_times = db.get_setting(conn, "schedule_times", "", 0)
|
||||||
|
for key, val in (("schedule_times", "23:59"),
|
||||||
|
("schedule_enabled", "0"),
|
||||||
|
("catch_up", "0"),
|
||||||
|
("page_size", "1000"),
|
||||||
|
("timeout", "300"),
|
||||||
|
("ssl_verify", "0"),
|
||||||
|
("max_prompt", "0"),
|
||||||
|
("api_base", "http://evil.invalid"),
|
||||||
|
("allow_register", "0")):
|
||||||
|
st, body = page(cli, "/api/settings", method="POST", json={key: val},
|
||||||
|
headers={"X-CSRF-Token": "smoke-csrf-token"})
|
||||||
|
chk("⑥ 越权写 %-16s =400" % key, st == 400, "status=%s" % st)
|
||||||
|
chk(" └ 报错里点名 %s" % key, key in body)
|
||||||
|
chk("⑥ 越权尝试确实没落库(schedule_times 未变)",
|
||||||
|
db.get_setting(conn, "schedule_times", "", 0) == keep_times)
|
||||||
|
chk("⑥ 实例级 api_base 未被改写",
|
||||||
|
"evil" not in db.get_setting(conn, "api_base", "", 0))
|
||||||
|
# ⑦ 但本人凭证必须写得进去(否则普通账号根本没法采集)
|
||||||
|
st, _ = page(cli, "/api/settings", method="POST",
|
||||||
|
json={"user_agent": "SMOKE-VIEWER-UA/1.0"},
|
||||||
|
headers={"X-CSRF-Token": "smoke-csrf-token"})
|
||||||
|
chk("⑦ 普通账号写本人 user_agent=200", st == 200, "status=%s" % st)
|
||||||
|
chk("⑦ 且只写进了自己名下",
|
||||||
|
db.get_setting(conn, "user_agent", "", V2) == "SMOKE-VIEWER-UA/1.0")
|
||||||
|
# ⑧ 任务页给普通账号渲染的是只读表,且没有「保存调度配置」按钮
|
||||||
|
st, tk = page(cli, "/tasks")
|
||||||
|
chk("⑧ 任务页标注调度只读", "仅管理员可改" in tk)
|
||||||
|
chk("⑧ 任务页无调度保存按钮", "保存调度配置" not in tk)
|
||||||
|
# ⑨ 配置页对普通账号只给凭证表单,采集参数渲染成只读表
|
||||||
|
st, cf = page(cli, "/config")
|
||||||
|
chk("⑨ 配置页有凭证表单", 'id="formCred"' in cf)
|
||||||
|
chk("⑨ 配置页无采集参数表单", 'id="formCollect"' not in cf)
|
||||||
|
chk("⑨ 配置页无实例级设置表单", 'id="formGlobal"' not in cf)
|
||||||
|
chk("⑨ 配置页说明范围", "只有这一块可改" in cf)
|
||||||
|
# ⑩ /api/status 的角色字段(大屏与前端靠它显隐管理员入口)
|
||||||
|
st, sj = page(cli, "/api/status")
|
||||||
|
chk("⑩ GET /api/status 普通账号=200", st == 200, "status=%s" % st)
|
||||||
|
if st == 200:
|
||||||
|
j = json.loads(sj)
|
||||||
|
chk("⑩ is_admin=False", j.get("is_admin") is False, "%s" % j.get("is_admin"))
|
||||||
|
chk("⑩ can_edit_schedule=False", j.get("can_edit_schedule") is False)
|
||||||
|
chk("⑩ can_view_logs=False", j.get("can_view_logs") is False)
|
||||||
|
finally:
|
||||||
|
conn.execute("DELETE FROM users WHERE username=?", (viewer2,))
|
||||||
|
# `user_id<>0` 是必须的:0 是实例级配置,不能当孤儿清理(见第 3 节的说明)
|
||||||
|
conn.execute("DELETE FROM settings WHERE user_id<>0"
|
||||||
|
" AND user_id NOT IN (SELECT id FROM users)")
|
||||||
|
|
||||||
# ---------------- 4. 历史缺陷防回归 ----------------
|
# ---------------- 4b. 全局键的落库位置 ----------------
|
||||||
print("== 4. 历史缺陷防回归 ==")
|
# 这一节盯的是「管理员改了但只有自己生效」这类**静默** bug:
|
||||||
|
# 全局键若被写进管理员的 user_id,其它账号读取时会回落到 DEFAULTS,
|
||||||
|
# 表现成「设置莫名其妙不生效」,而且不报任何错。
|
||||||
|
print("== 4b. 全局键必须落在实例级 user_id=0 ==")
|
||||||
|
chk("schedule_times 是全局键", config.is_global_key("schedule_times"))
|
||||||
|
chk("page_size 是全局键", config.is_global_key("page_size"))
|
||||||
|
chk("cookie 不是全局键(本人凭证)", not config.is_global_key("cookie"))
|
||||||
|
chk("slot:* 仍是个人级(每人各自记今天跑过没)",
|
||||||
|
not config.is_global_key("slot:09:00"))
|
||||||
|
chk("普通账号只被允许写 cookie/user_agent",
|
||||||
|
config.writable_by("cookie", False) and config.writable_by("user_agent", False)
|
||||||
|
and not config.writable_by("schedule_times", False)
|
||||||
|
and not config.writable_by("page_size", False))
|
||||||
with app.test_client() as cli:
|
with app.test_client() as cli:
|
||||||
login(cli, admin=True)
|
login(cli, ADMIN)
|
||||||
|
same = db.get_setting(conn, "schedule_times", "", 0)
|
||||||
|
st, _ = page(cli, "/api/settings", method="POST",
|
||||||
|
json={"schedule_times": same or "09:00"},
|
||||||
|
headers={"X-CSRF-Token": "smoke-csrf-token"})
|
||||||
|
chk("管理员写 schedule_times=200", st == 200, "status=%s" % st)
|
||||||
|
chk("只存在实例级那一份",
|
||||||
|
conn.execute("SELECT COUNT(*) FROM settings WHERE user_id=0"
|
||||||
|
" AND key='schedule_times'").fetchone()[0] == 1)
|
||||||
|
chk("管理员名下不留个人级副本(否则别人读不到)",
|
||||||
|
conn.execute("SELECT COUNT(*) FROM settings WHERE user_id<>0"
|
||||||
|
" AND key='schedule_times'").fetchone()[0] == 0)
|
||||||
|
# /api/status 是大屏与前端判断角色用的接口,必须真的能开且角色正确
|
||||||
|
# (它曾经因为改字段时引用了未定义的变量而 500,两层测试都没覆盖到)
|
||||||
|
st, sj = page(cli, "/api/status")
|
||||||
|
chk("GET /api/status 管理员=200", st == 200, "status=%s" % st)
|
||||||
|
if st == 200:
|
||||||
|
j = json.loads(sj)
|
||||||
|
chk("└ is_admin=True", j.get("is_admin") is True, "%s" % j.get("is_admin"))
|
||||||
|
chk("└ can_edit_schedule=True", j.get("can_edit_schedule") is True)
|
||||||
|
chk("└ 凭证只回「有没有 / 多少字符」",
|
||||||
|
all(k in j for k in ("cookie_set", "cookie_chars", "cookie_broken"))
|
||||||
|
and "cookie" not in j)
|
||||||
|
# 收尾自检:实例级配置必须还在(对照开头那份快照)
|
||||||
|
inst_after = {r["key"] for r in conn.execute("SELECT key FROM settings WHERE user_id=0")}
|
||||||
|
chk("跑完整轮 smoke 后,实例级配置一条不少", inst_before <= inst_after,
|
||||||
|
"丢失=%s" % sorted(inst_before - inst_after))
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# ---------------- 5. 历史缺陷防回归 ----------------
|
||||||
|
print("== 5. 历史缺陷防回归 ==")
|
||||||
|
with app.test_client() as cli:
|
||||||
|
login(cli, ADMIN)
|
||||||
# ① 非法日期曾 500
|
# ① 非法日期曾 500
|
||||||
st, body = page(cli, "/api/summary?from=abc&to=def")
|
st, body = page(cli, "/api/summary?from=abc&to=def")
|
||||||
chk("① /api/summary 非法日期=400", st == 400, "status=%s" % st)
|
chk("① /api/summary 非法日期=400", st == 400, "status=%s" % st)
|
||||||
@@ -219,8 +604,8 @@ def run() -> None:
|
|||||||
st, body = page(cli, "/api/maintenance/recount", method="POST", json={},
|
st, body = page(cli, "/api/maintenance/recount", method="POST", json={},
|
||||||
headers={"X-CSRF-Token": "smoke-csrf-token"})
|
headers={"X-CSRF-Token": "smoke-csrf-token"})
|
||||||
chk("⑩ recount=200", st == 200, "status=%s body=%s" % (st, body[:90]))
|
chk("⑩ recount=200", st == 200, "status=%s body=%s" % (st, body[:90]))
|
||||||
# ⑪ 非管理员调用户管理 API
|
# ⑪ 管理员不能删自己
|
||||||
st, _ = page(cli, "/api/users/1/delete", method="POST", json={},
|
st, _ = page(cli, "/api/users/%d/delete" % ADMIN, method="POST", json={},
|
||||||
headers={"X-CSRF-Token": "smoke-csrf-token"})
|
headers={"X-CSRF-Token": "smoke-csrf-token"})
|
||||||
chk("⑪ 删除自己=400(不允许)", st == 400, "status=%s" % st)
|
chk("⑪ 删除自己=400(不允许)", st == 400, "status=%s" % st)
|
||||||
# ⑫ CSRF 缺失必须 400
|
# ⑫ CSRF 缺失必须 400
|
||||||
@@ -248,10 +633,10 @@ def run() -> None:
|
|||||||
chk("⑭ 审计筛选结果不含其他动作", not others and bool(tags),
|
chk("⑭ 审计筛选结果不含其他动作", not others and bool(tags),
|
||||||
"命中=%d 混入=%s" % (len(tags), others))
|
"命中=%d 混入=%s" % (len(tags), others))
|
||||||
|
|
||||||
# ---------------- 5. 数据自洽 ----------------
|
# ---------------- 6. 数据自洽 ----------------
|
||||||
print("== 5. 数据自洽(只读) ==")
|
print("== 6. 数据自洽(只读) ==")
|
||||||
with app.test_client() as cli:
|
with app.test_client() as cli:
|
||||||
login(cli, admin=True)
|
login(cli, ADMIN)
|
||||||
mf = json.loads(page(cli, "/api/manifest")[1])
|
mf = json.loads(page(cli, "/api/manifest")[1])
|
||||||
src = (mf.get("sources") or [{}])[0]
|
src = (mf.get("sources") or [{}])[0]
|
||||||
chk("manifest 存档条数 == 数据源条数",
|
chk("manifest 存档条数 == 数据源条数",
|
||||||
@@ -264,28 +649,31 @@ def run() -> None:
|
|||||||
chk("summary 全量 credits 自洽",
|
chk("summary 全量 credits 自洽",
|
||||||
abs(float(sm.get("credits", 0)) - float(mf["totals"]["credits"])) < 0.005,
|
abs(float(sm.get("credits", 0)) - float(mf["totals"]["credits"])) < 0.005,
|
||||||
"%s vs %s" % (sm.get("credits"), mf["totals"]["credits"]))
|
"%s vs %s" % (sm.get("credits"), mf["totals"]["credits"]))
|
||||||
d = query.daily(db.get_db())
|
d = query.daily(db.get_db(), ADMIN)
|
||||||
chk("daily 逐日积分求和 == 存档总额",
|
chk("daily 逐日积分求和 == 存档总额",
|
||||||
abs(round(sum(float(x["c"]) for x in d), 2)
|
abs(round(sum(float(x["c"]) for x in d), 2)
|
||||||
- round(float(mf["totals"]["credits"]), 2)) < 0.005)
|
- round(float(mf["totals"]["credits"]), 2)) < 0.005)
|
||||||
chk("daily 逐日 h[24] 求和 == 当日积分",
|
chk("daily 逐日 h[24] 求和 == 当日积分",
|
||||||
all(abs(round(sum(x["h"]), 2) - round(x["c"], 2)) < 0.005 for x in d))
|
all(abs(round(sum(x["h"]), 2) - round(x["c"], 2)) < 0.005 for x in d))
|
||||||
note("存档 %s 条 / %s 积分 / %d 天" % (mf["totals"]["records"],
|
note("管理员存档 %s 条 / %s 积分 / %d 天" % (mf["totals"]["records"],
|
||||||
mf["totals"]["credits"], len(d)))
|
mf["totals"]["credits"], len(d)))
|
||||||
|
|
||||||
# ---------------- 6. class 名与 CSS 选择器对账 ----------------
|
# ---------------- 7. class 名与 CSS 选择器对账 ----------------
|
||||||
print("== 6. 页面 class 与 app.css 选择器对账 ==")
|
print("== 7. 页面 class 与 app.css 选择器对账 ==")
|
||||||
css = open(os.path.join(BASE, "workbuddy_portal", "web", "static", "css", "app.css"),
|
css = open(os.path.join(BASE, "workbuddy_portal", "web", "static", "css", "app.css"),
|
||||||
encoding="utf-8").read()
|
encoding="utf-8").read()
|
||||||
css_classes = set(re.findall(r"\.([A-Za-z][\w-]*)", css))
|
css_classes = set(re.findall(r"\.([A-Za-z][\w-]*)", css))
|
||||||
with app.test_client() as cli:
|
anon = ("/login", "/register")
|
||||||
login(cli, admin=True)
|
auth = ("/", "/records", "/tasks", "/config", "/logs", "/users", "/profile")
|
||||||
used: set[str] = set()
|
used: set[str] = set()
|
||||||
for p in ("/", "/records", "/tasks", "/config", "/logs", "/users", "/login"):
|
for p in anon:
|
||||||
if p == "/login":
|
|
||||||
with app.test_client() as c2:
|
with app.test_client() as c2:
|
||||||
html = page(c2, p)[1]
|
html = page(c2, p)[1]
|
||||||
else:
|
for m in re.findall(r'class="([^"]*)"', html):
|
||||||
|
used.update(t for t in m.split() if t)
|
||||||
|
with app.test_client() as cli:
|
||||||
|
login(cli, ADMIN)
|
||||||
|
for p in auth:
|
||||||
html = page(cli, p)[1]
|
html = page(cli, p)[1]
|
||||||
for m in re.findall(r'class="([^"]*)"', html):
|
for m in re.findall(r'class="([^"]*)"', html):
|
||||||
used.update(t for t in m.split() if t)
|
used.update(t for t in m.split() if t)
|
||||||
@@ -294,6 +682,214 @@ def run() -> None:
|
|||||||
missing = sorted(c for c in used - css_classes - allow)
|
missing = sorted(c for c in used - css_classes - allow)
|
||||||
chk("无「用了但 CSS 里不存在」的类名", not missing, "缺失=%s" % missing if missing else "")
|
chk("无「用了但 CSS 里不存在」的类名", not missing, "缺失=%s" % missing if missing else "")
|
||||||
|
|
||||||
|
# ---------------- 8. 备份 ----------------
|
||||||
|
# 这一节盯三件事:
|
||||||
|
# ① `safe_name` 是下载/恢复接口**唯一**吃文件名的收口点。漏检就是任意文件
|
||||||
|
# 读取 —— `../../data/instance.json` 能直接把主密钥拿走;
|
||||||
|
# ② 归档里必须同时有 manifest / 主库 / instance.json。少了 instance.json,
|
||||||
|
# settings 里的凭证密文就永远解不开了(cookie_key 在里面);
|
||||||
|
# ③ 备份是管理员专属能力,普通账号连列表都不能看。
|
||||||
|
# 「破坏 → 恢复 → 比对」的往返**不在这里做**:它要整库替换,不适合对真实库
|
||||||
|
# 执行,由隔离环境里的专项验证覆盖(见 docs/DEPLOYMENT.md 8.3)。
|
||||||
|
print("== 8. 备份:路径收口 / 归档完整 / 越权面 ==")
|
||||||
|
import shutil as _sh
|
||||||
|
import tempfile as _tf
|
||||||
|
import zipfile as _zf
|
||||||
|
from workbuddy_portal import backup
|
||||||
|
|
||||||
|
for bad in ("../../etc/passwd", "x.txt", "", "..", "a b.zip", "a.zip/../../x.zip"):
|
||||||
|
try:
|
||||||
|
got = backup.safe_name(bad)
|
||||||
|
# `a.zip/../../x.zip` 这类会被 basename 收敛成合法的 `x.zip`。
|
||||||
|
# 收敛不算漏检,但结果里**必须**不含任何路径成分。
|
||||||
|
bad_ok = bool(got) and got == os.path.basename(got) \
|
||||||
|
and "/" not in got and "\\" not in got and ".." not in got
|
||||||
|
except backup.BackupError:
|
||||||
|
bad_ok = True
|
||||||
|
chk("⑧ safe_name 收口 %r" % bad, bad_ok)
|
||||||
|
|
||||||
|
real_dir = config.BACKUP_DIR
|
||||||
|
tmp_bk = _tf.mkdtemp(prefix="wb-smoke-bk-")
|
||||||
|
# 第 5 节末尾把 conn 关掉了(那是它自己的收尾动作),这里重新拿一个独立的。
|
||||||
|
bconn = db.connect()
|
||||||
|
seen_before = {r["filename"] for r in bconn.execute("SELECT filename FROM backups")}
|
||||||
|
try:
|
||||||
|
config.BACKUP_DIR = tmp_bk # 别把真实 backups/ 搅乱
|
||||||
|
res = backup.create(bconn, "manual", "smoke", "smoke 断言")
|
||||||
|
ap = backup.path_of(res["filename"])
|
||||||
|
chk("⑧ 备份生成成功", bool(res["ok"]) and os.path.exists(ap), res["message"])
|
||||||
|
with _zf.ZipFile(ap) as z:
|
||||||
|
names = set(z.namelist())
|
||||||
|
chk("⑧ 归档含 manifest.json", backup.MANIFEST in names)
|
||||||
|
chk("⑧ 归档含主库快照", os.path.basename(config.SQLITE_PATH) in names)
|
||||||
|
chk("⑧ 归档含 instance.json(否则 cookie_key 丢失)",
|
||||||
|
backup.INSTANCE_NAME in names)
|
||||||
|
|
||||||
|
v = backup.verify(ap)
|
||||||
|
chk("⑧ verify 通过", v["integrity"] == "ok",
|
||||||
|
"uv=%s 条数=%s 账号=%s" % (v["schema_ver"], v["records"], v["users"]))
|
||||||
|
chk("⑧ verify 报的条数与库一致",
|
||||||
|
v["records"] == bconn.execute("SELECT COUNT(*) FROM usage_records").fetchone()[0],
|
||||||
|
"records=%s" % v["records"])
|
||||||
|
|
||||||
|
backup.create(bconn, "manual", "smoke", "第二份")
|
||||||
|
backup.sync_index(bconn)
|
||||||
|
backup.prune(bconn, keep=1, actor="smoke")
|
||||||
|
left = [f for f in os.listdir(tmp_bk) if f.endswith(backup.SUFFIX)]
|
||||||
|
chk("⑧ prune keep=1 后只剩 1 份", len(left) == 1, "剩=%d" % len(left))
|
||||||
|
|
||||||
|
tmp_user = "smoke_b_%s" % _rand()
|
||||||
|
bconn.execute("INSERT INTO users(username,password_hash,display_name,is_admin,"
|
||||||
|
"status,created_at) VALUES(?,?,'备份越权探针',0,'active',?)",
|
||||||
|
(tmp_user, security.hash_password("Smoke-Pass1"), db.now_str()))
|
||||||
|
bid = bconn.execute("SELECT id FROM users WHERE username=?", (tmp_user,)).fetchone()["id"]
|
||||||
|
try:
|
||||||
|
with app.test_client() as cb:
|
||||||
|
login(cb, bid)
|
||||||
|
for p in ("/backups", "/api/backups"):
|
||||||
|
st, _ = page(cb, p)
|
||||||
|
chk("⑧ 普通账号 GET %s 被拒" % p, st in (403, 401), "status=%s" % st)
|
||||||
|
# 导出是 zip,不能走 page()(它按文本解码,会炸在二进制上)
|
||||||
|
er = cb.get("/profile/export")
|
||||||
|
body = er.get_data()
|
||||||
|
try:
|
||||||
|
with _zf.ZipFile(io.BytesIO(body)) as z:
|
||||||
|
zn = z.namelist()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
zn = []
|
||||||
|
chk("⑧ 普通账号可导出本人数据(zip)",
|
||||||
|
er.status_code == 200 and len(zn) >= 4,
|
||||||
|
"状态=%s 条目=%s" % (er.status_code, zn))
|
||||||
|
chk("⑧ 导出包里没有 cookie 明文",
|
||||||
|
not any("cookie" in n.lower() for n in zn))
|
||||||
|
finally:
|
||||||
|
bconn.execute("DELETE FROM users WHERE id=?", (bid,))
|
||||||
|
finally:
|
||||||
|
config.BACKUP_DIR = real_dir
|
||||||
|
_sh.rmtree(tmp_bk, ignore_errors=True)
|
||||||
|
# 归档文件已经随临时目录没了,登记行留着就是脏数据(列表里会显示「已丢失」)
|
||||||
|
fresh = [r["filename"] for r in bconn.execute("SELECT filename FROM backups")
|
||||||
|
if r["filename"] not in seen_before]
|
||||||
|
for n in fresh:
|
||||||
|
bconn.execute("DELETE FROM backups WHERE filename=?", (n,))
|
||||||
|
bconn.commit()
|
||||||
|
bconn.close()
|
||||||
|
|
||||||
|
# ---------------- 9. 对外暴露面(安全与隐私基线) ----------------
|
||||||
|
# 这一节盯的是「直接挂到公网」时最容易漏掉的三类东西:
|
||||||
|
# ① 下载文件名 / 响应头注入 —— 用户名并不总是注册正则的产物
|
||||||
|
# (manage.py passwd 建号、老库升级上来的名字都可能带引号或 CR/LF);
|
||||||
|
# ② 内部异常直出 —— 原始异常文本会带绝对路径与 SQL 片段,是踩点的现成材料;
|
||||||
|
# ③ 读接口没有刹车 —— 一个注册账号循环调 /api/bundle 就能持续吃满 CPU。
|
||||||
|
print("== 9. 对外暴露面:响应头收敛 / 异常不外泄 / 读接口限速 ==")
|
||||||
|
|
||||||
|
for bad in ('a"b.csv', "a\r\nX-Evil: 1", "../../etc/passwd", "a\\b.zip", "..",
|
||||||
|
".", " ", "", "中文\n名.csv"):
|
||||||
|
got = security.safe_filename(bad, fallback="download")
|
||||||
|
chk("⑨ safe_filename 收敛 %r" % bad,
|
||||||
|
bool(got) and not any(c in got for c in '"\\\r\n/') and ".." not in got,
|
||||||
|
"-> %r" % got)
|
||||||
|
|
||||||
|
cd = security.content_disposition('a"b\r\nX-Evil: 1.zip')
|
||||||
|
quoted = cd.split('filename="', 1)[1].split('"', 1)[0] if 'filename="' in cd else "?"
|
||||||
|
chk("⑨ content_disposition 的 ASCII 名段无引号/换行/路径",
|
||||||
|
not any(c in quoted for c in '"\\\r\n/'), "-> %r" % cd[:80])
|
||||||
|
chk("⑨ content_disposition 带 RFC 5987 原名段", "filename*=UTF-8''" in cd)
|
||||||
|
|
||||||
|
# 端到端:直接造一个「非法用户名」的账号,看真实导出响应头是否仍然干净。
|
||||||
|
# 走 SQL 插入而不是接口,正是为了模拟「老库里已经有这种名字」的现状。
|
||||||
|
api_src = open(os.path.join(BASE, "workbuddy_portal", "web", "api.py"),
|
||||||
|
encoding="utf-8").read()
|
||||||
|
evil = 'smoke_ev"%s\\x' % _rand(4)
|
||||||
|
nconn = db.connect()
|
||||||
|
try:
|
||||||
|
nconn.execute("INSERT INTO users(username,password_hash,display_name,is_admin,"
|
||||||
|
"status,created_at) VALUES(?,?,'越权探针',0,'active',?)",
|
||||||
|
(evil, security.hash_password("Smoke-Pass1"), db.now_str()))
|
||||||
|
eid = nconn.execute("SELECT id FROM users WHERE username=?", (evil,)).fetchone()["id"]
|
||||||
|
with app.test_client() as ce:
|
||||||
|
login(ce, eid)
|
||||||
|
for path, what in (("/records/export", "按明细导出"), ("/profile/export", "个人数据包")):
|
||||||
|
h = ce.get(path).headers.get("Content-Disposition", "")
|
||||||
|
chk("⑨ %s 响应头已收敛" % what,
|
||||||
|
bool(h) and "\r" not in h and "\n" not in h and 'filename="' in h,
|
||||||
|
"-> %r" % h[:80])
|
||||||
|
finally:
|
||||||
|
nconn.execute("DELETE FROM users WHERE username=?", (evil,))
|
||||||
|
nconn.commit()
|
||||||
|
nconn.close()
|
||||||
|
|
||||||
|
# ② 接口层的兜底 catch 不能再把原始异常文本回给客户端。
|
||||||
|
# 注意断言的是「兜底分支」而不是「所有 str(e)」:BadParam / Busy /
|
||||||
|
# NotReady / ApiError / BackupError 这些都是**面向用户写的**业务异常,
|
||||||
|
# 文案本身就是要给用户看的,把它们也禁掉是过度收紧。
|
||||||
|
# 真正会带出绝对路径与 SQL 的只有 `except Exception` 那一条路。
|
||||||
|
generic = re.findall(r"except Exception as e:[^\n]*\n([^\n]*)", api_src)
|
||||||
|
chk("⑨ 每个 except Exception 都走 _internal(不回原始异常文本)",
|
||||||
|
bool(generic) and all("_internal(" in g for g in generic),
|
||||||
|
"n=%d" % len(generic))
|
||||||
|
|
||||||
|
# ③ 读接口限速:把阈值临时调小,验机制本身(真阈值在 security._API_RATE_MAX)
|
||||||
|
chk("⑨ 读接口阈值给得足够宽松(不会误伤正常翻页)",
|
||||||
|
security._API_RATE_MAX >= 60, "max=%d/60s" % security._API_RATE_MAX)
|
||||||
|
old_max = security._API_RATE_MAX
|
||||||
|
try:
|
||||||
|
security._API_RATE_MAX = 5
|
||||||
|
security.api_rate_reset()
|
||||||
|
with app.test_client() as cr:
|
||||||
|
login(cr, ADMIN)
|
||||||
|
codes = [page(cr, "/api/status")[0] for _ in range(6)]
|
||||||
|
chk("⑨ 读接口限速:前 5 次放行", codes[:5] == [200] * 5, "codes=%s" % codes)
|
||||||
|
chk("⑨ 读接口限速:第 6 次 429", codes[5] == 429, "codes=%s" % codes)
|
||||||
|
finally:
|
||||||
|
security._API_RATE_MAX = old_max
|
||||||
|
security.api_rate_reset()
|
||||||
|
|
||||||
|
# ④ 安全响应头:对外部署时这些是扫描器与浏览器共同依赖的基线
|
||||||
|
with app.test_client() as ch_:
|
||||||
|
h = ch_.get("/login").headers
|
||||||
|
for name in ("Content-Security-Policy", "Permissions-Policy",
|
||||||
|
"X-Content-Type-Options", "X-Frame-Options", "Referrer-Policy"):
|
||||||
|
chk("⑨ 响应头 %s 已下发" % name, name in h)
|
||||||
|
chk("⑨ 会话 Cookie 标了 HttpOnly", "HttpOnly" in h.get("Set-Cookie", ""))
|
||||||
|
|
||||||
|
# ⑥ 内部实现细节不下发给普通账号:表名、库文件路径这些是踩点材料,
|
||||||
|
# 普通账号看自己的数据,没有任何理由知道正本落在哪、叫什么表。
|
||||||
|
probe = "smoke_p_%s" % _rand()
|
||||||
|
nconn2 = db.connect()
|
||||||
|
try:
|
||||||
|
nconn2.execute("INSERT INTO users(username,password_hash,display_name,is_admin,"
|
||||||
|
"status,created_at) VALUES(?,?,'隐私探针',0,'active',?)",
|
||||||
|
(probe, security.hash_password("Smoke-Pass1"), db.now_str()))
|
||||||
|
pid = nconn2.execute("SELECT id FROM users WHERE username=?",
|
||||||
|
(probe,)).fetchone()["id"]
|
||||||
|
with app.test_client() as cu:
|
||||||
|
login(cu, pid)
|
||||||
|
ju = json.loads(page(cu, "/api/manifest")[1])
|
||||||
|
chk("⑨ 普通账号拿不到「数据源」清单", ju.get("sources") == [],
|
||||||
|
"sources=%s" % ju.get("sources"))
|
||||||
|
chk("⑨ 普通账号拿不到库文件路径", not ju.get("archive"),
|
||||||
|
"archive=%r" % ju.get("archive"))
|
||||||
|
chk("⑨ 但仍能拿到自己的存档总量(不是把整块砍掉)",
|
||||||
|
ju.get("totals", {}).get("records") == 0, "records=%s"
|
||||||
|
% ju.get("totals", {}).get("records"))
|
||||||
|
with app.test_client() as ca:
|
||||||
|
login(ca, ADMIN)
|
||||||
|
ja = json.loads(page(ca, "/api/manifest")[1])
|
||||||
|
chk("⑨ 管理员仍能看到「数据源」清单", bool(ja.get("sources")),
|
||||||
|
"n=%d" % len(ja.get("sources") or []))
|
||||||
|
finally:
|
||||||
|
nconn2.execute("DELETE FROM users WHERE username=?", (probe,))
|
||||||
|
nconn2.commit()
|
||||||
|
nconn2.close()
|
||||||
|
|
||||||
|
# ⑤ 大屏页的数据插值必须全部转义(这里只钉住这次修掉的那几处,
|
||||||
|
# 它们是「模型名 / 客户端名来自上游接口」这条链路上最短的几根引线)
|
||||||
|
dash = open(os.path.join(BASE, "workbuddy_portal", "web", "static", "dashboard",
|
||||||
|
"index.html"), encoding="utf-8").read()
|
||||||
|
for probe in ("<b>${p.name}", "<b>${d.name}", "${x.model}</td>", "${x.client}</td>"):
|
||||||
|
chk("⑨ 大屏页已转义 %s" % probe, probe not in dash)
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
print("工程目录:%s\n" % BASE)
|
print("工程目录:%s\n" % BASE)
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
# Copyright (c) 2026 Wang Chuanli
|
||||||
|
|
||||||
"""WorkBuddy Portal —— 独立 Flask 项目。
|
"""WorkBuddy Portal —— 独立 Flask 项目。
|
||||||
|
|
||||||
一个程序管全部:
|
一个程序管全部:
|
||||||
@@ -22,7 +25,7 @@ from flask import Flask, jsonify, render_template, request
|
|||||||
|
|
||||||
from . import config, db, security
|
from . import config, db, security
|
||||||
|
|
||||||
__version__ = "1.1.0"
|
__version__ = "1.5.0"
|
||||||
PROJECT_NAME = config.PROJECT_NAME
|
PROJECT_NAME = config.PROJECT_NAME
|
||||||
|
|
||||||
|
|
||||||
@@ -41,6 +44,10 @@ def _setup_logging(app):
|
|||||||
|
|
||||||
|
|
||||||
def create_app(start_scheduler=True, do_init_db=True, **overrides):
|
def create_app(start_scheduler=True, do_init_db=True, **overrides):
|
||||||
|
# 必须在任何写盘动作之前:把进程 umask 收到 0077,此后新建的 SQLite 库、
|
||||||
|
# -wal / -shm、导出 CSV、备份 zip、日志文件一律是 owner-only。
|
||||||
|
# 数据目录里躺着对话正文与主密钥,对外部署时同机其它用户不该读得到。
|
||||||
|
config.harden_process()
|
||||||
app = Flask(__name__,
|
app = Flask(__name__,
|
||||||
template_folder="web/templates",
|
template_folder="web/templates",
|
||||||
static_folder="web/static",
|
static_folder="web/static",
|
||||||
@@ -48,8 +55,14 @@ def create_app(start_scheduler=True, do_init_db=True, **overrides):
|
|||||||
app.config.update(
|
app.config.update(
|
||||||
SECRET_KEY=config.secret_key(),
|
SECRET_KEY=config.secret_key(),
|
||||||
PERMANENT_SESSION_LIFETIME=timedelta(hours=config.SESSION_HOURS),
|
PERMANENT_SESSION_LIFETIME=timedelta(hours=config.SESSION_HOURS),
|
||||||
|
# ---- 会话 Cookie 加固 ----
|
||||||
|
# HttpOnly:JS 读不到(XSS 也别想直接偷走会话)
|
||||||
SESSION_COOKIE_HTTPONLY=True,
|
SESSION_COOKIE_HTTPONLY=True,
|
||||||
SESSION_COOKIE_SAMESITE="Lax",
|
SESSION_COOKIE_SAMESITE="Lax",
|
||||||
|
# Secure:仅 HTTPS 下发。纯局域网 HTTP 部署必须留 0,否则浏览器
|
||||||
|
# 根本不会回传 Cookie,表现为「刚登录完又被弹回登录页」。
|
||||||
|
SESSION_COOKIE_SECURE=config.COOKIE_SECURE,
|
||||||
|
SESSION_COOKIE_PATH="/",
|
||||||
SESSION_COOKIE_NAME="workbuddy_portal_sid",
|
SESSION_COOKIE_NAME="workbuddy_portal_sid",
|
||||||
MAX_CONTENT_LENGTH=4 * 1024 * 1024,
|
MAX_CONTENT_LENGTH=4 * 1024 * 1024,
|
||||||
TEMPLATES_AUTO_RELOAD=True,
|
TEMPLATES_AUTO_RELOAD=True,
|
||||||
@@ -91,6 +104,19 @@ def create_app(start_scheduler=True, do_init_db=True, **overrides):
|
|||||||
return jsonify({"ok": False, "error": "forbidden", "message": "没有权限"}), 403
|
return jsonify({"ok": False, "error": "forbidden", "message": "没有权限"}), 403
|
||||||
return render_template("error.html", code=403, message="没有权限"), 403
|
return render_template("error.html", code=403, message="没有权限"), 403
|
||||||
|
|
||||||
|
@app.errorhandler(413)
|
||||||
|
def _413(e):
|
||||||
|
"""请求体过大(MAX_CONTENT_LENGTH)。
|
||||||
|
|
||||||
|
不加这一层的话 Flask 会吐一个默认 HTML 页,而接口调用方拿到的是
|
||||||
|
HTML 而不是 JSON —— 前端 `r.json()` 会抛异常,最后在界面上表现成
|
||||||
|
「请求失败:Unexpected token <」。这里统一成与其它错误一致的形状。
|
||||||
|
"""
|
||||||
|
msg = "请求体过大(上限 %d MB)" % (app.config["MAX_CONTENT_LENGTH"] // (1024 * 1024))
|
||||||
|
if request.path.startswith("/api/"):
|
||||||
|
return jsonify({"ok": False, "error": "too_large", "message": msg}), 413
|
||||||
|
return render_template("error.html", code=413, message=msg), 413
|
||||||
|
|
||||||
@app.errorhandler(500)
|
@app.errorhandler(500)
|
||||||
def _500(e):
|
def _500(e):
|
||||||
app.logger.exception("内部错误")
|
app.logger.exception("内部错误")
|
||||||
|
|||||||
@@ -0,0 +1,640 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
# Copyright (c) 2026 Wang Chuanli
|
||||||
|
|
||||||
|
"""备份管理:一致性快照、自动周期、保留份数、下载与恢复。
|
||||||
|
|
||||||
|
为什么需要它
|
||||||
|
------------
|
||||||
|
原来只有「手工把 data/usage.sqlite 拷一份」这一条路,问题有三个:
|
||||||
|
* 直接 cp 一个 WAL 库拿到的**不是**一致快照(-wal 里可能还有没落盘的帧)
|
||||||
|
* 备份躺在数据卷里,`docker compose down -v` 会把正本与副本一起删掉
|
||||||
|
* 恢复没有任何护栏:覆盖上去就完了,恢复错了也没有退路
|
||||||
|
|
||||||
|
本模块的三条设计决定
|
||||||
|
--------------------
|
||||||
|
1. **快照用 SQLite 在线备份 API**(`Connection.backup`),不是文件拷贝。
|
||||||
|
它按页复制并在复制期间持有读事务,所以采集正在写的时候拿到的也是
|
||||||
|
一个「某一时刻的完整库」。手工 cp 做不到这一点。
|
||||||
|
2. **归档是一个 zip**,内含所有 `*.sqlite` + `manifest.json`(+ 可选
|
||||||
|
`instance.json`)。好处是单文件下载、可校验、可跨机器搬到别处恢复;
|
||||||
|
而 `instance.json` 在里面是必要的 —— 没有 cookie_key 就解不开
|
||||||
|
settings 里的凭证密文,那样的「恢复」等于把所有人的 Cookie 弄丢。
|
||||||
|
代价是归档本身含密钥,所以它**永不入库、永不进镜像**(见 .gitignore /
|
||||||
|
.dockerignore 与 docs/DEPLOYMENT.md)。
|
||||||
|
3. **恢复走 SQL 级替换,不做文件 swap**。把归档解出来建一个临时库、先迁移
|
||||||
|
到当前 schema,然后在**一个写事务**里整表搬过去。这样:
|
||||||
|
* 不需要停机、不需要保证没有别的连接持有文件句柄(Windows 上文件
|
||||||
|
swap 会因句柄占用直接失败);
|
||||||
|
* 备份是老版本(user_version=2)也能恢复,迁移在临时库里先做完;
|
||||||
|
* 中途失败就是一个事务回滚,不会留下半个库。
|
||||||
|
|
||||||
|
恢复前的护栏:先给**当前**库自动打一份 `pre-restore` 快照。恢复错了还能回去。
|
||||||
|
|
||||||
|
恢复后还会做一件事:把所有账号的 `session_ver` 都 +1,于是**所有既有登录会话
|
||||||
|
立即失效**。理由见 `restore()` 里的注释 —— 归档里的 sv 可能与旧 Cookie 恰好
|
||||||
|
相等,那样会话会带着「一整套已被替换掉的账号与权限」继续用下去。
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from . import collect, config, db
|
||||||
|
|
||||||
|
log = logging.getLogger("wb.backup")
|
||||||
|
|
||||||
|
SUFFIX = ".zip"
|
||||||
|
MANIFEST = "manifest.json"
|
||||||
|
INSTANCE_NAME = "instance.json"
|
||||||
|
FORMAT_VERSION = 1
|
||||||
|
|
||||||
|
# 恢复时整表搬运的表清单。
|
||||||
|
# 刻意**不含 backups 自己**:它记的是「本机备份目录里有什么」,
|
||||||
|
# 属于当前实例的运行索引,拿旧库里的那份覆盖会凭空丢掉期间新增的条目
|
||||||
|
# (而按需重建索引是幂等的,见 sync_index)。
|
||||||
|
RESTORE_TABLES = ("users", "settings", "usage_records",
|
||||||
|
"collect_runs", "audit_log", "captchas")
|
||||||
|
|
||||||
|
|
||||||
|
class BackupError(Exception):
|
||||||
|
"""备份/恢复的业务性失败(归档损坏、文件缺失、跨度不符等)。"""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 路径与文件名 ----------------
|
||||||
|
def backup_dir():
|
||||||
|
config.ensure_dirs()
|
||||||
|
return config.BACKUP_DIR
|
||||||
|
|
||||||
|
|
||||||
|
def safe_name(name):
|
||||||
|
"""把用户传来的文件名收敛成「备份目录下的一个 zip」。
|
||||||
|
|
||||||
|
必须防住 `../../etc/passwd`、绝对路径、`sub/..` 这类穿越写法 ——
|
||||||
|
下载与恢复接口都直接吃文件名,这里是唯一的收口点。
|
||||||
|
"""
|
||||||
|
base = os.path.basename(str(name or "").strip().replace("\\", "/"))
|
||||||
|
if not base or base in (".", "..") or not base.endswith(SUFFIX):
|
||||||
|
raise BackupError("备份文件名不合法")
|
||||||
|
if any(c not in "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ._-"
|
||||||
|
for c in base):
|
||||||
|
raise BackupError("备份文件名含非法字符")
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def path_of(name):
|
||||||
|
return os.path.join(backup_dir(), safe_name(name))
|
||||||
|
|
||||||
|
|
||||||
|
def human(n):
|
||||||
|
n = float(n or 0)
|
||||||
|
for unit in ("B", "KB", "MB", "GB"):
|
||||||
|
if n < 1024 or unit == "GB":
|
||||||
|
return ("%d B" % n) if unit == "B" else ("%.1f %s" % (n, unit))
|
||||||
|
n /= 1024.0
|
||||||
|
|
||||||
|
|
||||||
|
def _app_version():
|
||||||
|
from . import __version__ # 延迟导入,避开包初始化顺序
|
||||||
|
return __version__
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 元数据 ----------------
|
||||||
|
def _db_files():
|
||||||
|
"""数据目录下所有 SQLite 库(不含 -wal / -shm 侧车)。"""
|
||||||
|
out = []
|
||||||
|
d = config.DATA_DIR
|
||||||
|
if not os.path.isdir(d):
|
||||||
|
return out
|
||||||
|
for fn in sorted(os.listdir(d)):
|
||||||
|
if not fn.endswith(".sqlite"):
|
||||||
|
continue
|
||||||
|
p = os.path.join(d, fn)
|
||||||
|
if os.path.isfile(p):
|
||||||
|
out.append(p)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(path):
|
||||||
|
h = hashlib.sha256()
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
for chunk in iter(lambda: f.read(1 << 20), b""):
|
||||||
|
h.update(chunk)
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot(src_path, dst_path):
|
||||||
|
"""用在线备份 API 生成一致快照(src 有并发写也安全)。"""
|
||||||
|
src = sqlite3.connect(src_path, timeout=8.0)
|
||||||
|
try:
|
||||||
|
dst = sqlite3.connect(dst_path)
|
||||||
|
try:
|
||||||
|
src.backup(dst)
|
||||||
|
finally:
|
||||||
|
dst.close()
|
||||||
|
finally:
|
||||||
|
src.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _stats():
|
||||||
|
"""归档时的关键计数,用于「挑一份恢复」与事后核对。"""
|
||||||
|
p = config.SQLITE_PATH
|
||||||
|
if not os.path.exists(p):
|
||||||
|
return {"records": 0, "credits": 0.0, "users": 0, "schema_ver": 0}
|
||||||
|
conn = sqlite3.connect("file:%s?mode=ro" % p.replace("\\", "/"), uri=True)
|
||||||
|
try:
|
||||||
|
n, cr = conn.execute("SELECT COUNT(*), COALESCE(SUM(credits),0)"
|
||||||
|
" FROM usage_records").fetchone()
|
||||||
|
u = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||||
|
uv = conn.execute("PRAGMA user_version").fetchone()[0]
|
||||||
|
return {"records": n, "credits": round(cr or 0, 2), "users": u, "schema_ver": uv}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_safely(zf, dest, names):
|
||||||
|
"""只按**基名**解压到 dest —— 归档可能是别人给的,挡掉 zip slip。
|
||||||
|
|
||||||
|
`ZipFile.extractall` 会因为条目名里的 `..` / 绝对路径写到目录之外,
|
||||||
|
而恢复接口正好是「吃一个外部文件」的入口,必须在这一层挡住。
|
||||||
|
"""
|
||||||
|
for n in names:
|
||||||
|
base = os.path.basename(n.replace("\\", "/"))
|
||||||
|
if not base or base in (".", ".."):
|
||||||
|
continue
|
||||||
|
with zf.open(n) as fsrc, open(os.path.join(dest, base), "wb") as fdst:
|
||||||
|
shutil.copyfileobj(fsrc, fdst)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 生成备份 ----------------
|
||||||
|
def create(conn, trigger="manual", actor=None, note=""):
|
||||||
|
"""打一份新备份,返回结果 dict。失败抛 BackupError。"""
|
||||||
|
dbs = _db_files()
|
||||||
|
if not dbs:
|
||||||
|
raise BackupError("数据目录里没有找到任何 *.sqlite,没什么可备份的")
|
||||||
|
|
||||||
|
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
name = "usage-%s%s" % (stamp, SUFFIX)
|
||||||
|
dest = os.path.join(backup_dir(), name)
|
||||||
|
seq = 1
|
||||||
|
while os.path.exists(dest): # 同一秒内连打两次也不能互相覆盖
|
||||||
|
name = "usage-%s-%d%s" % (stamp, seq, SUFFIX)
|
||||||
|
dest = os.path.join(backup_dir(), name)
|
||||||
|
seq += 1
|
||||||
|
|
||||||
|
st = _stats()
|
||||||
|
tmpdir = tempfile.mkdtemp(prefix="wb-backup-")
|
||||||
|
files_meta = []
|
||||||
|
try:
|
||||||
|
for i, src in enumerate(dbs):
|
||||||
|
fn = os.path.basename(src)
|
||||||
|
# 主库用在线备份 API;其余库(当前没有,但口径要留全)同理
|
||||||
|
snap = os.path.join(tmpdir, fn)
|
||||||
|
_snapshot(src, snap)
|
||||||
|
files_meta.append({"name": fn, "bytes": os.path.getsize(snap),
|
||||||
|
"sha256": _sha256(snap), "primary": src == config.SQLITE_PATH})
|
||||||
|
inst = config.INSTANCE_FILE
|
||||||
|
if os.path.exists(inst):
|
||||||
|
shutil.copy2(inst, os.path.join(tmpdir, INSTANCE_NAME))
|
||||||
|
files_meta.append({"name": INSTANCE_NAME,
|
||||||
|
"bytes": os.path.getsize(os.path.join(tmpdir, INSTANCE_NAME)),
|
||||||
|
"sha256": _sha256(os.path.join(tmpdir, INSTANCE_NAME)),
|
||||||
|
"secret": True})
|
||||||
|
manifest = {
|
||||||
|
"format": FORMAT_VERSION,
|
||||||
|
"app": config.PROJECT_NAME,
|
||||||
|
"version": _app_version(),
|
||||||
|
"created_at": db.now_str(),
|
||||||
|
"trigger": trigger,
|
||||||
|
"actor": actor or "",
|
||||||
|
"note": note or "",
|
||||||
|
"stats": st,
|
||||||
|
"files": files_meta,
|
||||||
|
"restore_note": ("恢复会整表替换 usage_records / settings / users 等;"
|
||||||
|
"恢复前系统会自动先备份当前库。"),
|
||||||
|
}
|
||||||
|
with zipfile.ZipFile(dest, "w", zipfile.ZIP_DEFLATED) as z:
|
||||||
|
z.writestr(MANIFEST, json.dumps(manifest, ensure_ascii=False, indent=2))
|
||||||
|
for fn in [f["name"] for f in files_meta]:
|
||||||
|
z.write(os.path.join(tmpdir, fn), fn)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
os.remove(dest) # 半成品不留,否则「列表里有一份打不开的备份」
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||||
|
|
||||||
|
# 归档里是「全库 + 主密钥」,权限等同管理员口令,落盘后立刻收紧。
|
||||||
|
# (进程 umask 已收到 0077,这里额外兜住「同名文件早已存在」的情况。)
|
||||||
|
config.harden_file(dest)
|
||||||
|
size = os.path.getsize(dest)
|
||||||
|
digest = _sha256(dest)
|
||||||
|
db.audit(conn, "backup_create", actor or "system",
|
||||||
|
"生成备份 %s(%s,%d 条 / %.2f 积分)" % (name, human(size), st["records"], st["credits"]),
|
||||||
|
"127.0.0.1", 0)
|
||||||
|
return {"ok": True, "filename": name, "bytes": size, "sha256": digest,
|
||||||
|
"trigger": trigger, "stats": st,
|
||||||
|
"message": "已生成备份 %s(%s,%d 条记录)" % (name, human(size), st["records"])}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 索引(表 <- 磁盘) ----------------
|
||||||
|
def _row_exists(conn, filename):
|
||||||
|
return conn.execute("SELECT 1 FROM backups WHERE filename=?", (filename,)).fetchone() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def sync_index(conn):
|
||||||
|
"""把磁盘上的归档登记进 backups 表,并把消失的标记 missing=1。
|
||||||
|
|
||||||
|
手工拷进来 / 手工删掉的备份都能因此被正确呈现 —— 索引只是缓存,
|
||||||
|
**磁盘才是事实来源**,所以这里做双向对齐而不是只信表。
|
||||||
|
"""
|
||||||
|
files = [f for f in os.listdir(backup_dir()) if f.endswith(SUFFIX)]
|
||||||
|
for fn in files:
|
||||||
|
if _row_exists(conn, fn):
|
||||||
|
continue
|
||||||
|
p = os.path.join(backup_dir(), fn)
|
||||||
|
meta = {"records": 0, "credits": 0.0, "users": 0, "schema_ver": 0}
|
||||||
|
created = ""
|
||||||
|
# 归档的 manifest 里记着它的真实来源(cli / manual / auto / pre-restore)。
|
||||||
|
# 直接用它的,不要一律标成 external —— 恢复前最需要判断的恰恰是
|
||||||
|
# 「这份是自动备份、还是我手工留的、还是恢复前系统自动存的那一份」。
|
||||||
|
trig = "external"
|
||||||
|
actor = None
|
||||||
|
note = "从磁盘发现"
|
||||||
|
try:
|
||||||
|
info = read_manifest(p)
|
||||||
|
meta = dict(meta, **info.get("stats", {}))
|
||||||
|
created = info.get("created_at") or ""
|
||||||
|
trig = (info.get("trigger") or "").strip() or "external"
|
||||||
|
actor = (info.get("actor") or "").strip() or None
|
||||||
|
note = (info.get("note") or "").strip() or note
|
||||||
|
except BackupError:
|
||||||
|
pass
|
||||||
|
if not created:
|
||||||
|
# 读不出 manifest 就用文件时间,至少让排序有意义
|
||||||
|
try:
|
||||||
|
created = datetime.fromtimestamp(os.path.getmtime(p)).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
except OSError:
|
||||||
|
created = db.now_str()
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO backups(filename,bytes,sha256,created_at,trigger,actor,"
|
||||||
|
" schema_ver,records,credits,users,note,missing)"
|
||||||
|
" VALUES(?,?,?,?,?,?,?,?,?,?,?,0)",
|
||||||
|
(fn, os.path.getsize(p), "", created, trig, actor,
|
||||||
|
meta.get("schema_ver", 0), meta.get("records", 0),
|
||||||
|
meta.get("credits", 0.0), meta.get("users", 0), note))
|
||||||
|
# 磁盘上没了 -> 标记,不删行:保留「这里曾经有过一份」的记录更利于追责
|
||||||
|
for r in conn.execute("SELECT id,filename,missing FROM backups").fetchall():
|
||||||
|
gone = not os.path.exists(os.path.join(backup_dir(), r["filename"]))
|
||||||
|
want = 1 if gone else 0
|
||||||
|
if r["missing"] != want:
|
||||||
|
conn.execute("UPDATE backups SET missing=? WHERE id=?", (want, r["id"]))
|
||||||
|
return len(files)
|
||||||
|
|
||||||
|
|
||||||
|
def listing(conn):
|
||||||
|
"""备份清单(新→旧),附磁盘实际大小。"""
|
||||||
|
out = []
|
||||||
|
for r in conn.execute("SELECT * FROM backups ORDER BY created_at DESC, id DESC"):
|
||||||
|
p = os.path.join(backup_dir(), r["filename"])
|
||||||
|
exists = os.path.exists(p)
|
||||||
|
d = dict(r)
|
||||||
|
d["exists"] = exists
|
||||||
|
if exists:
|
||||||
|
d["bytes"] = os.path.getsize(p)
|
||||||
|
d["size_h"] = human(d["bytes"])
|
||||||
|
out.append(d)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def total_bytes(conn):
|
||||||
|
return conn.execute("SELECT COALESCE(SUM(bytes),0) FROM backups WHERE missing=0").fetchone()[0]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 校验 ----------------
|
||||||
|
def _name_map(zf):
|
||||||
|
"""归档条目名 -> 真实条目名(按基名索引,容忍归档里带目录前缀)。"""
|
||||||
|
m = {}
|
||||||
|
for n in zf.namelist():
|
||||||
|
b = os.path.basename(n.replace("\\", "/"))
|
||||||
|
if b:
|
||||||
|
m[b] = n
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def read_manifest(path):
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(path) as z:
|
||||||
|
entry = _name_map(z).get(MANIFEST)
|
||||||
|
if entry is None:
|
||||||
|
raise BackupError("归档里没有 %s,不是本程序生成的备份" % MANIFEST)
|
||||||
|
return json.loads(z.read(entry).decode("utf-8"))
|
||||||
|
except (zipfile.BadZipFile, KeyError, ValueError, OSError) as e:
|
||||||
|
raise BackupError("归档无法解析(%s):%s" % (os.path.basename(path), e))
|
||||||
|
|
||||||
|
|
||||||
|
def verify(path):
|
||||||
|
"""校验一份归档是否可用于恢复(不修改任何东西)。"""
|
||||||
|
if not os.path.exists(path):
|
||||||
|
raise BackupError("备份文件不存在或已被删除")
|
||||||
|
man = read_manifest(path)
|
||||||
|
if int(man.get("format") or 0) > FORMAT_VERSION:
|
||||||
|
raise BackupError("归档格式版本 %s 高于本程序支持的 %s,请先升级程序"
|
||||||
|
% (man.get("format"), FORMAT_VERSION))
|
||||||
|
files = man.get("files") or []
|
||||||
|
primary = [f for f in files if f.get("primary")] or (files[:1] if files else [])
|
||||||
|
if not primary:
|
||||||
|
raise BackupError("归档里没有数据库文件")
|
||||||
|
|
||||||
|
tmpdir = tempfile.mkdtemp(prefix="wb-verify-")
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(path) as z:
|
||||||
|
nm = _name_map(z)
|
||||||
|
for f in files:
|
||||||
|
fn = os.path.basename(str(f.get("name") or ""))
|
||||||
|
if not fn:
|
||||||
|
raise BackupError("归档条目名不合法:%s" % f.get("name"))
|
||||||
|
if fn not in nm:
|
||||||
|
raise BackupError("归档缺少文件:%s" % f.get("name"))
|
||||||
|
_extract_safely(z, tmpdir, [nm[fn]])
|
||||||
|
db_path = os.path.join(tmpdir, os.path.basename(primary[0]["name"]))
|
||||||
|
if not os.path.exists(db_path):
|
||||||
|
raise BackupError("归档里的主数据库文件解不出来")
|
||||||
|
conn = sqlite3.connect("file:%s?mode=ro" % db_path.replace("\\", "/"), uri=True)
|
||||||
|
try:
|
||||||
|
integ = conn.execute("PRAGMA integrity_check").fetchone()[0]
|
||||||
|
uv = conn.execute("PRAGMA user_version").fetchone()[0]
|
||||||
|
n, cr = conn.execute("SELECT COUNT(*), COALESCE(SUM(credits),0)"
|
||||||
|
" FROM usage_records").fetchone()
|
||||||
|
users = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
raise BackupError("归档里的数据库打不开:%s" % e)
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||||
|
|
||||||
|
if integ != "ok":
|
||||||
|
raise BackupError("归档里的数据库完整性检查未通过:%s" % integ)
|
||||||
|
if uv > db.DB_SCHEMA_VERSION:
|
||||||
|
raise BackupError("归档的库结构版本 %d 比当前程序(%d)还新,无法恢复"
|
||||||
|
% (uv, db.DB_SCHEMA_VERSION))
|
||||||
|
return {"manifest": man, "integrity": integ, "schema_ver": uv,
|
||||||
|
"records": n, "credits": round(cr or 0, 2), "users": users,
|
||||||
|
"created_at": man.get("created_at") or "",
|
||||||
|
"version": man.get("version") or ""}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 恢复 ----------------
|
||||||
|
def _table_cols(conn, db_name, table):
|
||||||
|
return [r["name"] for r in conn.execute("PRAGMA %s.table_info(%s)" % (db_name, table))]
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_tables(conn, src_path):
|
||||||
|
"""把 src 库里的业务表整表搬到主库,一个事务内完成。
|
||||||
|
|
||||||
|
用**列名交集**而不是 `SELECT *` 对齐:老库 ALTER 出来的列顺序与新库
|
||||||
|
建表语句的列顺序不一定一致(ALTER 追加在末尾),`SELECT *` 会静默
|
||||||
|
错位 —— 那是最难查的一类数据损坏(字段整体串位,值都「合法」)。
|
||||||
|
"""
|
||||||
|
conn.execute("ATTACH DATABASE ? AS src", (src_path,))
|
||||||
|
try:
|
||||||
|
plan = []
|
||||||
|
for t in RESTORE_TABLES:
|
||||||
|
dst_cols = _table_cols(conn, "main", t)
|
||||||
|
src_cols = _table_cols(conn, "src", t)
|
||||||
|
if not src_cols:
|
||||||
|
continue # 归档里没有这张表(更老的版本)
|
||||||
|
common = [c for c in dst_cols if c in src_cols]
|
||||||
|
if not common:
|
||||||
|
continue
|
||||||
|
plan.append((t, common))
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
try:
|
||||||
|
for t, cols in plan:
|
||||||
|
cl = ",".join('"%s"' % c for c in cols)
|
||||||
|
conn.execute("DELETE FROM main.%s" % t)
|
||||||
|
conn.execute("INSERT INTO main.%s(%s) SELECT %s FROM src.%s"
|
||||||
|
% (t, cl, cl, t))
|
||||||
|
conn.execute("COMMIT")
|
||||||
|
except Exception:
|
||||||
|
conn.execute("ROLLBACK")
|
||||||
|
raise
|
||||||
|
return [t for t, _ in plan]
|
||||||
|
finally:
|
||||||
|
conn.execute("DETACH DATABASE src")
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_instance(src_file):
|
||||||
|
"""把归档里的 instance.json 覆盖回来(含 cookie_key / secret_key)。
|
||||||
|
|
||||||
|
必须先留一份当前文件:直接覆盖会让「本来就正常的那把钥匙」消失,
|
||||||
|
而以旧钥匙加密不了新数据 —— 那才是真正不可逆的一步。
|
||||||
|
"""
|
||||||
|
dst = config.INSTANCE_FILE
|
||||||
|
if os.path.exists(dst):
|
||||||
|
bak = "%s.pre-restore-%s" % (dst, datetime.now().strftime("%Y%m%d%H%M%S"))
|
||||||
|
try:
|
||||||
|
shutil.copy2(dst, bak)
|
||||||
|
except OSError as e:
|
||||||
|
raise BackupError("备份 instance.json 失败,已中止:%s" % e)
|
||||||
|
try:
|
||||||
|
shutil.copy2(src_file, dst)
|
||||||
|
if os.name == "posix":
|
||||||
|
os.chmod(dst, 0o600)
|
||||||
|
except OSError as e:
|
||||||
|
raise BackupError("写入 instance.json 失败:%s" % e)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def restore(conn, filename, include_instance=True, actor=None):
|
||||||
|
"""从归档恢复。整表替换,恢复前自动给当前库留一份 pre-restore 快照。
|
||||||
|
|
||||||
|
副作用:所有账号的 `session_ver` 会被 +1,**所有既有登录会话立即失效**
|
||||||
|
(恢复是全局性事件,旧会话描述的账号与权限可能已经被换掉了)。
|
||||||
|
|
||||||
|
返回结果 dict;失败抛 BackupError。
|
||||||
|
"""
|
||||||
|
path = path_of(filename)
|
||||||
|
info = verify(path) # 先验,验不过就不动任何东西
|
||||||
|
|
||||||
|
# 1) 护栏:先把**当前**库完整备份一份。恢复错了还能回到恢复之前。
|
||||||
|
try:
|
||||||
|
safety = create(conn, trigger="pre-restore", actor=actor or "system",
|
||||||
|
note="恢复 %s 之前的自动快照" % filename)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
raise BackupError("恢复前的安全备份失败,已中止(不会动你的数据):%s" % e)
|
||||||
|
|
||||||
|
tmpdir = tempfile.mkdtemp(prefix="wb-restore-")
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(path) as z:
|
||||||
|
_extract_safely(z, tmpdir, z.namelist())
|
||||||
|
primary = [f for f in info["manifest"]["files"] if f.get("primary")]
|
||||||
|
if not primary:
|
||||||
|
primary = [info["manifest"]["files"][0]]
|
||||||
|
src_path = os.path.join(tmpdir, os.path.basename(primary[0]["name"]))
|
||||||
|
if not os.path.exists(src_path):
|
||||||
|
raise BackupError("归档里缺少主数据库文件")
|
||||||
|
|
||||||
|
# 2) 在临时库上先迁移到当前 schema —— 备份是旧版本(uv=2/3)也能恢复,
|
||||||
|
# 而且迁移失败时正本一个字节都没动。
|
||||||
|
tmp_conn = sqlite3.connect(src_path)
|
||||||
|
try:
|
||||||
|
tmp_conn.row_factory = sqlite3.Row
|
||||||
|
db.init_db(conn=tmp_conn, create_admin=False)
|
||||||
|
finally:
|
||||||
|
tmp_conn.close()
|
||||||
|
# 迁移过程会把临时库带进 WAL 模式,侧车文件里可能还有未合并的帧。
|
||||||
|
# 切回 DELETE 模式让**单文件自包含** —— 后面 ATTACH 时就不依赖 -wal 了。
|
||||||
|
tmp_conn = sqlite3.connect(src_path)
|
||||||
|
try:
|
||||||
|
tmp_conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||||
|
tmp_conn.execute("PRAGMA journal_mode=DELETE")
|
||||||
|
finally:
|
||||||
|
tmp_conn.close()
|
||||||
|
|
||||||
|
# 3) 搬数据。持采集锁:搬的过程中不能让采集往里写。
|
||||||
|
with collect._Lock():
|
||||||
|
moved = _copy_tables(conn, src_path)
|
||||||
|
# 数据整表换过了 —— 让**所有**会话立即失效。
|
||||||
|
#
|
||||||
|
# 光靠「users.session_ver 从归档里搬过来」是不够的:如果某个人是在
|
||||||
|
# 打这份备份**之前**登录的,他那张 Cookie 里的 sv 正好等于归档里的值,
|
||||||
|
# 于是会话会「合法地」活下来 —— 而它描述的账号、角色、权限可能已经
|
||||||
|
# 被这次恢复整个替换过了。恢复是全局性事件,一律要求重新登录。
|
||||||
|
conn.execute("UPDATE users SET session_ver=COALESCE(session_ver,0)+1")
|
||||||
|
inst = False
|
||||||
|
if include_instance:
|
||||||
|
inst_file = os.path.join(tmpdir, INSTANCE_NAME)
|
||||||
|
if os.path.exists(inst_file):
|
||||||
|
inst = _restore_instance(inst_file)
|
||||||
|
out = _stats()
|
||||||
|
out["moved"] = moved
|
||||||
|
out["instance"] = inst
|
||||||
|
except collect.Busy as e:
|
||||||
|
raise BackupError("有采集任务正在运行,请等它结束后再恢复(%s)" % e)
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
raise BackupError("恢复过程中数据库报错,已回滚:%s" % e)
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||||
|
|
||||||
|
db.audit(conn, "backup_restore", actor or "system",
|
||||||
|
"从 %s 恢复:%d 条 / %.2f 积分 / %d 个账号%s(恢复前已自动备份 %s)"
|
||||||
|
% (filename, out["records"], out["credits"], out["users"],
|
||||||
|
",含 instance.json" if out["instance"] else "",
|
||||||
|
safety.get("filename")),
|
||||||
|
"127.0.0.1", 0)
|
||||||
|
return {
|
||||||
|
"ok": True, "filename": filename, "safety_backup": safety.get("filename"),
|
||||||
|
"moved": out["moved"], "restored_instance": out["instance"],
|
||||||
|
"stats": out, "message":
|
||||||
|
"已从 %s 恢复:%d 条记录 / %.2f 积分 / %d 个账号。"
|
||||||
|
"恢复前的库已自动备份为 %s。所有既有登录会话已失效,请重新登录。"
|
||||||
|
% (filename, out["records"], out["credits"], out["users"],
|
||||||
|
safety.get("filename")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 删除 / 清理 ----------------
|
||||||
|
def delete(conn, filename, actor=None):
|
||||||
|
"""删除一份备份(文件 + 索引行)。"""
|
||||||
|
name = safe_name(filename)
|
||||||
|
p = path_of(name)
|
||||||
|
if not os.path.exists(p):
|
||||||
|
conn.execute("UPDATE backups SET missing=1 WHERE filename=?", (name,))
|
||||||
|
raise BackupError("备份文件不存在(可能已被手工删除)")
|
||||||
|
os.remove(p)
|
||||||
|
conn.execute("DELETE FROM backups WHERE filename=?", (name,))
|
||||||
|
db.audit(conn, "backup_delete", actor or "system", "删除备份 %s" % name, "127.0.0.1", 0)
|
||||||
|
return {"ok": True, "filename": name, "message": "已删除备份 " + name}
|
||||||
|
|
||||||
|
|
||||||
|
def prune(conn, keep=None, actor=None):
|
||||||
|
"""按「保留份数」清理最旧的备份。返回删除清单。"""
|
||||||
|
if keep is None:
|
||||||
|
keep = db.get_int(conn, "backup_keep", 7)
|
||||||
|
keep = max(1, min(100, int(keep or 1)))
|
||||||
|
rows = conn.execute("SELECT * FROM backups ORDER BY created_at DESC, id DESC").fetchall()
|
||||||
|
# 只按「磁盘上真实存在」的算份数:已经手工删掉的条目不该占名额
|
||||||
|
alive = [r for r in rows if os.path.exists(os.path.join(backup_dir(), r["filename"]))]
|
||||||
|
victims = alive[keep:]
|
||||||
|
removed = []
|
||||||
|
for r in victims:
|
||||||
|
try:
|
||||||
|
os.remove(os.path.join(backup_dir(), r["filename"]))
|
||||||
|
conn.execute("DELETE FROM backups WHERE id=?", (r["id"],))
|
||||||
|
removed.append(r["filename"])
|
||||||
|
except OSError as e:
|
||||||
|
log.warning("清理旧备份 %s 失败:%s", r["filename"], e)
|
||||||
|
if removed:
|
||||||
|
db.audit(conn, "backup_prune", actor or "system",
|
||||||
|
"按保留 %d 份清理旧备份:%s" % (keep, ", ".join(removed)),
|
||||||
|
"127.0.0.1", 0)
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 自动备份 ----------------
|
||||||
|
def last_auto_at(conn):
|
||||||
|
r = conn.execute("SELECT MAX(created_at) FROM backups WHERE trigger IN ('auto','startup')"
|
||||||
|
).fetchone()
|
||||||
|
return (r[0] if r and r[0] else "")
|
||||||
|
|
||||||
|
|
||||||
|
def due(conn, now=None):
|
||||||
|
"""自动备份是否到期。返回 (是否到期, 距离上次的小时数, 周期小时数)。"""
|
||||||
|
if not db.get_bool(conn, "backup_enabled", True):
|
||||||
|
return False, 0.0, 0
|
||||||
|
every = max(1, min(720, db.get_int(conn, "backup_interval_hours", 24)))
|
||||||
|
last = last_auto_at(conn)
|
||||||
|
now = now or datetime.now()
|
||||||
|
if not last:
|
||||||
|
return True, -1.0, every
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(last, "%Y-%m-%d %H:%M:%S")
|
||||||
|
except ValueError:
|
||||||
|
return True, -1.0, every
|
||||||
|
hrs = (now - dt).total_seconds() / 3600.0
|
||||||
|
return hrs >= every, hrs, every
|
||||||
|
|
||||||
|
|
||||||
|
def maybe_auto(conn, now=None):
|
||||||
|
"""调度器每轮调用:到期就打一份 + 按份数清理。返回结果或 None。"""
|
||||||
|
ok, hrs, every = due(conn, now)
|
||||||
|
if not ok:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
r = create(conn, trigger="auto", actor="system",
|
||||||
|
note="自动备份(周期 %d 小时)" % every)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
log.error("自动备份失败:%s", e)
|
||||||
|
return None
|
||||||
|
removed = prune(conn, actor="system")
|
||||||
|
if removed:
|
||||||
|
log.info("自动备份后清理旧备份 %d 份", len(removed))
|
||||||
|
log.info("自动备份完成:%s", r["message"])
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def next_auto_at(conn, now=None):
|
||||||
|
"""下一次自动备份时间(给界面显示)。"""
|
||||||
|
if not db.get_bool(conn, "backup_enabled", True):
|
||||||
|
return None
|
||||||
|
every = max(1, min(720, db.get_int(conn, "backup_interval_hours", 24)))
|
||||||
|
last = last_auto_at(conn)
|
||||||
|
now = now or datetime.now()
|
||||||
|
if not last:
|
||||||
|
return now
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(last, "%Y-%m-%d %H:%M:%S")
|
||||||
|
except ValueError:
|
||||||
|
return now
|
||||||
|
nxt = dt + timedelta(hours=every)
|
||||||
|
return nxt if nxt > now else now
|
||||||
|
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
# Copyright (c) 2026 Wang Chuanli
|
||||||
|
|
||||||
|
"""图形验证码(自托管,零第三方依赖)。
|
||||||
|
|
||||||
|
设计要点
|
||||||
|
--------
|
||||||
|
1. **答案只存在服务端**。下发到浏览器的是一个随机 `captcha_id`,它本身
|
||||||
|
不含任何信息。之所以不把答案放进 Flask session:Flask 的 session 是
|
||||||
|
**签名而非加密**的(base64 + HMAC),客户端 base64 解开就能读到明文——
|
||||||
|
把答案放进去等于把答案直接送给机器人。
|
||||||
|
2. **一次性**。校验时无论成功失败都立刻删除该 `captcha_id`,
|
||||||
|
防止「一个码刷一万个用户名」的撞库变体。
|
||||||
|
3. **光栅图,不是 SVG**。SVG 是文本,答案会以明文出现在页面源码或 DOM 里,
|
||||||
|
必须用位图。这里手写 PNG 编码器(zlib 是标准库),点阵字模自带。
|
||||||
|
4. **字符集避开易混字符**(0/O、1/I/L),降低正常用户输错概率。
|
||||||
|
|
||||||
|
字模
|
||||||
|
----
|
||||||
|
5×7 点阵,`#` 为前景。渲染时**逐字符**随机放大、旋转、切变、加波浪偏移,
|
||||||
|
笔画用粗刷子画(旋转后不会出现断点),再叠背景纹理、噪点与压线干扰。
|
||||||
|
|
||||||
|
强度说明(为什么要这么多花样)
|
||||||
|
------------------------------
|
||||||
|
字模是固定的,而且就在这份源码里 —— 也就是说攻击者**知道**每个字符长什么样。
|
||||||
|
在这种情况下,提高自动识别成本的唯一手段就是让「同一字符的两次渲染」在像素上
|
||||||
|
尽量不同:角度、切变、缩放、波形相位、笔画粗细、颜色、干扰线位置全部随机。
|
||||||
|
纯模板匹配在这种变形下会失效,必须上带形变增强的模型,成本高一个数量级。
|
||||||
|
反过来说,也**不要**指望它能挡住有充足算力、专门针对本站训练的对手 ——
|
||||||
|
验证码是「提高成本」而不是「杜绝」。
|
||||||
|
"""
|
||||||
|
import hmac
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
import secrets
|
||||||
|
import struct
|
||||||
|
import zlib
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
ALPHABET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ" # 去掉 0 O 1 I L
|
||||||
|
|
||||||
|
_FONT = {
|
||||||
|
"2": (".###.", "#...#", "....#", "...#.", "..#..", ".#...", "#####"),
|
||||||
|
"3": ("####.", "....#", "....#", ".###.", "....#", "....#", "####."),
|
||||||
|
"4": ("...#.", "..##.", ".#.#.", "#..#.", "#####", "...#.", "...#."),
|
||||||
|
"5": ("#####", "#....", "####.", "....#", "....#", "#...#", ".###."),
|
||||||
|
"6": ("..##.", ".#...", "#....", "####.", "#...#", "#...#", ".###."),
|
||||||
|
"7": ("#####", "....#", "...#.", "..#..", ".#...", ".#...", ".#..."),
|
||||||
|
"8": (".###.", "#...#", "#...#", ".###.", "#...#", "#...#", ".###."),
|
||||||
|
"9": (".###.", "#...#", "#...#", ".####", "....#", "...#.", ".##.."),
|
||||||
|
"A": ("..#..", ".#.#.", "#...#", "#...#", "#####", "#...#", "#...#"),
|
||||||
|
"B": ("####.", "#...#", "#...#", "####.", "#...#", "#...#", "####."),
|
||||||
|
"C": (".###.", "#...#", "#....", "#....", "#....", "#...#", ".###."),
|
||||||
|
"D": ("###..", "#..#.", "#...#", "#...#", "#...#", "#..#.", "###.."),
|
||||||
|
"E": ("#####", "#....", "#....", "####.", "#....", "#....", "#####"),
|
||||||
|
"F": ("#####", "#....", "#....", "####.", "#....", "#....", "#...."),
|
||||||
|
"G": (".###.", "#...#", "#....", "#.###", "#...#", "#...#", ".###."),
|
||||||
|
"H": ("#...#", "#...#", "#...#", "#####", "#...#", "#...#", "#...#"),
|
||||||
|
"J": ("..###", "...#.", "...#.", "...#.", "...#.", "#..#.", ".##.."),
|
||||||
|
"K": ("#...#", "#..#.", "#.#..", "##...", "#.#..", "#..#.", "#...#"),
|
||||||
|
"M": ("#...#", "##.##", "#.#.#", "#...#", "#...#", "#...#", "#...#"),
|
||||||
|
"N": ("#...#", "##..#", "#.#.#", "#..##", "#...#", "#...#", "#...#"),
|
||||||
|
"P": ("####.", "#...#", "#...#", "####.", "#....", "#....", "#...."),
|
||||||
|
"Q": (".###.", "#...#", "#...#", "#...#", "#.#.#", "#..#.", ".##.#"),
|
||||||
|
"R": ("####.", "#...#", "#...#", "####.", "#.#..", "#..#.", "#...#"),
|
||||||
|
"S": (".####", "#....", "#....", ".###.", "....#", "....#", "####."),
|
||||||
|
"T": ("#####", "..#..", "..#..", "..#..", "..#..", "..#..", "..#.."),
|
||||||
|
"U": ("#...#", "#...#", "#...#", "#...#", "#...#", "#...#", ".###."),
|
||||||
|
"V": ("#...#", "#...#", "#...#", "#...#", "#...#", ".#.#.", "..#.."),
|
||||||
|
"W": ("#...#", "#...#", "#...#", "#...#", "#.#.#", "##.##", "#...#"),
|
||||||
|
"X": ("#...#", "#...#", ".#.#.", "..#..", ".#.#.", "#...#", "#...#"),
|
||||||
|
"Y": ("#...#", "#...#", ".#.#.", "..#..", "..#..", "..#..", "..#.."),
|
||||||
|
"Z": ("#####", "....#", "...#.", "..#..", ".#...", "#....", "#####"),
|
||||||
|
}
|
||||||
|
|
||||||
|
GLYPH_W, GLYPH_H = 5, 7
|
||||||
|
|
||||||
|
# 一次性验证码有效期(秒)。太短用户来不及看,太长给暴力破解留窗口。
|
||||||
|
TTL_SECONDS = 300
|
||||||
|
# 保留已过期记录多久后清理(仅用于体积控制,不影响安全性)
|
||||||
|
PURGE_AFTER_SECONDS = 3600
|
||||||
|
|
||||||
|
|
||||||
|
def random_code(length=4):
|
||||||
|
return "".join(secrets.choice(ALPHABET) for _ in range(length))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- PNG 编码(手写,无依赖) ----------------
|
||||||
|
def _chunk(tag, data):
|
||||||
|
return (struct.pack(">I", len(data)) + tag + data
|
||||||
|
+ struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF))
|
||||||
|
|
||||||
|
|
||||||
|
def encode_png(width, height, rgb):
|
||||||
|
"""把 RGB 字节串编码成 PNG(8 位真彩,无 alpha)。
|
||||||
|
|
||||||
|
rgb 长度必须是 width*height*3。每行前面加一个 filter 字节 0(None),
|
||||||
|
这是 PNG 对「一行一张扫描线」的强制要求。
|
||||||
|
"""
|
||||||
|
stride = width * 3
|
||||||
|
raw = bytearray()
|
||||||
|
for y in range(height):
|
||||||
|
raw.append(0)
|
||||||
|
raw += rgb[y * stride:(y + 1) * stride]
|
||||||
|
ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
|
||||||
|
return (b"\x89PNG\r\n\x1a\n"
|
||||||
|
+ _chunk(b"IHDR", ihdr)
|
||||||
|
+ _chunk(b"IDAT", zlib.compress(bytes(raw), 9))
|
||||||
|
+ _chunk(b"IEND", b""))
|
||||||
|
|
||||||
|
|
||||||
|
class _Canvas:
|
||||||
|
"""极小的 RGB 画布。坐标越界自动丢弃,省得每处调用都判边界。"""
|
||||||
|
|
||||||
|
def __init__(self, w, h, bg):
|
||||||
|
self.w, self.h = w, h
|
||||||
|
self.buf = bytearray(bg * (w * h))
|
||||||
|
|
||||||
|
def dot(self, x, y, color):
|
||||||
|
if 0 <= x < self.w and 0 <= y < self.h:
|
||||||
|
i = (y * self.w + x) * 3
|
||||||
|
self.buf[i:i + 3] = bytes(color)
|
||||||
|
|
||||||
|
def rect(self, x, y, w, h, color):
|
||||||
|
for dy in range(h):
|
||||||
|
for dx in range(w):
|
||||||
|
self.dot(x + dx, y + dy, color)
|
||||||
|
|
||||||
|
def line(self, x0, y0, x1, y1, color):
|
||||||
|
"""Bresenham 直线。"""
|
||||||
|
dx, dy = abs(x1 - x0), -abs(y1 - y0)
|
||||||
|
sx = 1 if x0 < x1 else -1
|
||||||
|
sy = 1 if y0 < y1 else -1
|
||||||
|
err = dx + dy
|
||||||
|
while True:
|
||||||
|
self.dot(x0, y0, color)
|
||||||
|
if x0 == x1 and y0 == y1:
|
||||||
|
return
|
||||||
|
e2 = 2 * err
|
||||||
|
if e2 >= dy:
|
||||||
|
err += dy
|
||||||
|
x0 += sx
|
||||||
|
if e2 <= dx:
|
||||||
|
err += dx
|
||||||
|
y0 += sy
|
||||||
|
|
||||||
|
def bytes(self):
|
||||||
|
return bytes(self.buf)
|
||||||
|
|
||||||
|
|
||||||
|
def _brush_line(cv, x0, y0, x1, y1, color, r):
|
||||||
|
"""用 r×r 方刷画一条线。
|
||||||
|
|
||||||
|
旋转后的笔画如果只用点阵格逐个平移,会出现锯齿状断点 —— 一圈一圈的
|
||||||
|
缝隙正好给「连通域分析」留了把手。这里改成沿线段走样并盖方刷,
|
||||||
|
笔画连续,旋转也不散架。
|
||||||
|
"""
|
||||||
|
steps = int(max(abs(x1 - x0), abs(y1 - y0))) + 1
|
||||||
|
o = r // 2
|
||||||
|
for i in range(steps + 1):
|
||||||
|
t = i / float(steps)
|
||||||
|
x = int(round(x0 + (x1 - x0) * t))
|
||||||
|
y = int(round(y0 + (y1 - y0) * t))
|
||||||
|
cv.rect(x - o, y - o, r, r, color)
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_char(cv, glyph, cx, cy, scale, color, rng):
|
||||||
|
"""在 (cx, cy) 为中心画一个字符:随机旋转 + 切变 + 波浪 + 粗笔画。
|
||||||
|
|
||||||
|
三段变换按「点阵坐标 -> 缩放居中 -> 切变 -> 旋转 -> 波浪纵向偏移」依次施加。
|
||||||
|
顺序不能乱:先切变再旋转,得到的才是「斜着写的手写体」而不是「被斜切的旋转体」。
|
||||||
|
"""
|
||||||
|
ang = rng.uniform(-0.38, 0.38) # 弧度,约 ±22°
|
||||||
|
cos_a, sin_a = math.cos(ang), math.sin(ang)
|
||||||
|
shear = rng.uniform(-0.32, 0.32)
|
||||||
|
amp = rng.uniform(0.0, 2.6) # 波浪振幅(像素)
|
||||||
|
period = rng.uniform(18.0, 42.0)
|
||||||
|
phase = rng.uniform(0.0, 6.283)
|
||||||
|
half_w = GLYPH_W * scale / 2.0
|
||||||
|
half_h = GLYPH_H * scale / 2.0
|
||||||
|
r = max(2, scale)
|
||||||
|
|
||||||
|
def place(col, row):
|
||||||
|
px = (col + 0.5) * scale - half_w
|
||||||
|
py = (row + 0.5) * scale - half_h
|
||||||
|
px += shear * py
|
||||||
|
x = cx + px * cos_a - py * sin_a
|
||||||
|
y = cy + px * sin_a + py * cos_a
|
||||||
|
return x, y + amp * math.sin(x / period + phase)
|
||||||
|
|
||||||
|
for row, bits in enumerate(glyph):
|
||||||
|
col = 0
|
||||||
|
while col < len(bits):
|
||||||
|
if bits[col] != "#":
|
||||||
|
col += 1
|
||||||
|
continue
|
||||||
|
start = col
|
||||||
|
while col + 1 < len(bits) and bits[col + 1] == "#":
|
||||||
|
col += 1 # 连续的一段合起来画,笔画才连得上
|
||||||
|
x0, y0 = place(start, row)
|
||||||
|
x1, y1 = place(col, row)
|
||||||
|
_brush_line(cv, x0, y0, x1, y1, color, r)
|
||||||
|
col += 1
|
||||||
|
|
||||||
|
|
||||||
|
def render(code, width=150, height=56, scale=5, rng=None):
|
||||||
|
"""把验证码渲染成 PNG 字节串。
|
||||||
|
|
||||||
|
字体大小、角度、切变、波浪、颜色、干扰线全部逐次随机 ——
|
||||||
|
目标不是「好看」,而是让同一串字符的两次渲染在像素上尽量不同,
|
||||||
|
从而让「预存字模 + 模板匹配」这条最便宜的攻击路线失效。
|
||||||
|
"""
|
||||||
|
rng = rng or random.SystemRandom()
|
||||||
|
n = len(code)
|
||||||
|
gap = 9
|
||||||
|
# 宽度按最大可能字号算,且左右各留够旋转半径 ——
|
||||||
|
# 旋转后的字符会往两侧探出约半个字高,留窄了最外侧那个字会被裁掉一截,
|
||||||
|
# 而「被裁掉一角的字符」会直接变成一次没道理的输错(体验问题,不是安全问题)。
|
||||||
|
text_w = n * GLYPH_W * (scale + 1) + (n - 1) * gap
|
||||||
|
need_w = text_w + int(GLYPH_H * (scale + 1) * 0.9) + 8
|
||||||
|
if need_w > width:
|
||||||
|
width = need_w
|
||||||
|
# 高度同理:旋转后的字符比原始点阵高不少,切了顶就等于少一个笔画特征
|
||||||
|
need_h = int(GLYPH_H * (scale + 1) * 1.7) + 8
|
||||||
|
if need_h > height:
|
||||||
|
height = need_h
|
||||||
|
x0 = max(5, (width - text_w) // 2)
|
||||||
|
y0 = height // 2
|
||||||
|
|
||||||
|
# 背景不做纯色:纯色底可以用一个阈值把前景整片切出来。
|
||||||
|
# 用「两色之间做斜向渐变」能让全局二值化的效果明显变差。
|
||||||
|
c1 = tuple(rng.randint(236, 252) for _ in range(3))
|
||||||
|
c2 = tuple(rng.randint(214, 240) for _ in range(3))
|
||||||
|
slant = rng.uniform(-1.0, 1.0)
|
||||||
|
cv = _Canvas(width, height, c1)
|
||||||
|
for y in range(height):
|
||||||
|
for x in range(width):
|
||||||
|
t = (x / float(width - 1 or 1)) * 0.6 + (y / float(height - 1 or 1)) * 0.4
|
||||||
|
t = min(1.0, max(0.0, t + slant * 0.15))
|
||||||
|
cv.dot(x, y, tuple(int(c1[i] + (c2[i] - c1[i]) * t) for i in range(3)))
|
||||||
|
|
||||||
|
# 1) 底层干扰线(先画,压在字下面)
|
||||||
|
for _ in range(3):
|
||||||
|
cv.line(rng.randint(0, width - 1), rng.randint(0, height - 1),
|
||||||
|
rng.randint(0, width - 1), rng.randint(0, height - 1),
|
||||||
|
tuple(rng.randint(170, 215) for _ in range(3)))
|
||||||
|
|
||||||
|
# 2) 字符本体
|
||||||
|
step = GLYPH_W * (scale + 1) + gap
|
||||||
|
for i, ch in enumerate(code):
|
||||||
|
glyph = _FONT.get(ch)
|
||||||
|
if glyph is None:
|
||||||
|
continue
|
||||||
|
# 逐字符字号抖动:字符宽度不再一致,按列投影切分就失效了
|
||||||
|
s = max(3, scale + rng.choice((-1, 0, 0, 1)))
|
||||||
|
cx = x0 + i * step + GLYPH_W * (scale + 1) / 2.0 + rng.uniform(-3.0, 3.0)
|
||||||
|
cy = y0 + rng.uniform(-3.0, 3.0)
|
||||||
|
color = tuple(rng.randint(15, 95) for _ in range(3))
|
||||||
|
_draw_char(cv, glyph, cx, cy, s, color, rng)
|
||||||
|
|
||||||
|
# 3) 前景噪点:破坏「按连通域找字符」的假设
|
||||||
|
for _ in range(70):
|
||||||
|
cv.dot(rng.randint(0, width - 1), rng.randint(0, height - 1),
|
||||||
|
tuple(rng.randint(90, 195) for _ in range(3)))
|
||||||
|
|
||||||
|
# 4) 压在字上的干扰线:最有效的反 OCR 手段,但太密人也认不出,
|
||||||
|
# 所以刻意控制成 2~3 条细线。
|
||||||
|
for _ in range(rng.randint(2, 3)):
|
||||||
|
y = rng.randint(2, height - 3)
|
||||||
|
cv.line(0, y, width - 1, y + rng.randint(-11, 11),
|
||||||
|
tuple(rng.randint(120, 175) for _ in range(3)))
|
||||||
|
|
||||||
|
return encode_png(width, height, cv.bytes())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 挑战的存储与校验(SQLite) ----------------
|
||||||
|
def _fmt(dt):
|
||||||
|
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
|
||||||
|
def purge(conn, now=None):
|
||||||
|
"""清掉过期的挑战。每次新建时顺手调用(有 expires_at 索引,代价很小)。"""
|
||||||
|
now = now or datetime.now()
|
||||||
|
cut = _fmt(now - timedelta(seconds=PURGE_AFTER_SECONDS))
|
||||||
|
conn.execute("DELETE FROM captchas WHERE expires_at < ?", (cut,))
|
||||||
|
|
||||||
|
|
||||||
|
def create(conn, purpose, length=4, ttl=TTL_SECONDS, now=None):
|
||||||
|
"""新建一个挑战,返回 (captcha_id, code)。code 只应交给渲染函数,不要下发。"""
|
||||||
|
now = now or datetime.now()
|
||||||
|
code = random_code(length)
|
||||||
|
cid = secrets.token_urlsafe(24)
|
||||||
|
purge(conn, now)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO captchas(id,answer,purpose,created_at,expires_at) VALUES(?,?,?,?,?)",
|
||||||
|
(cid, code, purpose, _fmt(now), _fmt(now + timedelta(seconds=ttl))))
|
||||||
|
return cid, code
|
||||||
|
|
||||||
|
|
||||||
|
def verify(conn, captcha_id, answer, purpose, now=None):
|
||||||
|
"""校验并**立即作废**该挑战。返回 True/False。
|
||||||
|
|
||||||
|
永远不区分「过期」「不存在」「答案错」——对外只回一句人话,
|
||||||
|
避免把「这个 id 存在但答错了」这类信息透露给攻击者。
|
||||||
|
"""
|
||||||
|
if not captcha_id or answer is None:
|
||||||
|
return False
|
||||||
|
row = conn.execute("SELECT * FROM captchas WHERE id=?", (captcha_id,)).fetchone()
|
||||||
|
# 先删后判:无论结果如何都不允许第二次使用同一个 id
|
||||||
|
conn.execute("DELETE FROM captchas WHERE id=?", (captcha_id,))
|
||||||
|
if row is None or row["purpose"] != purpose:
|
||||||
|
return False
|
||||||
|
now = now or datetime.now()
|
||||||
|
if row["expires_at"] < _fmt(now):
|
||||||
|
return False
|
||||||
|
return hmac.compare_digest(str(row["answer"]), str(answer).strip().upper())
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
# Copyright (c) 2026 Wang Chuanli
|
||||||
|
|
||||||
"""云端用量接口客户端(纯 urllib,不依赖 requests/浏览器)。
|
"""云端用量接口客户端(纯 urllib,不依赖 requests/浏览器)。
|
||||||
|
|
||||||
接口:POST {api_base}/billing/meter/get-user-request-usage
|
接口:POST {api_base}/billing/meter/get-user-request-usage
|
||||||
|
|||||||
@@ -1,14 +1,27 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
# Copyright (c) 2026 Wang Chuanli
|
||||||
|
|
||||||
"""采集主流程:云端增量 -> SQLite(去重、断点、漂移校验、运行记录)。
|
"""采集主流程:云端增量 -> SQLite(去重、断点、漂移校验、运行记录)。
|
||||||
|
|
||||||
与原 fetch_usage.py 的差别:
|
与原 fetch_usage.py 的差别:
|
||||||
* 存档正本从 CSV 换成 SQLite,去重由 `ON CONFLICT(request_id)` 承担
|
* 存档正本从 CSV 换成 SQLite,去重由 `ON CONFLICT(user_id, request_id)` 承担
|
||||||
* 断点由 `SELECT MAX(ts)` 承担,不再需要全量读入内存
|
* 断点由 `SELECT MAX(ts) WHERE user_id=?` 承担,不再需要全量读入内存
|
||||||
* 每次运行落一条 collect_runs 记录,页面据此展示任务历史与日志
|
* 每次运行落一条 collect_runs 记录,页面据此展示任务历史与日志
|
||||||
* 采集互斥用文件锁,保证「单写者」——SQLite 只允许一个写进程
|
* 采集互斥用文件锁,保证「单写者」——SQLite 只允许一个写进程
|
||||||
|
|
||||||
|
**多用户约定(最重要)**
|
||||||
|
所有写入函数都要求显式传入 `uid`,且 `uid` 是 `conn` 之后的第一个位置参数、
|
||||||
|
没有默认值。采集**只使用该账号自己保存的 Cookie**:
|
||||||
|
|
||||||
|
* 明文的 Cookie 从来只存在于内存里(库里是 crypto.encrypt 后的密文)
|
||||||
|
* 不再支持 `WB_COOKIE` 环境变量作为采集凭证 —— 那会让所有人共用一份凭证,
|
||||||
|
一旦生效就是「A 的采集把数据写进 B 的账号」这种串号事故。
|
||||||
|
环境变量只保留给 `manage.py import-creds` 做一次性导入。
|
||||||
"""
|
"""
|
||||||
import csv
|
import csv
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
@@ -24,8 +37,19 @@ class Busy(Exception):
|
|||||||
"""已有采集在跑。"""
|
"""已有采集在跑。"""
|
||||||
|
|
||||||
|
|
||||||
|
class NotReady(Exception):
|
||||||
|
"""该账号还没配好凭证 —— 不是错误,只是「没什么可做的」。"""
|
||||||
|
|
||||||
|
|
||||||
# ---------------- 互斥锁 ----------------
|
# ---------------- 互斥锁 ----------------
|
||||||
class _Lock:
|
class _Lock:
|
||||||
|
"""全局单写者锁。
|
||||||
|
|
||||||
|
刻意**不做成按用户加锁**:SQLite 同一时刻只允许一个写事务,
|
||||||
|
按用户并行反而会在 busy_timeout 上互相拖死。串行跑完所有人的采集,
|
||||||
|
总耗时与并发差别很小(每个人一天也就拉一次)。
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, path=LOCK_PATH):
|
def __init__(self, path=LOCK_PATH):
|
||||||
self.path = path
|
self.path = path
|
||||||
self.fd = None
|
self.fd = None
|
||||||
@@ -62,18 +86,17 @@ class _Lock:
|
|||||||
|
|
||||||
|
|
||||||
# ---------------- 运行记录 ----------------
|
# ---------------- 运行记录 ----------------
|
||||||
def start_run(conn, trigger):
|
def start_run(conn, uid, trigger):
|
||||||
cur = conn.execute("INSERT INTO collect_runs(trigger,status,started_at) VALUES(?,?,?)",
|
cur = conn.execute("INSERT INTO collect_runs(user_id,trigger,status,started_at)"
|
||||||
(trigger, "running", db.now_str()))
|
" VALUES(?,?,?,?)", (uid, trigger, "running", db.now_str()))
|
||||||
return cur.lastrowid
|
return cur.lastrowid
|
||||||
|
|
||||||
|
|
||||||
def finish_run(conn, run_id, status, **kw):
|
def finish_run(conn, run_id, status, **kw):
|
||||||
fields = ["finished_at", "duration_ms", "win_from", "win_to", "fetched",
|
|
||||||
"added", "dup", "total", "conflicts", "exit_code", "message", "detail"]
|
|
||||||
sets = ["status=?", "finished_at=?"]
|
sets = ["status=?", "finished_at=?"]
|
||||||
vals = [status, db.now_str()]
|
vals = [status, db.now_str()]
|
||||||
for f in fields[1:]:
|
for f in ("duration_ms", "win_from", "win_to", "fetched", "added", "dup",
|
||||||
|
"total", "conflicts", "exit_code", "message", "detail"):
|
||||||
if f in kw:
|
if f in kw:
|
||||||
sets.append("%s=?" % f)
|
sets.append("%s=?" % f)
|
||||||
vals.append(kw[f])
|
vals.append(kw[f])
|
||||||
@@ -83,10 +106,11 @@ def finish_run(conn, run_id, status, **kw):
|
|||||||
|
|
||||||
# ---------------- 入库 ----------------
|
# ---------------- 入库 ----------------
|
||||||
_UPSERT = """
|
_UPSERT = """
|
||||||
INSERT INTO usage_records(request_id,ts,day,hour,model,client,credits,prompt,
|
INSERT INTO usage_records(user_id,request_id,ts,day,hour,model,client,credits,prompt,
|
||||||
first_seen,last_seen,cloud_ts)
|
first_seen,last_seen,cloud_ts)
|
||||||
VALUES(:request_id,:ts,:day,:hour,:model,:client,:credits,:prompt,:first_seen,:last_seen,:cloud_ts)
|
VALUES(:user_id,:request_id,:ts,:day,:hour,:model,:client,:credits,:prompt,
|
||||||
ON CONFLICT(request_id) DO UPDATE SET
|
:first_seen,:last_seen,:cloud_ts)
|
||||||
|
ON CONFLICT(user_id,request_id) DO UPDATE SET
|
||||||
last_seen = excluded.last_seen,
|
last_seen = excluded.last_seen,
|
||||||
cloud_ts = excluded.cloud_ts,
|
cloud_ts = excluded.cloud_ts,
|
||||||
ts = CASE WHEN excluded.ts <> '' AND excluded.ts < usage_records.ts
|
ts = CASE WHEN excluded.ts <> '' AND excluded.ts < usage_records.ts
|
||||||
@@ -104,10 +128,11 @@ ON CONFLICT(request_id) DO UPDATE SET
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _row_dict(n):
|
def _row_dict(n, uid):
|
||||||
ts = (n.get("ts") or "").strip()[:19]
|
ts = (n.get("ts") or "").strip()[:19]
|
||||||
hh = ts[11:13]
|
hh = ts[11:13]
|
||||||
return {
|
return {
|
||||||
|
"user_id": uid,
|
||||||
"request_id": n["request_id"],
|
"request_id": n["request_id"],
|
||||||
"ts": ts,
|
"ts": ts,
|
||||||
"day": ts[:10],
|
"day": ts[:10],
|
||||||
@@ -122,8 +147,8 @@ def _row_dict(n):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def upsert(conn, normalized, drift_tolerance=5, log=None):
|
def upsert(conn, uid, normalized, drift_tolerance=5, log=None):
|
||||||
"""按 request_id 去重写入。返回 (added, dup, conflicts) 与逐行警告。
|
"""按 (user_id, request_id) 去重写入。返回 (added, dup, conflicts) 与逐行警告。
|
||||||
|
|
||||||
每 200 条一个事务(连接是 autocommit,不显式 BEGIN 的话每条 INSERT 都要
|
每 200 条一个事务(连接是 autocommit,不显式 BEGIN 的话每条 INSERT 都要
|
||||||
单独 fsync)。upsert 本身幂等,所以按块提交是安全的。
|
单独 fsync)。upsert 本身幂等,所以按块提交是安全的。
|
||||||
@@ -132,7 +157,7 @@ def upsert(conn, normalized, drift_tolerance=5, log=None):
|
|||||||
for n in normalized:
|
for n in normalized:
|
||||||
if not n.get("request_id") or not n.get("ts"):
|
if not n.get("request_id") or not n.get("ts"):
|
||||||
continue
|
continue
|
||||||
recs.append(_row_dict(n))
|
recs.append(_row_dict(n, uid))
|
||||||
added = dup = 0
|
added = dup = 0
|
||||||
conflicts = []
|
conflicts = []
|
||||||
for i in range(0, len(recs), 200):
|
for i in range(0, len(recs), 200):
|
||||||
@@ -143,8 +168,9 @@ def upsert(conn, normalized, drift_tolerance=5, log=None):
|
|||||||
if own_tx:
|
if own_tx:
|
||||||
conn.execute("BEGIN")
|
conn.execute("BEGIN")
|
||||||
try:
|
try:
|
||||||
old = {x["request_id"]: x["ts"] for x in
|
old = {x["request_id"]: x["ts"] for x in conn.execute(
|
||||||
conn.execute("SELECT request_id,ts FROM usage_records WHERE request_id IN (%s)" % ph, ids)}
|
"SELECT request_id,ts FROM usage_records WHERE user_id=? AND request_id IN (%s)"
|
||||||
|
% ph, [uid] + ids)}
|
||||||
for r in chunk:
|
for r in chunk:
|
||||||
prev = old.get(r["request_id"])
|
prev = old.get(r["request_id"])
|
||||||
if prev is None:
|
if prev is None:
|
||||||
@@ -175,18 +201,49 @@ def upsert(conn, normalized, drift_tolerance=5, log=None):
|
|||||||
return added, dup, conflicts
|
return added, dup, conflicts
|
||||||
|
|
||||||
|
|
||||||
def record_count(conn):
|
def record_count(conn, uid):
|
||||||
return conn.execute("SELECT COUNT(*) FROM usage_records").fetchone()[0]
|
return conn.execute("SELECT COUNT(*) FROM usage_records WHERE user_id=?",
|
||||||
|
(uid or 0,)).fetchone()[0]
|
||||||
|
|
||||||
|
|
||||||
def last_ts(conn):
|
def max_range_days(conn):
|
||||||
return conn.execute("SELECT MAX(ts) FROM usage_records").fetchone()[0]
|
"""单次采集允许的最长跨度(天)。
|
||||||
|
|
||||||
|
配置值是给管理员的旋钮,**代码层的硬顶**才是兜底:历史脏数据、直接改库、
|
||||||
|
或者某次误配置都不该让一次请求变成几千次云端调用。
|
||||||
|
接口校验、页面提示、sync() 里的实际收窄都读这一个函数,避免三处口径漂移。
|
||||||
|
"""
|
||||||
|
n = db.get_int(conn, "collect_max_range_days", config.COLLECT_MAX_RANGE_DAYS_HARD)
|
||||||
|
return max(1, min(config.COLLECT_MAX_RANGE_DAYS_HARD, n))
|
||||||
|
|
||||||
|
|
||||||
|
def min_interval_seconds(conn):
|
||||||
|
"""同一账号两次手动采集之间的最小间隔(秒)。"""
|
||||||
|
return max(0, min(3600, db.get_int(conn, "collect_min_interval_seconds", 60)))
|
||||||
|
|
||||||
|
|
||||||
|
def last_ts(conn, uid):
|
||||||
|
return conn.execute("SELECT MAX(ts) FROM usage_records WHERE user_id=?",
|
||||||
|
(uid or 0,)).fetchone()[0]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 凭证读取(解密) ----------------
|
||||||
|
def load_credentials(conn, uid):
|
||||||
|
"""取该账号的 (cookie, user_agent)。
|
||||||
|
|
||||||
|
Cookie 从库里读出来是密文,由 db.get_secret 解密;解不开会抛
|
||||||
|
db.SecretUnreadable(多半是 instance.json 里的 cookie_key 被换过),
|
||||||
|
这时应当明确告诉用户「重新粘贴 Cookie」,而不是当成「未配置」静默跳过。
|
||||||
|
"""
|
||||||
|
cookie = db.get_secret(conn, "cookie", uid).strip()
|
||||||
|
ua = (db.get_setting(conn, "user_agent", "", uid) or "").strip()
|
||||||
|
return cookie, ua
|
||||||
|
|
||||||
|
|
||||||
# ---------------- 主同步 ----------------
|
# ---------------- 主同步 ----------------
|
||||||
def sync(conn, trigger="manual", from_dt=None, to_dt=None, verify_days=None,
|
def sync(conn, uid, trigger="manual", from_dt=None, to_dt=None, verify_days=None,
|
||||||
do_write=True, log=None):
|
do_write=True, log=None):
|
||||||
"""增量同步。
|
"""增量同步(仅限 uid 这个账号)。
|
||||||
|
|
||||||
trigger: manual | schedule | cli | startup(写进 collect_runs 便于区分来源)
|
trigger: manual | schedule | cli | startup(写进 collect_runs 便于区分来源)
|
||||||
返回 result dict;异常时抛出 ApiError(调用方决定如何展示)。
|
返回 result dict;异常时抛出 ApiError(调用方决定如何展示)。
|
||||||
@@ -198,40 +255,39 @@ def sync(conn, trigger="manual", from_dt=None, to_dt=None, verify_days=None,
|
|||||||
if log:
|
if log:
|
||||||
log(msg)
|
log(msg)
|
||||||
|
|
||||||
s = db.get_settings(conn)
|
s = db.get_settings(conn, uid=uid)
|
||||||
cookie = (s.get("cookie") or "").strip() or os.environ.get("WB_COOKIE", "").strip()
|
cookie, ua = load_credentials(conn, uid)
|
||||||
ua = (s.get("user_agent") or "").strip() or os.environ.get("WB_UA", "").strip()
|
|
||||||
api_base = s.get("api_base") or config.API_BASE
|
api_base = s.get("api_base") or config.API_BASE
|
||||||
api_path = s.get("api_path") or config.API_PATH
|
api_path = s.get("api_path") or config.API_PATH
|
||||||
# 一律走 db.get_int/get_float:settings 表的值由后台页面自由输入,
|
# 一律走 db.get_int/get_float:settings 表的值由后台页面自由输入,
|
||||||
# 直接 int() 会让一个手滑的字符把整条采集链路打断(历史 bug)。
|
# 直接 int() 会让一个手滑的字符把整条采集链路打断(历史 bug)。
|
||||||
page_size = db.get_int(conn, "page_size", 200)
|
page_size = db.get_int(conn, "page_size", 200, uid)
|
||||||
rewind = db.get_int(conn, "rewind_minutes", 2)
|
rewind = db.get_int(conn, "rewind_minutes", 2, uid)
|
||||||
drift = db.get_int(conn, "drift_tolerance_minutes", 5)
|
drift = db.get_int(conn, "drift_tolerance_minutes", 5, uid)
|
||||||
max_prompt = db.get_int(conn, "max_prompt", 0)
|
max_prompt = db.get_int(conn, "max_prompt", 0, uid)
|
||||||
timeout = db.get_int(conn, "timeout", 30)
|
timeout = db.get_int(conn, "timeout", 30, uid)
|
||||||
ssl_verify = db.get_bool(conn, "ssl_verify", True)
|
ssl_verify = db.get_bool(conn, "ssl_verify", True, uid)
|
||||||
if verify_days is None:
|
if verify_days is None:
|
||||||
verify_days = db.get_int(conn, "verify_days", 0)
|
verify_days = db.get_int(conn, "verify_days", 0, uid)
|
||||||
# 夹到合法区间,避免历史脏数据(如超大的 page_size)把云端打爆
|
# 夹到合法区间,避免历史脏数据(如超大的 page_size)把云端打爆
|
||||||
lo, hi, _ = config.NUM_SETTINGS["page_size"]
|
lo, hi, _ = config.NUM_SETTINGS["page_size"]
|
||||||
page_size = max(lo, min(hi, page_size))
|
page_size = max(lo, min(hi, page_size))
|
||||||
|
|
||||||
run_id = start_run(conn, trigger) if do_write else None
|
run_id = start_run(conn, uid, trigger) if do_write else None
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
base = {"win_from": None, "win_to": None, "fetched": 0,
|
base = {"win_from": None, "win_to": None, "fetched": 0,
|
||||||
"added": 0, "dup": 0, "conflicts": 0}
|
"added": 0, "dup": 0, "conflicts": 0}
|
||||||
|
|
||||||
if not cookie:
|
if not cookie:
|
||||||
msg = "未配置 Cookie,请到「配置管理」页粘贴,或设置环境变量 WB_COOKIE"
|
msg = "未配置 Cookie,请到「配置管理」页粘贴自己账号的 Cookie"
|
||||||
_log("[error] " + msg)
|
_log("[error] " + msg)
|
||||||
if run_id:
|
if run_id:
|
||||||
finish_run(conn, run_id, "error", exit_code=2, message=msg,
|
finish_run(conn, run_id, "error", exit_code=2, message=msg,
|
||||||
duration_ms=int((time.time() - t0) * 1000), detail="\n".join(lines), **base)
|
duration_ms=int((time.time() - t0) * 1000), detail="\n".join(lines), **base)
|
||||||
raise ApiError(msg)
|
raise NotReady(msg)
|
||||||
|
|
||||||
total_before = record_count(conn)
|
total_before = record_count(conn, uid)
|
||||||
tail = last_ts(conn)
|
tail = last_ts(conn, uid)
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
_log("存档:%d 条%s" % (total_before, (",最后记录 " + tail) if tail else "(空)"))
|
_log("存档:%d 条%s" % (total_before, (",最后记录 " + tail) if tail else "(空)"))
|
||||||
|
|
||||||
@@ -246,6 +302,18 @@ def sync(conn, trigger="manual", from_dt=None, to_dt=None, verify_days=None,
|
|||||||
end = to_dt or now
|
end = to_dt or now
|
||||||
if start >= end:
|
if start >= end:
|
||||||
start = end - timedelta(minutes=rewind)
|
start = end - timedelta(minutes=rewind)
|
||||||
|
# 跨度上限(**代码层兜底**,不只是接口校验)。
|
||||||
|
# 采集一次 = 对云端发 ceil(条数/page_size) 次请求,跨度越长请求越多。
|
||||||
|
# 不设顶时,一个注册账号用 from=2000-01-01 就能让服务端替它打几千次云端,
|
||||||
|
# 同时独占全局采集锁与一个 waitress 线程 —— 最省力的资源耗尽方式。
|
||||||
|
# 这里对「显式跨度」和「断点很旧导致的实际跨度」一视同仁地收窄。
|
||||||
|
max_days = max_range_days(conn)
|
||||||
|
if end - start > timedelta(days=max_days):
|
||||||
|
original = start
|
||||||
|
start = end - timedelta(days=max_days)
|
||||||
|
_log("[warn] 请求跨度超过上限 %d 天,已自动收窄起点:%s -> %s"
|
||||||
|
% (max_days, original.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
start.strftime("%Y-%m-%d %H:%M:%S")))
|
||||||
_log("同步区间:%s ~ %s" % (start.strftime("%Y-%m-%d %H:%M:%S"),
|
_log("同步区间:%s ~ %s" % (start.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
end.strftime("%Y-%m-%d %H:%M:%S")))
|
end.strftime("%Y-%m-%d %H:%M:%S")))
|
||||||
|
|
||||||
@@ -267,15 +335,17 @@ def sync(conn, trigger="manual", from_dt=None, to_dt=None, verify_days=None,
|
|||||||
new_rows = [client.normalize(r, max_prompt=max_prompt) for r in raw]
|
new_rows = [client.normalize(r, max_prompt=max_prompt) for r in raw]
|
||||||
_log("云端返回:%d 条" % len(raw))
|
_log("云端返回:%d 条" % len(raw))
|
||||||
|
|
||||||
added, dup, conflicts = upsert(conn, new_rows, drift_tolerance=drift, log=_log)
|
added, dup, conflicts = upsert(conn, uid, new_rows, drift_tolerance=drift, log=_log)
|
||||||
|
|
||||||
# 整日完整性校验(默认关闭;用于排查缺记录)
|
# 整日完整性校验(默认关闭;用于排查缺记录)
|
||||||
if verify_days > 0:
|
if verify_days > 0:
|
||||||
days = [r["day"] for r in conn.execute(
|
days = [r["day"] for r in conn.execute(
|
||||||
"SELECT DISTINCT day FROM usage_records ORDER BY day DESC LIMIT ?", (verify_days,))]
|
"SELECT DISTINCT day FROM usage_records WHERE user_id=? ORDER BY day DESC LIMIT ?",
|
||||||
|
(uid, verify_days))]
|
||||||
_log("完整性校验:最近 %d 天" % len(days))
|
_log("完整性校验:最近 %d 天" % len(days))
|
||||||
for d in sorted(days):
|
for d in sorted(days):
|
||||||
local = conn.execute("SELECT COUNT(*) FROM usage_records WHERE day=?", (d,)).fetchone()[0]
|
local = conn.execute("SELECT COUNT(*) FROM usage_records WHERE user_id=? AND day=?",
|
||||||
|
(uid, d)).fetchone()[0]
|
||||||
d0 = datetime.strptime(d, "%Y-%m-%d")
|
d0 = datetime.strptime(d, "%Y-%m-%d")
|
||||||
try:
|
try:
|
||||||
raw2, t2 = client.fetch_range(d0, d0.replace(hour=23, minute=59, second=59),
|
raw2, t2 = client.fetch_range(d0, d0.replace(hour=23, minute=59, second=59),
|
||||||
@@ -287,14 +357,15 @@ def sync(conn, trigger="manual", from_dt=None, to_dt=None, verify_days=None,
|
|||||||
continue
|
continue
|
||||||
cloud = t2.get(d, 0)
|
cloud = t2.get(d, 0)
|
||||||
if local < cloud:
|
if local < cloud:
|
||||||
a2, _, _ = upsert(conn, [client.normalize(r, max_prompt=max_prompt) for r in raw2],
|
a2, _, _ = upsert(conn, uid,
|
||||||
|
[client.normalize(r, max_prompt=max_prompt) for r in raw2],
|
||||||
drift_tolerance=drift)
|
drift_tolerance=drift)
|
||||||
added += a2
|
added += a2
|
||||||
_log(" %s:云端 %d / 本地 %d → 补入 %d 条" % (d, cloud, local, a2))
|
_log(" %s:云端 %d / 本地 %d → 补入 %d 条" % (d, cloud, local, a2))
|
||||||
else:
|
else:
|
||||||
_log(" %s:云端 %d / 本地 %d OK" % (d, cloud, local))
|
_log(" %s:云端 %d / 本地 %d OK" % (d, cloud, local))
|
||||||
|
|
||||||
total_after = record_count(conn)
|
total_after = record_count(conn, uid)
|
||||||
status = "warn" if conflicts else "ok"
|
status = "warn" if conflicts else "ok"
|
||||||
msg = "新增 %d 条,重复 %d 条,存档共 %d 条" % (added, dup, total_after)
|
msg = "新增 %d 条,重复 %d 条,存档共 %d 条" % (added, dup, total_after)
|
||||||
_log("RESULT: added=%d dup=%d total=%d" % (added, dup, total_after))
|
_log("RESULT: added=%d dup=%d total=%d" % (added, dup, total_after))
|
||||||
@@ -310,27 +381,31 @@ def sync(conn, trigger="manual", from_dt=None, to_dt=None, verify_days=None,
|
|||||||
"total": total_after, "conflicts": len(conflicts),
|
"total": total_after, "conflicts": len(conflicts),
|
||||||
"win_from": start.strftime("%Y-%m-%d %H:%M:%S"),
|
"win_from": start.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
"win_to": end.strftime("%Y-%m-%d %H:%M:%S"),
|
"win_to": end.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
"message": msg, "lines": lines, "run_id": run_id}
|
"message": msg, "lines": lines, "run_id": run_id, "uid": uid}
|
||||||
|
|
||||||
|
|
||||||
def run_sync(trigger="manual", **kw):
|
def run_sync(trigger="manual", **kw):
|
||||||
"""带锁的同步入口(供 CLI / 调度器 / 页面手动触发共用)。"""
|
"""带锁的同步入口(供 CLI / 调度器 / 页面手动触发共用)。
|
||||||
|
|
||||||
|
必须显式给出 `uid=...`;漏传会由 sync() 直接报 TypeError,
|
||||||
|
不会退化成「用某个默认账号去采集」。
|
||||||
|
"""
|
||||||
with _Lock():
|
with _Lock():
|
||||||
conn = db.thread_conn()
|
conn = db.thread_conn()
|
||||||
return sync(conn, trigger=trigger, **kw)
|
return sync(conn, trigger=trigger, **kw)
|
||||||
|
|
||||||
|
|
||||||
# ---------------- 补全 / 导入 / 导出 ----------------
|
# ---------------- 补全 / 导入 / 导出 ----------------
|
||||||
def fill_prompt(conn, log=print):
|
def fill_prompt(conn, uid, log=print):
|
||||||
"""补全缺失的 User Prompt(官网导出的 xlsx 会丢约 22%,云端仍保留)。"""
|
"""补全该账号缺失的 User Prompt(官网导出的 xlsx 会丢约 22%,云端仍保留)。"""
|
||||||
s = db.get_settings(conn)
|
s = db.get_settings(conn, uid=uid)
|
||||||
cookie = (s.get("cookie") or "").strip() or os.environ.get("WB_COOKIE", "").strip()
|
cookie, ua = load_credentials(conn, uid)
|
||||||
ua = (s.get("user_agent") or "").strip()
|
max_prompt = db.get_int(conn, "max_prompt", 0, uid)
|
||||||
max_prompt = db.get_int(conn, "max_prompt", 0)
|
|
||||||
if not cookie:
|
if not cookie:
|
||||||
raise ApiError("未配置 Cookie")
|
raise NotReady("未配置 Cookie")
|
||||||
todo = conn.execute("SELECT request_id, day FROM usage_records "
|
todo = conn.execute("SELECT request_id, day FROM usage_records "
|
||||||
"WHERE COALESCE(prompt,'')='' ORDER BY day").fetchall()
|
"WHERE user_id=? AND COALESCE(prompt,'')='' ORDER BY day",
|
||||||
|
(uid,)).fetchall()
|
||||||
if not todo:
|
if not todo:
|
||||||
log("没有缺失的 User Prompt")
|
log("没有缺失的 User Prompt")
|
||||||
return 0
|
return 0
|
||||||
@@ -342,9 +417,9 @@ def fill_prompt(conn, log=print):
|
|||||||
raw, _ = client.fetch_range(d0, d0.replace(hour=23, minute=59, second=59),
|
raw, _ = client.fetch_range(d0, d0.replace(hour=23, minute=59, second=59),
|
||||||
cookie, ua, s.get("api_base") or config.API_BASE,
|
cookie, ua, s.get("api_base") or config.API_BASE,
|
||||||
s.get("api_path") or config.API_PATH,
|
s.get("api_path") or config.API_PATH,
|
||||||
page_size=db.get_int(conn, "page_size", 200),
|
page_size=db.get_int(conn, "page_size", 200, uid),
|
||||||
timeout=db.get_int(conn, "timeout", 30),
|
timeout=db.get_int(conn, "timeout", 30, uid),
|
||||||
ssl_verify=db.get_bool(conn, "ssl_verify", True))
|
ssl_verify=db.get_bool(conn, "ssl_verify", True, uid))
|
||||||
for r in raw:
|
for r in raw:
|
||||||
rid = (r.get("requestId") or "").strip()
|
rid = (r.get("requestId") or "").strip()
|
||||||
if rid:
|
if rid:
|
||||||
@@ -354,21 +429,22 @@ def fill_prompt(conn, log=print):
|
|||||||
for row in todo:
|
for row in todo:
|
||||||
p = pool.get(row["request_id"], "")
|
p = pool.get(row["request_id"], "")
|
||||||
if p:
|
if p:
|
||||||
conn.execute("UPDATE usage_records SET prompt=? WHERE request_id=?", (p, row["request_id"]))
|
conn.execute("UPDATE usage_records SET prompt=? WHERE user_id=? AND request_id=?",
|
||||||
|
(p, uid, row["request_id"]))
|
||||||
n += 1
|
n += 1
|
||||||
left = conn.execute("SELECT COUNT(*) FROM usage_records WHERE COALESCE(prompt,'')=''").fetchone()[0]
|
left = conn.execute("SELECT COUNT(*) FROM usage_records WHERE user_id=?"
|
||||||
|
" AND COALESCE(prompt,'')=''", (uid,)).fetchone()[0]
|
||||||
log("补全 %d 条,仍为空 %d 条" % (n, left))
|
log("补全 %d 条,仍为空 %d 条" % (n, left))
|
||||||
return n
|
return n
|
||||||
|
|
||||||
|
|
||||||
def import_xlsx(conn, path, log=print):
|
def import_xlsx(conn, uid, path, log=print):
|
||||||
"""从官网「用量明细 - 导出」的 xlsx 合入(按 request_id 去重)。"""
|
"""从官网「用量明细 - 导出」的 xlsx 合入(按 request_id 去重)。"""
|
||||||
try:
|
try:
|
||||||
import openpyxl
|
import openpyxl
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise ApiError("需要 openpyxl:pip install openpyxl")
|
raise ApiError("需要 openpyxl:pip install openpyxl")
|
||||||
s = db.get_settings(conn)
|
max_prompt = db.get_int(conn, "max_prompt", 0, uid)
|
||||||
max_prompt = db.get_int(conn, "max_prompt", 0)
|
|
||||||
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
|
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
|
||||||
it = wb.worksheets[0].iter_rows(values_only=True)
|
it = wb.worksheets[0].iter_rows(values_only=True)
|
||||||
header = [str(c).strip() if c is not None else "" for c in next(it)]
|
header = [str(c).strip() if c is not None else "" for c in next(it)]
|
||||||
@@ -398,17 +474,33 @@ def import_xlsx(conn, path, log=print):
|
|||||||
rows.append({"request_id": rid, "ts": t, "credits": round(cr, 2), "prompt": px,
|
rows.append({"request_id": rid, "ts": t, "credits": round(cr, 2), "prompt": px,
|
||||||
"model": str(row[i_m] or "-").strip() or "-",
|
"model": str(row[i_m] or "-").strip() or "-",
|
||||||
"client": str(row[i_cl] or "-").strip() or "-"})
|
"client": str(row[i_cl] or "-").strip() or "-"})
|
||||||
added, dup, _ = upsert(conn, rows)
|
added, dup, _ = upsert(conn, uid, rows)
|
||||||
log("[xlsx] 读取 %d 条,去重后新增 %d 条,存档共 %d 条" % (len(rows), added, record_count(conn)))
|
log("[xlsx] 读取 %d 条,去重后新增 %d 条,该账号存档共 %d 条"
|
||||||
|
% (len(rows), added, record_count(conn, uid)))
|
||||||
return added
|
return added
|
||||||
|
|
||||||
|
|
||||||
def export_csv(conn, path=None):
|
def export_csv(conn, uid, path=None, username=None):
|
||||||
"""导出与旧存档 / 官网 xlsx 完全同构的 CSV(备份与对端交换用)。"""
|
"""导出与旧存档 / 官网 xlsx 完全同构的 CSV(备份与对端交换用)。
|
||||||
path = path or os.path.join(config.EXPORT_DIR, "usage_records.csv")
|
|
||||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
文件名带账号名:多用户下所有人导出到同一个目录,
|
||||||
|
不带归属就会互相覆盖。
|
||||||
|
|
||||||
|
账号名必须收敛后才能拼进路径:`manage.py passwd` 建号时**不校验**用户名,
|
||||||
|
老库升级上来的名字也可能带 `/` 或 `..`。直接拼接就是路径穿越 ——
|
||||||
|
一个叫 `..\\..\\x` 的账号能把 CSV 写到数据目录之外。
|
||||||
|
这里只保留 [A-Za-z0-9._-],结果固定落在 EXPORT_DIR 之下。
|
||||||
|
|
||||||
|
另:导出文件里是**对话正文的明文**,所以落盘后立刻收紧权限
|
||||||
|
(进程 umask 已经收到 0077,这里是针对「文件早已存在、权限是旧的」那种情况)。
|
||||||
|
"""
|
||||||
|
if path is None:
|
||||||
|
tag = re.sub(r"[^A-Za-z0-9._-]", "_", str(username or ""))[:48].strip("._-")
|
||||||
|
tag = tag or ("u%s" % uid)
|
||||||
|
path = os.path.join(config.EXPORT_DIR, "usage_records_%s.csv" % tag)
|
||||||
|
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||||
rows = conn.execute("SELECT request_id,credits,prompt,model,client,ts FROM usage_records "
|
rows = conn.execute("SELECT request_id,credits,prompt,model,client,ts FROM usage_records "
|
||||||
"ORDER BY ts, request_id")
|
"WHERE user_id=? ORDER BY ts, request_id", (uid,))
|
||||||
n = 0
|
n = 0
|
||||||
with open(path, "w", encoding="utf-8-sig", newline="") as f:
|
with open(path, "w", encoding="utf-8-sig", newline="") as f:
|
||||||
w = csv.writer(f)
|
w = csv.writer(f)
|
||||||
@@ -417,11 +509,16 @@ def export_csv(conn, path=None):
|
|||||||
w.writerow([r["request_id"], "%.2f" % r["credits"], r["prompt"] or "",
|
w.writerow([r["request_id"], "%.2f" % r["credits"], r["prompt"] or "",
|
||||||
r["model"], r["client"], r["ts"]])
|
r["model"], r["client"], r["ts"]])
|
||||||
n += 1
|
n += 1
|
||||||
|
config.harden_file(path)
|
||||||
return path, n
|
return path, n
|
||||||
|
|
||||||
|
|
||||||
def migrate_from_csv(conn, path, log=print):
|
def migrate_from_csv(conn, uid, path, log=print):
|
||||||
"""把旧版 data/usage_records.csv 全量导入 SQLite(幂等,可重复执行)。"""
|
"""把旧版 data/usage_records.csv 全量导入 SQLite(幂等,可重复执行)。
|
||||||
|
|
||||||
|
导入的数据归属 `uid` 指定的账号 —— 老存档是单用户的,
|
||||||
|
必须由调用方明确「这份数据算谁的」。
|
||||||
|
"""
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
raise FileNotFoundError(path)
|
raise FileNotFoundError(path)
|
||||||
with open(path, "rb") as fb:
|
with open(path, "rb") as fb:
|
||||||
@@ -442,8 +539,8 @@ def migrate_from_csv(conn, path, log=print):
|
|||||||
"prompt": " ".join(str(r.get("User Prompt") or "").split()),
|
"prompt": " ".join(str(r.get("User Prompt") or "").split()),
|
||||||
"model": (r.get("模型") or "-").strip() or "-",
|
"model": (r.get("模型") or "-").strip() or "-",
|
||||||
"client": (r.get("客户端") or "-").strip() or "-"})
|
"client": (r.get("客户端") or "-").strip() or "-"})
|
||||||
before = record_count(conn)
|
before = record_count(conn, uid)
|
||||||
added, dup, _ = upsert(conn, recs)
|
added, dup, _ = upsert(conn, uid, recs)
|
||||||
log("[migrate] 源文件 %d 条 → 新增 %d / 已存在 %d,入库前 %d 条,现共 %d 条"
|
log("[migrate] 源文件 %d 条 → 新增 %d / 已存在 %d,入库前 %d 条,现共 %d 条"
|
||||||
% (len(recs), added, dup, before, record_count(conn)))
|
% (len(recs), added, dup, before, record_count(conn, uid)))
|
||||||
return added
|
return added
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
# Copyright (c) 2026 Wang Chuanli
|
||||||
|
|
||||||
"""基础配置。
|
"""基础配置。
|
||||||
|
|
||||||
刻意保持「薄」:凡是运行期要改的东西(cookie、调度周期、采集参数)都放数据库
|
刻意保持「薄」:凡是运行期要改的东西(cookie、调度周期、采集参数)都放数据库
|
||||||
@@ -25,8 +28,18 @@ EXPORT_DIR = os.path.join(DATA_DIR, "exports")
|
|||||||
APP_LOG = os.path.join(LOG_DIR, "app.log")
|
APP_LOG = os.path.join(LOG_DIR, "app.log")
|
||||||
INSTANCE_FILE = os.path.join(DATA_DIR, "instance.json")
|
INSTANCE_FILE = os.path.join(DATA_DIR, "instance.json")
|
||||||
|
|
||||||
# 旧版脚本项目的存档(迁移用;--migrate-csv 默认读这里)
|
# 备份落点。**刻意与 DATA_DIR 分开**:
|
||||||
|
# * 容器里 DATA_DIR 挂的是数据卷,`docker compose down -v` 会连卷一起删;
|
||||||
|
# 备份若躺在同一个卷里,就等于「正本与副本同时消失」—— 备份的意义没了。
|
||||||
|
# * 备份里含 settings 的凭证密文与 users 的口令散列,必须能单独控制权限、
|
||||||
|
# 单独挂卷、单独排除出镜像(见 .dockerignore 的 backups/)。
|
||||||
|
BACKUP_DIR = os.environ.get("WB_BACKUP_DIR") or os.path.join(BASE_DIR, "backups")
|
||||||
|
|
||||||
|
# 旧版脚本项目的存档(迁移用;--migrate-csv 默认读这里)。
|
||||||
|
# v1.3.0 起旧版被收进工作区级的 legacy-v1/ 目录,所以第一个候选是新位置,
|
||||||
|
# 后面两个保留以兼容「还没挪走」的部署。
|
||||||
LEGACY_CSV_CANDIDATES = [
|
LEGACY_CSV_CANDIDATES = [
|
||||||
|
os.path.join(os.path.dirname(BASE_DIR), "legacy-v1", "data", "usage_records.csv"),
|
||||||
os.path.join(os.path.dirname(BASE_DIR), "data", "usage_records.csv"),
|
os.path.join(os.path.dirname(BASE_DIR), "data", "usage_records.csv"),
|
||||||
os.path.join(BASE_DIR, "data", "usage_records.csv"),
|
os.path.join(BASE_DIR, "data", "usage_records.csv"),
|
||||||
]
|
]
|
||||||
@@ -36,9 +49,30 @@ API_BASE = "https://www.workbuddy.cn"
|
|||||||
API_PATH = "/billing/meter/get-user-request-usage"
|
API_PATH = "/billing/meter/get-user-request-usage"
|
||||||
|
|
||||||
# ---------------- 采集参数默认值(可被 settings 表覆盖)----------------
|
# ---------------- 采集参数默认值(可被 settings 表覆盖)----------------
|
||||||
|
# settings 表是 (user_id, key) 复合主键:user_id=0 表示**实例级**,
|
||||||
|
# 其余表示**个人级**(每个账号一份,互不可见)。见下方的 GLOBAL_KEYS。
|
||||||
DEFAULTS = {
|
DEFAULTS = {
|
||||||
|
# ---- 实例级:连接的是哪个云端 ----
|
||||||
"api_base": API_BASE,
|
"api_base": API_BASE,
|
||||||
"api_path": API_PATH,
|
"api_path": API_PATH,
|
||||||
|
# ---- 实例级:开放注册与防攻击策略 ----
|
||||||
|
"allow_register": "1", # 是否开放自助注册
|
||||||
|
"register_max_per_ip": "3", # 同一 IP 每天最多注册几个账号
|
||||||
|
"captcha_policy": "always", # always | adaptive | off(见 CAPTCHA_POLICIES)
|
||||||
|
"captcha_length": "4", # 验证码字符数 4~6
|
||||||
|
# ---- 实例级:采集调度(全实例统一,见 GLOBAL_KEYS)----
|
||||||
|
"schedule_enabled": "1",
|
||||||
|
"schedule_times": "09:00,17:00", # 每天固定时刻(逗号分隔,本地时区)
|
||||||
|
"catch_up": "1", # 启动时补跑当天已错过且未执行的槽位
|
||||||
|
"catch_up_grace_hours": "12", # 超过该小时数就不再补跑
|
||||||
|
"max_schedule_slots_per_day": "6", # 每天最多几个时刻(挡住「填 200 个时刻」)
|
||||||
|
"collect_min_interval_seconds": "60", # 同一账号两次手动采集的最小间隔
|
||||||
|
"collect_max_range_days": "31", # 单次采集的最长跨度(硬顶 31 天 = 1 个月)
|
||||||
|
# ---- 实例级:自动备份 ----
|
||||||
|
"backup_enabled": "1", # 是否开启自动备份
|
||||||
|
"backup_interval_hours": "24", # 备份周期(小时)
|
||||||
|
"backup_keep": "7", # 保留最近几份,超出的自动删除最旧的
|
||||||
|
# ---- 实例级:采集参数 ----
|
||||||
"page_size": "200",
|
"page_size": "200",
|
||||||
"rewind_minutes": "2", # 断点回退分钟数
|
"rewind_minutes": "2", # 断点回退分钟数
|
||||||
"drift_tolerance_minutes": "5", # 云端比本地早超过该值才告警
|
"drift_tolerance_minutes": "5", # 云端比本地早超过该值才告警
|
||||||
@@ -46,19 +80,72 @@ DEFAULTS = {
|
|||||||
"verify_days": "0", # 每次采集后做整日完整性校验的天数
|
"verify_days": "0", # 每次采集后做整日完整性校验的天数
|
||||||
"timeout": "30",
|
"timeout": "30",
|
||||||
"ssl_verify": "1", # 校验云端 HTTPS 证书(cookie 是凭证,不该裸奔)
|
"ssl_verify": "1", # 校验云端 HTTPS 证书(cookie 是凭证,不该裸奔)
|
||||||
# 调度
|
# ---- 个人级:凭证(每个账号自己的,Cookie 静态加密后入库)----
|
||||||
"schedule_enabled": "1",
|
|
||||||
"schedule_times": "09:00,17:00", # 每天固定时刻(逗号分隔,本地时区)
|
|
||||||
"catch_up": "1", # 启动时补跑当天已错过且未执行的槽位
|
|
||||||
"catch_up_grace_hours": "12", # 超过该小时数就不再补跑
|
|
||||||
# 凭证
|
|
||||||
"cookie": "",
|
"cookie": "",
|
||||||
"user_agent": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
"user_agent": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||||
"(KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36"),
|
"(KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ---------------- 配置的作用域与写权限(改这里之前先读完整段)----------------
|
||||||
|
#
|
||||||
|
# 三级回落:**个人级(user_id=n) -> 实例级(user_id=0) -> config.DEFAULTS**。
|
||||||
|
# 但「谁能写哪个键」和「键存在哪一级」是两件事,本项目把两者对齐成一条规则:
|
||||||
|
#
|
||||||
|
# 普通用户只能写 USER_EDITABLE_KEYS(本人凭证);
|
||||||
|
# 其余所有键都归管理员,且一律存在**实例级**(user_id=0)。
|
||||||
|
#
|
||||||
|
# 为什么采集参数 / 调度也要放到实例级,而不是「个人级但只有管理员能写」:
|
||||||
|
# 如果它们只在管理员自己的 user_id 下,其它账号读取时会回落到
|
||||||
|
# DEFAULTS,管理员改的值对别人**完全不生效** —— 那才是真正的坑。
|
||||||
|
# 统一放实例级,语义是「一台部署一套采集与调度策略」,读起来也简单。
|
||||||
|
GLOBAL_KEYS = {
|
||||||
|
# 云端接口
|
||||||
|
"api_base", "api_path",
|
||||||
|
# 开放注册与防攻击策略
|
||||||
|
"allow_register", "register_max_per_ip", "captcha_policy", "captcha_length",
|
||||||
|
# 采集调度(v1.3.0 起为实例级:普通用户只读,不能设置频率)
|
||||||
|
"schedule_enabled", "schedule_times", "catch_up", "catch_up_grace_hours",
|
||||||
|
"max_schedule_slots_per_day",
|
||||||
|
# 任务频率与采集跨度(v1.4.0 起:对外提供服务时必须能限流,
|
||||||
|
# 否则一个注册账号就能拿 /api/collect 把云端与线程池打满)
|
||||||
|
"collect_min_interval_seconds", "collect_max_range_days",
|
||||||
|
# 自动备份(谁掌握备份谁就掌握全库数据,所以归管理员)
|
||||||
|
"backup_enabled", "backup_interval_hours", "backup_keep",
|
||||||
|
# 采集参数(同理:允许普通用户调 page_size/关 ssl_verify 都是越权)
|
||||||
|
"page_size", "rewind_minutes", "drift_tolerance_minutes", "max_prompt",
|
||||||
|
"verify_days", "timeout", "ssl_verify",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 普通用户**唯一**可写的两个键:本人账号的云端凭证。
|
||||||
|
# cookie —— 静态加密后入库,页面/接口只回掩码
|
||||||
|
# user_agent —— 必须与拿 Cookie 的那次请求同源,所以和 Cookie 归在一起
|
||||||
|
# 这两个键是个人级、且不参与实例级回落(见 db.NO_FALLBACK_KEYS):
|
||||||
|
# 回落等于「用别人的身份采集」,是最严重的一类越权。
|
||||||
|
USER_EDITABLE_KEYS = {"cookie", "user_agent"}
|
||||||
|
|
||||||
|
|
||||||
|
def is_user_key(key):
|
||||||
|
"""是否属于「个人凭证」类配置。"""
|
||||||
|
return key in USER_EDITABLE_KEYS
|
||||||
|
|
||||||
|
|
||||||
|
def writable_by(key, is_admin):
|
||||||
|
"""当前角色能否写这个键 —— 前后端与测试都走这一个判断,避免两处规则漂移。"""
|
||||||
|
if key in USER_EDITABLE_KEYS:
|
||||||
|
return True
|
||||||
|
return bool(is_admin)
|
||||||
|
|
||||||
|
# 验证码策略
|
||||||
|
CAPTCHA_POLICIES = {
|
||||||
|
"always": "始终要求(默认,最安全)",
|
||||||
|
"adaptive": "仅在同一来源连续失败 2 次后要求",
|
||||||
|
"off": "关闭(仅当前面有可信网关做鉴权时才考虑)",
|
||||||
|
}
|
||||||
|
|
||||||
# 页面展示用:哪些键属于「敏感」,在界面上做掩码
|
# 页面展示用:哪些键属于「敏感」,在界面上做掩码
|
||||||
SECRET_KEYS = {"cookie"}
|
SECRET_KEYS = {"cookie"}
|
||||||
|
# 需要静态加密后再入库的键(明文只存在于内存与请求体里)
|
||||||
|
ENCRYPTED_KEYS = {"cookie"}
|
||||||
|
|
||||||
# 内部簿记键前缀:调度槽位标记等,**不属于用户可配置项**,
|
# 内部簿记键前缀:调度槽位标记等,**不属于用户可配置项**,
|
||||||
# 不在 /api/settings 里回传,也不允许通过接口写入。
|
# 不在 /api/settings 里回传,也不允许通过接口写入。
|
||||||
@@ -69,6 +156,11 @@ def is_internal_key(key):
|
|||||||
return any(str(key).startswith(p) for p in INTERNAL_PREFIXES)
|
return any(str(key).startswith(p) for p in INTERNAL_PREFIXES)
|
||||||
|
|
||||||
|
|
||||||
|
def is_global_key(key):
|
||||||
|
"""实例级键:所有账号共用一份,只有管理员可写。"""
|
||||||
|
return key in GLOBAL_KEYS
|
||||||
|
|
||||||
|
|
||||||
# ---------------- 设置项校验表 ----------------
|
# ---------------- 设置项校验表 ----------------
|
||||||
# 这些键必须能安全地转成数字:后台页面是自由文本框,用户敲错一个字符
|
# 这些键必须能安全地转成数字:后台页面是自由文本框,用户敲错一个字符
|
||||||
# 就会让采集在 int() 处抛 ValueError(历史 bug),所以写入时校验、读取时兜底。
|
# 就会让采集在 int() 处抛 ValueError(历史 bug),所以写入时校验、读取时兜底。
|
||||||
@@ -81,18 +173,35 @@ NUM_SETTINGS = {
|
|||||||
"verify_days": (0, 90, "天"),
|
"verify_days": (0, 90, "天"),
|
||||||
"timeout": (5, 300, "秒"),
|
"timeout": (5, 300, "秒"),
|
||||||
"catch_up_grace_hours": (1, 168, "小时"),
|
"catch_up_grace_hours": (1, 168, "小时"),
|
||||||
|
"captcha_length": (4, 6, "个字符"),
|
||||||
|
"register_max_per_ip": (1, 50, "个/天"),
|
||||||
|
"max_schedule_slots_per_day": (1, 12, "个/天"),
|
||||||
|
"collect_min_interval_seconds": (0, 3600, "秒"),
|
||||||
|
"collect_max_range_days": (1, 31, "天"),
|
||||||
|
"backup_interval_hours": (1, 720, "小时"),
|
||||||
|
"backup_keep": (1, 100, "份"),
|
||||||
}
|
}
|
||||||
BOOL_SETTINGS = {"schedule_enabled", "catch_up"}
|
BOOL_SETTINGS = {"schedule_enabled", "catch_up", "allow_register", "backup_enabled"}
|
||||||
|
|
||||||
|
# 采集跨度的**硬顶**:无论 settings 里被改成什么(含历史脏数据、手工改库),
|
||||||
|
# 代码层一律按这个上限夹一次。写进配置只是给管理员一个更严的旋钮,
|
||||||
|
# 不是「改大就能突破」——上限必须由代码兜底,不能只靠校验。
|
||||||
|
COLLECT_MAX_RANGE_DAYS_HARD = 31
|
||||||
|
# 每日调度时刻的硬顶(同上)
|
||||||
|
SCHEDULE_SLOTS_HARD_MAX = 12
|
||||||
|
|
||||||
_TRUE = ("1", "true", "yes", "on", "是", "启用")
|
_TRUE = ("1", "true", "yes", "on", "是", "启用")
|
||||||
|
|
||||||
|
|
||||||
def normalize_setting(key, raw):
|
def normalize_setting(key, raw, conn=None):
|
||||||
"""校验并规范化单个设置值。
|
"""校验并规范化单个设置值。
|
||||||
|
|
||||||
返回 (value, error):
|
返回 (value, error):
|
||||||
* value 为可直接写入 settings 表的字符串;error 非空时 value 为 None。
|
* value 为可直接写入 settings 表的字符串;error 非空时 value 为 None。
|
||||||
* 未知键(不在 DEFAULTS 里)直接拒绝,避免接口被用来写任意键。
|
* 未知键(不在 DEFAULTS 里)直接拒绝,避免接口被用来写任意键。
|
||||||
|
* `conn` 可选:个别键的上限本身是可配置的(如每日时刻数受
|
||||||
|
`max_schedule_slots_per_day` 约束),有连接时才查得到。
|
||||||
|
不传时只做代码层的硬顶校验,所以离线调用不会因此失败。
|
||||||
"""
|
"""
|
||||||
if key not in DEFAULTS:
|
if key not in DEFAULTS:
|
||||||
return None, "未知配置项:%s" % key
|
return None, "未知配置项:%s" % key
|
||||||
@@ -122,17 +231,43 @@ def normalize_setting(key, raw):
|
|||||||
parsed = scheduler.parse_times(raw)
|
parsed = scheduler.parse_times(raw)
|
||||||
if not parsed:
|
if not parsed:
|
||||||
return None, "每日时刻格式不对,正确写法如 09:00,17:00"
|
return None, "每日时刻格式不对,正确写法如 09:00,17:00"
|
||||||
|
if len(parsed) > SCHEDULE_SLOTS_HARD_MAX:
|
||||||
|
return None, "每日时刻最多 %d 个(当前填了 %d 个)" % (
|
||||||
|
SCHEDULE_SLOTS_HARD_MAX, len(parsed))
|
||||||
|
# 可配置的更严上限:时刻数量直接决定调度器的采集频次,
|
||||||
|
# 是「一个账号能不能把云端与线程池打满」的开关,所以要有刹车。
|
||||||
|
if conn is not None:
|
||||||
|
from . import db as _db
|
||||||
|
cap = _db.get_int(conn, "max_schedule_slots_per_day", 6)
|
||||||
|
cap = max(1, min(SCHEDULE_SLOTS_HARD_MAX, cap))
|
||||||
|
if len(parsed) > cap:
|
||||||
|
return None, ("每日时刻最多 %d 个(当前填了 %d 个)。"
|
||||||
|
"如需更多,请先把「每日调度时刻上限」调大。"
|
||||||
|
% (cap, len(parsed)))
|
||||||
return ",".join(parsed), None
|
return ",".join(parsed), None
|
||||||
|
|
||||||
|
if key == "captcha_policy":
|
||||||
|
v = str(raw).strip().lower()
|
||||||
|
if v not in CAPTCHA_POLICIES:
|
||||||
|
return None, "验证码策略只能是 %s" % " / ".join(sorted(CAPTCHA_POLICIES))
|
||||||
|
return v, None
|
||||||
|
|
||||||
if key in ("api_base", "api_path"):
|
if key in ("api_base", "api_path"):
|
||||||
v = str(raw).strip()
|
v = str(raw).strip()
|
||||||
if not v:
|
if not v:
|
||||||
return None, "%s 不能为空" % key
|
return None, "%s 不能为空" % key
|
||||||
if key == "api_base" and not v.startswith(("http://", "https://")):
|
if key == "api_base":
|
||||||
|
if not v.startswith(("http://", "https://")):
|
||||||
return None, "接口基址需以 http:// 或 https:// 开头"
|
return None, "接口基址需以 http:// 或 https:// 开头"
|
||||||
|
# 云元数据地址永远不该是「云端接口」:它是 SSRF 拿云上临时凭证
|
||||||
|
# 最经典的一跳,而且没有任何合法的采集场景需要它。
|
||||||
|
host = v.split("//", 1)[1].split("/", 1)[0].split(":")[0].lower()
|
||||||
|
if host in ("169.254.169.254", "metadata.google.internal", "[fd00:ec2::254]"):
|
||||||
|
return None, "接口基址不能指向云元数据地址"
|
||||||
return v, None
|
return v, None
|
||||||
|
|
||||||
if key == "cookie":
|
if key == "cookie":
|
||||||
|
# 明文原样返回,由 db.set_setting 负责加密后再落库
|
||||||
return str(raw).strip(), None
|
return str(raw).strip(), None
|
||||||
|
|
||||||
return str(raw).strip(), None
|
return str(raw).strip(), None
|
||||||
@@ -141,29 +276,167 @@ def normalize_setting(key, raw):
|
|||||||
DEFAULT_HOST = "0.0.0.0" # 局域网可访问
|
DEFAULT_HOST = "0.0.0.0" # 局域网可访问
|
||||||
DEFAULT_PORT = 8848
|
DEFAULT_PORT = 8848
|
||||||
SESSION_HOURS = 12
|
SESSION_HOURS = 12
|
||||||
MAX_LOGIN_FAILS = 5 # 同 IP 连续失败次数
|
MAX_LOGIN_FAILS = 5 # 同 IP 连续失败次数(硬锁)
|
||||||
LOGIN_LOCK_MINUTES = 10
|
LOGIN_LOCK_MINUTES = 10 # 硬锁时长(仅 IP 维度)
|
||||||
|
# 用户名维度的**软退避**:对外提供服务后,「知道一个用户名就能把它锁死 10 分钟」
|
||||||
|
# 本身就是一种攻击(拿管理员用户名当武器,别人也用不了)。所以用户名维度
|
||||||
|
# 只产生秒级、递增、有封顶的等待,真正的重锁只按来源 IP 施加。
|
||||||
|
USER_SOFT_THRESHOLD = 5 # 同一用户名失败超过这个次数才开始退避
|
||||||
|
USER_SOFT_CAP_SECONDS = 60 # 退避封顶
|
||||||
|
# 同一来源的登录尝试总量(含成功):挡住「慢慢撞、不触发失败阈值」的形态
|
||||||
|
LOGIN_ATTEMPTS_PER_IP = 40
|
||||||
|
LOGIN_ATTEMPTS_WINDOW = 300 # 秒
|
||||||
|
|
||||||
|
# ---------------- 反向代理与传输安全 ----------------
|
||||||
|
def _env_flag(name, default="0"):
|
||||||
|
return os.environ.get(name, default).strip().lower() in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
|
|
||||||
|
# 是否信任 X-Forwarded-For。**默认不信任**。
|
||||||
|
# 直接暴露给公网(或前面只有一个「追加型」代理)时,XFF 的第 0 段是攻击者
|
||||||
|
# 自己填的:一旦采信,验证码限速、注册配额、登录锁定三道 IP 防线会同时失效
|
||||||
|
# (实测:每次换一个伪造 XFF,45 次验证码请求全部放行)。
|
||||||
|
# 只有在**你自己的**反向代理会重写该头(nginx: `$remote_addr`)时才置 1。
|
||||||
|
TRUST_PROXY = _env_flag("WB_TRUST_PROXY")
|
||||||
|
# 强制跳转 HTTPS(配合反代时用;读到 X-Forwarded-Proto: https 就不跳)
|
||||||
|
FORCE_HTTPS = _env_flag("WB_FORCE_HTTPS")
|
||||||
|
# 会话 Cookie 是否只走 HTTPS。纯局域网 HTTP 部署必须留 0,否则浏览器不发送,
|
||||||
|
# 表现为「登录成功但立刻又跳回登录页」,极难排查。
|
||||||
|
COOKIE_SECURE = _env_flag("WB_COOKIE_SECURE")
|
||||||
|
|
||||||
|
# 访问日志:waitress 自己不记 access log,上线后没有访问日志等于出事无据可查。
|
||||||
|
# 只记非静态资源请求,写进 logs/app.log(滚动 2MB × 3)。
|
||||||
|
ACCESS_LOG = _env_flag("WB_ACCESS_LOG", "1")
|
||||||
|
|
||||||
|
# waitress 线程数。与容器 CPU 上限配套:线程越多,单实例能同时吃进的
|
||||||
|
# 慢请求(如采集、导出)就越多 —— 对外提供服务时这是资源上限的一部分。
|
||||||
|
THREADS = int(os.environ.get("WB_THREADS") or 8)
|
||||||
|
|
||||||
|
# 首个管理员的初始口令。**不给默认值**:留空时 db.init_db 会生成一个随机口令
|
||||||
|
# 并只在启动日志里打印一次 —— 硬编码一个 admin123 等于把公网实例的钥匙挂在门上。
|
||||||
|
ADMIN_USER = os.environ.get("WB_ADMIN_USER") or "admin"
|
||||||
|
ADMIN_PASSWORD = os.environ.get("WB_ADMIN_PASSWORD") or ""
|
||||||
|
|
||||||
|
# 口令黑名单:这些是自动撞库字典的头几页,命中即拒。
|
||||||
|
# 只在「设置/修改口令」时校验(登录不校验),所以不会把用老口令的人挡在门外。
|
||||||
|
WEAK_PASSWORDS = {
|
||||||
|
"12345678", "123456789", "1234567890", "password", "password1", "password123",
|
||||||
|
"passw0rd", "qwertyui", "qwerty123", "abc12345", "abcd1234", "admin123",
|
||||||
|
"admin888", "admin1234", "administrator", "root1234", "letmein1", "welcome1",
|
||||||
|
"iloveyou", "monkey123", "dragon123", "sunshine", "princess", "football",
|
||||||
|
"baseball", "11111111", "00000000", "88888888", "66666666", "asdasd123",
|
||||||
|
"1qaz2wsx", "zxcvbnm1", "a1234567", "workbuddy", "codebuddy",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------- 账号与口令策略 ----------------
|
||||||
|
USERNAME_RE = r"^[A-Za-z0-9][A-Za-z0-9_.\-]{2,31}$" # 3~32 位,字母开头
|
||||||
|
PASSWORD_MIN = 8
|
||||||
|
PASSWORD_MAX = 128
|
||||||
|
# 开启注册后,未配置 Cookie 的新账号在概览页会被提示「去配置」——
|
||||||
|
# 采集只使用**本人**的 Cookie,绝不复用别人的(否则会串号)。
|
||||||
|
PROFILE_EMAIL_MAX = 128
|
||||||
|
|
||||||
|
# 会话 Cookie 的 Secure 开关在文件上方的「反向代理与传输安全」段,
|
||||||
|
# 与 TRUST_PROXY / FORCE_HTTPS 放在一起 —— 这三个必须一起决定,
|
||||||
|
# 拆开写很容易出现「开了强制 HTTPS 却忘了 Secure」这类半截配置。
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 落盘权限(对外部署的隐私基线)----------------
|
||||||
|
# 数据目录里躺着的东西比想象中敏感:
|
||||||
|
# * usage_records.prompt —— 用户与 AI 的完整对话正文
|
||||||
|
# * settings.value —— 凭证密文;instance.json —— 拆密文的主密钥
|
||||||
|
# * exports/*.csv —— 上面那份对话正文的**明文**副本
|
||||||
|
# * backups/*.zip —— 「全库 + 主密钥」的压缩包
|
||||||
|
# 默认 umask 022 会把它们留成 0644 / 0755,也就是**同机任何用户都能读**。
|
||||||
|
# 在对外提供服务的机器上,这不是理论风险(多租户主机、共享 CI runner、
|
||||||
|
# 被入侵的低权限进程都是现实场景)。
|
||||||
|
#
|
||||||
|
# 处理方式分两层:
|
||||||
|
# 1. `harden_process()` 把进程 umask 收到 0077 —— 此后本进程新建的**一切**
|
||||||
|
# 文件都天然是 owner-only(SQLite 的 -wal/-shm 也包括在内)。
|
||||||
|
# 刻意选择「一条设置管全部」而不是逐个 chmod:逐点 chmod 一定会漏,
|
||||||
|
# 而漏掉的那个文件里往往正好是最新的对话正文。
|
||||||
|
# 2. `harden_dir()` / `harden_file()` 用于收紧**已经存在**的目录与文件
|
||||||
|
# (老版本留下的 0755 目录、手工拷进来的备份等)。
|
||||||
|
# Windows 没有 umask 这一套(权限走 ACL),两个函数直接返回。
|
||||||
|
def harden_process():
|
||||||
|
"""把进程 umask 收紧到 0077。必须在任何写盘动作之前调用。"""
|
||||||
|
if os.name != "posix":
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
os.umask(0o077)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def harden_dir(path, mode=0o700):
|
||||||
|
"""把目录权限收紧到 owner-only。失败不致命(只读 / 网络文件系统)。"""
|
||||||
|
if os.name != "posix" or not path:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
os.chmod(path, mode)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def harden_file(path, mode=0o600):
|
||||||
|
"""把文件权限收紧到 owner-only。渲染/导出/备份落盘后调用。"""
|
||||||
|
if os.name != "posix" or not path:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
os.chmod(path, mode)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def ensure_dirs():
|
def ensure_dirs():
|
||||||
for d in (DATA_DIR, LOG_DIR, EXPORT_DIR):
|
for d in (DATA_DIR, LOG_DIR, EXPORT_DIR, BACKUP_DIR):
|
||||||
os.makedirs(d, exist_ok=True)
|
os.makedirs(d, exist_ok=True)
|
||||||
|
harden_dir(d)
|
||||||
|
|
||||||
|
|
||||||
|
def _instance_read():
|
||||||
|
"""读 data/instance.json(不存在或损坏都当空字典,不让启动因此失败)。"""
|
||||||
|
ensure_dirs()
|
||||||
|
if not os.path.exists(INSTANCE_FILE):
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
with open(INSTANCE_FILE, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f) or {}
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _instance_init(key, maker):
|
||||||
|
"""取 instance.json 里的 key,没有就生成并持久化。"""
|
||||||
|
data = _instance_read()
|
||||||
|
val = data.get(key)
|
||||||
|
if not val:
|
||||||
|
val = maker()
|
||||||
|
data[key] = val
|
||||||
|
try:
|
||||||
|
with open(INSTANCE_FILE, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||||
|
# 这个文件里躺着 SECRET_KEY 与 cookie_key,权限等同管理员口令。
|
||||||
|
# 默认 umask 022 会留下 0644(同机其他用户可读),所以在 POSIX 上
|
||||||
|
# 显式收紧到 0600。Windows 没有这个概念,忽略即可。
|
||||||
|
if os.name == "posix":
|
||||||
|
os.chmod(INSTANCE_FILE, 0o600)
|
||||||
|
except OSError:
|
||||||
|
pass # 只读文件系统时退化为「本次进程内有效」
|
||||||
|
return val
|
||||||
|
|
||||||
|
|
||||||
def secret_key():
|
def secret_key():
|
||||||
"""SECRET_KEY 持久化在 data/instance.json,避免每次重启把登录态全踢掉。"""
|
"""SECRET_KEY 持久化在 data/instance.json,避免每次重启把登录态全踢掉。"""
|
||||||
ensure_dirs()
|
return _instance_init("secret_key", lambda: secrets.token_hex(32))
|
||||||
data = {}
|
|
||||||
if os.path.exists(INSTANCE_FILE):
|
|
||||||
try:
|
def encryption_key():
|
||||||
with open(INSTANCE_FILE, "r", encoding="utf-8") as f:
|
"""Cookie 静态加密的主密钥(32 字节)。
|
||||||
data = json.load(f) or {}
|
|
||||||
except (OSError, ValueError):
|
与 SECRET_KEY **分开**存放:两者轮换的代价完全不同 —— 换 SECRET_KEY
|
||||||
data = {}
|
只是让所有人重新登录,换这把会让已存的 Cookie 全部解不开。
|
||||||
key = data.get("secret_key")
|
所以混用同一个值会让「想轮换其中一个」变成一件危险的事。
|
||||||
if not key:
|
"""
|
||||||
key = secrets.token_hex(32)
|
return bytes.fromhex(_instance_init("cookie_key", lambda: secrets.token_hex(32)))
|
||||||
data["secret_key"] = key
|
|
||||||
with open(INSTANCE_FILE, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
||||||
return key
|
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
# Copyright (c) 2026 Wang Chuanli
|
||||||
|
|
||||||
|
"""对称加密 —— 给「数据库里的 Cookie」做静态加密。
|
||||||
|
|
||||||
|
为什么要自己写而不用 `cryptography` / `pycryptodome`
|
||||||
|
----------------------------------------------------
|
||||||
|
本项目刻意保持零第三方依赖(`requirements.txt` 只有 Flask / waitress / openpyxl),
|
||||||
|
而这里需要的原语只有两个,都能在 RFC 里逐行对照实现:
|
||||||
|
|
||||||
|
* **ChaCha20** 流密码(RFC 8439 §2.3)—— 加密
|
||||||
|
* **HMAC-SHA256**(RFC 2104)—— 认证,采用 **encrypt-then-MAC**
|
||||||
|
|
||||||
|
明文密钥不是口令而是 32 字节随机数,所以派生不需要慢速 KDF
|
||||||
|
(PBKDF2/scrypt 是为「低熵口令」设计的),用 HMAC 做一次密钥分离即可。
|
||||||
|
|
||||||
|
密文格式
|
||||||
|
--------
|
||||||
|
v1.<b64(salt)>.<b64(nonce)>.<b64(ciphertext)>.<b64(tag)>
|
||||||
|
|
||||||
|
* salt —— 16 字节随机,用于把主密钥分离成 enc/mac 两把子密钥
|
||||||
|
* nonce —— 12 字节随机,每次加密都重新生成(绝不复用)
|
||||||
|
* tag —— HMAC(mac_key, nonce || ciphertext) 的 SHA-256
|
||||||
|
|
||||||
|
**不做压缩**:Cookie 是几百到几千字节的高熵串,压缩比接近 1,
|
||||||
|
反而会引入 CRIME 类侧信道,不值得。
|
||||||
|
|
||||||
|
向后兼容
|
||||||
|
--------
|
||||||
|
`decrypt()` 遇到不是 `v1.` 开头的值会**原样返回**,这样从旧版本
|
||||||
|
(Cookie 明文存在 settings 表)升级过来不会立刻炸;下次写入时自然
|
||||||
|
会被改写为密文(见 `db.set_secret`)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import secrets
|
||||||
|
import struct
|
||||||
|
|
||||||
|
PREFIX = "v1."
|
||||||
|
|
||||||
|
# RFC 8439 §2.3 的常数:"expand 32-byte k"
|
||||||
|
_CONST = b"expand 32-byte k"
|
||||||
|
_MASK = 0xFFFFFFFF
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- ChaCha20 ----------------
|
||||||
|
def _rotl32(v, c):
|
||||||
|
return ((v << c) & _MASK) | (v >> (32 - c))
|
||||||
|
|
||||||
|
|
||||||
|
def _quarter_round(s, a, b, c, d):
|
||||||
|
"""RFC 8439 §2.1。就地修改 s。"""
|
||||||
|
s[a] = (s[a] + s[b]) & _MASK
|
||||||
|
s[d] = _rotl32(s[d] ^ s[a], 16)
|
||||||
|
s[c] = (s[c] + s[d]) & _MASK
|
||||||
|
s[b] = _rotl32(s[b] ^ s[c], 12)
|
||||||
|
s[a] = (s[a] + s[b]) & _MASK
|
||||||
|
s[d] = _rotl32(s[d] ^ s[a], 8)
|
||||||
|
s[c] = (s[c] + s[d]) & _MASK
|
||||||
|
s[b] = _rotl32(s[b] ^ s[c], 7)
|
||||||
|
|
||||||
|
|
||||||
|
def chacha20_block(key32, counter, nonce12):
|
||||||
|
"""产出一个 64 字节的块(RFC 8439 §2.3.2)。"""
|
||||||
|
st = (list(struct.unpack("<4I", _CONST))
|
||||||
|
+ list(struct.unpack("<8I", key32))
|
||||||
|
+ [counter & _MASK]
|
||||||
|
+ list(struct.unpack("<3I", nonce12)))
|
||||||
|
w = list(st)
|
||||||
|
for _ in range(10): # 10 组 = 20 轮
|
||||||
|
_quarter_round(w, 0, 4, 8, 12)
|
||||||
|
_quarter_round(w, 1, 5, 9, 13)
|
||||||
|
_quarter_round(w, 2, 6, 10, 14)
|
||||||
|
_quarter_round(w, 3, 7, 11, 15)
|
||||||
|
_quarter_round(w, 0, 5, 10, 15)
|
||||||
|
_quarter_round(w, 1, 6, 11, 12)
|
||||||
|
_quarter_round(w, 2, 7, 8, 13)
|
||||||
|
_quarter_round(w, 3, 4, 9, 14)
|
||||||
|
return struct.pack("<16I", *[(w[i] + st[i]) & _MASK for i in range(16)])
|
||||||
|
|
||||||
|
|
||||||
|
def _keystream(key32, nonce12, n):
|
||||||
|
"""按需生成 n 字节密钥流。counter 从 1 开始(0 号块留给 Poly1305 用,这里不用)。"""
|
||||||
|
out = bytearray()
|
||||||
|
counter = 1
|
||||||
|
while len(out) < n:
|
||||||
|
out += chacha20_block(key32, counter, nonce12)
|
||||||
|
counter += 1
|
||||||
|
return bytes(out[:n])
|
||||||
|
|
||||||
|
|
||||||
|
def _xor(a, b):
|
||||||
|
return bytes(x ^ y for x, y in zip(a, b))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 密钥分离 ----------------
|
||||||
|
def _derive(master, salt, label):
|
||||||
|
"""HMAC 做一次密钥分离:主密钥是高熵随机数,一次 HMAC 足够。"""
|
||||||
|
return hmac.new(master, salt + label, hashlib.sha256).digest()
|
||||||
|
|
||||||
|
|
||||||
|
def _b64(raw):
|
||||||
|
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
||||||
|
|
||||||
|
|
||||||
|
def _unb64(text):
|
||||||
|
pad = "=" * (-len(text) % 4)
|
||||||
|
return base64.urlsafe_b64decode(text + pad)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 对外接口 ----------------
|
||||||
|
def is_encrypted(value):
|
||||||
|
return isinstance(value, str) and value.startswith(PREFIX)
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt(plaintext, master):
|
||||||
|
"""加密任意字符串;空值原样返回(不产生「有密文的空值」这种歧义状态)。
|
||||||
|
|
||||||
|
master: 32 字节主密钥(bytes)。返回可直接存库的 ASCII 字符串。
|
||||||
|
"""
|
||||||
|
if plaintext is None or plaintext == "":
|
||||||
|
return ""
|
||||||
|
if not isinstance(master, (bytes, bytearray)) or len(master) != 32:
|
||||||
|
raise ValueError("主密钥必须是 32 字节")
|
||||||
|
data = plaintext.encode("utf-8") if isinstance(plaintext, str) else bytes(plaintext)
|
||||||
|
salt = secrets.token_bytes(16)
|
||||||
|
nonce = secrets.token_bytes(12)
|
||||||
|
enc_key = _derive(bytes(master), salt, b"enc")
|
||||||
|
mac_key = _derive(bytes(master), salt, b"mac")
|
||||||
|
ct = _xor(data, _keystream(enc_key, nonce, len(data)))
|
||||||
|
tag = hmac.new(mac_key, nonce + ct, hashlib.sha256).digest()
|
||||||
|
return PREFIX + ".".join((_b64(salt), _b64(nonce), _b64(ct), _b64(tag)))
|
||||||
|
|
||||||
|
|
||||||
|
class DecryptError(ValueError):
|
||||||
|
"""密文被篡改、格式损坏或密钥不对。"""
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt(token, master):
|
||||||
|
"""解密。
|
||||||
|
|
||||||
|
* 非密文(历史明文、空串)原样返回,便于平滑升级
|
||||||
|
* 密文校验失败抛 DecryptError —— **绝不**「失败就返回原值」,
|
||||||
|
否则一次篡改会被静默当成合法明文用下去
|
||||||
|
"""
|
||||||
|
if not token or not isinstance(token, str):
|
||||||
|
return ""
|
||||||
|
if not token.startswith(PREFIX):
|
||||||
|
return token # 兼容旧的明文存储
|
||||||
|
if not isinstance(master, (bytes, bytearray)) or len(master) != 32:
|
||||||
|
raise DecryptError("主密钥必须是 32 字节")
|
||||||
|
parts = token[len(PREFIX):].split(".")
|
||||||
|
if len(parts) != 4:
|
||||||
|
raise DecryptError("密文格式不正确")
|
||||||
|
try:
|
||||||
|
salt, nonce, ct, tag = (_unb64(p) for p in parts)
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
raise DecryptError("密文 base64 解码失败:%s" % e)
|
||||||
|
if len(salt) != 16 or len(nonce) != 12 or len(tag) != 32:
|
||||||
|
raise DecryptError("密文长度不合法")
|
||||||
|
mac_key = _derive(bytes(master), salt, b"mac")
|
||||||
|
want = hmac.new(mac_key, nonce + ct, hashlib.sha256).digest()
|
||||||
|
# 先比 MAC 再解密:认证失败时不接触密文,避免 padding/解析类侧信道
|
||||||
|
if not hmac.compare_digest(want, tag):
|
||||||
|
raise DecryptError("完整性校验失败(密文被篡改或主密钥已更换)")
|
||||||
|
enc_key = _derive(bytes(master), salt, b"enc")
|
||||||
|
return _xor(ct, _keystream(enc_key, nonce, len(ct))).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def fingerprint(plaintext):
|
||||||
|
"""值指纹:用于「是否换过」的判断,不能反推原文。"""
|
||||||
|
if not plaintext:
|
||||||
|
return ""
|
||||||
|
return hashlib.sha256(plaintext.encode("utf-8")).hexdigest()[:16]
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""SQLite 访问层。
|
# SPDX-License-Identifier: MIT
|
||||||
|
# Copyright (c) 2026 Wang Chuanli
|
||||||
|
|
||||||
|
"""SQLite 访问层(多用户版)。
|
||||||
|
|
||||||
并发约定(重要):
|
并发约定(重要):
|
||||||
* WAL 模式 —— 采集写入期间页面查询不会被 `database is locked` 挡住
|
* WAL 模式 —— 采集写入期间页面查询不会被 `database is locked` 挡住
|
||||||
@@ -7,18 +10,61 @@
|
|||||||
(由 scheduler / CLI 共享的 collect.lock 保证)
|
(由 scheduler / CLI 共享的 collect.lock 保证)
|
||||||
* busy_timeout=8s —— 偶发并发时等待而不是立刻报错
|
* busy_timeout=8s —— 偶发并发时等待而不是立刻报错
|
||||||
* 每个线程独立连接(sqlite3 默认禁止跨线程复用连接)
|
* 每个线程独立连接(sqlite3 默认禁止跨线程复用连接)
|
||||||
|
|
||||||
|
多用户约定(改代码前务必先读):
|
||||||
|
* `user_id = 0` 在 settings / collect_runs / audit_log 里表示**实例级**;
|
||||||
|
usage_records 里 0 是「历史遗留数据尚未归属」的兜底值,正常不会出现。
|
||||||
|
* **settings 里只有两个键是个人级:`cookie` 与 `user_agent`**(即
|
||||||
|
`config.USER_EDITABLE_KEYS`)。调度、采集参数、接口地址、注册策略全部
|
||||||
|
是实例级(`config.GLOBAL_KEYS`),`set_setting()` 会把它们强制写到
|
||||||
|
user_id=0 —— 因此「管理员改了但别人不生效」这类 bug 在结构上不存在。
|
||||||
|
* `get_settings()` 会把 `ENCRYPTED_KEYS`(Cookie)**一律置空**;
|
||||||
|
要拿明文只有 `get_secret()` 一条路。这样任何「顺手打印一下全部配置」
|
||||||
|
的代码都不可能把凭证带出去。
|
||||||
|
* `NO_FALLBACK_KEYS`(Cookie / User-Agent)**不参与实例级回退**:
|
||||||
|
Cookie 是账号凭证,回落等于串号,是最严重的一类越权。
|
||||||
|
* `slot:*` 调度簿记键是**个人级**(每个账号各自记「今天这个槽位跑过没」),
|
||||||
|
虽然时刻本身是实例级的 —— 这两个千万别一起改。
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import threading
|
import threading
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from . import config
|
from . import config, crypto
|
||||||
|
|
||||||
|
log = logging.getLogger("wb.db")
|
||||||
|
|
||||||
_local = threading.local()
|
_local = threading.local()
|
||||||
_init_lock = threading.Lock()
|
_init_lock = threading.Lock()
|
||||||
_initialized = False
|
_initialized = False
|
||||||
|
|
||||||
|
# 库结构版本。写在 PRAGMA user_version 里,用来判断是否需要迁移。
|
||||||
|
# 1 -> 单用户布局(settings 以 key 为主键,usage_records 以 request_id 为主键)
|
||||||
|
# 2 -> 多用户布局(见 schema.sql 顶部说明)
|
||||||
|
# 3 -> 调度与采集参数从个人级提升为实例级(普通用户只读;见 config.GLOBAL_KEYS)
|
||||||
|
# 4 -> users.session_ver(改密/重置/停用后旧会话立即失效)+ backups 备份索引表
|
||||||
|
DB_SCHEMA_VERSION = 4
|
||||||
|
|
||||||
|
# 这些键即使个人作用域没有值,也**不**回落到实例级
|
||||||
|
NO_FALLBACK_KEYS = {"cookie", "user_agent"}
|
||||||
|
|
||||||
|
# 首次初始化时若没有提供管理员口令,生成的随机口令只留在这里,
|
||||||
|
# **绝不写进数据库或审计**(审计里出现口令等于把它永久留档)。
|
||||||
|
# `manage.py init` / entrypoint 负责把它打印到启动日志。
|
||||||
|
_generated_admin_password = None
|
||||||
|
|
||||||
|
|
||||||
|
def generated_admin_password():
|
||||||
|
"""本次进程启动时生成的管理员口令(没有生成过则为 None)。"""
|
||||||
|
return _generated_admin_password
|
||||||
|
|
||||||
|
|
||||||
|
class SecretUnreadable(Exception):
|
||||||
|
"""密文解不开 —— 通常是 data/instance.json 里的 cookie_key 被换过。"""
|
||||||
|
|
||||||
|
|
||||||
def now_str():
|
def now_str():
|
||||||
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
@@ -55,87 +101,435 @@ def close_thread_conn():
|
|||||||
_local.conn = None
|
_local.conn = None
|
||||||
|
|
||||||
|
|
||||||
# ---------------- 初始化 ----------------
|
def _schema_sql():
|
||||||
|
with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "schema.sql"),
|
||||||
|
"r", encoding="utf-8") as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 初始化 / 迁移 ----------------
|
||||||
|
def _table_cols(conn, table):
|
||||||
|
return {r["name"] for r in conn.execute("PRAGMA table_info(%s)" % table)}
|
||||||
|
|
||||||
|
|
||||||
|
def _has_table(conn, name):
|
||||||
|
return bool(conn.execute(
|
||||||
|
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (name,)).fetchone())
|
||||||
|
|
||||||
|
|
||||||
|
def _first_owner_uid(conn):
|
||||||
|
"""历史数据归谁:优先第一个管理员,其次第一个账号。"""
|
||||||
|
row = conn.execute("SELECT id FROM users WHERE is_admin=1 ORDER BY id LIMIT 1").fetchone()
|
||||||
|
if row:
|
||||||
|
return row["id"]
|
||||||
|
row = conn.execute("SELECT id FROM users ORDER BY id LIMIT 1").fetchone()
|
||||||
|
return row["id"] if row else 0
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_all_user_indexes(conn):
|
||||||
|
"""删掉本项目自建的全部索引(idx_*)。
|
||||||
|
|
||||||
|
必须先删:`ALTER TABLE ... RENAME TO` 会**把索引一起带走**(名字仍指向
|
||||||
|
改名后的表),于是后面 `CREATE INDEX IF NOT EXISTS` 会被当成「已存在」
|
||||||
|
静默跳过,最终新表上一个索引都没有 —— 表面完全正常,只是慢几百倍。
|
||||||
|
`sqlite_autoindex_*` 是主键/唯一约束的隐式索引,不能动,靠前缀过滤掉。
|
||||||
|
"""
|
||||||
|
names = [r["name"] for r in conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'idx_%'")]
|
||||||
|
for n in names:
|
||||||
|
conn.execute('DROP INDEX IF EXISTS "%s"' % n)
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate(conn):
|
||||||
|
"""把老库升到 DB_SCHEMA_VERSION。幂等,返回迁移说明列表。
|
||||||
|
|
||||||
|
顺序不能变:
|
||||||
|
1. 删索引(否则改名会把索引名占住,新表建不出索引)
|
||||||
|
2. 改名主键变了的表(settings / usage_records)
|
||||||
|
3. ALTER 加列(collect_runs / audit_log / users)
|
||||||
|
4. 跑 schema.sql —— 此时所有列都齐了,表与索引一次建全
|
||||||
|
5. 回填数据、删掉 _v1_ 旧表
|
||||||
|
"""
|
||||||
|
ver = conn.execute("PRAGMA user_version").fetchone()[0]
|
||||||
|
if ver >= DB_SCHEMA_VERSION:
|
||||||
|
return []
|
||||||
|
done = []
|
||||||
|
ts = now_str()
|
||||||
|
owner = _first_owner_uid(conn)
|
||||||
|
|
||||||
|
tset = _table_cols(conn, "settings")
|
||||||
|
tur = _table_cols(conn, "usage_records")
|
||||||
|
rebuild_settings = "user_id" not in tset
|
||||||
|
rebuild_records = "user_id" not in tur
|
||||||
|
|
||||||
|
_drop_all_user_indexes(conn)
|
||||||
|
if rebuild_settings:
|
||||||
|
conn.execute("ALTER TABLE settings RENAME TO _v1_settings")
|
||||||
|
if rebuild_records:
|
||||||
|
conn.execute("ALTER TABLE usage_records RENAME TO _v1_usage_records")
|
||||||
|
|
||||||
|
# ---- 只加列的表用 ALTER,代价小得多。放在建表之前,好让 schema.sql
|
||||||
|
# 里的 CREATE INDEX 一次就成功(索引引用了这些新列)----
|
||||||
|
if "user_id" not in _table_cols(conn, "collect_runs"):
|
||||||
|
conn.execute("ALTER TABLE collect_runs ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0")
|
||||||
|
conn.execute("UPDATE collect_runs SET user_id=?", (owner,))
|
||||||
|
done.append("collect_runs 增加 user_id")
|
||||||
|
if "user_id" not in _table_cols(conn, "audit_log"):
|
||||||
|
conn.execute("ALTER TABLE audit_log ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0")
|
||||||
|
done.append("audit_log 增加 user_id")
|
||||||
|
tusers = _table_cols(conn, "users")
|
||||||
|
for col, ddl in (("email", "TEXT"),
|
||||||
|
("status", "TEXT NOT NULL DEFAULT 'active'"),
|
||||||
|
("register_ip", "TEXT"),
|
||||||
|
("last_login_ip", "TEXT"),
|
||||||
|
("session_ver", "INTEGER NOT NULL DEFAULT 0")):
|
||||||
|
if col not in tusers:
|
||||||
|
conn.execute("ALTER TABLE users ADD COLUMN %s %s" % (col, ddl))
|
||||||
|
done.append("users 增加 %s" % col)
|
||||||
|
conn.execute("UPDATE users SET status='active' WHERE status IS NULL OR status=''")
|
||||||
|
|
||||||
|
conn.executescript(_schema_sql()) # 建出新表 + 全部索引
|
||||||
|
|
||||||
|
if rebuild_settings:
|
||||||
|
# 老布局里 cookie / user_agent 是实例级的 —— 留在实例级等于
|
||||||
|
# 「所有人共用管理员的凭证」,必须归到 owner 名下,且之后永不回落。
|
||||||
|
conn.execute("INSERT INTO settings(user_id,key,value,updated_at)"
|
||||||
|
" SELECT 0,key,value,updated_at FROM _v1_settings"
|
||||||
|
" WHERE key NOT IN ('cookie','user_agent')")
|
||||||
|
conn.execute("INSERT INTO settings(user_id,key,value,updated_at)"
|
||||||
|
" SELECT ?,key,value,updated_at FROM _v1_settings"
|
||||||
|
" WHERE key IN ('cookie','user_agent') AND value IS NOT NULL AND value <> ''",
|
||||||
|
(owner,))
|
||||||
|
conn.execute("DROP TABLE _v1_settings")
|
||||||
|
done.append("settings 改为 (user_id,key) 复合主键:旧值归实例级,"
|
||||||
|
"Cookie/UA 已归属账号 #%d" % owner)
|
||||||
|
|
||||||
|
if rebuild_records:
|
||||||
|
conn.execute("INSERT INTO usage_records(user_id,request_id,ts,day,hour,model,client,"
|
||||||
|
"credits,prompt,first_seen,last_seen,cloud_ts)"
|
||||||
|
" SELECT ?,request_id,ts,day,hour,model,client,credits,prompt,"
|
||||||
|
"first_seen,last_seen,cloud_ts FROM _v1_usage_records", (owner,))
|
||||||
|
conn.execute("DROP TABLE _v1_usage_records")
|
||||||
|
done.append("usage_records 增加 user_id,主键改为 (user_id, request_id),"
|
||||||
|
"历史数据归属账号 #%d" % owner)
|
||||||
|
|
||||||
|
conn.execute("UPDATE users SET is_admin=1 WHERE id=?", (owner,))
|
||||||
|
conn.execute("PRAGMA user_version=%d" % DB_SCHEMA_VERSION)
|
||||||
|
if done:
|
||||||
|
conn.execute("INSERT INTO audit_log(user_id,at,actor,action,detail,ip)"
|
||||||
|
" VALUES(0,?,?,?,?,?)",
|
||||||
|
(ts, "system", "schema_migrate", ";".join(done)[:500], "127.0.0.1"))
|
||||||
|
return done
|
||||||
|
|
||||||
|
|
||||||
|
def _encrypt_legacy_secrets(conn):
|
||||||
|
"""把历史**明文**凭证就地加密。幂等,可反复执行。
|
||||||
|
|
||||||
|
老版本把 Cookie 直接明文写进 settings 表。升级后即便功能正常,
|
||||||
|
「库里躺着一段明文凭证」本身就是风险:备份文件、磁盘镜像、
|
||||||
|
误提交、排障时的一次 dump 都会把它带出去。
|
||||||
|
这里对 `config.ENCRYPTED_KEYS` 里所有**非 v1. 前缀**的值加密一次;
|
||||||
|
已加密的值会因前缀判定被跳过,所以每次启动跑一遍是安全的。
|
||||||
|
"""
|
||||||
|
if not config.ENCRYPTED_KEYS:
|
||||||
|
return []
|
||||||
|
key = config.encryption_key()
|
||||||
|
done = []
|
||||||
|
for r in conn.execute("SELECT user_id,key,value FROM settings").fetchall():
|
||||||
|
if r["key"] not in config.ENCRYPTED_KEYS:
|
||||||
|
continue
|
||||||
|
raw = r["value"]
|
||||||
|
if not raw or crypto.is_encrypted(raw):
|
||||||
|
continue
|
||||||
|
conn.execute("UPDATE settings SET value=?,updated_at=? WHERE user_id=? AND key=?",
|
||||||
|
(crypto.encrypt(raw, key), now_str(), r["user_id"], r["key"]))
|
||||||
|
done.append("settings[uid=%s].%s" % (r["user_id"], r["key"]))
|
||||||
|
return done
|
||||||
|
|
||||||
|
|
||||||
|
def _promote_personal_to_global(conn):
|
||||||
|
"""把「原本个人级、现在实例级」的配置收敛到 user_id=0。幂等,可反复执行。
|
||||||
|
|
||||||
|
v1.3.0 把调度与采集参数从个人级升为实例级(普通用户只读)。升级时
|
||||||
|
必须做两件事,否则会静默丢配置:
|
||||||
|
|
||||||
|
1. **把第一个管理员的个人值提升到实例级** —— 管理员此前设的
|
||||||
|
`schedule_times=08:00` 若不提升,读取路径会因为「全局键不再看个人
|
||||||
|
作用域」而直接跳过它,表现成「设置莫名其妙回到默认值」。
|
||||||
|
2. **删掉所有个人作用域里的全局键** —— 留着不会被读(get_settings 会
|
||||||
|
跳过),但会让后面排障的人以为「这个键是个人级的」。
|
||||||
|
|
||||||
|
对本来就属于实例级的键(api_base / allow_register 等)这是空操作。
|
||||||
|
"""
|
||||||
|
owner = _first_owner_uid(conn)
|
||||||
|
promoted, cleaned = [], 0
|
||||||
|
for k in sorted(config.GLOBAL_KEYS):
|
||||||
|
if owner:
|
||||||
|
mine = conn.execute("SELECT value FROM settings WHERE user_id=? AND key=?",
|
||||||
|
(owner, k)).fetchone()
|
||||||
|
if mine is not None and mine["value"] is not None:
|
||||||
|
cur = conn.execute("SELECT value FROM settings WHERE user_id=0 AND key=?",
|
||||||
|
(k,)).fetchone()
|
||||||
|
if cur is None or cur["value"] != mine["value"]:
|
||||||
|
set_setting(conn, k, mine["value"], 0) # 全局键强制落 uid=0
|
||||||
|
promoted.append(k)
|
||||||
|
# DELETE 的 rowcount 对「没有匹配行」返回 0,所以这里天然幂等
|
||||||
|
cur2 = conn.execute("DELETE FROM settings WHERE user_id<>0 AND key=?", (k,))
|
||||||
|
cleaned += cur2.rowcount or 0
|
||||||
|
return promoted, cleaned
|
||||||
|
|
||||||
|
|
||||||
|
def _create_first_admin(conn, admin_user, admin_password, ts):
|
||||||
|
"""建库后的第一个管理员。
|
||||||
|
|
||||||
|
口令优先级:显式参数 -> 环境变量 `WB_ADMIN_PASSWORD` -> **随机生成**。
|
||||||
|
这里刻意**不再兜底 `admin123`**:对外提供服务时,一个硬编码的默认口令
|
||||||
|
等于「所有按默认配置部署的实例共用同一把钥匙」,而扫描器恰好就在扫它。
|
||||||
|
生成的口令只在启动日志里打印一次,并被提示立即修改。
|
||||||
|
"""
|
||||||
|
global _generated_admin_password
|
||||||
|
from .security import hash_password
|
||||||
|
pwd = (admin_password or config.ADMIN_PASSWORD or "").strip()
|
||||||
|
generated = False
|
||||||
|
if not pwd:
|
||||||
|
pwd = secrets.token_urlsafe(12)
|
||||||
|
generated = True
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO users(username,password_hash,display_name,is_admin,status,"
|
||||||
|
" created_at) VALUES(?,?,?,1,'active',?)",
|
||||||
|
(admin_user, hash_password(pwd), "管理员", ts))
|
||||||
|
if generated:
|
||||||
|
_generated_admin_password = pwd
|
||||||
|
# 只进日志,**不进数据库**:审计表里出现口令等于把它永久留档。
|
||||||
|
log.warning("=" * 68)
|
||||||
|
log.warning("首次初始化:已为管理员 %s 生成随机口令 —— 请立即抄走并登录修改", admin_user)
|
||||||
|
log.warning(" 用户名:%s", admin_user)
|
||||||
|
log.warning(" 口 令:%s", pwd)
|
||||||
|
log.warning(" 该口令只在这里显示一次,不会写入数据库、日志文件之外的任何地方。")
|
||||||
|
log.warning(" 下次启动不会再显示(账号已存在)。忘了就用 manage.py passwd 重置。")
|
||||||
|
log.warning("=" * 68)
|
||||||
|
return generated
|
||||||
|
|
||||||
|
|
||||||
def init_db(conn=None, create_admin=True, admin_user="admin", admin_password=None):
|
def init_db(conn=None, create_admin=True, admin_user="admin", admin_password=None):
|
||||||
"""建表 + 灌默认配置。可重复执行(幂等)。"""
|
"""建表 / 迁移 / 灌默认配置。可重复执行(幂等)。返回迁移说明列表。"""
|
||||||
global _initialized
|
global _initialized
|
||||||
own = conn is None
|
own = conn is None
|
||||||
conn = conn or connect()
|
conn = conn or connect()
|
||||||
try:
|
try:
|
||||||
with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "schema.sql"),
|
if not _has_table(conn, "users"):
|
||||||
"r", encoding="utf-8") as f:
|
# 全新库:schema.sql 一次到位(避免走迁移路径去 ALTER 不存在的表)
|
||||||
conn.executescript(f.read())
|
conn.executescript(_schema_sql())
|
||||||
# 默认配置(不覆盖已有值)
|
conn.execute("PRAGMA user_version=%d" % DB_SCHEMA_VERSION)
|
||||||
|
migrated = []
|
||||||
|
else:
|
||||||
|
migrated = _migrate(conn)
|
||||||
|
# 无论如何再跑一次:幂等补齐(例如后续版本新增了表/索引,
|
||||||
|
# 而老库的 user_version 已经是最新,就不会走 _migrate 了)
|
||||||
|
conn.executescript(_schema_sql())
|
||||||
ts = now_str()
|
ts = now_str()
|
||||||
|
# 默认配置灌在**实例级**(user_id=0)。个人作用域不预置行,
|
||||||
|
# 读取时按「个人 -> 实例 -> DEFAULTS」三级回落,语义更清楚。
|
||||||
|
#
|
||||||
|
# 凭证类键(cookie / user_agent)**刻意不灌**:它们是个人级的,
|
||||||
|
# 实例级存一份没有任何读取路径会用到(NO_FALLBACK_KEYS 挡住了回落),
|
||||||
|
# 只会让「读一下实例配置看看」的人拿到一个不该存在的凭证位。
|
||||||
for k, v in config.DEFAULTS.items():
|
for k, v in config.DEFAULTS.items():
|
||||||
conn.execute("INSERT OR IGNORE INTO settings(key,value,updated_at) VALUES(?,?,?)",
|
if k in config.USER_EDITABLE_KEYS:
|
||||||
(k, v, ts))
|
continue
|
||||||
|
conn.execute("INSERT OR IGNORE INTO settings(user_id,key,value,updated_at)"
|
||||||
|
" VALUES(0,?,?,?)", (k, v, ts))
|
||||||
|
# 顺手清掉历史遗留在实例级的凭证行(老版本曾把它们当实例级配置存过)
|
||||||
|
for k in sorted(config.USER_EDITABLE_KEYS):
|
||||||
|
conn.execute("DELETE FROM settings WHERE user_id=0 AND key=?", (k,))
|
||||||
|
# 顺手把历史明文凭证加密(幂等;新库无事可做)
|
||||||
|
enc = _encrypt_legacy_secrets(conn)
|
||||||
|
if enc:
|
||||||
|
migrated.append("明文凭证已加密:%s" % ", ".join(enc))
|
||||||
|
conn.execute("INSERT INTO audit_log(user_id,at,actor,action,detail,ip)"
|
||||||
|
" VALUES(0,?,?,?,?,?)",
|
||||||
|
(ts, "system", "encrypt_secrets",
|
||||||
|
"明文凭证已加密:%s" % ", ".join(enc)[:400], "127.0.0.1"))
|
||||||
|
# 调度/采集参数在 v1.3.0 升为实例级:把管理员那份提升上去并清掉个人残留
|
||||||
|
promoted, cleaned = _promote_personal_to_global(conn)
|
||||||
|
if promoted or cleaned:
|
||||||
|
note = "配置作用域收敛:提升 %s 到实例级%s" % (
|
||||||
|
", ".join(promoted) if promoted else "(无)",
|
||||||
|
",清理 %d 条个人级残留" % cleaned if cleaned else "")
|
||||||
|
migrated.append(note)
|
||||||
|
conn.execute("INSERT INTO audit_log(user_id,at,actor,action,detail,ip)"
|
||||||
|
" VALUES(0,?,?,?,?,?)",
|
||||||
|
(ts, "system", "promote_global_settings", note[:400], "127.0.0.1"))
|
||||||
if create_admin:
|
if create_admin:
|
||||||
n = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
n = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||||
if n == 0:
|
if n == 0:
|
||||||
from .security import hash_password
|
_create_first_admin(conn, admin_user, admin_password, ts)
|
||||||
pwd = admin_password or "admin123"
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO users(username,password_hash,display_name,is_admin,created_at)"
|
|
||||||
" VALUES(?,?,?,1,?)", (admin_user, hash_password(pwd), "管理员", ts))
|
|
||||||
_initialized = True
|
_initialized = True
|
||||||
|
return migrated
|
||||||
finally:
|
finally:
|
||||||
if own:
|
if own:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
# ---------------- 配置读写 ----------------
|
# ---------------- 配置读写(按作用域) ----------------
|
||||||
def get_setting(conn, key, default=None):
|
def get_setting(conn, key, default=None, uid=0):
|
||||||
row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
|
"""取单个配置。
|
||||||
if row is None or row["value"] is None:
|
|
||||||
return config.DEFAULTS.get(key, default)
|
**加密键一律返回空串**:想拿 Cookie 明文只能用 get_secret(),
|
||||||
|
避免任何「顺手读一下配置」的代码把凭证带进日志或响应体。
|
||||||
|
"""
|
||||||
|
if key in config.ENCRYPTED_KEYS:
|
||||||
|
return ""
|
||||||
|
uid = 0 if config.is_global_key(key) else (uid or 0)
|
||||||
|
row = conn.execute("SELECT value FROM settings WHERE user_id=? AND key=?",
|
||||||
|
(uid, key)).fetchone()
|
||||||
|
if row is not None and row["value"] is not None:
|
||||||
return row["value"]
|
return row["value"]
|
||||||
|
if uid and key not in NO_FALLBACK_KEYS:
|
||||||
|
row = conn.execute("SELECT value FROM settings WHERE user_id=0 AND key=?",
|
||||||
|
(key,)).fetchone()
|
||||||
|
if row is not None and row["value"] is not None:
|
||||||
|
return row["value"]
|
||||||
|
return config.DEFAULTS.get(key, default)
|
||||||
|
|
||||||
|
|
||||||
def get_settings(conn, keys=None):
|
def get_settings(conn, keys=None, uid=0):
|
||||||
rows = conn.execute("SELECT key,value FROM settings").fetchall()
|
"""取该账号的**有效配置**(DEFAULTS -> 实例级 -> 个人级 三级合并)。
|
||||||
got = {r["key"]: r["value"] for r in rows}
|
|
||||||
|
Cookie 等加密键固定为空串,页面/接口可以直接整体回传。
|
||||||
|
"""
|
||||||
out = dict(config.DEFAULTS)
|
out = dict(config.DEFAULTS)
|
||||||
out.update(got)
|
for r in conn.execute("SELECT key,value FROM settings WHERE user_id=0"):
|
||||||
|
out[r["key"]] = r["value"]
|
||||||
|
if uid:
|
||||||
|
for r in conn.execute("SELECT key,value FROM settings WHERE user_id=?", (uid,)):
|
||||||
|
if config.is_global_key(r["key"]):
|
||||||
|
continue # 个人作用域里不该有全局键,有也不认
|
||||||
|
out[r["key"]] = r["value"]
|
||||||
|
for k in config.ENCRYPTED_KEYS:
|
||||||
|
out[k] = ""
|
||||||
if keys:
|
if keys:
|
||||||
return {k: out.get(k) for k in keys}
|
return {k: out.get(k) for k in keys}
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def set_setting(conn, key, value):
|
def set_setting(conn, key, value, uid=0):
|
||||||
conn.execute("INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) "
|
"""写单个配置。全局键强制落到 user_id=0;加密键自动加密后落库。"""
|
||||||
"ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at",
|
uid = 0 if config.is_global_key(key) else (uid or 0)
|
||||||
(key, "" if value is None else str(value), now_str()))
|
text = "" if value is None else str(value)
|
||||||
|
if key in config.ENCRYPTED_KEYS and text:
|
||||||
|
text = crypto.encrypt(text, config.encryption_key())
|
||||||
|
conn.execute("INSERT INTO settings(user_id,key,value,updated_at) VALUES(?,?,?,?) "
|
||||||
|
"ON CONFLICT(user_id,key) DO UPDATE SET value=excluded.value,"
|
||||||
|
" updated_at=excluded.updated_at", (uid, key, text, now_str()))
|
||||||
|
|
||||||
|
|
||||||
def set_settings(conn, pairs):
|
def set_settings(conn, pairs, uid=0):
|
||||||
for k, v in pairs.items():
|
for k, v in pairs.items():
|
||||||
set_setting(conn, k, v)
|
set_setting(conn, k, v, uid)
|
||||||
|
|
||||||
|
|
||||||
def get_int(conn, key, default=0):
|
def get_int(conn, key, default=0, uid=0):
|
||||||
try:
|
try:
|
||||||
return int(float(get_setting(conn, key, default)))
|
return int(float(get_setting(conn, key, default, uid)))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return default
|
return default
|
||||||
|
|
||||||
|
|
||||||
def get_float(conn, key, default=0.0):
|
def get_float(conn, key, default=0.0, uid=0):
|
||||||
try:
|
try:
|
||||||
return float(get_setting(conn, key, default))
|
return float(get_setting(conn, key, default, uid))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return default
|
return default
|
||||||
|
|
||||||
|
|
||||||
def get_bool(conn, key, default=False):
|
def get_bool(conn, key, default=False, uid=0):
|
||||||
v = str(get_setting(conn, key, "1" if default else "0")).strip().lower()
|
v = str(get_setting(conn, key, "1" if default else "0", uid)).strip().lower()
|
||||||
return v in ("1", "true", "yes", "on", "是")
|
return v in ("1", "true", "yes", "on", "是")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 凭证(加密存储) ----------------
|
||||||
|
def get_secret(conn, key, uid=0):
|
||||||
|
"""取凭证明文。仅在真正要用它对外发请求时调用。"""
|
||||||
|
row = conn.execute("SELECT value FROM settings WHERE user_id=? AND key=?",
|
||||||
|
(uid or 0, key)).fetchone()
|
||||||
|
if row is None or not row["value"]:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
return crypto.decrypt(row["value"], config.encryption_key())
|
||||||
|
except crypto.DecryptError as e:
|
||||||
|
raise SecretUnreadable("%s 无法解密:%s" % (key, e))
|
||||||
|
|
||||||
|
|
||||||
|
def set_secret(conn, key, value, uid=0):
|
||||||
|
set_setting(conn, key, value, uid)
|
||||||
|
|
||||||
|
|
||||||
|
def secret_state(conn, key, uid=0):
|
||||||
|
"""给界面用的凭证状态:只回「有没有 / 多少字符 / 尾部 4 位」,绝不含明文。"""
|
||||||
|
row = conn.execute("SELECT value FROM settings WHERE user_id=? AND key=?",
|
||||||
|
(uid or 0, key)).fetchone()
|
||||||
|
if row is None or not row["value"]:
|
||||||
|
return {"set": False, "chars": 0, "tail": "", "broken": False, "at": ""}
|
||||||
|
try:
|
||||||
|
plain = crypto.decrypt(row["value"], config.encryption_key())
|
||||||
|
except crypto.DecryptError:
|
||||||
|
return {"set": True, "chars": 0, "tail": "", "broken": True, "at": ""}
|
||||||
|
at = conn.execute("SELECT updated_at FROM settings WHERE user_id=? AND key=?",
|
||||||
|
(uid or 0, key)).fetchone()
|
||||||
|
return {"set": bool(plain), "chars": len(plain),
|
||||||
|
"tail": plain[-4:] if len(plain) >= 4 else "",
|
||||||
|
"broken": False, "at": (at["updated_at"] if at else "") or ""}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 账号 ----------------
|
||||||
|
def user_by_id(conn, uid):
|
||||||
|
return conn.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def user_by_name(conn, username):
|
||||||
|
return conn.execute("SELECT * FROM users WHERE username=?", (username,)).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def active_users(conn):
|
||||||
|
"""启用状态的账号(调度器按人遍历)。"""
|
||||||
|
return conn.execute("SELECT * FROM users WHERE status='active' ORDER BY id").fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
def user_count(conn):
|
||||||
|
return conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||||
|
|
||||||
|
|
||||||
|
def session_ver_of(row):
|
||||||
|
"""安全读取 session_ver。
|
||||||
|
|
||||||
|
取不到就返回 0 —— 迁移中途 / 老库尚未 ALTER 时不该因此抛异常,
|
||||||
|
那会把「一次可恢复的登录失效」变成「整站 500」。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if "session_ver" not in row.keys():
|
||||||
|
return 0
|
||||||
|
except AttributeError: # 不是 Row(dict 等)
|
||||||
|
return 0
|
||||||
|
return int(row["session_ver"] or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def bump_session_ver(conn, uid):
|
||||||
|
"""把该账号所有既有会话立即作废(改密 / 管理员重置 / 停用 / 删除前)。
|
||||||
|
|
||||||
|
会话里记着签发时的 session_ver,每个请求回查一次;不等就丢弃会话。
|
||||||
|
少了这一步,「我怀疑会话泄漏了所以改密码」会变成一个假的安心动作 ——
|
||||||
|
旧会话依然有效到 12 小时之后。
|
||||||
|
"""
|
||||||
|
conn.execute("UPDATE users SET session_ver=COALESCE(session_ver,0)+1 WHERE id=?",
|
||||||
|
(int(uid or 0),))
|
||||||
|
|
||||||
|
|
||||||
# ---------------- 审计 ----------------
|
# ---------------- 审计 ----------------
|
||||||
def audit(conn, action, actor=None, detail=None, ip=None):
|
def audit(conn, action, actor=None, detail=None, ip=None, uid=0):
|
||||||
conn.execute("INSERT INTO audit_log(at,actor,action,detail,ip) VALUES(?,?,?,?,?)",
|
conn.execute("INSERT INTO audit_log(user_id,at,actor,action,detail,ip)"
|
||||||
(now_str(), actor, action, detail, ip))
|
" VALUES(?,?,?,?,?,?)", (uid or 0, now_str(), actor, action, detail, ip))
|
||||||
|
|
||||||
|
|
||||||
# ---------------- Flask 集成 ----------------
|
# ---------------- Flask 集成 ----------------
|
||||||
|
|||||||
@@ -1,14 +1,26 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
# Copyright (c) 2026 Wang Chuanli
|
||||||
|
|
||||||
"""SQL 聚合层:所有统计都在 SQLite 里算完再出去,页面不再搬运全量明细。
|
"""SQL 聚合层:所有统计都在 SQLite 里算完再出去,页面不再搬运全量明细。
|
||||||
|
|
||||||
返回结构刻意与旧版 dashboard/data/*.json 的字段保持一致(d/c/k/fc/bc/m/h、
|
返回结构刻意与旧版 dashboard/data/*.json 的字段保持一致(d/c/k/fc/bc/m/h、
|
||||||
id/c/m/cl/t/px …),这样 ECharts 大屏的渲染代码一行都不用改,只换数据来源。
|
id/c/m/cl/t/px …),这样 ECharts 大屏的渲染代码一行都不用改,只换数据来源。
|
||||||
|
|
||||||
|
**多用户约定(最重要)**
|
||||||
|
所有公开函数都要求显式传入 `uid`(归属账号),且 `uid` 是 `conn` 之后的
|
||||||
|
第一个位置参数、**没有默认值**。这是有意设计的:
|
||||||
|
|
||||||
|
忘记传 uid 会直接 TypeError,而不是静默把「全部人的数据」算进去。
|
||||||
|
|
||||||
|
聚合层内部一律通过 `_where(..., uid)` 把 `user_id = ?` 拼进 WHERE,
|
||||||
|
所以任何一条 SQL 都不可能跨账号取数。
|
||||||
"""
|
"""
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from . import config, db
|
from . import config, db
|
||||||
|
|
||||||
SCHEMA_VERSION = 4
|
SCHEMA_VERSION = 5 # /api/manifest 里对外的结构版本(多用户改版)
|
||||||
TOP_EXCERPT_LEN = 400
|
TOP_EXCERPT_LEN = 400
|
||||||
DEFAULT_TOP_N = 200
|
DEFAULT_TOP_N = 200
|
||||||
# 大屏页一次最多下发多少条窗口明细(页面要拿它在浏览器里算窗口 TOP / 散点)。
|
# 大屏页一次最多下发多少条窗口明细(页面要拿它在浏览器里算窗口 TOP / 散点)。
|
||||||
@@ -43,8 +55,9 @@ def norm_window(frm=None, to=None):
|
|||||||
return f, t
|
return f, t
|
||||||
|
|
||||||
|
|
||||||
def _where(frm=None, to=None, model=None, client=None, q=None):
|
def _where(frm=None, to=None, model=None, client=None, q=None, uid=0):
|
||||||
w, p = [], []
|
"""拼 WHERE。**user_id 永远在第一个条件上**,任何调用方都绕不过去。"""
|
||||||
|
w, p = ["user_id = ?"], [uid or 0]
|
||||||
if frm:
|
if frm:
|
||||||
w.append("day >= ?")
|
w.append("day >= ?")
|
||||||
p.append(frm)
|
p.append(frm)
|
||||||
@@ -60,7 +73,7 @@ def _where(frm=None, to=None, model=None, client=None, q=None):
|
|||||||
if q:
|
if q:
|
||||||
w.append("(prompt LIKE ? OR request_id LIKE ?)")
|
w.append("(prompt LIKE ? OR request_id LIKE ?)")
|
||||||
p += ["%" + q + "%", "%" + q + "%"]
|
p += ["%" + q + "%", "%" + q + "%"]
|
||||||
return ("WHERE " + " AND ".join(w)) if w else "", p
|
return "WHERE " + " AND ".join(w), p
|
||||||
|
|
||||||
|
|
||||||
def _excerpt(s, n):
|
def _excerpt(s, n):
|
||||||
@@ -71,8 +84,8 @@ def _excerpt(s, n):
|
|||||||
|
|
||||||
|
|
||||||
# ---------------- 逐日聚合 ----------------
|
# ---------------- 逐日聚合 ----------------
|
||||||
def daily(conn, frm=None, to=None, with_maps=True):
|
def daily(conn, uid, frm=None, to=None, with_maps=True):
|
||||||
w, p = _where(frm, to)
|
w, p = _where(frm, to, uid=uid)
|
||||||
days = {}
|
days = {}
|
||||||
for r in conn.execute(
|
for r in conn.execute(
|
||||||
"SELECT day d, COUNT(*) k, ROUND(SUM(credits),2) c,"
|
"SELECT day d, COUNT(*) k, ROUND(SUM(credits),2) c,"
|
||||||
@@ -98,8 +111,8 @@ def daily(conn, frm=None, to=None, with_maps=True):
|
|||||||
|
|
||||||
|
|
||||||
# ---------------- 维度汇总 ----------------
|
# ---------------- 维度汇总 ----------------
|
||||||
def _dim(conn, col, frm=None, to=None):
|
def _dim(conn, uid, col, frm=None, to=None):
|
||||||
w, p = _where(frm, to)
|
w, p = _where(frm, to, uid=uid)
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT %s name, COUNT(*) calls, ROUND(SUM(credits),2) credits,"
|
"SELECT %s name, COUNT(*) calls, ROUND(SUM(credits),2) credits,"
|
||||||
" SUM(CASE WHEN credits<=0 THEN 1 ELSE 0 END) freeCalls,"
|
" SUM(CASE WHEN credits<=0 THEN 1 ELSE 0 END) freeCalls,"
|
||||||
@@ -122,8 +135,8 @@ def _dim(conn, col, frm=None, to=None):
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def dims(conn, frm=None, to=None):
|
def dims(conn, uid, frm=None, to=None):
|
||||||
hours = {int(r["name"]): r for r in _dim(conn, "printf('%02d',hour)", frm, to)}
|
hours = {int(r["name"]): r for r in _dim(conn, uid, "printf('%02d',hour)", frm, to)}
|
||||||
hlist = []
|
hlist = []
|
||||||
for i in range(24):
|
for i in range(24):
|
||||||
h = "%02d" % i
|
h = "%02d" % i
|
||||||
@@ -132,14 +145,14 @@ def dims(conn, frm=None, to=None):
|
|||||||
"lastDay": "", "avgPerCall": 0.0, "freeRate": 0.0}))
|
"lastDay": "", "avgPerCall": 0.0, "freeRate": 0.0}))
|
||||||
for i, o in enumerate(hlist):
|
for i, o in enumerate(hlist):
|
||||||
o["name"] = "%02d" % i
|
o["name"] = "%02d" % i
|
||||||
return {"model": _dim(conn, "model", frm, to),
|
return {"model": _dim(conn, uid, "model", frm, to),
|
||||||
"client": _dim(conn, "client", frm, to),
|
"client": _dim(conn, uid, "client", frm, to),
|
||||||
"hour": hlist}
|
"hour": hlist}
|
||||||
|
|
||||||
|
|
||||||
# ---------------- 单笔榜 ----------------
|
# ---------------- 单笔榜 ----------------
|
||||||
def top(conn, frm=None, to=None, n=DEFAULT_TOP_N):
|
def top(conn, uid, frm=None, to=None, n=DEFAULT_TOP_N):
|
||||||
w, p = _where(frm, to)
|
w, p = _where(frm, to, uid=uid)
|
||||||
items = []
|
items = []
|
||||||
for i, r in enumerate(conn.execute(
|
for i, r in enumerate(conn.execute(
|
||||||
"SELECT request_id, credits, model, client, ts, prompt FROM usage_records %s"
|
"SELECT request_id, credits, model, client, ts, prompt FROM usage_records %s"
|
||||||
@@ -151,8 +164,8 @@ def top(conn, frm=None, to=None, n=DEFAULT_TOP_N):
|
|||||||
|
|
||||||
|
|
||||||
# ---------------- 明细(窗口内精简记录,不带 prompt 全文)----------------
|
# ---------------- 明细(窗口内精简记录,不带 prompt 全文)----------------
|
||||||
def records(conn, frm=None, to=None, excerpt=96, limit=0, offset=0, newest_first=False):
|
def records(conn, uid, frm=None, to=None, excerpt=96, limit=0, offset=0, newest_first=False):
|
||||||
w, p = _where(frm, to)
|
w, p = _where(frm, to, uid=uid)
|
||||||
order = "ORDER BY ts DESC, request_id DESC" if newest_first else "ORDER BY ts, request_id"
|
order = "ORDER BY ts DESC, request_id DESC" if newest_first else "ORDER BY ts, request_id"
|
||||||
sql = ("SELECT request_id, credits, model, client, ts,"
|
sql = ("SELECT request_id, credits, model, client, ts,"
|
||||||
" substr(replace(replace(COALESCE(prompt,''),char(10),' '),char(13),' '),1,?) px"
|
" substr(replace(replace(COALESCE(prompt,''),char(10),' '),char(13),' '),1,?) px"
|
||||||
@@ -165,14 +178,15 @@ def records(conn, frm=None, to=None, excerpt=96, limit=0, offset=0, newest_first
|
|||||||
"cl": r["client"], "t": r["ts"], "px": (r["px"] or "")} for r in conn.execute(sql, args)]
|
"cl": r["client"], "t": r["ts"], "px": (r["px"] or "")} for r in conn.execute(sql, args)]
|
||||||
|
|
||||||
|
|
||||||
def records_page(conn, frm=None, to=None, model=None, client=None, q=None,
|
def records_page(conn, uid, frm=None, to=None, model=None, client=None, q=None,
|
||||||
page=1, size=50, order="ts_desc", with_prompt=True):
|
page=1, size=50, order="ts_desc", with_prompt=True):
|
||||||
frm, to = norm_window(frm, to)
|
frm, to = norm_window(frm, to)
|
||||||
size = max(1, min(int(size or 50), MAX_PAGE_SIZE))
|
size = max(1, min(int(size or 50), MAX_PAGE_SIZE))
|
||||||
page = max(1, int(page or 1))
|
page = max(1, int(page or 1))
|
||||||
w, p = _where(frm, to, model, client, q)
|
w, p = _where(frm, to, model, client, q, uid=uid)
|
||||||
total = conn.execute("SELECT COUNT(*) FROM usage_records %s" % w, p).fetchone()[0]
|
total = conn.execute("SELECT COUNT(*) FROM usage_records %s" % w, p).fetchone()[0]
|
||||||
agg = conn.execute("SELECT ROUND(COALESCE(SUM(credits),0),2) c FROM usage_records %s" % w, p).fetchone()
|
agg = conn.execute("SELECT ROUND(COALESCE(SUM(credits),0),2) c FROM usage_records %s"
|
||||||
|
% w, p).fetchone()
|
||||||
orders = {"ts_desc": "ts DESC, request_id", "ts": "ts, request_id",
|
orders = {"ts_desc": "ts DESC, request_id", "ts": "ts, request_id",
|
||||||
"credits_desc": "credits DESC, ts DESC", "credits": "credits, ts"}
|
"credits_desc": "credits DESC, ts DESC", "credits": "credits, ts"}
|
||||||
ob = orders.get(order, orders["ts_desc"])
|
ob = orders.get(order, orders["ts_desc"])
|
||||||
@@ -192,11 +206,11 @@ def records_page(conn, frm=None, to=None, model=None, client=None, q=None,
|
|||||||
"pages": max(1, (total + size - 1) // size), "items": items}
|
"pages": max(1, (total + size - 1) // size), "items": items}
|
||||||
|
|
||||||
|
|
||||||
def iter_records(conn, frm=None, to=None, model=None, client=None, q=None,
|
def iter_records(conn, uid, frm=None, to=None, model=None, client=None, q=None,
|
||||||
order="ts_desc", with_prompt=True, batch=1000):
|
order="ts_desc", with_prompt=True, batch=1000):
|
||||||
"""流式产出明细(给导出用):不把整个结果集读进内存。"""
|
"""流式产出明细(给导出用):不把整个结果集读进内存。"""
|
||||||
frm, to = norm_window(frm, to)
|
frm, to = norm_window(frm, to)
|
||||||
w, p = _where(frm, to, model, client, q)
|
w, p = _where(frm, to, model, client, q, uid=uid)
|
||||||
orders = {"ts_desc": "ts DESC, request_id", "ts": "ts, request_id",
|
orders = {"ts_desc": "ts DESC, request_id", "ts": "ts, request_id",
|
||||||
"credits_desc": "credits DESC, ts DESC", "credits": "credits, ts"}
|
"credits_desc": "credits DESC, ts DESC", "credits": "credits, ts"}
|
||||||
ob = orders.get(order, orders["ts_desc"])
|
ob = orders.get(order, orders["ts_desc"])
|
||||||
@@ -216,13 +230,14 @@ def iter_records(conn, frm=None, to=None, model=None, client=None, q=None,
|
|||||||
|
|
||||||
|
|
||||||
# ---------------- 全局元信息 ----------------
|
# ---------------- 全局元信息 ----------------
|
||||||
def months(conn):
|
def months(conn, uid=0):
|
||||||
return [r[0] for r in conn.execute(
|
return [r[0] for r in conn.execute(
|
||||||
"SELECT DISTINCT substr(day,1,7) m FROM usage_records ORDER BY m")]
|
"SELECT DISTINCT substr(day,1,7) m FROM usage_records WHERE user_id=? ORDER BY m",
|
||||||
|
(uid or 0,))]
|
||||||
|
|
||||||
|
|
||||||
def totals(conn, frm=None, to=None):
|
def totals(conn, uid, frm=None, to=None):
|
||||||
w, p = _where(frm, to)
|
w, p = _where(frm, to, uid=uid)
|
||||||
r = conn.execute(
|
r = conn.execute(
|
||||||
"SELECT COUNT(*) n, ROUND(COALESCE(SUM(credits),0),2) c,"
|
"SELECT COUNT(*) n, ROUND(COALESCE(SUM(credits),0),2) c,"
|
||||||
" SUM(CASE WHEN credits<=0 THEN 1 ELSE 0 END) fc,"
|
" SUM(CASE WHEN credits<=0 THEN 1 ELSE 0 END) fc,"
|
||||||
@@ -239,69 +254,89 @@ def totals(conn, frm=None, to=None):
|
|||||||
"first": r["t0"] or "", "last": r["t1"] or ""}
|
"first": r["t0"] or "", "last": r["t1"] or ""}
|
||||||
|
|
||||||
|
|
||||||
def day_list(conn):
|
def day_list(conn, uid=0):
|
||||||
return [r[0] for r in conn.execute("SELECT DISTINCT day FROM usage_records ORDER BY day")]
|
return [r[0] for r in conn.execute(
|
||||||
|
"SELECT DISTINCT day FROM usage_records WHERE user_id=? ORDER BY day", (uid or 0,))]
|
||||||
|
|
||||||
|
|
||||||
def manifest(conn):
|
def manifest(conn, uid, sources=True):
|
||||||
t = totals(conn)
|
"""该账号的数据清单 + 采集健康状态(凭证是否已配置 = 本人是否配了 Cookie)。
|
||||||
|
|
||||||
|
`sources=False` 时不下发「数据源」清单(表名、库文件名这类内部实现细节)。
|
||||||
|
对外提供服务后,普通注册用户没有任何理由知道正本叫什么表、库文件在哪 ——
|
||||||
|
这些是踩点阶段可以直接用的材料,属于「能不给就不给」的那一类。
|
||||||
|
大屏页的「数据源」卡片会因此对非管理员留空,这是预期行为。
|
||||||
|
"""
|
||||||
|
t = totals(conn, uid)
|
||||||
db_bytes = conn.execute("PRAGMA page_count").fetchone()[0] * \
|
db_bytes = conn.execute("PRAGMA page_count").fetchone()[0] * \
|
||||||
conn.execute("PRAGMA page_size").fetchone()[0]
|
conn.execute("PRAGMA page_size").fetchone()[0]
|
||||||
runs = conn.execute("SELECT COUNT(*) FROM collect_runs").fetchone()[0]
|
runs = conn.execute("SELECT COUNT(*) FROM collect_runs WHERE user_id=?", (uid,)).fetchone()[0]
|
||||||
last_run = conn.execute("SELECT * FROM collect_runs ORDER BY id DESC LIMIT 1").fetchone()
|
last_run = conn.execute("SELECT * FROM collect_runs WHERE user_id=?"
|
||||||
health = db.get_setting(conn, "cookie", "")
|
" ORDER BY id DESC LIMIT 1", (uid,)).fetchone()
|
||||||
mons = months(conn) # 只算一次(原来在返回体里调了两遍)
|
cred = db.secret_state(conn, "cookie", uid)
|
||||||
|
mons = months(conn, uid) # 只算一次(原来在返回体里调了两遍)
|
||||||
return {
|
return {
|
||||||
"schema": SCHEMA_VERSION,
|
"schema": SCHEMA_VERSION,
|
||||||
"generated": db.now_str(),
|
"generated": db.now_str(),
|
||||||
"archive": "data/usage.sqlite",
|
# archive / producer / note 只在管理员侧有意义(普通用户看的是自己的数据,
|
||||||
"producer": "workbuddy-portal(Flask + SQLite)",
|
# 不需要知道正本落在哪个文件、由什么进程产出)。
|
||||||
"note": "数据正本为 SQLite 表 usage_records;daily/dims/top 均为 SQL 实时聚合结果。",
|
"archive": "data/usage.sqlite" if sources else "",
|
||||||
|
"producer": "workbuddy-portal(Flask + SQLite)" if sources else "",
|
||||||
|
"note": ("数据正本为 SQLite 表 usage_records;daily/dims/top 均为 SQL 实时聚合结果,"
|
||||||
|
"且只统计当前登录账号的归属数据。") if sources
|
||||||
|
else "daily/dims/top 均为 SQL 实时聚合结果,且只统计当前登录账号的归属数据。",
|
||||||
"totals": {"records": t["records"], "credits": t["credits"], "calls": t["calls"],
|
"totals": {"records": t["records"], "credits": t["credits"], "calls": t["calls"],
|
||||||
"freeCalls": t["freeCalls"], "billableCalls": t["billableCalls"],
|
"freeCalls": t["freeCalls"], "billableCalls": t["billableCalls"],
|
||||||
"days": day_list(conn), "months": mons,
|
"days": day_list(conn, uid), "months": mons,
|
||||||
"models": t["models"], "clients": t["clients"],
|
"models": t["models"], "clients": t["clients"],
|
||||||
"first": t["first"], "last": t["last"],
|
"first": t["first"], "last": t["last"],
|
||||||
"topCredits": (conn.execute("SELECT COALESCE(MAX(credits),0) FROM usage_records")
|
"topCredits": (conn.execute(
|
||||||
.fetchone()[0] or 0.0)},
|
"SELECT COALESCE(MAX(credits),0) FROM usage_records WHERE user_id=?",
|
||||||
|
(uid,)).fetchone()[0] or 0.0)},
|
||||||
"months": mons,
|
"months": mons,
|
||||||
"sources": [
|
"sources": ([
|
||||||
{"path": "usage_records", "role": "明细正本(SQLite 表)", "count": t["records"],
|
{"path": "usage_records", "role": "明细正本(SQLite 表,按账号隔离)",
|
||||||
"bytes": db_bytes},
|
"count": t["records"], "bytes": db_bytes},
|
||||||
{"path": "daily 聚合视图", "role": "逐日聚合(SQL GROUP BY day)", "count": t["days"], "bytes": 0},
|
{"path": "daily 聚合视图", "role": "逐日聚合(SQL GROUP BY day)",
|
||||||
|
"count": t["days"], "bytes": 0},
|
||||||
{"path": "dims 聚合视图", "role": "模型/客户端/时段汇总(SQL GROUP BY)",
|
{"path": "dims 聚合视图", "role": "模型/客户端/时段汇总(SQL GROUP BY)",
|
||||||
"count": t["models"] + t["clients"] + 24, "bytes": 0},
|
"count": t["models"] + t["clients"] + 24, "bytes": 0},
|
||||||
{"path": "top 查询", "role": "单笔消耗榜(ORDER BY credits DESC)", "count": DEFAULT_TOP_N, "bytes": 0},
|
{"path": "top 查询", "role": "单笔消耗榜(ORDER BY credits DESC)",
|
||||||
{"path": "collect_runs", "role": "采集运行历史", "count": runs, "bytes": 0},
|
"count": DEFAULT_TOP_N, "bytes": 0},
|
||||||
],
|
{"path": "collect_runs", "role": "采集运行历史(本账号)", "count": runs, "bytes": 0},
|
||||||
|
] if sources else []),
|
||||||
"focusDay": (last_run["win_to"] or "")[:10] if last_run else "",
|
"focusDay": (last_run["win_to"] or "")[:10] if last_run else "",
|
||||||
"health": {"cookie": bool(health and health.strip()),
|
"health": {"cookie": cred["set"] and not cred["broken"],
|
||||||
|
"cookieChars": cred["chars"],
|
||||||
|
"cookieBroken": cred["broken"],
|
||||||
"lastRunAt": last_run["started_at"] if last_run else "",
|
"lastRunAt": last_run["started_at"] if last_run else "",
|
||||||
"lastRunStatus": last_run["status"] if last_run else ""},
|
"lastRunStatus": last_run["status"] if last_run else ""},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def bundle(conn, frm=None, to=None, top_n=DEFAULT_TOP_N, excerpt=140,
|
def bundle(conn, uid, frm=None, to=None, top_n=DEFAULT_TOP_N, excerpt=140,
|
||||||
records_cap=BUNDLE_RECORDS_CAP):
|
records_cap=BUNDLE_RECORDS_CAP, sources=True):
|
||||||
"""大屏页一次请求拿齐所需数据。
|
"""大屏页一次请求拿齐所需数据(全部限定在当前账号内)。
|
||||||
|
|
||||||
窗口裁剪:records(明细)、dims(维度)、totals(KPI)随 frm/to 变化。
|
窗口裁剪:records(明细)、dims(维度)、totals(KPI)随 frm/to 变化。
|
||||||
刻意不裁剪:daily(全量逐日,供日历与日期轴,体量小)、top(全局 TOP 榜)。
|
刻意不裁剪:daily(全量逐日,供日历与日期轴,体量小)、top(全局 TOP 榜)。
|
||||||
|
|
||||||
records 有上限(records_cap)并在响应里标注 recordsTruncated,
|
records 有上限(records_cap)并在响应里标注 recordsTruncated,
|
||||||
避免存档长大后「全部」区间把整包明细都压到浏览器。
|
避免存档长大后「全部」区间把整包明细都压到浏览器。
|
||||||
|
|
||||||
|
`sources=False` 时不下发内部实现清单(表名 / 库文件),见 `manifest()`。
|
||||||
"""
|
"""
|
||||||
frm, to = norm_window(frm, to)
|
frm, to = norm_window(frm, to)
|
||||||
tot = totals(conn, frm, to)
|
tot = totals(conn, uid, frm, to)
|
||||||
# 只有真的会超限时才改成「取最近 N 条」,避免改变现有正常路径的行为
|
# 只有真的会超限时才改成「取最近 N 条」,避免改变现有正常路径的行为
|
||||||
truncated = tot["records"] > records_cap
|
truncated = tot["records"] > records_cap
|
||||||
recs = records(conn, frm, to, excerpt=excerpt,
|
recs = records(conn, uid, frm, to, excerpt=excerpt,
|
||||||
limit=records_cap if truncated else 0, newest_first=truncated)
|
limit=records_cap if truncated else 0, newest_first=truncated)
|
||||||
return {
|
return {
|
||||||
"manifest": manifest(conn),
|
"manifest": manifest(conn, uid, sources=sources),
|
||||||
"daily": daily(conn), # 全量逐日(体量小,供日历与日期轴)
|
"daily": daily(conn, uid), # 全量逐日(体量小,供日历与日期轴)
|
||||||
"dims": dims(conn, frm, to), # 窗口内维度
|
"dims": dims(conn, uid, frm, to), # 窗口内维度
|
||||||
"top": top(conn, None, None, top_n)["items"], # 全局 TOP 榜(对应「全局 TOP200」视图)
|
"top": top(conn, uid, None, None, top_n)["items"], # 该账号全局 TOP 榜
|
||||||
"records": recs,
|
"records": recs,
|
||||||
"recordsTotal": tot["records"],
|
"recordsTotal": tot["records"],
|
||||||
"recordsCap": records_cap,
|
"recordsCap": records_cap,
|
||||||
@@ -312,7 +347,7 @@ def bundle(conn, frm=None, to=None, top_n=DEFAULT_TOP_N, excerpt=140,
|
|||||||
|
|
||||||
|
|
||||||
# ---------------- 环比 ----------------
|
# ---------------- 环比 ----------------
|
||||||
def summary(conn, frm, to):
|
def summary(conn, uid, frm, to):
|
||||||
"""KPI + 环比。前一段必须完整落在存档范围内,否则不给假数字。
|
"""KPI + 环比。前一段必须完整落在存档范围内,否则不给假数字。
|
||||||
|
|
||||||
frm/to 会先归一化(容错 '2026-09-08 12:00:00'、'2026/09/08' 等写法),
|
frm/to 会先归一化(容错 '2026-09-08 12:00:00'、'2026/09/08' 等写法),
|
||||||
@@ -321,18 +356,19 @@ def summary(conn, frm, to):
|
|||||||
frm, to = norm_window(frm, to)
|
frm, to = norm_window(frm, to)
|
||||||
if not frm or not to:
|
if not frm or not to:
|
||||||
# 无法识别的日期:退化成全量口径,不抛异常(API 层会先校验并返回 400)
|
# 无法识别的日期:退化成全量口径,不抛异常(API 层会先校验并返回 400)
|
||||||
t = totals(conn)
|
t = totals(conn, uid)
|
||||||
frm, to = t["firstDay"], t["lastDay"]
|
frm, to = t["firstDay"], t["lastDay"]
|
||||||
if not frm or not to:
|
if not frm or not to:
|
||||||
frm = to = datetime.now().strftime("%Y-%m-%d")
|
frm = to = datetime.now().strftime("%Y-%m-%d")
|
||||||
cur = totals(conn, frm, to)
|
cur = totals(conn, uid, frm, to)
|
||||||
days = (datetime.strptime(to, "%Y-%m-%d") - datetime.strptime(frm, "%Y-%m-%d")).days + 1
|
days = (datetime.strptime(to, "%Y-%m-%d") - datetime.strptime(frm, "%Y-%m-%d")).days + 1
|
||||||
p_to = (datetime.strptime(frm, "%Y-%m-%d") - timedelta(days=1)).strftime("%Y-%m-%d")
|
p_to = (datetime.strptime(frm, "%Y-%m-%d") - timedelta(days=1)).strftime("%Y-%m-%d")
|
||||||
p_frm = (datetime.strptime(p_to, "%Y-%m-%d") - timedelta(days=days - 1)).strftime("%Y-%m-%d")
|
p_frm = (datetime.strptime(p_to, "%Y-%m-%d") - timedelta(days=days - 1)).strftime("%Y-%m-%d")
|
||||||
first_day = conn.execute("SELECT MIN(day) FROM usage_records").fetchone()[0]
|
first_day = conn.execute("SELECT MIN(day) FROM usage_records WHERE user_id=?",
|
||||||
|
(uid or 0,)).fetchone()[0]
|
||||||
prev = None
|
prev = None
|
||||||
if first_day and p_frm >= first_day:
|
if first_day and p_frm >= first_day:
|
||||||
prev = totals(conn, p_frm, p_to)
|
prev = totals(conn, uid, p_frm, p_to)
|
||||||
out = dict(cur)
|
out = dict(cur)
|
||||||
out["window"] = {"from": frm, "to": to, "days": days}
|
out["window"] = {"from": frm, "to": to, "days": days}
|
||||||
out["avgPerCall"] = round(cur["credits"] / cur["calls"], 4) if cur["calls"] else 0.0
|
out["avgPerCall"] = round(cur["credits"] / cur["calls"], 4) if cur["calls"] else 0.0
|
||||||
@@ -347,7 +383,8 @@ def summary(conn, frm, to):
|
|||||||
out["delta"] = None
|
out["delta"] = None
|
||||||
# 残日:最后一天不是完整的一天
|
# 残日:最后一天不是完整的一天
|
||||||
if to == datetime.now().strftime("%Y-%m-%d"):
|
if to == datetime.now().strftime("%Y-%m-%d"):
|
||||||
row = conn.execute("SELECT MAX(ts) FROM usage_records WHERE day=?", (to,)).fetchone()
|
row = conn.execute("SELECT MAX(ts) FROM usage_records WHERE user_id=? AND day=?",
|
||||||
|
(uid or 0, to)).fetchone()
|
||||||
if row and row[0]:
|
if row and row[0]:
|
||||||
out["partial"] = {"date": to, "hhmm": row[0][11:16]}
|
out["partial"] = {"date": to, "hhmm": row[0][11:16]}
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
# Copyright (c) 2026 Wang Chuanli
|
||||||
|
|
||||||
"""进程内调度器(手写,不依赖 APScheduler)。
|
"""进程内调度器(手写,不依赖 APScheduler)。
|
||||||
|
|
||||||
为什么不引 APScheduler:
|
为什么不引 APScheduler:
|
||||||
@@ -6,6 +9,13 @@
|
|||||||
* 需要「启动补跑」(程序没开的时候错过了时刻,开机后要补上)
|
* 需要「启动补跑」(程序没开的时候错过了时刻,开机后要补上)
|
||||||
* 需要和 CLI 共享同一把文件锁,避免两处同时采集
|
* 需要和 CLI 共享同一把文件锁,避免两处同时采集
|
||||||
|
|
||||||
|
多用户
|
||||||
|
------
|
||||||
|
调度配置(开关 / 时刻 / 补跑 / slot:* 簿记)都是**个人级**设置,
|
||||||
|
所以 tick 会遍历所有启用状态的账号,各自判断有没有到期槽位。
|
||||||
|
好处是「A 想 9 点采、B 想 21 点采」互不影响;代价是串行执行 ——
|
||||||
|
这是刻意的,SQLite 单写者不允许并发采集。
|
||||||
|
|
||||||
单实例保证:
|
单实例保证:
|
||||||
* Flask 的 reloader 会 fork 两个进程 → 只在 WERKZEUG_RUN_MAIN 里启动
|
* Flask 的 reloader 会 fork 两个进程 → 只在 WERKZEUG_RUN_MAIN 里启动
|
||||||
* 多进程部署时用环境变量 WB_DISABLE_SCHEDULER=1 关掉除一个之外的所有实例
|
* 多进程部署时用环境变量 WB_DISABLE_SCHEDULER=1 关掉除一个之外的所有实例
|
||||||
@@ -16,10 +26,10 @@ import os
|
|||||||
import threading
|
import threading
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from . import collect, db
|
from . import backup, collect, db
|
||||||
|
|
||||||
log = logging.getLogger("wb.scheduler")
|
log = logging.getLogger("wb.scheduler")
|
||||||
SLOT_PREFIX = "slot:" # settings 键:slot:09:00 -> 最近执行的日期
|
SLOT_PREFIX = "slot:" # settings 键:slot:09:00 -> 最近执行的日期(按 user_id 存)
|
||||||
|
|
||||||
|
|
||||||
def parse_times(raw):
|
def parse_times(raw):
|
||||||
@@ -47,25 +57,25 @@ def parse_times(raw):
|
|||||||
_parse_times = parse_times
|
_parse_times = parse_times
|
||||||
|
|
||||||
|
|
||||||
def slots(conn):
|
def slots(conn, uid=0):
|
||||||
return parse_times(db.get_setting(conn, "schedule_times"))
|
return parse_times(db.get_setting(conn, "schedule_times", uid=uid))
|
||||||
|
|
||||||
|
|
||||||
def last_run_of_slot(conn, slot):
|
def last_run_of_slot(conn, uid, slot):
|
||||||
return db.get_setting(conn, SLOT_PREFIX + slot, "")
|
return db.get_setting(conn, SLOT_PREFIX + slot, "", uid)
|
||||||
|
|
||||||
|
|
||||||
def mark_slot(conn, slot, day):
|
def mark_slot(conn, uid, slot, day):
|
||||||
db.set_setting(conn, SLOT_PREFIX + slot, day)
|
db.set_setting(conn, SLOT_PREFIX + slot, day, uid)
|
||||||
|
|
||||||
|
|
||||||
def next_run_at(conn, now=None):
|
def next_run_at(conn, uid=0, now=None):
|
||||||
"""下一次计划执行时间(仅按配置推算,不含补跑)。"""
|
"""下一次计划执行时间(仅按配置推算,不含补跑)。"""
|
||||||
if not db.get_bool(conn, "schedule_enabled", True):
|
if not db.get_bool(conn, "schedule_enabled", True, uid):
|
||||||
return None
|
return None
|
||||||
now = now or datetime.now()
|
now = now or datetime.now()
|
||||||
best = None
|
best = None
|
||||||
for s in slots(conn):
|
for s in slots(conn, uid):
|
||||||
hh, mm = map(int, s.split(":"))
|
hh, mm = map(int, s.split(":"))
|
||||||
cand = now.replace(hour=hh, minute=mm, second=0, microsecond=0)
|
cand = now.replace(hour=hh, minute=mm, second=0, microsecond=0)
|
||||||
if cand <= now:
|
if cand <= now:
|
||||||
@@ -75,24 +85,24 @@ def next_run_at(conn, now=None):
|
|||||||
return best
|
return best
|
||||||
|
|
||||||
|
|
||||||
def due_slots(conn, now=None):
|
def due_slots(conn, uid=0, now=None):
|
||||||
"""返回此刻应当执行的槽位列表(含启动补跑)。"""
|
"""返回此刻应当执行的槽位列表(含启动补跑)。"""
|
||||||
if not db.get_bool(conn, "schedule_enabled", True):
|
if not db.get_bool(conn, "schedule_enabled", True, uid):
|
||||||
return []
|
return []
|
||||||
now = now or datetime.now()
|
now = now or datetime.now()
|
||||||
today = now.strftime("%Y-%m-%d")
|
today = now.strftime("%Y-%m-%d")
|
||||||
# 用 get_int 兜底:catch_up_grace_hours 在后台是自由文本框,
|
# 用 get_int 兜底:catch_up_grace_hours 在后台是自由文本框,
|
||||||
# 历史上填成 "12h" 会让这里 int() 抛 ValueError,把 /tasks 打成 500。
|
# 历史上填成 "12h" 会让这里 int() 抛 ValueError,把 /tasks 打成 500。
|
||||||
grace_hours = db.get_int(conn, "catch_up_grace_hours", 12)
|
grace_hours = db.get_int(conn, "catch_up_grace_hours", 12, uid)
|
||||||
grace = timedelta(hours=max(1, grace_hours))
|
grace = timedelta(hours=max(1, grace_hours))
|
||||||
catch_up = db.get_bool(conn, "catch_up", True)
|
catch_up = db.get_bool(conn, "catch_up", True, uid)
|
||||||
out = []
|
out = []
|
||||||
for s in slots(conn):
|
for s in slots(conn, uid):
|
||||||
hh, mm = map(int, s.split(":"))
|
hh, mm = map(int, s.split(":"))
|
||||||
when = now.replace(hour=hh, minute=mm, second=0, microsecond=0)
|
when = now.replace(hour=hh, minute=mm, second=0, microsecond=0)
|
||||||
if when > now:
|
if when > now:
|
||||||
continue # 还没到点
|
continue # 还没到点
|
||||||
if last_run_of_slot(conn, s) == today:
|
if last_run_of_slot(conn, uid, s) == today:
|
||||||
continue # 今天这个槽位已跑过
|
continue # 今天这个槽位已跑过
|
||||||
if when < now - grace and catch_up:
|
if when < now - grace and catch_up:
|
||||||
continue # 错过太久,不补(避免开机狂刷)
|
continue # 错过太久,不补(避免开机狂刷)
|
||||||
@@ -140,19 +150,42 @@ class Scheduler:
|
|||||||
conn = db.thread_conn()
|
conn = db.thread_conn()
|
||||||
now = now or datetime.now()
|
now = now or datetime.now()
|
||||||
today = now.strftime("%Y-%m-%d")
|
today = now.strftime("%Y-%m-%d")
|
||||||
for slot in due_slots(conn, now):
|
for u in db.active_users(conn):
|
||||||
|
uid = u["id"]
|
||||||
|
try:
|
||||||
|
pending = due_slots(conn, uid, now)
|
||||||
|
except Exception as e: # 单个账号配置坏了不能拖垮其他人
|
||||||
|
log.error("账号 #%s(%s) 读取调度配置失败:%s", uid, u["username"], e)
|
||||||
|
continue
|
||||||
|
for slot in pending:
|
||||||
scheduled = now.replace(hour=int(slot[:2]), minute=int(slot[3:]),
|
scheduled = now.replace(hour=int(slot[:2]), minute=int(slot[3:]),
|
||||||
second=0, microsecond=0)
|
second=0, microsecond=0)
|
||||||
trigger = "startup" if now - scheduled > timedelta(minutes=5) else "schedule"
|
trigger = "startup" if now - scheduled > timedelta(minutes=5) else "schedule"
|
||||||
log.info("触发采集:槽位 %s(%s)", slot, trigger)
|
log.info("触发采集:账号 %s 槽位 %s(%s)", u["username"], slot, trigger)
|
||||||
mark_slot(conn, slot, today) # 先占位,避免采集失败被无限重试打爆云端
|
# 先占位,避免采集失败被无限重试打爆云端
|
||||||
|
mark_slot(conn, uid, slot, today)
|
||||||
|
if not db.secret_state(conn, "cookie", uid)["set"]:
|
||||||
|
log.info("跳过:账号 %s 还没配置自己的 Cookie", u["username"])
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
r = collect.run_sync(trigger=trigger)
|
r = collect.run_sync(trigger=trigger, uid=uid)
|
||||||
log.info("采集完成:%s", r["message"])
|
log.info("采集完成:%s → %s", u["username"], r["message"])
|
||||||
except collect.Busy as e:
|
except collect.Busy as e:
|
||||||
log.warning("跳过(%s)", e)
|
log.warning("跳过(%s)", e)
|
||||||
|
except db.SecretUnreadable as e:
|
||||||
|
log.error("账号 %s 的 Cookie 解不开:%s", u["username"], e)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error("采集失败:%s", e)
|
log.error("账号 %s 采集失败:%s", u["username"], e)
|
||||||
|
|
||||||
|
# ---- 自动备份(实例级,与具体账号无关,所以放在账号循环之外)----
|
||||||
|
# 有采集在跑就跳过,等下一轮:备份会整库读一遍,没必要和采集抢磁盘。
|
||||||
|
try:
|
||||||
|
if os.path.exists(collect.LOCK_PATH):
|
||||||
|
log.debug("有采集在跑,本次跳过自动备份")
|
||||||
|
else:
|
||||||
|
backup.maybe_auto(conn, now)
|
||||||
|
except Exception as e: # 备份失败不能拖累调度本身
|
||||||
|
log.exception("自动备份出错:%s", e)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
-- WorkBuddy Portal —— SQLite 表结构
|
-- WorkBuddy Portal —— SQLite 表结构
|
||||||
-- 设计要点:
|
-- 设计要点:
|
||||||
-- * usage_records 是唯一正本,request_id 为主键,去重靠 ON CONFLICT,不再依赖内存比对
|
-- * 多用户:usage_records / collect_runs / audit_log 都带 user_id;
|
||||||
|
-- 每个账号只看得到自己的数据,管理员也不越过这条线(见 docs/ARCHITECTURE.md)
|
||||||
|
-- * settings 是 (user_id, key) 复合主键:user_id=0 为实例级,其余为个人级
|
||||||
|
-- * usage_records 主键是 (user_id, request_id):去重按「人 + 请求」,
|
||||||
|
-- 不同账号拿到相同 requestId 时互不覆盖
|
||||||
-- * ts 存「本地保留的最早开始时间」;cloud_ts 存云端最近一次返回的时间(观察长请求前移)
|
-- * ts 存「本地保留的最早开始时间」;cloud_ts 存云端最近一次返回的时间(观察长请求前移)
|
||||||
-- * day / hour 是冗余列,配合索引让区间扫描与 GROUP BY 都能走索引
|
-- * day / hour 是冗余列,配合索引让区间扫描与 GROUP BY 都能走索引
|
||||||
-- * prompt 单独存一列且默认不参与任何列表接口(占传输量约 80%)
|
-- * prompt 单独存一列且默认不参与任何列表接口(占传输量约 80%)
|
||||||
|
--
|
||||||
|
-- 升级:本文件是 DDL 的唯一来源。db._migrate() 用「改名旧表 -> 重跑本文件 ->
|
||||||
|
-- 回填数据 -> 删旧表 -> 再跑一次本文件补索引」的方式做在线迁移。
|
||||||
|
|
||||||
PRAGMA journal_mode = WAL;
|
PRAGMA journal_mode = WAL;
|
||||||
PRAGMA synchronous = NORMAL;
|
PRAGMA synchronous = NORMAL;
|
||||||
|
|
||||||
|
-- ---------------- 采集正本 ----------------
|
||||||
CREATE TABLE IF NOT EXISTS usage_records (
|
CREATE TABLE IF NOT EXISTS usage_records (
|
||||||
request_id TEXT PRIMARY KEY,
|
user_id INTEGER NOT NULL DEFAULT 0, -- 归属账号(usage_records.user_id -> users.id)
|
||||||
|
request_id TEXT NOT NULL,
|
||||||
ts TEXT NOT NULL, -- 'YYYY-MM-DD HH:MM:SS'
|
ts TEXT NOT NULL, -- 'YYYY-MM-DD HH:MM:SS'
|
||||||
day TEXT NOT NULL, -- 'YYYY-MM-DD'
|
day TEXT NOT NULL, -- 'YYYY-MM-DD'
|
||||||
hour INTEGER NOT NULL, -- 0..23
|
hour INTEGER NOT NULL, -- 0..23
|
||||||
@@ -19,19 +28,23 @@ CREATE TABLE IF NOT EXISTS usage_records (
|
|||||||
prompt TEXT,
|
prompt TEXT,
|
||||||
first_seen TEXT NOT NULL, -- 本地首次入库时间
|
first_seen TEXT NOT NULL, -- 本地首次入库时间
|
||||||
last_seen TEXT NOT NULL, -- 本地最近一次见到的时间
|
last_seen TEXT NOT NULL, -- 本地最近一次见到的时间
|
||||||
cloud_ts TEXT -- 云端最近一次返回的 requestTime
|
cloud_ts TEXT, -- 云端最近一次返回的 requestTime
|
||||||
|
PRIMARY KEY (user_id, request_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_ur_day ON usage_records(day);
|
-- 索引一律以 user_id 打头:所有查询都带「归属人」这个条件,
|
||||||
CREATE INDEX IF NOT EXISTS idx_ur_day_hour ON usage_records(day, hour);
|
-- 少了它会退化成全表扫描(多用户下这是最容易踩的性能坑)。
|
||||||
CREATE INDEX IF NOT EXISTS idx_ur_model_day ON usage_records(model, day);
|
CREATE INDEX IF NOT EXISTS idx_ur_day ON usage_records(user_id, day);
|
||||||
CREATE INDEX IF NOT EXISTS idx_ur_client_day ON usage_records(client, day);
|
CREATE INDEX IF NOT EXISTS idx_ur_day_hour ON usage_records(user_id, day, hour);
|
||||||
CREATE INDEX IF NOT EXISTS idx_ur_credits ON usage_records(credits DESC);
|
CREATE INDEX IF NOT EXISTS idx_ur_model_day ON usage_records(user_id, model, day);
|
||||||
CREATE INDEX IF NOT EXISTS idx_ur_ts ON usage_records(ts);
|
CREATE INDEX IF NOT EXISTS idx_ur_client_day ON usage_records(user_id, client, day);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ur_credits ON usage_records(user_id, credits DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ur_ts ON usage_records(user_id, ts);
|
||||||
|
|
||||||
-- 采集运行历史(任务管理 + 日志管理的正本)
|
-- ---------------- 采集运行历史 ----------------
|
||||||
CREATE TABLE IF NOT EXISTS collect_runs (
|
CREATE TABLE IF NOT EXISTS collect_runs (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL DEFAULT 0, -- 哪次「谁」的采集
|
||||||
trigger TEXT NOT NULL, -- manual | schedule | cli | startup
|
trigger TEXT NOT NULL, -- manual | schedule | cli | startup
|
||||||
status TEXT NOT NULL, -- running | ok | warn | error
|
status TEXT NOT NULL, -- running | ok | warn | error
|
||||||
started_at TEXT NOT NULL,
|
started_at TEXT NOT NULL,
|
||||||
@@ -42,7 +55,7 @@ CREATE TABLE IF NOT EXISTS collect_runs (
|
|||||||
fetched INTEGER DEFAULT 0, -- 云端返回条数
|
fetched INTEGER DEFAULT 0, -- 云端返回条数
|
||||||
added INTEGER DEFAULT 0,
|
added INTEGER DEFAULT 0,
|
||||||
dup INTEGER DEFAULT 0,
|
dup INTEGER DEFAULT 0,
|
||||||
total INTEGER DEFAULT 0, -- 入库后总条数
|
total INTEGER DEFAULT 0, -- 入库后该账号总条数
|
||||||
conflicts INTEGER DEFAULT 0,
|
conflicts INTEGER DEFAULT 0,
|
||||||
exit_code INTEGER,
|
exit_code INTEGER,
|
||||||
message TEXT, -- 一句话结论
|
message TEXT, -- 一句话结论
|
||||||
@@ -50,29 +63,41 @@ CREATE TABLE IF NOT EXISTS collect_runs (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_runs_started ON collect_runs(started_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_runs_started ON collect_runs(started_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_runs_user ON collect_runs(user_id, id DESC);
|
||||||
|
|
||||||
-- 键值配置:cookie / user_agent / 调度时刻 / 采集参数 / 调度槽位去重标记
|
-- ---------------- 键值配置 ----------------
|
||||||
|
-- user_id = 0 : 实例级(接口基址、注册开关、验证码策略)
|
||||||
|
-- user_id > 0 : 个人级(自己填的 Cookie / UA、采集参数、调度时刻、slot:* 簿记)
|
||||||
CREATE TABLE IF NOT EXISTS settings (
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
key TEXT PRIMARY KEY,
|
user_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
key TEXT NOT NULL,
|
||||||
value TEXT,
|
value TEXT,
|
||||||
updated_at TEXT
|
updated_at TEXT,
|
||||||
|
PRIMARY KEY (user_id, key)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- 后台登录账号(局域网访问必须)
|
-- ---------------- 账号 ----------------
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
username TEXT UNIQUE NOT NULL,
|
username TEXT UNIQUE NOT NULL,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
display_name TEXT,
|
display_name TEXT,
|
||||||
is_admin INTEGER NOT NULL DEFAULT 1,
|
email TEXT,
|
||||||
|
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active', -- active | disabled
|
||||||
|
session_ver INTEGER NOT NULL DEFAULT 0, -- 会话版本:改密/重置/停用 +1,旧会话立即失效
|
||||||
created_at TEXT,
|
created_at TEXT,
|
||||||
|
register_ip TEXT, -- 自助注册来源,用于每日限额
|
||||||
last_login_at TEXT,
|
last_login_at TEXT,
|
||||||
|
last_login_ip TEXT,
|
||||||
login_count INTEGER NOT NULL DEFAULT 0
|
login_count INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
-- 操作审计(登录、改配置、手动触发等)
|
-- ---------------- 操作审计 ----------------
|
||||||
|
-- 刻意**不记录任何凭证**:detail 里只写「改了哪些键」,不写键的值。
|
||||||
CREATE TABLE IF NOT EXISTS audit_log (
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL DEFAULT 0, -- 归属账号(0 = 系统 / CLI / 未登录事件)
|
||||||
at TEXT NOT NULL,
|
at TEXT NOT NULL,
|
||||||
actor TEXT,
|
actor TEXT,
|
||||||
action TEXT NOT NULL,
|
action TEXT NOT NULL,
|
||||||
@@ -81,3 +106,39 @@ CREATE TABLE IF NOT EXISTS audit_log (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_audit_at ON audit_log(at DESC);
|
CREATE INDEX IF NOT EXISTS idx_audit_at ON audit_log(at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id, id DESC);
|
||||||
|
|
||||||
|
-- ---------------- 图形验证码 ----------------
|
||||||
|
-- 答案只在服务端存在。下发到浏览器的只是 id,且用一次即删。
|
||||||
|
CREATE TABLE IF NOT EXISTS captchas (
|
||||||
|
id TEXT PRIMARY KEY, -- 随机 token(下发给客户端)
|
||||||
|
answer TEXT NOT NULL, -- 正确答案(绝不下发)
|
||||||
|
purpose TEXT NOT NULL, -- login | register
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
used_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_captcha_expires ON captchas(expires_at);
|
||||||
|
|
||||||
|
-- ---------------- 备份索引 ----------------
|
||||||
|
-- 表里只放「元数据」,备份文件本身在 config.BACKUP_DIR(**不在** data/ 卷里,
|
||||||
|
-- 免得 `docker compose down -v` 把正本和备份一起删掉)。
|
||||||
|
-- 刻意不记录备份内容、也不记录任何凭证 —— 这里只是一份可下载清单。
|
||||||
|
CREATE TABLE IF NOT EXISTS backups (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
filename TEXT NOT NULL UNIQUE, -- 备份目录内的文件名(不含路径)
|
||||||
|
bytes INTEGER NOT NULL DEFAULT 0, -- 归档大小
|
||||||
|
sha256 TEXT NOT NULL DEFAULT '', -- 归档整体校验值(前 64 位十六进制)
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
trigger TEXT NOT NULL DEFAULT 'manual', -- manual | auto | cli | pre-restore
|
||||||
|
actor TEXT, -- 触发者用户名(自动备份为 system)
|
||||||
|
schema_ver INTEGER NOT NULL DEFAULT 0, -- 归档时的 user_version
|
||||||
|
records INTEGER NOT NULL DEFAULT 0, -- 归档时的记录条数(便于挑一份恢复)
|
||||||
|
credits REAL NOT NULL DEFAULT 0,
|
||||||
|
users INTEGER NOT NULL DEFAULT 0,
|
||||||
|
note TEXT, -- 备注 / 恢复来源
|
||||||
|
missing INTEGER NOT NULL DEFAULT 0 -- 1 = 文件已不在磁盘上(手工删过)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_backups_at ON backups(created_at DESC);
|
||||||
|
|||||||
@@ -1,115 +1,459 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""密码哈希、登录装饰器、CSRF。
|
# SPDX-License-Identifier: MIT
|
||||||
|
# Copyright (c) 2026 Wang Chuanli
|
||||||
|
|
||||||
局域网可访问 ⇒ 必须有鉴权。这里用 Werkzeug 自带的 PBKDF2,不引第三方依赖。
|
"""鉴权、口令策略、图形验证码、限速、CSRF、安全响应头。
|
||||||
|
|
||||||
|
多用户下的三条红线
|
||||||
|
------------------
|
||||||
|
1. **Cookie(账号凭证)是按 user_id 分作用域存的**,读取一律走
|
||||||
|
`db.get_secret(conn, "cookie", uid)`;`db.get_settings()` 会把凭证置空,
|
||||||
|
所以「顺手把配置回传给前端」这类代码不可能把它带出去。
|
||||||
|
2. **禁用/删除账号立刻失效**:`current_user()` 每个请求回查一次
|
||||||
|
users.status 与 users.session_ver,不靠会话过期来兜底。
|
||||||
|
3. **限速按「来源 IP」和「用户名」双维度计数**,但两者的**强度刻意不同**:
|
||||||
|
IP 维度是真锁,用户名维度只是秒级退避。原因见 `user_soft_left` 的注释。
|
||||||
|
|
||||||
|
来源 IP 的取法(对外提供服务时最容易出错的一处)
|
||||||
|
------------------------------------------------
|
||||||
|
`client_ip()` 是**全站唯一**的取客户端地址入口。默认只信 `remote_addr`:
|
||||||
|
反向代理若用 `$proxy_add_x_forwarded_for`(追加语义),请求头里第 0 段就是
|
||||||
|
攻击者自己填的字符串,采信它等于把验证码限速、注册配额、登录锁定三道
|
||||||
|
IP 防线一起交出去。只有显式设置 `WB_TRUST_PROXY=1`(且你的代理会重写该头)
|
||||||
|
时才读 X-Forwarded-For,而且**取最右侧**那一段 —— 最右边是离我们最近的
|
||||||
|
一跳,由我们自己的代理写入,客户端伪造不了。
|
||||||
"""
|
"""
|
||||||
import functools
|
import functools
|
||||||
import hmac
|
import hmac
|
||||||
|
import ipaddress
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
|
import sqlite3
|
||||||
import time
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
from flask import (current_app, flash, jsonify, redirect, render_template, request,
|
from flask import (current_app, flash, g, jsonify, redirect, render_template,
|
||||||
session, url_for)
|
request, session, url_for)
|
||||||
from werkzeug.security import check_password_hash, generate_password_hash
|
|
||||||
|
|
||||||
from . import config, db
|
from . import captcha, config, db
|
||||||
|
|
||||||
# 简易失败计数(内存即可:单进程部署,重启清零可接受)
|
log = logging.getLogger("wb.security")
|
||||||
_fails = {} # ip -> [count, first_ts]
|
|
||||||
_FAILS_MAX_IPS = 4096 # 上限,防止大量来源 IP 把字典撑爆
|
# ---------------- 来源 IP(全站唯一入口) ----------------
|
||||||
_FAILS_TTL = 3600 # 超过 1 小时无更新的条目会被清理
|
def _valid_ip(s):
|
||||||
|
try:
|
||||||
|
ipaddress.ip_address(s)
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def client_ip():
|
||||||
|
"""当前请求的客户端地址。
|
||||||
|
|
||||||
|
* `WB_TRUST_PROXY` 未开启(默认):直接用 `remote_addr`。
|
||||||
|
直接暴露公网、或前面挂了「追加型」代理时,XFF 的第一段是攻击者可控的。
|
||||||
|
* 已开启:读 X-Forwarded-For 并**取最右侧**合法 IP。
|
||||||
|
最右侧是最近一跳(我们自己的代理)写入的,客户端加不进去。
|
||||||
|
多级代理(CDN -> nginx)需要按跳数取值,本项目不支持 —— 那样只能靠
|
||||||
|
代理侧传 `X-Real-IP` 之类的可信头,不要在这里猜。
|
||||||
|
"""
|
||||||
|
remote = (request.remote_addr or "").strip()
|
||||||
|
if not config.TRUST_PROXY:
|
||||||
|
return remote
|
||||||
|
raw = request.headers.get("X-Forwarded-For", "")
|
||||||
|
if not raw:
|
||||||
|
return remote
|
||||||
|
for part in reversed([p.strip() for p in raw.split(",")]):
|
||||||
|
# 去掉 IPv6 的 [..]:port 写法
|
||||||
|
cand = part.strip("[]").split("%")[0]
|
||||||
|
if cand.count(":") == 1 and cand.rsplit(":", 1)[1].isdigit():
|
||||||
|
cand = cand.rsplit(":", 1)[0] # IPv4:port
|
||||||
|
if _valid_ip(cand):
|
||||||
|
return cand
|
||||||
|
# 头里全是垃圾 -> 退回 remote_addr,而不是把一个伪造值当 IP 用
|
||||||
|
log.warning("X-Forwarded-For 里没有合法 IP,已回退 remote_addr:%r", raw[:120])
|
||||||
|
return remote
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 失败计数(内存即可) ----------------
|
||||||
|
# 单进程部署(见 README 的部署约束),重启清零可接受;
|
||||||
|
# 真正的防爆破靠「验证码 + 双维度限速」两道,而不是靠计数持久化。
|
||||||
|
_fails = {} # key -> [count, last_ts]
|
||||||
|
_tries = {} # ip -> [count, window_started_at](含成功,只看总量)
|
||||||
|
_FAILS_MAX_KEYS = 8192 # 上限,防止海量来源把字典撑爆
|
||||||
|
_FAILS_TTL = 3600 # 超过 1 小时无更新即清理
|
||||||
|
|
||||||
|
CAPTCHA_SESSION_PREFIX = "cap_"
|
||||||
|
|
||||||
|
|
||||||
def _prune_fails(now=None):
|
def _prune_fails(now=None):
|
||||||
"""清掉过期条目;条目数超上限时按时间淘汰最旧的。"""
|
|
||||||
now = now or time.time()
|
now = now or time.time()
|
||||||
dead = [ip for ip, c in _fails.items() if now - c[1] > _FAILS_TTL]
|
dead = [k for k, c in _fails.items() if now - c[1] > _FAILS_TTL]
|
||||||
for ip in dead:
|
for k in dead:
|
||||||
_fails.pop(ip, None)
|
_fails.pop(k, None)
|
||||||
if len(_fails) > _FAILS_MAX_IPS:
|
if len(_fails) > _FAILS_MAX_KEYS:
|
||||||
for ip, _ in sorted(_fails.items(), key=lambda kv: kv[1][1])[:len(_fails) - _FAILS_MAX_IPS]:
|
for k, _ in sorted(_fails.items(), key=lambda kv: kv[1][1])[:len(_fails) - _FAILS_MAX_KEYS]:
|
||||||
_fails.pop(ip, None)
|
_fails.pop(k, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _ip_key(ip):
|
||||||
|
return "ip:" + (ip or "")
|
||||||
|
|
||||||
|
|
||||||
|
def _user_key(username):
|
||||||
|
return "user:" + (username or "").strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def note_fail(key):
|
||||||
|
now = time.time()
|
||||||
|
_prune_fails(now)
|
||||||
|
c = _fails.get(key)
|
||||||
|
if c is None or now - c[1] > config.LOGIN_LOCK_MINUTES * 60:
|
||||||
|
_fails[key] = [1, now]
|
||||||
|
return 1
|
||||||
|
c[0] += 1
|
||||||
|
c[1] = now
|
||||||
|
return c[0]
|
||||||
|
|
||||||
|
|
||||||
|
def is_locked(key):
|
||||||
|
c = _fails.get(key)
|
||||||
|
if not c or c[0] < config.MAX_LOGIN_FAILS:
|
||||||
|
return False
|
||||||
|
return time.time() - c[1] <= config.LOGIN_LOCK_MINUTES * 60
|
||||||
|
|
||||||
|
|
||||||
|
def clear_fail(key):
|
||||||
|
_fails.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
|
def lock_left(key):
|
||||||
|
c = _fails.get(key)
|
||||||
|
if not c:
|
||||||
|
return 0
|
||||||
|
return max(0, int(config.LOGIN_LOCK_MINUTES * 60 - (time.time() - c[1])))
|
||||||
|
|
||||||
|
|
||||||
|
def fail_count(key):
|
||||||
|
c = _fails.get(key)
|
||||||
|
return c[0] if c else 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---- IP 维度:真锁(来源地址现在已经不可伪造,锁得住真正的攻击者)----
|
||||||
|
def ip_lock_left(ip):
|
||||||
|
c = _fails.get(_ip_key(ip))
|
||||||
|
if not c or c[0] < config.MAX_LOGIN_FAILS:
|
||||||
|
return 0
|
||||||
|
return max(0, int(config.LOGIN_LOCK_MINUTES * 60 - (time.time() - c[1])))
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 用户名维度:只做秒级退避,**不做长锁** ----
|
||||||
|
def user_soft_left(username):
|
||||||
|
"""知道一个用户名就能把它锁死 10 分钟 —— 那本身就是攻击。
|
||||||
|
|
||||||
|
对外提供服务后,管理员用户名是公开信息(导航里就写着),
|
||||||
|
如果按用户名施加长锁,任何人只要连打 5 次错误口令,就能让真正的管理员
|
||||||
|
十分钟进不去。所以这里改成「递增且有封顶」的秒级等待:
|
||||||
|
超过阈值后第 1 次 1s、第 2 次 2s …… 封顶 60s。
|
||||||
|
真正的重锁只按来源 IP 施加(`ip_lock_left`)—— 那才是攻击者无法伪造、
|
||||||
|
也无法甩锅给别人的东西。命中阈值的同时,攻击者自己的 IP 也在计数,
|
||||||
|
所以这种「软」不会让爆破变得可行。
|
||||||
|
"""
|
||||||
|
c = _fails.get(_user_key(username))
|
||||||
|
if not c or c[0] < config.USER_SOFT_THRESHOLD:
|
||||||
|
return 0
|
||||||
|
delay = min(config.USER_SOFT_CAP_SECONDS,
|
||||||
|
1 << min(10, c[0] - config.USER_SOFT_THRESHOLD))
|
||||||
|
return max(0, int(delay - (time.time() - c[1])))
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 单 IP 登录尝试总量(含成功):挡住「慢慢撞、不触发失败阈值」----
|
||||||
|
def note_try(ip):
|
||||||
|
now = time.time()
|
||||||
|
if len(_tries) > _FAILS_MAX_KEYS:
|
||||||
|
_tries.clear()
|
||||||
|
cur = _tries.get(ip)
|
||||||
|
if cur is None or now - cur[1] > config.LOGIN_ATTEMPTS_WINDOW:
|
||||||
|
_tries[ip] = [1, now]
|
||||||
|
return 1
|
||||||
|
cur[0] += 1
|
||||||
|
return cur[0]
|
||||||
|
|
||||||
|
|
||||||
|
def try_window_left(ip):
|
||||||
|
cur = _tries.get(ip)
|
||||||
|
if not cur or cur[0] < config.LOGIN_ATTEMPTS_PER_IP:
|
||||||
|
return 0
|
||||||
|
return max(0, int(config.LOGIN_ATTEMPTS_WINDOW - (time.time() - cur[1])))
|
||||||
|
|
||||||
|
|
||||||
|
def auth_locked(ip, username=""):
|
||||||
|
"""还需等待的秒数(0 = 放行)。"""
|
||||||
|
return max(ip_lock_left(ip), try_window_left(ip), user_soft_left(username))
|
||||||
|
|
||||||
|
|
||||||
|
def auth_block_reason(ip, username=""):
|
||||||
|
"""被挡的原因码:ip / rate / user / 空。用于给出**准确**的提示语。"""
|
||||||
|
if ip_lock_left(ip):
|
||||||
|
return "ip"
|
||||||
|
if try_window_left(ip):
|
||||||
|
return "rate"
|
||||||
|
if user_soft_left(username):
|
||||||
|
return "user"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def auth_block_message(reason, seconds):
|
||||||
|
if reason == "ip":
|
||||||
|
return "该来源登录失败次数过多,请 %d 秒后再试" % seconds
|
||||||
|
if reason == "rate":
|
||||||
|
return "登录请求过于频繁,请 %d 秒后再试" % seconds
|
||||||
|
return "尝试过于频繁,请 %d 秒后再试" % seconds
|
||||||
|
|
||||||
|
|
||||||
|
def note_auth_fail(ip, username=""):
|
||||||
|
n1 = note_fail(_ip_key(ip))
|
||||||
|
n2 = note_fail(_user_key(username)) if username else 0
|
||||||
|
return max(n1, n2)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_auth_fail(ip, username=""):
|
||||||
|
"""登录成功后清掉两个维度的计数。
|
||||||
|
|
||||||
|
用户名维度必须在成功时清零:否则「攻击者打了几次 + 主人自己登一次」
|
||||||
|
之后,主人仍会被自己之前那几次的退避拖住。
|
||||||
|
"""
|
||||||
|
clear_fail(_ip_key(ip))
|
||||||
|
if username:
|
||||||
|
clear_fail(_user_key(username))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 通用动作限速(重操作保护) ----------------
|
||||||
|
# 采集 / 导出 / 整库整理这类动作的代价远高于普通页面请求:
|
||||||
|
# 一次 /api/collect 会让服务端对云端发起成百上千次请求,并独占一个线程;
|
||||||
|
# 一次导出会把整张表扫一遍。对外提供服务时必须有刹车,
|
||||||
|
# 否则**一个注册账号**就能把实例的线程与带宽吃干净。
|
||||||
|
_actions = {} # "name:uid" -> next_allowed_ts
|
||||||
|
|
||||||
|
|
||||||
|
def action_allowed(key, min_interval):
|
||||||
|
"""返回 (是否允许, 还需等待秒数)。允许时会把下次可执行时间推后。"""
|
||||||
|
now = time.time()
|
||||||
|
if len(_actions) > _FAILS_MAX_KEYS:
|
||||||
|
_actions.clear()
|
||||||
|
nxt = _actions.get(key) or 0
|
||||||
|
if now < nxt:
|
||||||
|
return False, int(nxt - now) + 1
|
||||||
|
_actions[key] = now + max(0, int(min_interval))
|
||||||
|
return True, 0
|
||||||
|
|
||||||
|
|
||||||
|
def action_wait_left(key):
|
||||||
|
return max(0, int((_actions.get(key) or 0) - time.time()))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 通用读接口限速(对外部署的刹车) ----------------
|
||||||
|
# 登录有 IP 锁定、注册有配额 + 验证码、采集有最小间隔 —— 但**读接口没有刹车**。
|
||||||
|
# 对外提供服务后,一个注册账号循环调 /api/bundle(它要算全量逐日聚合,还会
|
||||||
|
# 下发最多 2 万条明细)就能持续吃掉 CPU 与出口带宽;这不需要任何漏洞,
|
||||||
|
# 只要把浏览器 F12 里的那个请求放进 for 循环。
|
||||||
|
# 所以这里按「账号(未登录时按来源 IP)」加一个滑动窗口上限。
|
||||||
|
# 阈值刻意给得宽松:大屏切一次筛选只发 1~2 个请求,正常用户碰不到它。
|
||||||
|
_API_RATE_MAX = 240 # 每窗口允许的请求数
|
||||||
|
_API_RATE_WINDOW = 60 # 窗口长度(秒)
|
||||||
|
_API_HITS_MAX_KEYS = 8192 # 字典上限,防止海量来源把内存撑爆
|
||||||
|
_api_hits = {} # key -> [count, window_started_at]
|
||||||
|
|
||||||
|
|
||||||
|
def api_rate_ok(key):
|
||||||
|
"""返回 (是否放行, 还需等待秒数)。"""
|
||||||
|
now = time.time()
|
||||||
|
if len(_api_hits) > _API_HITS_MAX_KEYS:
|
||||||
|
_api_hits.clear()
|
||||||
|
cur = _api_hits.get(key)
|
||||||
|
if cur is None or now - cur[1] > _API_RATE_WINDOW:
|
||||||
|
_api_hits[key] = [1, now]
|
||||||
|
return True, 0
|
||||||
|
cur[0] += 1
|
||||||
|
if cur[0] > _API_RATE_MAX:
|
||||||
|
return False, int(_API_RATE_WINDOW - (now - cur[1])) + 1
|
||||||
|
return True, 0
|
||||||
|
|
||||||
|
|
||||||
|
def api_rate_reset():
|
||||||
|
"""清空读接口计数(测试与排障用)。"""
|
||||||
|
_api_hits.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 下载文件名 / 响应头 ----------------
|
||||||
|
# 导出文件名里含用户名,而用户名**并不总是**注册接口那条正则的产物:
|
||||||
|
# `manage.py passwd` 建号时不做校验,老库升级上来的名字也可能带各种字符。
|
||||||
|
# 一旦名字里有引号或 CR/LF,直接拼进响应头就是**响应头注入**(响应拆分):
|
||||||
|
# 引号之后的内容会被下游解析成新的头。所以这里一律先收敛。
|
||||||
|
_UNSAFE_FN = re.compile(r"[^A-Za-z0-9._-]+")
|
||||||
|
|
||||||
|
|
||||||
|
def safe_filename(name, fallback="download", maxlen=64):
|
||||||
|
"""把任意字符串收敛成可安全放进头部 / 文件系统的 ASCII 名。"""
|
||||||
|
s = _UNSAFE_FN.sub("_", str(name or "")).replace("..", "_").strip("._-")
|
||||||
|
s = s[:maxlen].strip("._-")
|
||||||
|
return s or fallback
|
||||||
|
|
||||||
|
|
||||||
|
def content_disposition(filename, fallback="download"):
|
||||||
|
"""构造安全的 Content-Disposition。
|
||||||
|
|
||||||
|
两段都给:
|
||||||
|
* `filename=` —— 纯 ASCII 收敛名,任何客户端都认,且不可能含引号/换行
|
||||||
|
* `filename*=` —— RFC 5987 的 UTF-8 原名,让中文名在浏览器里仍然好看
|
||||||
|
只用后者会有老客户端兼容问题,只用前者会把中文名变成一串下划线。
|
||||||
|
"""
|
||||||
|
raw = str(filename or "").replace("\\", "/").split("/")[-1].strip() or fallback
|
||||||
|
return 'attachment; filename="%s"; filename*=UTF-8\'\'%s' % (
|
||||||
|
safe_filename(raw, fallback=fallback), urllib.parse.quote(raw, safe=""))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 口令 / 用户名策略 ----------------
|
||||||
|
_USERNAME_RE = re.compile(config.USERNAME_RE)
|
||||||
|
|
||||||
|
|
||||||
def hash_password(p):
|
def hash_password(p):
|
||||||
|
from werkzeug.security import generate_password_hash
|
||||||
return generate_password_hash(p, method="pbkdf2:sha256:200000")
|
return generate_password_hash(p, method="pbkdf2:sha256:200000")
|
||||||
|
|
||||||
|
|
||||||
def verify_password(hashed, p):
|
def verify_password(hashed, p):
|
||||||
|
from werkzeug.security import check_password_hash
|
||||||
try:
|
try:
|
||||||
return check_password_hash(hashed, p)
|
return check_password_hash(hashed, p)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def login_ok(conn, username, password):
|
def username_problem(name):
|
||||||
row = conn.execute("SELECT * FROM users WHERE username=?", (username,)).fetchone()
|
"""校验用户名。开放注册后这是第一个入口,必须收紧。"""
|
||||||
if row is None or not verify_password(row["password_hash"], password):
|
name = (name or "").strip()
|
||||||
|
if not name:
|
||||||
|
return "用户名必填"
|
||||||
|
if not _USERNAME_RE.match(name):
|
||||||
|
return "用户名需 3~32 位,以字母或数字开头,只能用字母、数字、下划线、点、连字符"
|
||||||
|
if name.lower() in ("admin", "administrator", "root", "system", "guest", "null"):
|
||||||
|
return "该用户名为系统保留字,请换一个"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def password_problem(new, new2=None, username=None):
|
||||||
|
"""口令强度:8 位以上,且至少包含两类字符。
|
||||||
|
|
||||||
|
比原来的「只要 6 位」严格——因为现在任何人都能自助注册,
|
||||||
|
弱口令直接决定了整个实例的抗爆破能力。
|
||||||
|
"""
|
||||||
|
new = new or ""
|
||||||
|
if len(new) < config.PASSWORD_MIN:
|
||||||
|
return "密码至少 %d 位" % config.PASSWORD_MIN
|
||||||
|
if len(new) > config.PASSWORD_MAX:
|
||||||
|
return "密码过长(上限 %d 位)" % config.PASSWORD_MAX
|
||||||
|
classes = sum(bool(re.search(p, new)) for p in
|
||||||
|
(r"[a-z]", r"[A-Z]", r"[0-9]", r"[^A-Za-z0-9]"))
|
||||||
|
if classes < 2:
|
||||||
|
return "密码需包含大写字母、小写字母、数字、符号中的至少两类"
|
||||||
|
# 黑名单只挡「字典头几页」,命中即拒。只在设置/修改口令时校验,
|
||||||
|
# 登录路径不校验 —— 所以不会把用老口令的人挡在门外。
|
||||||
|
if new.lower() in config.WEAK_PASSWORDS:
|
||||||
|
return "这个密码在常见弱口令字典里,请换一个"
|
||||||
|
if new2 is not None and new2 != new:
|
||||||
|
return "两次输入的新密码不一致"
|
||||||
|
if username and new.lower() == str(username).lower():
|
||||||
|
return "密码不能与用户名相同"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# 兼容旧名(原来的 api.py 内部函数)
|
||||||
|
_check_password = password_problem
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 登录 ----------------
|
||||||
|
# 口令校验的**哑哈希**:用户名不存在时也走一次同代价的 PBKDF2。
|
||||||
|
# 不补这一步的话,「账号不存在」会比「口令错误」快一到两个数量级
|
||||||
|
# (前者根本不做哈希计算),攻击者拿一个秒表就能枚举出哪些用户名真实存在。
|
||||||
|
# 对外开放时用户名枚举通常是撞库的第一步,所以两条路径的耗时必须对齐。
|
||||||
|
_DUMMY_HASH = None
|
||||||
|
|
||||||
|
|
||||||
|
def _dummy_verify(password):
|
||||||
|
global _DUMMY_HASH
|
||||||
|
if _DUMMY_HASH is None:
|
||||||
|
_DUMMY_HASH = hash_password(secrets.token_urlsafe(16))
|
||||||
|
verify_password(_DUMMY_HASH, password)
|
||||||
|
|
||||||
|
|
||||||
|
def login_ok(conn, username, password):
|
||||||
|
"""校验口令。返回 (user_row, error_message)。
|
||||||
|
|
||||||
|
停用账号与口令错误返回**同一句话**,避免探测哪些用户名存在
|
||||||
|
(不过自助注册本身就暴露了用户名唯一性,这里只是不打额外的广告)。
|
||||||
|
"""
|
||||||
|
username = (username or "").strip()
|
||||||
|
row = conn.execute("SELECT * FROM users WHERE username=?", (username,)).fetchone()
|
||||||
|
if row is None:
|
||||||
|
_dummy_verify(password)
|
||||||
|
return None, "用户名或密码不正确"
|
||||||
|
if not verify_password(row["password_hash"], password):
|
||||||
|
return None, "用户名或密码不正确"
|
||||||
|
if (row["status"] or "active") != "active":
|
||||||
|
return None, "该账号已被停用,请联系管理员"
|
||||||
conn.execute("UPDATE users SET last_login_at=?, login_count=login_count+1 WHERE id=?",
|
conn.execute("UPDATE users SET last_login_at=?, login_count=login_count+1 WHERE id=?",
|
||||||
(db.now_str(), row["id"]))
|
(db.now_str(), row["id"]))
|
||||||
return row
|
return row, None
|
||||||
|
|
||||||
|
|
||||||
# ---------------- 跳转目标白名单(防开放重定向) ----------------
|
|
||||||
def safe_next(target, fallback="/"):
|
|
||||||
"""只允许站内相对路径。
|
|
||||||
|
|
||||||
`//evil.com`、`/\\evil.com`、`https://evil.com` 都必须拒绝:
|
|
||||||
`//` 开头是协议相对 URL,浏览器会把 `//evil.com` 当成外站跳转。
|
|
||||||
"""
|
|
||||||
if not target:
|
|
||||||
return fallback
|
|
||||||
t = str(target).strip()
|
|
||||||
if not t.startswith("/"):
|
|
||||||
return fallback
|
|
||||||
if t.startswith("//") or t.startswith("/\\") or "\\" in t:
|
|
||||||
return fallback
|
|
||||||
# 去重斜杠后仍以 // 开头的(如 "/\t/evil")一并拒绝
|
|
||||||
if t.lstrip("/").startswith("//"):
|
|
||||||
return fallback
|
|
||||||
if "\r" in t or "\n" in t:
|
|
||||||
return fallback
|
|
||||||
return t
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------- 登录失败限速 ----------------
|
|
||||||
def note_fail(ip):
|
|
||||||
now = time.time()
|
|
||||||
_prune_fails(now)
|
|
||||||
c = _fails.get(ip)
|
|
||||||
if c is None or now - c[1] > config.LOGIN_LOCK_MINUTES * 60:
|
|
||||||
_fails[ip] = [1, now]
|
|
||||||
return 1
|
|
||||||
c[0] += 1
|
|
||||||
return c[0]
|
|
||||||
|
|
||||||
|
|
||||||
def is_locked(ip):
|
|
||||||
c = _fails.get(ip)
|
|
||||||
if not c or c[0] < config.MAX_LOGIN_FAILS:
|
|
||||||
return False
|
|
||||||
return time.time() - c[1] <= config.LOGIN_LOCK_MINUTES * 60
|
|
||||||
|
|
||||||
|
|
||||||
def clear_fail(ip):
|
|
||||||
_fails.pop(ip, None)
|
|
||||||
|
|
||||||
|
|
||||||
def lock_left(ip):
|
|
||||||
c = _fails.get(ip)
|
|
||||||
if not c:
|
|
||||||
return 0
|
|
||||||
return max(0, int(config.LOGIN_LOCK_MINUTES * 60 - (time.time() - c[1])))
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------- 会话 ----------------
|
# ---------------- 会话 ----------------
|
||||||
def current_user():
|
def current_user():
|
||||||
|
"""当前登录用户(dict)或 None。
|
||||||
|
|
||||||
|
每个请求回查一次 users 表,两道校验:
|
||||||
|
* `status` —— 账号被停用/删除后**立刻**失效,而不是等 12 小时会话过期
|
||||||
|
* `session_ver` —— 改密码 / 管理员重置 / 停用后,签发时的那一版会话
|
||||||
|
立即作废。少了它,「怀疑会话泄漏了所以改密码」就是个假的安心动作:
|
||||||
|
旧会话照样有效到 12 小时之后。
|
||||||
|
结果缓存在 flask.g 里,一次请求只查一次。
|
||||||
|
"""
|
||||||
|
if "wb_user" in g:
|
||||||
|
return g.wb_user
|
||||||
uid = session.get("uid")
|
uid = session.get("uid")
|
||||||
if not uid:
|
user = None
|
||||||
return None
|
if uid:
|
||||||
return {"id": uid, "username": session.get("uname"), "display_name": session.get("dname"),
|
try:
|
||||||
"is_admin": bool(session.get("adm", 1))}
|
row = db.get_db().execute(
|
||||||
|
"SELECT id,username,display_name,is_admin,status,session_ver"
|
||||||
|
" FROM users WHERE id=?", (uid,)).fetchone()
|
||||||
|
except sqlite3.OperationalError as e:
|
||||||
|
# users 表结构与代码不一致(典型场景:升级到新版本后没跑 init_db
|
||||||
|
# 就先把 Web 起起来了,老库还没有 session_ver 这一列)。
|
||||||
|
# 这里必须**大声**报错。若和下面「无请求上下文」一起被静默吞掉,
|
||||||
|
# 症状会变成「全站所有人被踢下线、日志里什么都没有」——
|
||||||
|
# 界面上只看到「登录成功又立刻跳回登录页」,极难归因。
|
||||||
|
log.error("会话校验失败:users 表结构与代码不一致(%s);"
|
||||||
|
"请执行 `python manage.py init` 完成迁移", e)
|
||||||
|
row = None
|
||||||
|
except Exception: # noqa: BLE001 (无请求上下文等)
|
||||||
|
row = None
|
||||||
|
if row is None or (row["status"] or "active") != "active":
|
||||||
|
session.clear()
|
||||||
|
elif session.get("sv", 0) != db.session_ver_of(row):
|
||||||
|
# 老会话没有 sv 字段时按 0 处理,这样升级本身不会把所有人踢下线
|
||||||
|
session.clear()
|
||||||
|
else:
|
||||||
|
user = {"id": row["id"], "username": row["username"],
|
||||||
|
"display_name": row["display_name"] or row["username"],
|
||||||
|
"is_admin": bool(row["is_admin"])}
|
||||||
|
# 个人信息(显示名)改过之后立即生效,不必重新登录
|
||||||
|
session["dname"] = user["display_name"]
|
||||||
|
session["adm"] = 1 if user["is_admin"] else 0
|
||||||
|
g.wb_user = user
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
def is_admin():
|
def is_admin():
|
||||||
@@ -118,14 +462,21 @@ def is_admin():
|
|||||||
|
|
||||||
|
|
||||||
def login_session(user):
|
def login_session(user):
|
||||||
|
"""建立登录会话。
|
||||||
|
|
||||||
|
`session.clear()` 是必须的:既清掉前一次的残留,
|
||||||
|
也顺带换掉 CSRF token 与验证码 id —— 这正是防「会话固定」的做法。
|
||||||
|
|
||||||
|
`sv` 记下签发时的 users.session_ver:之后一旦账号改密 / 被重置 / 被停用,
|
||||||
|
这一版会话会在下一个请求就被判为过期。
|
||||||
|
"""
|
||||||
session.clear()
|
session.clear()
|
||||||
session["uid"] = user["id"]
|
session["uid"] = user["id"]
|
||||||
session["uname"] = user["username"]
|
session["uname"] = user["username"]
|
||||||
session["dname"] = user["display_name"] or user["username"]
|
session["dname"] = user["display_name"] or user["username"]
|
||||||
try:
|
|
||||||
session["adm"] = 1 if user["is_admin"] else 0
|
session["adm"] = 1 if user["is_admin"] else 0
|
||||||
except (KeyError, IndexError, TypeError):
|
session["sv"] = db.session_ver_of(user)
|
||||||
session["adm"] = 1
|
session["login_at"] = db.now_str()
|
||||||
session.permanent = True
|
session.permanent = True
|
||||||
|
|
||||||
|
|
||||||
@@ -164,6 +515,100 @@ def admin_required(fn):
|
|||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 跳转目标白名单(防开放重定向) ----------------
|
||||||
|
def safe_next(target, fallback="/"):
|
||||||
|
"""只允许站内相对路径。
|
||||||
|
|
||||||
|
`//evil.com`、`/\\evil.com`、`https://evil.com` 都必须拒绝:
|
||||||
|
`//` 开头是协议相对 URL,浏览器会把 `//evil.com` 当成外站跳转。
|
||||||
|
"""
|
||||||
|
if not target:
|
||||||
|
return fallback
|
||||||
|
t = str(target).strip()
|
||||||
|
if not t.startswith("/"):
|
||||||
|
return fallback
|
||||||
|
if t.startswith("//") or t.startswith("/\\") or "\\" in t:
|
||||||
|
return fallback
|
||||||
|
if t.lstrip("/").startswith("//"):
|
||||||
|
return fallback
|
||||||
|
if "\r" in t or "\n" in t:
|
||||||
|
return fallback
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 图形验证码 ----------------
|
||||||
|
def captcha_required(conn, ip, username=""):
|
||||||
|
"""按 captcha_policy 决定本次是否需要验证码。"""
|
||||||
|
policy = (db.get_setting(conn, "captcha_policy", "always") or "always").strip().lower()
|
||||||
|
if policy == "off":
|
||||||
|
return False
|
||||||
|
if policy == "adaptive":
|
||||||
|
# 「自适应」= 这个来源出过问题才要求,日常登录不打扰
|
||||||
|
return (fail_count(_ip_key(ip)) >= 2
|
||||||
|
or (username and fail_count(_user_key(username)) >= 2))
|
||||||
|
return True # always(默认)
|
||||||
|
|
||||||
|
|
||||||
|
def issue_captcha(conn, purpose):
|
||||||
|
"""新建验证码并把 id 记进会话,返回 PNG 字节。答案绝不离开服务端。"""
|
||||||
|
try:
|
||||||
|
length = int(db.get_setting(conn, "captcha_length", 4) or 4)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
length = 4
|
||||||
|
length = max(4, min(6, length))
|
||||||
|
cid, code = captcha.create(conn, purpose, length=length)
|
||||||
|
session[CAPTCHA_SESSION_PREFIX + purpose] = cid
|
||||||
|
return captcha.render(code, width=150 if length <= 4 else 150 + (length - 4) * 32)
|
||||||
|
|
||||||
|
|
||||||
|
def consume_captcha(conn, purpose, answer):
|
||||||
|
"""校验并作废本次验证码。会话里的 id 一并丢掉,逼迫下次换一张新图。"""
|
||||||
|
cid = session.pop(CAPTCHA_SESSION_PREFIX + purpose, None)
|
||||||
|
return captcha.verify(conn, cid, (answer or "").strip().upper(), purpose)
|
||||||
|
|
||||||
|
|
||||||
|
# 验证码出图限速:出图本身要做点阵渲染 + zlib,不设限就是一条廉价的
|
||||||
|
# CPU/带宽放大路径(有人拿它当免费的图片生成器刷)。
|
||||||
|
_cap_fetch = {} # ip -> [count, window_started_at]
|
||||||
|
_CAP_FETCH_MAX = 40 # 每窗口最多出图张数
|
||||||
|
_CAP_FETCH_WINDOW = 60 # 窗口长度(秒)
|
||||||
|
|
||||||
|
|
||||||
|
def captcha_fetch_allowed(ip):
|
||||||
|
now = time.time()
|
||||||
|
cur = _cap_fetch.get(ip)
|
||||||
|
if cur is None or now - cur[1] > _CAP_FETCH_WINDOW:
|
||||||
|
if len(_cap_fetch) > _FAILS_MAX_KEYS:
|
||||||
|
_cap_fetch.clear()
|
||||||
|
_cap_fetch[ip] = [1, now]
|
||||||
|
return True
|
||||||
|
cur[0] += 1
|
||||||
|
return cur[0] <= _CAP_FETCH_MAX
|
||||||
|
|
||||||
|
|
||||||
|
def audit_login_fail(conn, username, detail, ip):
|
||||||
|
"""登录失败审计。
|
||||||
|
|
||||||
|
`user_id` 留 0:此时还不能确定是谁(可能是有人在撞别人的账号),
|
||||||
|
但 `actor` 记下被尝试的用户名,便于事后按人名检索。
|
||||||
|
"""
|
||||||
|
db.audit(conn, "login_failed", username or "-", detail, ip, 0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 注册开关与配额 ----------------
|
||||||
|
def register_allowed(conn):
|
||||||
|
return db.get_bool(conn, "allow_register", True)
|
||||||
|
|
||||||
|
|
||||||
|
def register_quota(conn, ip):
|
||||||
|
"""同一 IP 当天的注册配额。返回 (是否允许, 已注册数, 上限)。"""
|
||||||
|
limit = db.get_int(conn, "register_max_per_ip", 3)
|
||||||
|
today = db.now_str()[:10]
|
||||||
|
n = conn.execute("SELECT COUNT(*) FROM users WHERE register_ip=?"
|
||||||
|
" AND substr(COALESCE(created_at,''),1,10)=?", (ip, today)).fetchone()[0]
|
||||||
|
return n < limit, n, limit
|
||||||
|
|
||||||
|
|
||||||
# ---------------- CSRF ----------------
|
# ---------------- CSRF ----------------
|
||||||
def csrf_token():
|
def csrf_token():
|
||||||
t = session.get("_csrf")
|
t = session.get("_csrf")
|
||||||
@@ -179,15 +624,97 @@ def check_csrf():
|
|||||||
sent = request.form.get("_csrf") or request.headers.get("X-CSRF-Token") or ""
|
sent = request.form.get("_csrf") or request.headers.get("X-CSRF-Token") or ""
|
||||||
if not sent or not hmac.compare_digest(sent, session.get("_csrf", "")):
|
if not sent or not hmac.compare_digest(sent, session.get("_csrf", "")):
|
||||||
if wants_json():
|
if wants_json():
|
||||||
return jsonify({"ok": False, "error": "csrf", "message": "CSRF 校验失败,请刷新页面"}), 400
|
return jsonify({"ok": False, "error": "csrf",
|
||||||
|
"message": "CSRF 校验失败,请刷新页面"}), 400
|
||||||
return "CSRF 校验失败,请刷新页面后重试", 400
|
return "CSRF 校验失败,请刷新页面后重试", 400
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 安全响应头 ----------------
|
||||||
|
# 这些头是「纵深防御」:本项目的输出都过了 Jinja 自动转义 + app.js 手动转义,
|
||||||
|
# 但多一层 nosniff / frame-ancestors 能让「某处漏转义」不至于直接变成可利用的 XSS。
|
||||||
|
CSP = ("default-src 'self'; "
|
||||||
|
"img-src 'self' data:; "
|
||||||
|
"style-src 'self' 'unsafe-inline'; "
|
||||||
|
"script-src 'self' 'unsafe-inline'; "
|
||||||
|
"connect-src 'self'; "
|
||||||
|
"font-src 'self' data:; "
|
||||||
|
"object-src 'none'; "
|
||||||
|
"base-uri 'self'; "
|
||||||
|
"form-action 'self'; "
|
||||||
|
"frame-ancestors 'none'")
|
||||||
|
|
||||||
|
|
||||||
|
def apply_security_headers(resp):
|
||||||
|
resp.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||||
|
resp.headers.setdefault("X-Frame-Options", "DENY")
|
||||||
|
# 不让站内 URL(可能含 next=、run= 等参数)随外链 referer 泄漏出去
|
||||||
|
resp.headers.setdefault("Referrer-Policy", "same-origin")
|
||||||
|
resp.headers.setdefault("Content-Security-Policy", CSP)
|
||||||
|
resp.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin")
|
||||||
|
# 本项目不需要任何浏览器外设能力,显式关掉:缩小「被内嵌页面 / 被第三方
|
||||||
|
# 脚本滥用」时的可用面,同时也是对外部署时安全扫描的基本要求。
|
||||||
|
resp.headers.setdefault(
|
||||||
|
"Permissions-Policy",
|
||||||
|
"geolocation=(), microphone=(), camera=(), payment=(), usb=()")
|
||||||
|
# HSTS 只在「确认这个部署跑在 HTTPS 上」时才发:在纯 HTTP 部署上发它,
|
||||||
|
# 浏览器会把该域名的 http 访问强行升级,表现成「打开就白屏」。
|
||||||
|
# 判据是管理员显式打开了 COOKIE_SECURE 或 FORCE_HTTPS。
|
||||||
|
if config.COOKIE_SECURE or config.FORCE_HTTPS:
|
||||||
|
resp.headers.setdefault("Strict-Transport-Security",
|
||||||
|
"max-age=31536000; includeSubDomains")
|
||||||
|
if request.path.startswith("/api/") or request.path.startswith("/captcha"):
|
||||||
|
resp.headers.setdefault("Cache-Control", "no-store")
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def needs_https_redirect():
|
||||||
|
"""当前请求是否该被跳到 https(仅在显式开启 WB_FORCE_HTTPS 时才判断)。"""
|
||||||
|
if not config.FORCE_HTTPS or request.is_secure:
|
||||||
|
return False
|
||||||
|
# 反代终止 TLS 时,Flask 看到的是 http;靠 X-Forwarded-Proto 还原真实协议。
|
||||||
|
# 这个头只在「你已经决定信任代理」的前提下才有意义,所以与 TRUST_PROXY 绑定。
|
||||||
|
if config.TRUST_PROXY and (request.headers.get("X-Forwarded-Proto") or "").lower() == "https":
|
||||||
|
return False
|
||||||
|
if request.method not in ("GET", "HEAD"):
|
||||||
|
return False # 不重定向 POST:会丢请求体,行为难以预期
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _access_log(resp, started):
|
||||||
|
if not config.ACCESS_LOG:
|
||||||
|
return resp
|
||||||
|
path = request.path
|
||||||
|
if path.startswith("/static/") or path == "/captcha.png":
|
||||||
|
return resp # 静态资源与验证码出图会把日志刷满
|
||||||
|
log.info("%s %s -> %s %dms ip=%s", request.method, path, resp.status_code,
|
||||||
|
int((time.time() - started) * 1000), client_ip())
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
def init_app(app):
|
def init_app(app):
|
||||||
app.jinja_env.globals["csrf_token"] = csrf_token
|
app.jinja_env.globals["csrf_token"] = csrf_token
|
||||||
app.jinja_env.globals["current_user"] = current_user
|
app.jinja_env.globals["current_user"] = current_user
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
def _guard():
|
def _guard():
|
||||||
|
g.wb_t0 = time.time()
|
||||||
|
# 读接口限速放在最前面:被限流的请求不该再走后面那些更贵的事情
|
||||||
|
# (HTTPS 判断、CSRF、会话校验)。
|
||||||
|
# 键用 session 里的 uid(不做回查),未登录时退化成来源 IP ——
|
||||||
|
# 这里只是限速,不承担鉴权职责,所以不需要一个「可信」的身份。
|
||||||
|
if request.path.startswith("/api/"):
|
||||||
|
who = session.get("uid") or ("ip:" + client_ip())
|
||||||
|
ok, wait = api_rate_ok("api:%s" % who)
|
||||||
|
if not ok:
|
||||||
|
return jsonify({"ok": False, "error": "rate_limited",
|
||||||
|
"message": "请求过于频繁,请 %d 秒后再试" % wait}), 429
|
||||||
|
if needs_https_redirect():
|
||||||
|
url = request.url.replace("http://", "https://", 1)
|
||||||
|
return redirect(url, code=301)
|
||||||
return check_csrf()
|
return check_csrf()
|
||||||
|
|
||||||
|
@app.after_request
|
||||||
|
def _headers(resp):
|
||||||
|
_access_log(resp, getattr(g, "wb_t0", time.time()))
|
||||||
|
return apply_security_headers(resp)
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
# Copyright (c) 2026 Wang Chuanli
|
||||||
|
|
||||||
"""Web 层包:蓝图注册。"""
|
"""Web 层包:蓝图注册。"""
|
||||||
from . import api, views
|
from . import api, views
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
# Copyright (c) 2026 Wang Chuanli
|
||||||
|
|
||||||
"""JSON API —— ECharts 大屏与后台页面的数据入口。
|
"""JSON API —— ECharts 大屏与后台页面的数据入口。
|
||||||
|
|
||||||
约定:
|
约定:
|
||||||
@@ -6,16 +9,57 @@
|
|||||||
* 参数 from/to 为 'YYYY-MM-DD';缺省则不限(即全量)
|
* 参数 from/to 为 'YYYY-MM-DD';缺省则不限(即全量)
|
||||||
* 列表类接口默认不返回 prompt 全文(占传输量约 80%),只有 /api/top 与
|
* 列表类接口默认不返回 prompt 全文(占传输量约 80%),只有 /api/top 与
|
||||||
/api/records/<request_id> 会带
|
/api/records/<request_id> 会带
|
||||||
|
|
||||||
|
**多用户约定**
|
||||||
|
每个接口都只操作 `current_user()["id"]` 那份数据。查询函数要求显式传 uid,
|
||||||
|
所以这里漏传会直接 TypeError(而不是静默返回全量)。
|
||||||
|
|
||||||
|
写权限只有两条规则(`config.writable_by`,前后端与测试共用同一判断):
|
||||||
|
|
||||||
|
* 普通用户**只能**写 `config.USER_EDITABLE_KEYS`(本人的 cookie / user_agent);
|
||||||
|
* 其余键(接口地址、注册策略、采集参数、调度时刻)只有管理员能写,
|
||||||
|
任何越权写入都会被 `/api/settings` 拒绝并在审计里记一笔 `settings_rejected`。
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request
|
from flask import Blueprint, jsonify, request, send_file, session
|
||||||
|
|
||||||
from .. import collect, config, db, query, scheduler
|
from .. import backup, collect, config, db, query, scheduler, security
|
||||||
from ..security import admin_required, current_user, login_required
|
from ..security import admin_required, current_user, is_admin, login_required
|
||||||
|
|
||||||
bp = Blueprint("api", __name__, url_prefix="/api")
|
bp = Blueprint("api", __name__, url_prefix="/api")
|
||||||
|
log = logging.getLogger("wb.api")
|
||||||
|
|
||||||
|
|
||||||
|
def _uid():
|
||||||
|
u = current_user()
|
||||||
|
return u["id"] if u else 0
|
||||||
|
|
||||||
|
|
||||||
|
def _internal(e, where):
|
||||||
|
"""内部异常的**唯一出口**:细节只进日志,响应只给一个可对账的事件号。
|
||||||
|
|
||||||
|
以前这里是 `jsonify({"message": str(e)})` —— 把原始异常文本原样回给客户端。
|
||||||
|
那等于把服务端内部结构送给调用方:绝对路径(`/app/workbuddy_portal/...`)、
|
||||||
|
SQL 语句、`sqlite3` 的错误、备份目录位置都可能出现在里面。
|
||||||
|
对一个对外开放的站点来说,这些正好是踩点阶段最想要的信息。
|
||||||
|
现在改成:完整堆栈写进服务端日志(带事件号),客户端只拿到事件号 ——
|
||||||
|
既不影响「出事了去查日志」的运维动作,也不泄露任何内部细节。
|
||||||
|
"""
|
||||||
|
eid = secrets.token_hex(4)
|
||||||
|
log.exception("[%s] %s 处理失败", eid, where)
|
||||||
|
return jsonify({"ok": False, "error": "internal",
|
||||||
|
"message": "服务器内部错误,请稍后重试"
|
||||||
|
"(事件号 %s,管理员可在「日志管理」中检索)" % eid}), 500
|
||||||
|
|
||||||
|
|
||||||
|
def _ip():
|
||||||
|
"""客户端地址。一律走 security.client_ip() —— 见那里的注释:
|
||||||
|
直接取 X-Forwarded-For 第 0 段会让「来源」变成请求方自己填的字符串。"""
|
||||||
|
return security.client_ip()
|
||||||
|
|
||||||
|
|
||||||
def _arg(name, default=None):
|
def _arg(name, default=None):
|
||||||
@@ -23,6 +67,11 @@ def _arg(name, default=None):
|
|||||||
return v if v not in (None, "") else default
|
return v if v not in (None, "") else default
|
||||||
|
|
||||||
|
|
||||||
|
def _json_body():
|
||||||
|
body = request.get_json(silent=True)
|
||||||
|
return body if isinstance(body, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
class BadParam(ValueError):
|
class BadParam(ValueError):
|
||||||
"""查询参数不合法 -> 由 __init__ 的 ValueError 处理器统一转成 400。"""
|
"""查询参数不合法 -> 由 __init__ 的 ValueError 处理器统一转成 400。"""
|
||||||
|
|
||||||
@@ -57,7 +106,15 @@ def _int(name, default, lo=1, hi=2000):
|
|||||||
@bp.get("/manifest")
|
@bp.get("/manifest")
|
||||||
@login_required
|
@login_required
|
||||||
def api_manifest():
|
def api_manifest():
|
||||||
return jsonify(query.manifest(db.get_db()))
|
u = current_user()
|
||||||
|
# 数据源清单(表名 / 库文件)属于内部实现细节,只给管理员;
|
||||||
|
# 普通账号看自己的数据,没有理由知道正本落在哪个文件、叫什么表。
|
||||||
|
m = query.manifest(db.get_db(), u["id"], sources=u["is_admin"])
|
||||||
|
# 角色标记只在这里补:大屏是**静态页**,拿不到 Jinja 上下文,
|
||||||
|
# 只能靠数据接口知道自己该不该渲染「日志管理」这类管理员入口。
|
||||||
|
# 前端隐藏只是不给死链,真正的闸门始终是服务端的 @admin_required。
|
||||||
|
m["role"] = "admin" if u["is_admin"] else "user"
|
||||||
|
return jsonify(m)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/bundle")
|
@bp.get("/bundle")
|
||||||
@@ -65,31 +122,34 @@ def api_manifest():
|
|||||||
def api_bundle():
|
def api_bundle():
|
||||||
"""大屏页一次拿齐:全量 daily + 窗口 dims/top/records。"""
|
"""大屏页一次拿齐:全量 daily + 窗口 dims/top/records。"""
|
||||||
frm, to = _win()
|
frm, to = _win()
|
||||||
return jsonify(query.bundle(db.get_db(), frm, to, top_n=_int("topN", query.DEFAULT_TOP_N, 1, 1000)))
|
return jsonify(query.bundle(db.get_db(), _uid(), frm, to,
|
||||||
|
top_n=_int("topN", query.DEFAULT_TOP_N, 1, 1000),
|
||||||
|
sources=is_admin()))
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/summary")
|
@bp.get("/summary")
|
||||||
@login_required
|
@login_required
|
||||||
def api_summary():
|
def api_summary():
|
||||||
conn = db.get_db()
|
conn = db.get_db()
|
||||||
|
uid = _uid()
|
||||||
frm, to = _win()
|
frm, to = _win()
|
||||||
if not frm or not to:
|
if not frm or not to:
|
||||||
t = query.totals(conn)
|
t = query.totals(conn, uid)
|
||||||
frm, to = t["firstDay"], t["lastDay"]
|
frm, to = t["firstDay"], t["lastDay"]
|
||||||
return jsonify(query.summary(conn, frm, to))
|
return jsonify(query.summary(conn, uid, frm, to))
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/daily")
|
@bp.get("/daily")
|
||||||
@login_required
|
@login_required
|
||||||
def api_daily():
|
def api_daily():
|
||||||
return jsonify({"days": query.daily(db.get_db(), *_win())})
|
return jsonify({"days": query.daily(db.get_db(), _uid(), *_win())})
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/dims")
|
@bp.get("/dims")
|
||||||
@login_required
|
@login_required
|
||||||
def api_dims():
|
def api_dims():
|
||||||
conn = db.get_db()
|
conn = db.get_db()
|
||||||
d = query.dims(conn, *_win())
|
d = query.dims(conn, _uid(), *_win())
|
||||||
dim = _arg("dim")
|
dim = _arg("dim")
|
||||||
if dim in d:
|
if dim in d:
|
||||||
return jsonify({dim: d[dim]})
|
return jsonify({dim: d[dim]})
|
||||||
@@ -100,17 +160,18 @@ def api_dims():
|
|||||||
@login_required
|
@login_required
|
||||||
def api_top():
|
def api_top():
|
||||||
conn = db.get_db()
|
conn = db.get_db()
|
||||||
return jsonify(query.top(conn, *_win(), n=_int("n", 50, 1, 1000)))
|
return jsonify(query.top(conn, _uid(), *_win(), n=_int("n", 50, 1, 1000)))
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/records")
|
@bp.get("/records")
|
||||||
@login_required
|
@login_required
|
||||||
def api_records():
|
def api_records():
|
||||||
conn = db.get_db()
|
conn = db.get_db()
|
||||||
|
uid = _uid()
|
||||||
frm, to = _win()
|
frm, to = _win()
|
||||||
page = _int("page", 1, 1, 100000)
|
page = _int("page", 1, 1, 100000)
|
||||||
size = _int("size", 50, 1, 500)
|
size = _int("size", 50, 1, 500)
|
||||||
r = query.records_page(conn, frm, to, model=_arg("model"), client=_arg("client"),
|
r = query.records_page(conn, uid, frm, to, model=_arg("model"), client=_arg("client"),
|
||||||
q=_arg("q"), page=page, size=size, order=_arg("order", "ts_desc"),
|
q=_arg("q"), page=page, size=size, order=_arg("order", "ts_desc"),
|
||||||
with_prompt=False if _arg("lean") == "1" else True)
|
with_prompt=False if _arg("lean") == "1" else True)
|
||||||
return jsonify(r)
|
return jsonify(r)
|
||||||
@@ -119,8 +180,9 @@ def api_records():
|
|||||||
@bp.get("/records/<request_id>")
|
@bp.get("/records/<request_id>")
|
||||||
@login_required
|
@login_required
|
||||||
def api_record(request_id):
|
def api_record(request_id):
|
||||||
row = db.get_db().execute(
|
# user_id 必须进 WHERE:否则改一个 URL 就能读到别人的 Prompt 全文
|
||||||
"SELECT * FROM usage_records WHERE request_id=?", (request_id,)).fetchone()
|
row = db.get_db().execute("SELECT * FROM usage_records WHERE user_id=? AND request_id=?",
|
||||||
|
(_uid(), request_id)).fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
return jsonify({"ok": False, "message": "记录不存在"}), 404
|
return jsonify({"ok": False, "message": "记录不存在"}), 404
|
||||||
return jsonify(dict(row))
|
return jsonify(dict(row))
|
||||||
@@ -132,14 +194,16 @@ def api_runs():
|
|||||||
conn = db.get_db()
|
conn = db.get_db()
|
||||||
rows = conn.execute("SELECT id,trigger,status,started_at,finished_at,duration_ms,win_from,"
|
rows = conn.execute("SELECT id,trigger,status,started_at,finished_at,duration_ms,win_from,"
|
||||||
"win_to,fetched,added,dup,total,conflicts,exit_code,message"
|
"win_to,fetched,added,dup,total,conflicts,exit_code,message"
|
||||||
" FROM collect_runs ORDER BY id DESC LIMIT ?", (_int("limit", 50, 1, 500),))
|
" FROM collect_runs WHERE user_id=? ORDER BY id DESC LIMIT ?",
|
||||||
|
(_uid(), _int("limit", 50, 1, 500)))
|
||||||
return jsonify({"items": [dict(r) for r in rows]})
|
return jsonify({"items": [dict(r) for r in rows]})
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/runs/<int:run_id>")
|
@bp.get("/runs/<int:run_id>")
|
||||||
@login_required
|
@login_required
|
||||||
def api_run(run_id):
|
def api_run(run_id):
|
||||||
row = db.get_db().execute("SELECT * FROM collect_runs WHERE id=?", (run_id,)).fetchone()
|
row = db.get_db().execute("SELECT * FROM collect_runs WHERE id=? AND user_id=?",
|
||||||
|
(run_id, _uid())).fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
return jsonify({"ok": False, "message": "运行记录不存在"}), 404
|
return jsonify({"ok": False, "message": "运行记录不存在"}), 404
|
||||||
return jsonify(dict(row))
|
return jsonify(dict(row))
|
||||||
@@ -149,34 +213,96 @@ def api_run(run_id):
|
|||||||
@login_required
|
@login_required
|
||||||
def api_status():
|
def api_status():
|
||||||
conn = db.get_db()
|
conn = db.get_db()
|
||||||
|
u = current_user()
|
||||||
|
uid = u["id"]
|
||||||
sch = scheduler.get_scheduler()
|
sch = scheduler.get_scheduler()
|
||||||
nxt = scheduler.next_run_at(conn)
|
nxt = scheduler.next_run_at(conn, uid)
|
||||||
last = conn.execute("SELECT * FROM collect_runs ORDER BY id DESC LIMIT 1").fetchone()
|
last = conn.execute("SELECT * FROM collect_runs WHERE user_id=? ORDER BY id DESC LIMIT 1",
|
||||||
running = conn.execute("SELECT COUNT(*) FROM collect_runs WHERE status='running'").fetchone()[0]
|
(uid,)).fetchone()
|
||||||
|
running = conn.execute("SELECT COUNT(*) FROM collect_runs WHERE user_id=? AND status='running'",
|
||||||
|
(uid,)).fetchone()[0]
|
||||||
|
cred = db.secret_state(conn, "cookie", uid)
|
||||||
|
nxt_bk = backup.next_auto_at(conn) if u["is_admin"] else None
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"server_time": db.now_str(),
|
"server_time": db.now_str(),
|
||||||
|
# 角色能力:大屏等**静态页**拿不到 Jinja 上下文,只能靠这个字段
|
||||||
|
# 决定要不要渲染管理员专属入口(如「日志管理」「备份管理」)。服务端仍会
|
||||||
|
# 对这些入口再做一次鉴权,前端隐藏只是为了不给出误导性的按钮。
|
||||||
|
"is_admin": bool(u["is_admin"]),
|
||||||
|
"can_edit_schedule": bool(u["is_admin"]),
|
||||||
|
"can_view_logs": bool(u["is_admin"]),
|
||||||
|
"can_manage_backups": bool(u["is_admin"]),
|
||||||
|
"limits": {
|
||||||
|
# 对外提供服务时的三道闸门,前端据此提前禁用按钮而不是等 409
|
||||||
|
"collect_min_interval_seconds": collect.min_interval_seconds(conn),
|
||||||
|
"collect_max_range_days": collect.max_range_days(conn),
|
||||||
|
"schedule_slots": len(scheduler.slots(conn, uid)),
|
||||||
|
"schedule_slots_cap":
|
||||||
|
max(1, min(config.SCHEDULE_SLOTS_HARD_MAX,
|
||||||
|
db.get_int(conn, "max_schedule_slots_per_day", 6))),
|
||||||
|
},
|
||||||
|
"running_lock": os.path.exists(collect.LOCK_PATH),
|
||||||
"scheduler": {
|
"scheduler": {
|
||||||
"running": sch.running,
|
"running": sch.running,
|
||||||
"enabled": db.get_bool(conn, "schedule_enabled", True),
|
"enabled": db.get_bool(conn, "schedule_enabled", True, uid),
|
||||||
"times": scheduler.slots(conn),
|
"times": scheduler.slots(conn, uid),
|
||||||
"next_run": nxt.strftime("%Y-%m-%d %H:%M:%S") if nxt else None,
|
"next_run": nxt.strftime("%Y-%m-%d %H:%M:%S") if nxt else None,
|
||||||
"catch_up": db.get_bool(conn, "catch_up", True),
|
"catch_up": db.get_bool(conn, "catch_up", True, uid),
|
||||||
"lockfile": os.path.exists(collect.LOCK_PATH),
|
"lockfile": os.path.exists(collect.LOCK_PATH),
|
||||||
},
|
},
|
||||||
|
"backup": {
|
||||||
|
"enabled": db.get_bool(conn, "backup_enabled", True),
|
||||||
|
"interval_hours": db.get_int(conn, "backup_interval_hours", 24),
|
||||||
|
"keep": db.get_int(conn, "backup_keep", 7),
|
||||||
|
"last": backup.last_auto_at(conn) or None,
|
||||||
|
"next": nxt_bk.strftime("%Y-%m-%d %H:%M:%S") if nxt_bk else None,
|
||||||
|
},
|
||||||
"running_runs": running,
|
"running_runs": running,
|
||||||
"last_run": dict(last) if last else None,
|
"last_run": dict(last) if last else None,
|
||||||
"cookie_set": bool((db.get_setting(conn, "cookie") or "").strip()),
|
# 只回「有没有配」与字符数,绝不回凭证内容
|
||||||
|
"cookie_set": bool(cred["set"] and not cred["broken"]),
|
||||||
|
"cookie_chars": cred["chars"],
|
||||||
|
"cookie_broken": cred["broken"],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/collect")
|
@bp.post("/collect")
|
||||||
@login_required
|
@login_required
|
||||||
def api_collect():
|
def api_collect():
|
||||||
"""手动触发一次采集(后台线程之外同步执行,页面等待结果)。"""
|
"""手动触发一次采集(同步执行,页面等待结果)。
|
||||||
body = request.get_json(silent=True) or {}
|
|
||||||
if not isinstance(body, dict):
|
对外提供服务后,这里必须有三道闸门,缺一不可:
|
||||||
return jsonify({"ok": False, "message": "请求体必须是对象"}), 400
|
|
||||||
|
1. **已有任务在跑就拒绝新任务**。采集是全局单写者(SQLite 同一时刻只允许
|
||||||
|
一个写进程,见 collect._Lock)。原来的实现是「后来者在锁上等」,
|
||||||
|
而 waitress 只有 8 个线程 —— 一个人连点几下就能把线程占满,
|
||||||
|
表现为整个站点变慢甚至无响应。现在明确回 409。
|
||||||
|
2. **同一账号的最小间隔**。挡住「连点按钮 / 脚本循环调」这种形态;
|
||||||
|
间隔由 `collect_min_interval_seconds` 控制(实例级,管理员可调)。
|
||||||
|
3. **跨度上限**。`from=2000-01-01` 会让服务端对云端发出成百上千次请求,
|
||||||
|
这是最省力的资源耗尽方式。上限 `collect_max_range_days`(硬顶 31 天)。
|
||||||
|
"""
|
||||||
|
u = current_user()
|
||||||
|
conn = db.get_db()
|
||||||
|
uid = u["id"]
|
||||||
|
body = _json_body()
|
||||||
|
|
||||||
|
# ---- 闸门 1:不能有别的任务在跑 ----
|
||||||
|
if os.path.exists(collect.LOCK_PATH):
|
||||||
|
return jsonify({"ok": False, "error": "busy",
|
||||||
|
"message": "已有采集任务正在运行,请等它结束后再试"
|
||||||
|
"(进度见「任务管理」)"}), 409
|
||||||
|
|
||||||
|
# ---- 闸门 2:频率 ----
|
||||||
|
gap = collect.min_interval_seconds(conn)
|
||||||
|
allowed, wait = security.action_allowed("collect:%d" % uid, gap)
|
||||||
|
if not allowed:
|
||||||
|
return jsonify({"ok": False, "error": "too_frequent",
|
||||||
|
"message": "同一账号两次采集之间需间隔 %d 秒,请 %d 秒后再试"
|
||||||
|
% (gap, wait)}), 429
|
||||||
|
|
||||||
frm, to = body.get("from"), body.get("to")
|
frm, to = body.get("from"), body.get("to")
|
||||||
|
limit = collect.max_range_days(conn)
|
||||||
try:
|
try:
|
||||||
kw = {}
|
kw = {}
|
||||||
if frm:
|
if frm:
|
||||||
@@ -191,40 +317,64 @@ def api_collect():
|
|||||||
kw["to_dt"] = datetime.strptime(d, "%Y-%m-%d").replace(hour=23, minute=59, second=59)
|
kw["to_dt"] = datetime.strptime(d, "%Y-%m-%d").replace(hour=23, minute=59, second=59)
|
||||||
if kw.get("from_dt") and kw.get("to_dt") and kw["from_dt"] > kw["to_dt"]:
|
if kw.get("from_dt") and kw.get("to_dt") and kw["from_dt"] > kw["to_dt"]:
|
||||||
raise BadParam("起始日期不能晚于结束日期")
|
raise BadParam("起始日期不能晚于结束日期")
|
||||||
r = collect.run_sync(trigger="manual", **kw)
|
# ---- 闸门 3:跨度 ----
|
||||||
|
if kw.get("from_dt"):
|
||||||
|
end = kw.get("to_dt") or datetime.now()
|
||||||
|
days = (end - kw["from_dt"]).days
|
||||||
|
if days > limit:
|
||||||
|
raise BadParam(
|
||||||
|
"采集跨度最长 %d 天,本次请求是 %d 天。"
|
||||||
|
"请缩小日期范围后分批采集(每次最多 %d 天)。"
|
||||||
|
% (limit, days, limit))
|
||||||
|
r = collect.run_sync(trigger="manual", uid=uid, **kw)
|
||||||
except BadParam as e:
|
except BadParam as e:
|
||||||
return jsonify({"ok": False, "error": "bad_request", "message": str(e)}), 400
|
return jsonify({"ok": False, "error": "bad_request", "message": str(e)}), 400
|
||||||
except collect.Busy as e:
|
except collect.Busy as e:
|
||||||
return jsonify({"ok": False, "error": "busy", "message": str(e)}), 409
|
return jsonify({"ok": False, "error": "busy", "message": str(e)}), 409
|
||||||
|
except collect.NotReady as e:
|
||||||
|
return jsonify({"ok": False, "error": "no_cookie", "message": str(e)}), 409
|
||||||
|
except db.SecretUnreadable as e:
|
||||||
|
return jsonify({"ok": False, "error": "cookie_broken",
|
||||||
|
"message": "已保存的 Cookie 无法解密(实例密钥被更换过):%s。"
|
||||||
|
"请到「配置管理」重新粘贴。" % e}), 409
|
||||||
except collect.ApiError as e:
|
except collect.ApiError as e:
|
||||||
code = 401 if e.cookie_expired else 502
|
code = 401 if e.cookie_expired else 502
|
||||||
return jsonify({"ok": False, "error": "cookie_expired" if e.cookie_expired else "api",
|
return jsonify({"ok": False, "error": "cookie_expired" if e.cookie_expired else "api",
|
||||||
"message": str(e)}), code
|
"message": str(e)}), code
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
return jsonify({"ok": False, "error": "internal", "message": str(e)}), 500
|
return _internal(e, "POST /api/collect uid=%s" % uid)
|
||||||
db.audit(db.get_db(), "collect", (current_user() or {}).get("username"), r["message"],
|
db.audit(conn, "collect", u["username"], r["message"], _ip(), uid)
|
||||||
request.remote_addr)
|
|
||||||
return jsonify({"ok": True, "result": r})
|
return jsonify({"ok": True, "result": r})
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/audit")
|
@bp.get("/audit")
|
||||||
@login_required
|
@login_required
|
||||||
def api_audit():
|
def api_audit():
|
||||||
"""操作审计分页(日志管理页用;原来只能看最近 40 条)。"""
|
"""操作审计分页。管理员看全部(便于追责),普通用户只看自己触发的。"""
|
||||||
conn = db.get_db()
|
conn = db.get_db()
|
||||||
|
u = current_user()
|
||||||
action = _arg("action")
|
action = _arg("action")
|
||||||
page = _int("page", 1, 1, 100000)
|
page = _int("page", 1, 1, 100000)
|
||||||
size = _int("size", 50, 1, 500)
|
size = _int("size", 50, 1, 500)
|
||||||
w, p = "", []
|
w, p = [], []
|
||||||
|
if not u["is_admin"]:
|
||||||
|
w.append("user_id = ?")
|
||||||
|
p.append(u["id"])
|
||||||
if action:
|
if action:
|
||||||
w, p = "WHERE action = ?", [action]
|
w.append("action = ?")
|
||||||
total = conn.execute("SELECT COUNT(*) FROM audit_log %s" % w, p).fetchone()[0]
|
p.append(action)
|
||||||
rows = conn.execute("SELECT * FROM audit_log %s ORDER BY id DESC LIMIT ? OFFSET ?" % w,
|
ws = ("WHERE " + " AND ".join(w)) if w else ""
|
||||||
|
total = conn.execute("SELECT COUNT(*) FROM audit_log %s" % ws, p).fetchone()[0]
|
||||||
|
rows = conn.execute("SELECT * FROM audit_log %s ORDER BY id DESC LIMIT ? OFFSET ?" % ws,
|
||||||
p + [size, (page - 1) * size])
|
p + [size, (page - 1) * size])
|
||||||
|
# 动作清单不带 action 条件,否则只剩下当前那一个动作可选
|
||||||
|
base_p = [u["id"]] if not u["is_admin"] else []
|
||||||
|
base = "WHERE user_id = ?" if not u["is_admin"] else ""
|
||||||
actions = [r[0] for r in conn.execute(
|
actions = [r[0] for r in conn.execute(
|
||||||
"SELECT DISTINCT action FROM audit_log ORDER BY action")]
|
"SELECT DISTINCT action FROM audit_log %s ORDER BY action" % base, base_p)]
|
||||||
return jsonify({"total": total, "page": page, "size": size,
|
return jsonify({"total": total, "page": page, "size": size,
|
||||||
"pages": max(1, (total + size - 1) // size),
|
"pages": max(1, (total + size - 1) // size),
|
||||||
|
"scope": "all" if u["is_admin"] else "self",
|
||||||
"actions": actions,
|
"actions": actions,
|
||||||
"items": [dict(r) for r in rows]})
|
"items": [dict(r) for r in rows]})
|
||||||
|
|
||||||
@@ -233,13 +383,34 @@ def api_audit():
|
|||||||
@bp.post("/maintenance/<action>")
|
@bp.post("/maintenance/<action>")
|
||||||
@login_required
|
@login_required
|
||||||
def api_maintenance(action):
|
def api_maintenance(action):
|
||||||
"""把 CLI 里的维护动作搬到页面上:补全 prompt / VACUUM / 导出 CSV。"""
|
"""把 CLI 里的维护动作搬到页面上。
|
||||||
|
|
||||||
|
只有 `vacuum` 是**实例级**动作(整个库一起整理),所以它仅管理员可用;
|
||||||
|
其余三个都只作用于当前账号自己的数据。
|
||||||
|
"""
|
||||||
conn = db.get_db()
|
conn = db.get_db()
|
||||||
user = (current_user() or {}).get("username")
|
u = current_user()
|
||||||
|
uid = u["id"]
|
||||||
|
if action == "vacuum" and not u["is_admin"]:
|
||||||
|
return jsonify({"ok": False, "error": "forbidden",
|
||||||
|
"message": "数据库整理是整库操作,仅管理员可执行"}), 403
|
||||||
|
# 这四类动作都是「整库扫一遍」级别:导出会全表流式扫、补全会连续打云端、
|
||||||
|
# vacuum 会锁库。各自加一个按账号的最小间隔,挡住脚本循环调用。
|
||||||
|
hvy = {"fill-prompt": 60, "export-csv": 15, "vacuum": 120, "recount": 5}
|
||||||
|
if action in hvy:
|
||||||
|
ok, wait = security.action_allowed("maint:%s:%d" % (action, uid), hvy[action])
|
||||||
|
if not ok:
|
||||||
|
return jsonify({"ok": False, "error": "too_frequent",
|
||||||
|
"message": "该动作刚执行过,请 %d 秒后再试" % wait}), 429
|
||||||
try:
|
try:
|
||||||
if action == "fill-prompt":
|
if action == "fill-prompt":
|
||||||
try:
|
try:
|
||||||
n = collect.fill_prompt(conn, log=lambda m: None)
|
n = collect.fill_prompt(conn, uid, log=lambda m: None)
|
||||||
|
except collect.NotReady as e:
|
||||||
|
return jsonify({"ok": False, "error": "no_cookie", "message": str(e)}), 409
|
||||||
|
except db.SecretUnreadable as e:
|
||||||
|
return jsonify({"ok": False, "error": "cookie_broken",
|
||||||
|
"message": "已保存的 Cookie 无法解密,请重新粘贴:%s" % e}), 409
|
||||||
except collect.ApiError as e:
|
except collect.ApiError as e:
|
||||||
return jsonify({"ok": False, "error": "api", "message": str(e)}), 502
|
return jsonify({"ok": False, "error": "api", "message": str(e)}), 502
|
||||||
msg = "补全 %d 条 User Prompt" % n
|
msg = "补全 %d 条 User Prompt" % n
|
||||||
@@ -247,22 +418,131 @@ def api_maintenance(action):
|
|||||||
before = os.path.getsize(config.SQLITE_PATH) if os.path.exists(config.SQLITE_PATH) else 0
|
before = os.path.getsize(config.SQLITE_PATH) if os.path.exists(config.SQLITE_PATH) else 0
|
||||||
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||||
conn.execute("VACUUM")
|
conn.execute("VACUUM")
|
||||||
|
conn.execute("PRAGMA optimize")
|
||||||
after = os.path.getsize(config.SQLITE_PATH) if os.path.exists(config.SQLITE_PATH) else 0
|
after = os.path.getsize(config.SQLITE_PATH) if os.path.exists(config.SQLITE_PATH) else 0
|
||||||
msg = "数据库整理完成:%s → %s" % (_human(before), _human(after))
|
msg = "数据库整理完成:%s → %s" % (_human(before), _human(after))
|
||||||
elif action == "export-csv":
|
elif action == "export-csv":
|
||||||
path, n = collect.export_csv(conn)
|
path, n = collect.export_csv(conn, uid, username=u["username"])
|
||||||
msg = "已导出 %d 条到 %s" % (n, os.path.relpath(path, config.BASE_DIR))
|
msg = "已导出 %d 条到 %s" % (n, os.path.relpath(path, config.BASE_DIR))
|
||||||
elif action == "recount":
|
elif action == "recount":
|
||||||
n = collect.record_count(conn)
|
n = collect.record_count(conn, uid)
|
||||||
msg = "存档当前 %d 条记录" % n
|
msg = "存档当前 %d 条记录" % n
|
||||||
else:
|
else:
|
||||||
return jsonify({"ok": False, "error": "unknown", "message": "未知维护动作"}), 404
|
return jsonify({"ok": False, "error": "unknown", "message": "未知维护动作"}), 404
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
return jsonify({"ok": False, "error": "internal", "message": str(e)}), 500
|
return _internal(e, "POST /api/maintenance/%s uid=%s" % (action, uid))
|
||||||
db.audit(conn, "maintenance:" + action, user, msg, request.remote_addr)
|
db.audit(conn, "maintenance:" + action, u["username"], msg, _ip(), uid)
|
||||||
return jsonify({"ok": True, "message": msg})
|
return jsonify({"ok": True, "message": msg})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 备份管理(仅管理员) ----------------
|
||||||
|
def _backup_error(e):
|
||||||
|
return jsonify({"ok": False, "error": "backup", "message": str(e)}), 400
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/backups")
|
||||||
|
@admin_required
|
||||||
|
def api_backups():
|
||||||
|
conn = db.get_db()
|
||||||
|
backup.sync_index(conn)
|
||||||
|
return jsonify({
|
||||||
|
"items": backup.listing(conn),
|
||||||
|
"dir": config.BACKUP_DIR,
|
||||||
|
"total_human": backup.human(backup.total_bytes(conn)),
|
||||||
|
"enabled": db.get_bool(conn, "backup_enabled", True),
|
||||||
|
"interval_hours": db.get_int(conn, "backup_interval_hours", 24),
|
||||||
|
"keep": db.get_int(conn, "backup_keep", 7),
|
||||||
|
"last_auto": backup.last_auto_at(conn),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/backups")
|
||||||
|
@admin_required
|
||||||
|
def api_backup_create():
|
||||||
|
conn = db.get_db()
|
||||||
|
u = current_user()
|
||||||
|
# 打一份整库快照是重活(整库读一遍 + 压缩),别让脚本连打
|
||||||
|
ok, wait = security.action_allowed("backup:create", 30)
|
||||||
|
if not ok:
|
||||||
|
return jsonify({"ok": False, "error": "too_frequent",
|
||||||
|
"message": "刚打过备份,请 %d 秒后再试" % wait}), 429
|
||||||
|
note = str(_json_body().get("note") or "").strip()[:200]
|
||||||
|
try:
|
||||||
|
r = backup.create(conn, trigger="manual", actor=u["username"], note=note)
|
||||||
|
except backup.BackupError as e:
|
||||||
|
return _backup_error(e)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return _internal(e, "POST /api/backups")
|
||||||
|
removed = backup.prune(conn, actor=u["username"])
|
||||||
|
if removed:
|
||||||
|
r["message"] += ";按保留份数清理了 %d 份旧备份" % len(removed)
|
||||||
|
r["pruned"] = removed
|
||||||
|
return jsonify(r)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/backups/<filename>")
|
||||||
|
@admin_required
|
||||||
|
def api_backup_download(filename):
|
||||||
|
"""下载一份归档。文件名必须过 backup.safe_name 的收口。"""
|
||||||
|
try:
|
||||||
|
p = backup.path_of(filename)
|
||||||
|
except backup.BackupError as e:
|
||||||
|
return _backup_error(e)
|
||||||
|
if not os.path.exists(p):
|
||||||
|
return jsonify({"ok": False, "error": "not_found",
|
||||||
|
"message": "备份文件不存在或已被删除"}), 404
|
||||||
|
db.audit(db.get_db(), "backup_download", current_user()["username"],
|
||||||
|
"下载备份 %s" % os.path.basename(p), _ip(), 0)
|
||||||
|
return send_file(p, as_attachment=True, download_name=os.path.basename(p),
|
||||||
|
mimetype="application/zip")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/backups/<filename>/restore")
|
||||||
|
@admin_required
|
||||||
|
def api_backup_restore(filename):
|
||||||
|
"""从归档恢复整库。
|
||||||
|
|
||||||
|
这是本系统里**破坏性最强**的一个操作:它会把当前所有账号、所有用量、
|
||||||
|
所有配置替换成归档里的那一份。所以:
|
||||||
|
* 恢复前自动给当前库打一份 pre-restore 快照(错了能回去)
|
||||||
|
* 默认把 instance.json 一并恢复(否则 Cookie 密文解不开)
|
||||||
|
* 完成后所有既有会话失效(密钥与账号可能都变了),必须重新登录
|
||||||
|
"""
|
||||||
|
conn = db.get_db()
|
||||||
|
u = current_user()
|
||||||
|
body = _json_body()
|
||||||
|
include_instance = str(body.get("include_instance", "1")).lower() not in ("0", "false", "off", "no")
|
||||||
|
try:
|
||||||
|
r = backup.restore(conn, filename, include_instance=include_instance,
|
||||||
|
actor=u["username"])
|
||||||
|
except backup.BackupError as e:
|
||||||
|
return _backup_error(e)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return _internal(e, "POST /api/backups/restore")
|
||||||
|
return jsonify(r)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/backups/<filename>/delete")
|
||||||
|
@admin_required
|
||||||
|
def api_backup_delete(filename):
|
||||||
|
conn = db.get_db()
|
||||||
|
try:
|
||||||
|
r = backup.delete(conn, filename, actor=current_user()["username"])
|
||||||
|
except backup.BackupError as e:
|
||||||
|
return _backup_error(e)
|
||||||
|
return jsonify(r)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/backups/prune")
|
||||||
|
@admin_required
|
||||||
|
def api_backup_prune():
|
||||||
|
conn = db.get_db()
|
||||||
|
removed = backup.prune(conn, actor=current_user()["username"])
|
||||||
|
return jsonify({"ok": True, "removed": removed,
|
||||||
|
"message": ("已清理 %d 份旧备份" % len(removed)) if removed
|
||||||
|
else "没有需要清理的备份"})
|
||||||
|
|
||||||
|
|
||||||
def _human(n):
|
def _human(n):
|
||||||
for unit in ("B", "KB", "MB", "GB"):
|
for unit in ("B", "KB", "MB", "GB"):
|
||||||
if n < 1024 or unit == "GB":
|
if n < 1024 or unit == "GB":
|
||||||
@@ -273,16 +553,21 @@ def _human(n):
|
|||||||
@bp.get("/settings")
|
@bp.get("/settings")
|
||||||
@login_required
|
@login_required
|
||||||
def api_settings_get():
|
def api_settings_get():
|
||||||
|
"""当前账号的**有效配置**(不含任何凭证明文)。"""
|
||||||
conn = db.get_db()
|
conn = db.get_db()
|
||||||
s = db.get_settings(conn)
|
u = current_user()
|
||||||
if (s.get("cookie") or "").strip():
|
s = db.get_settings(conn, uid=u["id"])
|
||||||
s["cookie_hint"] = "%d 字符,…%s" % (len(s["cookie"]), s["cookie"][-12:])
|
st = db.secret_state(conn, "cookie", u["id"])
|
||||||
else:
|
s["cookie_hint"] = ("%d 字符,…%s" % (st["chars"], st["tail"])) if st["set"] else ""
|
||||||
s["cookie_hint"] = ""
|
s["cookie_broken"] = st["broken"]
|
||||||
s.pop("cookie", None) # 不回传明文凭证
|
|
||||||
# 内部簿记键(slot:09:00 这类调度槽位标记)不属于配置项,绝不外泄
|
# 内部簿记键(slot:09:00 这类调度槽位标记)不属于配置项,绝不外泄
|
||||||
for k in [k for k in list(s) if config.is_internal_key(k)]:
|
for k in [k for k in list(s) if config.is_internal_key(k)]:
|
||||||
s.pop(k, None)
|
s.pop(k, None)
|
||||||
|
s["_globalKeys"] = sorted(config.GLOBAL_KEYS)
|
||||||
|
s["_userKeys"] = sorted(config.USER_EDITABLE_KEYS)
|
||||||
|
s["_canEditGlobal"] = bool(u["is_admin"])
|
||||||
|
s["_canManageBackups"] = bool(u["is_admin"])
|
||||||
|
s["_role"] = "admin" if u["is_admin"] else "user"
|
||||||
return jsonify(s)
|
return jsonify(s)
|
||||||
|
|
||||||
|
|
||||||
@@ -290,137 +575,235 @@ def api_settings_get():
|
|||||||
@login_required
|
@login_required
|
||||||
def api_settings_post():
|
def api_settings_post():
|
||||||
conn = db.get_db()
|
conn = db.get_db()
|
||||||
body = request.get_json(silent=True) or {}
|
u = current_user()
|
||||||
if not isinstance(body, dict):
|
uid = u["id"]
|
||||||
return jsonify({"ok": False, "message": "请求体必须是对象"}), 400
|
body = _json_body()
|
||||||
changed, errors, ignored = [], [], []
|
changed, errors, ignored, denied = [], [], [], []
|
||||||
for k, v in body.items():
|
for k, v in body.items():
|
||||||
if config.is_internal_key(k):
|
if config.is_internal_key(k):
|
||||||
ignored.append(k)
|
ignored.append(k)
|
||||||
continue # slot:* 是调度簿记,不允许前台写
|
continue # slot:* 是调度簿记,不允许前台写
|
||||||
|
if k not in config.DEFAULTS:
|
||||||
|
errors.append("未知配置项:%s" % k)
|
||||||
|
continue
|
||||||
|
if not config.writable_by(k, u["is_admin"]):
|
||||||
|
# 普通用户只能写**本人凭证**(cookie / user_agent)。其余键 ——
|
||||||
|
# 接口基址、注册策略、采集参数、调度时刻 —— 一律归管理员:
|
||||||
|
# 否则任意注册用户就能把大家的数据采集指向别的服务器,
|
||||||
|
# 或者把 page_size 调到 1000 去 hammer 云端接口。
|
||||||
|
denied.append(k)
|
||||||
|
continue
|
||||||
if k == "cookie":
|
if k == "cookie":
|
||||||
if not str(v).strip():
|
raw = str(v).strip()
|
||||||
|
if not raw:
|
||||||
continue # 空值不动,避免误清
|
continue # 空值不动,避免误清
|
||||||
if str(v).strip().lower() in ("__clear__", "-"):
|
if raw.lower() in ("__clear__", "-"):
|
||||||
db.set_setting(conn, "cookie", "")
|
db.set_secret(conn, "cookie", "", uid)
|
||||||
changed.append(k)
|
changed.append(k)
|
||||||
continue
|
continue
|
||||||
val, err = config.normalize_setting(k, v)
|
val, err = config.normalize_setting(k, v, conn)
|
||||||
if err:
|
if err:
|
||||||
errors.append(err)
|
errors.append(err)
|
||||||
continue
|
continue
|
||||||
db.set_setting(conn, k, val)
|
if k in config.ENCRYPTED_KEYS:
|
||||||
|
db.set_secret(conn, k, val, uid)
|
||||||
|
else:
|
||||||
|
db.set_setting(conn, k, val, uid)
|
||||||
changed.append(k)
|
changed.append(k)
|
||||||
|
if denied:
|
||||||
|
errors.append("以下配置仅管理员可修改,本账号无法保存:%s。"
|
||||||
|
"普通账号可以维护的是本人凭证(Cookie / User-Agent)。"
|
||||||
|
% "、".join(sorted(denied)))
|
||||||
if errors:
|
if errors:
|
||||||
db.audit(conn, "settings_rejected", (current_user() or {}).get("username"),
|
db.audit(conn, "settings_rejected", u["username"], ";".join(errors)[:500],
|
||||||
";".join(errors)[:500], request.remote_addr)
|
_ip(), uid)
|
||||||
return jsonify({"ok": False, "error": "invalid", "message": ";".join(errors),
|
return jsonify({"ok": False, "error": "invalid", "message": ";".join(errors),
|
||||||
"errors": errors, "changed": sorted(changed)}), 400
|
"errors": errors, "changed": sorted(changed)}), 400
|
||||||
# 调整调度配置后清掉槽位标记,让新时刻立即生效
|
# 改了每日时刻:清掉**不再存在的**槽位标记(所有账号一起清)。
|
||||||
if {"schedule_times", "schedule_enabled"} & set(changed):
|
# 这里刻意不做「全清」——时刻是实例级的,全清会让全部账号在宽限期内
|
||||||
conn.execute("DELETE FROM settings WHERE key LIKE ?", (scheduler.SLOT_PREFIX + "%",))
|
# 一起重采一遍;只清失效槽位,既让新时刻立即生效,又不会造成批量重采。
|
||||||
db.audit(conn, "settings", (current_user() or {}).get("username"),
|
if "schedule_times" in changed:
|
||||||
"修改:" + (",".join(sorted(changed)) or "(无变化)"), request.remote_addr)
|
keep = set(scheduler.slots(conn, 0))
|
||||||
|
stale = [(r["user_id"], r["key"]) for r in conn.execute(
|
||||||
|
"SELECT user_id,key FROM settings WHERE key LIKE ?", (scheduler.SLOT_PREFIX + "%",))
|
||||||
|
if r["key"][len(scheduler.SLOT_PREFIX):] not in keep]
|
||||||
|
for row_uid, skey in stale:
|
||||||
|
conn.execute("DELETE FROM settings WHERE user_id=? AND key=?", (row_uid, skey))
|
||||||
|
# 调小时刻数上限后,多余的槽位标记也该跟着清,否则「缩到 2 个时刻」之后
|
||||||
|
# 另外几个时刻的标记会一直躺在库里,看着像系统还在按旧配置跑。
|
||||||
|
if "max_schedule_slots_per_day" in changed:
|
||||||
|
keep = set(scheduler.slots(conn, 0))
|
||||||
|
for r in conn.execute("SELECT user_id,key FROM settings WHERE key LIKE ?",
|
||||||
|
(scheduler.SLOT_PREFIX + "%",)).fetchall():
|
||||||
|
if r["key"][len(scheduler.SLOT_PREFIX):] not in keep:
|
||||||
|
conn.execute("DELETE FROM settings WHERE user_id=? AND key=?",
|
||||||
|
(r["user_id"], r["key"]))
|
||||||
|
db.audit(conn, "settings", u["username"],
|
||||||
|
"修改:" + (",".join(sorted(changed)) or "(无变化)"), _ip(), uid)
|
||||||
return jsonify({"ok": True, "changed": sorted(changed), "ignored": sorted(ignored)})
|
return jsonify({"ok": True, "changed": sorted(changed), "ignored": sorted(ignored)})
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/password")
|
@bp.post("/password")
|
||||||
@login_required
|
@login_required
|
||||||
def api_password():
|
def api_password():
|
||||||
from ..security import hash_password, verify_password
|
|
||||||
conn = db.get_db()
|
conn = db.get_db()
|
||||||
body = request.get_json(silent=True) or {}
|
|
||||||
u = current_user()
|
u = current_user()
|
||||||
|
body = _json_body()
|
||||||
row = conn.execute("SELECT * FROM users WHERE id=?", (u["id"],)).fetchone()
|
row = conn.execute("SELECT * FROM users WHERE id=?", (u["id"],)).fetchone()
|
||||||
if row is None or not verify_password(row["password_hash"], body.get("old") or ""):
|
if row is None or not security.verify_password(row["password_hash"], body.get("old") or ""):
|
||||||
return jsonify({"ok": False, "message": "原密码不正确"}), 400
|
return jsonify({"ok": False, "message": "原密码不正确"}), 400
|
||||||
new = (body.get("new") or "").strip()
|
new = (body.get("new") or "").strip()
|
||||||
err = _check_password(new, body.get("new2"))
|
err = security.password_problem(new, body.get("new2"), u["username"])
|
||||||
if err:
|
if err:
|
||||||
return jsonify({"ok": False, "message": err}), 400
|
return jsonify({"ok": False, "message": err}), 400
|
||||||
conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(new), u["id"]))
|
conn.execute("UPDATE users SET password_hash=? WHERE id=?",
|
||||||
db.audit(conn, "password", u["username"], "修改登录密码", request.remote_addr)
|
(security.hash_password(new), u["id"]))
|
||||||
return jsonify({"ok": True, "message": "密码已更新"})
|
# 改密即作废**其他**设备的会话:改密码的动机往往就是怀疑它泄漏了,
|
||||||
|
# 只改散列却留着旧会话,等于给自己一个假的安心。
|
||||||
|
db.bump_session_ver(conn, u["id"])
|
||||||
|
# 当前这次会话跟着刷新到新版本,否则用户改完密码立刻被自己踢下线。
|
||||||
|
# (安全上「全部踢掉」更好,但体验太差会让人不敢改密码。)
|
||||||
|
with_row = conn.execute("SELECT * FROM users WHERE id=?", (u["id"],)).fetchone()
|
||||||
|
session["sv"] = db.session_ver_of(with_row)
|
||||||
|
db.audit(conn, "password", u["username"], "修改登录密码(其他设备会话已失效)",
|
||||||
|
_ip(), u["id"])
|
||||||
|
return jsonify({"ok": True, "message": "密码已更新,其他设备上的登录已失效"})
|
||||||
|
|
||||||
|
|
||||||
# ---------------- 用户管理(原来只有 CLI passwd) ----------------
|
@bp.post("/profile")
|
||||||
def _check_password(new, new2=None):
|
@login_required
|
||||||
if len(new or "") < 6:
|
def api_profile():
|
||||||
return "密码至少 6 位"
|
"""自助修改个人资料(显示名 / 邮箱)。用户名不可改 —— 它是审计里的主键。"""
|
||||||
if len(new) > 128:
|
conn = db.get_db()
|
||||||
return "密码过长(上限 128 位)"
|
u = current_user()
|
||||||
if new2 is not None and new2 != new:
|
body = _json_body()
|
||||||
return "两次输入的新密码不一致"
|
changed = []
|
||||||
return None
|
if "display_name" in body:
|
||||||
|
name = (body.get("display_name") or "").strip()[:64] or u["username"]
|
||||||
|
conn.execute("UPDATE users SET display_name=? WHERE id=?", (name, u["id"]))
|
||||||
|
changed.append("显示名")
|
||||||
|
if "email" in body:
|
||||||
|
email = (body.get("email") or "").strip()[:config.PROFILE_EMAIL_MAX]
|
||||||
|
if email and ("@" not in email or " " in email):
|
||||||
|
return jsonify({"ok": False, "message": "邮箱格式不正确"}), 400
|
||||||
|
conn.execute("UPDATE users SET email=? WHERE id=?", (email or None, u["id"]))
|
||||||
|
changed.append("邮箱")
|
||||||
|
if not changed:
|
||||||
|
return jsonify({"ok": False, "message": "没有要修改的内容"}), 400
|
||||||
|
db.audit(conn, "profile", u["username"], "修改:" + "、".join(changed),
|
||||||
|
_ip(), u["id"])
|
||||||
|
return jsonify({"ok": True, "message": "已更新:" + "、".join(changed)})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 用户管理(管理员) ----------------
|
||||||
|
def _user_public(r):
|
||||||
|
"""用户行 -> 可下发结构。**绝不包含口令散列,也不包含任何凭证。**"""
|
||||||
|
return {"id": r["id"], "username": r["username"], "display_name": r["display_name"],
|
||||||
|
"email": r["email"], "is_admin": bool(r["is_admin"]),
|
||||||
|
"status": r["status"] or "active", "created_at": r["created_at"],
|
||||||
|
"register_ip": r["register_ip"], "last_login_at": r["last_login_at"],
|
||||||
|
"last_login_ip": r["last_login_ip"], "login_count": r["login_count"]}
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/users")
|
@bp.get("/users")
|
||||||
@admin_required
|
@admin_required
|
||||||
def api_users():
|
def api_users():
|
||||||
rows = db.get_db().execute(
|
rows = db.get_db().execute("SELECT * FROM users ORDER BY id").fetchall()
|
||||||
"SELECT id,username,display_name,is_admin,created_at,last_login_at,login_count"
|
return jsonify({"items": [_user_public(r) for r in rows]})
|
||||||
" FROM users ORDER BY id").fetchall()
|
|
||||||
return jsonify({"items": [dict(r) for r in rows]})
|
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/users")
|
@bp.post("/users")
|
||||||
@admin_required
|
@admin_required
|
||||||
def api_user_create():
|
def api_user_create():
|
||||||
from ..security import hash_password
|
|
||||||
conn = db.get_db()
|
conn = db.get_db()
|
||||||
body = request.get_json(silent=True) or {}
|
me = current_user()
|
||||||
|
body = _json_body()
|
||||||
name = (body.get("username") or "").strip()
|
name = (body.get("username") or "").strip()
|
||||||
pwd = (body.get("password") or "").strip()
|
pwd = (body.get("password") or "").strip()
|
||||||
if not name or len(name) > 32:
|
err = security.username_problem(name) or security.password_problem(
|
||||||
return jsonify({"ok": False, "message": "用户名必填且不超过 32 字符"}), 400
|
pwd, body.get("password2"), name)
|
||||||
err = _check_password(pwd, body.get("password2"))
|
|
||||||
if err:
|
if err:
|
||||||
return jsonify({"ok": False, "message": err}), 400
|
return jsonify({"ok": False, "message": err}), 400
|
||||||
exist = conn.execute("SELECT id FROM users WHERE username=?", (name,)).fetchone()
|
if db.user_by_name(conn, name):
|
||||||
if exist:
|
|
||||||
return jsonify({"ok": False, "message": "用户名已存在"}), 400
|
return jsonify({"ok": False, "message": "用户名已存在"}), 400
|
||||||
conn.execute("INSERT INTO users(username,password_hash,display_name,is_admin,created_at)"
|
# 默认建**普通账号**:多用户系统里「默认给管理员」是最常见的越权起点
|
||||||
" VALUES(?,?,?,?,?)",
|
adm = 1 if str(body.get("is_admin", "0")) in ("1", "true", "on") else 0
|
||||||
(name, hash_password(pwd), (body.get("display_name") or name).strip()[:64],
|
cur = conn.execute(
|
||||||
1 if str(body.get("is_admin", "1")) in ("1", "true", "on") else 0,
|
"INSERT INTO users(username,password_hash,display_name,email,is_admin,status,created_at)"
|
||||||
db.now_str()))
|
" VALUES(?,?,?,?,?, 'active', ?)",
|
||||||
db.audit(conn, "user_create", (current_user() or {}).get("username"), "新建用户 " + name,
|
(name, security.hash_password(pwd),
|
||||||
request.remote_addr)
|
(body.get("display_name") or name).strip()[:64],
|
||||||
return jsonify({"ok": True, "message": "已创建用户 " + name})
|
(body.get("email") or "").strip()[:128] or None, adm, db.now_str()))
|
||||||
|
db.audit(conn, "user_create", me["username"],
|
||||||
|
"新建用户 %s(%s)" % (name, "管理员" if adm else "普通"), _ip(), me["id"])
|
||||||
|
return jsonify({"ok": True, "message": "已创建用户 %s" % name, "id": cur.lastrowid})
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/users/<int:uid>")
|
@bp.post("/users/<int:uid>")
|
||||||
@admin_required
|
@admin_required
|
||||||
def api_user_update(uid):
|
def api_user_update(uid):
|
||||||
from ..security import hash_password
|
|
||||||
conn = db.get_db()
|
conn = db.get_db()
|
||||||
row = conn.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone()
|
me = current_user()
|
||||||
|
row = db.user_by_id(conn, uid)
|
||||||
if row is None:
|
if row is None:
|
||||||
return jsonify({"ok": False, "message": "用户不存在"}), 404
|
return jsonify({"ok": False, "message": "用户不存在"}), 404
|
||||||
body = request.get_json(silent=True) or {}
|
body = _json_body()
|
||||||
me = current_user()
|
|
||||||
changed = []
|
changed = []
|
||||||
if "display_name" in body:
|
if "display_name" in body:
|
||||||
conn.execute("UPDATE users SET display_name=? WHERE id=?",
|
conn.execute("UPDATE users SET display_name=? WHERE id=?",
|
||||||
((body.get("display_name") or "").strip()[:64], uid))
|
((body.get("display_name") or "").strip()[:64], uid))
|
||||||
changed.append("显示名")
|
changed.append("显示名")
|
||||||
|
if "email" in body:
|
||||||
|
email = (body.get("email") or "").strip()[:config.PROFILE_EMAIL_MAX]
|
||||||
|
if email and ("@" not in email or " " in email):
|
||||||
|
return jsonify({"ok": False, "message": "邮箱格式不正确"}), 400
|
||||||
|
conn.execute("UPDATE users SET email=? WHERE id=?", (email or None, uid))
|
||||||
|
changed.append("邮箱")
|
||||||
if "is_admin" in body:
|
if "is_admin" in body:
|
||||||
v = 1 if str(body.get("is_admin")) in ("1", "true", "on") else 0
|
v = 1 if str(body.get("is_admin")) in ("1", "true", "on") else 0
|
||||||
if uid == me["id"] and not v:
|
if uid == me["id"] and not v:
|
||||||
return jsonify({"ok": False, "message": "不能取消自己的管理员身份"}), 400
|
return jsonify({"ok": False, "message": "不能取消自己的管理员身份"}), 400
|
||||||
|
if not v and row["is_admin"]:
|
||||||
|
left = conn.execute("SELECT COUNT(*) FROM users WHERE is_admin=1 AND status='active'"
|
||||||
|
" AND id<>?", (uid,)).fetchone()[0]
|
||||||
|
if left == 0:
|
||||||
|
return jsonify({"ok": False,
|
||||||
|
"message": "至少要保留一个启用状态的管理员"}), 400
|
||||||
conn.execute("UPDATE users SET is_admin=? WHERE id=?", (v, uid))
|
conn.execute("UPDATE users SET is_admin=? WHERE id=?", (v, uid))
|
||||||
changed.append("管理员")
|
changed.append("管理员")
|
||||||
|
if "status" in body:
|
||||||
|
v = "active" if str(body.get("status")) in ("active", "1", "true", "on") else "disabled"
|
||||||
|
if uid == me["id"] and v != "active":
|
||||||
|
return jsonify({"ok": False, "message": "不能停用自己的账号"}), 400
|
||||||
|
if v != "active":
|
||||||
|
left = conn.execute("SELECT COUNT(*) FROM users WHERE is_admin=1 AND status='active'"
|
||||||
|
" AND id<>?", (uid,)).fetchone()[0]
|
||||||
|
if row["is_admin"] and left == 0:
|
||||||
|
return jsonify({"ok": False,
|
||||||
|
"message": "至少要保留一个启用状态的管理员"}), 400
|
||||||
|
conn.execute("UPDATE users SET status=? WHERE id=?", (v, uid))
|
||||||
|
if v != "active":
|
||||||
|
# 停用必须**连会话一起断**:只改 status 的话,对方手上那台设备
|
||||||
|
# 要到下一个请求才被拦(也不是不行),但版本号一并推掉更干净 ——
|
||||||
|
# 将来若有人把 current_user 的状态检查挪走,这里还有一道。
|
||||||
|
db.bump_session_ver(conn, uid)
|
||||||
|
changed.append("状态→" + ("启用" if v == "active" else "停用"))
|
||||||
pwd = (body.get("password") or "").strip()
|
pwd = (body.get("password") or "").strip()
|
||||||
if pwd:
|
if pwd:
|
||||||
err = _check_password(pwd, body.get("password2"))
|
err = security.password_problem(pwd, body.get("password2"), row["username"])
|
||||||
if err:
|
if err:
|
||||||
return jsonify({"ok": False, "message": err}), 400
|
return jsonify({"ok": False, "message": err}), 400
|
||||||
conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(pwd), uid))
|
conn.execute("UPDATE users SET password_hash=? WHERE id=?",
|
||||||
|
(security.hash_password(pwd), uid))
|
||||||
|
# 管理员重置口令后,该账号在别处的登录必须立刻失效 ——
|
||||||
|
# 重置口令的典型场景就是「怀疑账号被盗」。
|
||||||
|
db.bump_session_ver(conn, uid)
|
||||||
changed.append("密码")
|
changed.append("密码")
|
||||||
if not changed:
|
if not changed:
|
||||||
return jsonify({"ok": False, "message": "没有要修改的内容"}), 400
|
return jsonify({"ok": False, "message": "没有要修改的内容"}), 400
|
||||||
db.audit(conn, "user_update", me["username"],
|
db.audit(conn, "user_update", me["username"],
|
||||||
"修改用户 %s:%s" % (row["username"], "、".join(changed)), request.remote_addr)
|
"修改用户 %s:%s" % (row["username"], "、".join(changed)),
|
||||||
|
_ip(), me["id"])
|
||||||
return jsonify({"ok": True, "message": "已更新:" + "、".join(changed)})
|
return jsonify({"ok": True, "message": "已更新:" + "、".join(changed)})
|
||||||
|
|
||||||
|
|
||||||
@@ -429,15 +812,41 @@ def api_user_update(uid):
|
|||||||
def api_user_delete(uid):
|
def api_user_delete(uid):
|
||||||
conn = db.get_db()
|
conn = db.get_db()
|
||||||
me = current_user()
|
me = current_user()
|
||||||
row = conn.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone()
|
row = db.user_by_id(conn, uid)
|
||||||
if row is None:
|
if row is None:
|
||||||
return jsonify({"ok": False, "message": "用户不存在"}), 404
|
return jsonify({"ok": False, "message": "用户不存在"}), 404
|
||||||
if uid == me["id"]:
|
if uid == me["id"]:
|
||||||
return jsonify({"ok": False, "message": "不能删除当前登录的自己"}), 400
|
return jsonify({"ok": False, "message": "不能删除当前登录的自己"}), 400
|
||||||
n = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
if conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] <= 1:
|
||||||
if n <= 1:
|
|
||||||
return jsonify({"ok": False, "message": "至少要保留一个账号"}), 400
|
return jsonify({"ok": False, "message": "至少要保留一个账号"}), 400
|
||||||
|
if row["is_admin"]:
|
||||||
|
left = conn.execute("SELECT COUNT(*) FROM users WHERE is_admin=1 AND status='active'"
|
||||||
|
" AND id<>?", (uid,)).fetchone()[0]
|
||||||
|
if left == 0:
|
||||||
|
return jsonify({"ok": False, "message": "至少要保留一个启用状态的管理员"}), 400
|
||||||
|
keep = str(_json_body().get("keep_data", "")).strip() in ("1", "true", "on", "yes")
|
||||||
|
if not keep:
|
||||||
|
# 默认连同数据一起删 —— 留下孤儿数据既占空间,也会在重新注册
|
||||||
|
# 同名用户时被新用户看到(历史遗留 user_id 复用风险)
|
||||||
|
conn.execute("DELETE FROM usage_records WHERE user_id=?", (uid,))
|
||||||
|
conn.execute("DELETE FROM settings WHERE user_id=?", (uid,))
|
||||||
|
conn.execute("DELETE FROM collect_runs WHERE user_id=?", (uid,))
|
||||||
conn.execute("DELETE FROM users WHERE id=?", (uid,))
|
conn.execute("DELETE FROM users WHERE id=?", (uid,))
|
||||||
db.audit(conn, "user_delete", me["username"], "删除用户 " + row["username"],
|
db.audit(conn, "user_delete", me["username"],
|
||||||
request.remote_addr)
|
"删除用户 %s(%s)" % (row["username"], "保留其数据" if keep else "连同数据一并删除"),
|
||||||
|
_ip(), me["id"])
|
||||||
return jsonify({"ok": True, "message": "已删除 " + row["username"]})
|
return jsonify({"ok": True, "message": "已删除 " + row["username"]})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/captcha")
|
||||||
|
@login_required
|
||||||
|
def api_captcha_note():
|
||||||
|
"""给前端一个「验证码怎么工作」的自述,便于排障时自检。"""
|
||||||
|
conn = db.get_db()
|
||||||
|
return jsonify({
|
||||||
|
"policy": db.get_setting(conn, "captcha_policy", "always"),
|
||||||
|
"length": db.get_int(conn, "captcha_length", 4),
|
||||||
|
"ttl_seconds": 300,
|
||||||
|
"image_url": "/captcha.png",
|
||||||
|
"note": "答案只存在服务端 captchas 表;一次性使用,校验后立即删除。",
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,41 +1,49 @@
|
|||||||
/* ============================================================
|
/* ============================================================
|
||||||
WorkBuddy Portal —— 统一视觉系统
|
WorkBuddy Portal —— 统一视觉系统
|
||||||
设计令牌与大屏页(static/dashboard/index.html)保持一致,
|
风格取向:工程控制台。中性灰阶打底,单一强调色(蓝),
|
||||||
两处用同一套色板/圆角/间距,避免后台与大屏像两个产品。
|
不用渐变、不用辉光、不做彩色装饰条 —— 信息本身是唯一的主角。
|
||||||
|
设计令牌与大屏页(static/dashboard/index.html)共用一套,
|
||||||
|
两处颜色/圆角/间距保持一致,避免后台与大屏像两个产品。
|
||||||
|
|
||||||
|
约定:模板只使用类名,不依赖任何渐变或阴影细节;
|
||||||
|
因此调整本文件不需要改动任何 Jinja 模板。
|
||||||
============================================================ */
|
============================================================ */
|
||||||
:root {
|
:root {
|
||||||
/* 底色与层次 */
|
/* 底色与层次 */
|
||||||
--bg: #080d1a;
|
--bg: #0e1013; /* 页面底 */
|
||||||
--panel: rgba(255, 255, 255, .045);
|
--bg-elev: #14171b; /* 顶栏 / 表头等抬升面 */
|
||||||
--panel-hi: rgba(255, 255, 255, .07);
|
--panel: #16191e; /* 卡片 */
|
||||||
--panel-dim: rgba(0, 0, 0, .30);
|
--panel-hi: #1d2128; /* 卡片内交互面(按钮、分段控件) */
|
||||||
--line: rgba(255, 255, 255, .09);
|
--panel-dim: #101318; /* 输入框 / 代码块 */
|
||||||
--line-soft: rgba(255, 255, 255, .06);
|
|
||||||
|
--line: #262b33; /* 常规分隔线 */
|
||||||
|
--line-soft: #1f242b; /* 表格行分隔线 */
|
||||||
|
|
||||||
/* 文本 */
|
/* 文本 */
|
||||||
--text: #e8edf7;
|
--text: #e4e7eb;
|
||||||
--sub: #8b9bb4;
|
--sub: #98a1ac;
|
||||||
--dim: #64748b;
|
--dim: #6b7480;
|
||||||
|
|
||||||
/* 主色板(与大屏 ECharts PALETTE 同源) */
|
/* 强调色与语义色(低饱和,只做状态区分,不做视觉噪音) */
|
||||||
--cyan: #22d3ee;
|
--accent: #4c8df6;
|
||||||
--violet: #a78bfa;
|
--accent-dim: rgba(76, 141, 246, .14);
|
||||||
--amber: #fbbf24;
|
--cyan: #4aa8c0;
|
||||||
--green: #34d399;
|
--violet: #8b7ec8;
|
||||||
--red: #f87171;
|
--amber: #c9952f;
|
||||||
--blue: #60a5fa;
|
--green: #4ba97b;
|
||||||
--pink: #f472b6;
|
--red: #cf6679;
|
||||||
|
--blue: #4c8df6;
|
||||||
|
--pink: #c07a9e;
|
||||||
|
|
||||||
/* 语义色(用量口径:升用琥珀、降用青,刻意不用红绿) */
|
/* 语义色(用量口径:升用琥珀、降用蓝,刻意不用红绿) */
|
||||||
--up: var(--amber);
|
--up: var(--amber);
|
||||||
--down: var(--cyan);
|
--down: var(--accent);
|
||||||
|
|
||||||
/* 形状与节奏 */
|
/* 形状与节奏 */
|
||||||
--r-card: 14px;
|
--r-card: 8px;
|
||||||
--r-ctl: 9px;
|
--r-ctl: 6px;
|
||||||
--r-pill: 6px;
|
--r-pill: 4px;
|
||||||
--gap: 16px;
|
--gap: 16px;
|
||||||
--shadow: 0 12px 34px rgba(0, 0, 0, .42);
|
|
||||||
|
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
@@ -44,10 +52,7 @@
|
|||||||
html, body { margin: 0; padding: 0; }
|
html, body { margin: 0; padding: 0; }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background:
|
background: var(--bg);
|
||||||
radial-gradient(1100px 600px at 12% -10%, rgba(34, 211, 238, .10), transparent 60%),
|
|
||||||
radial-gradient(900px 500px at 100% 0%, rgba(167, 139, 250, .10), transparent 55%),
|
|
||||||
var(--bg);
|
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font: 13.5px/1.65 "Microsoft YaHei", "PingFang SC", system-ui, -apple-system,
|
font: 13.5px/1.65 "Microsoft YaHei", "PingFang SC", system-ui, -apple-system,
|
||||||
"Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
"Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
@@ -55,7 +60,7 @@ body {
|
|||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
a { color: var(--cyan); text-decoration: none; transition: color .15s; }
|
a { color: var(--accent); text-decoration: none; transition: color .15s; }
|
||||||
a:hover { text-decoration: underline; }
|
a:hover { text-decoration: underline; }
|
||||||
/* 表单控件必须显式继承字体:曾经写过 `font: 13px/1.5 inherit`,
|
/* 表单控件必须显式继承字体:曾经写过 `font: 13px/1.5 inherit`,
|
||||||
那是非法声明(简写里不能出现 inherit 作为字族),整条被浏览器丢弃,
|
那是非法声明(简写里不能出现 inherit 作为字族),整条被浏览器丢弃,
|
||||||
@@ -63,9 +68,9 @@ a:hover { text-decoration: underline; }
|
|||||||
button, input, select, textarea { font-family: inherit; font-size: 13px; }
|
button, input, select, textarea { font-family: inherit; font-size: 13px; }
|
||||||
|
|
||||||
code {
|
code {
|
||||||
background: rgba(255, 255, 255, .07); padding: 1px 5px; border-radius: 4px;
|
background: var(--panel-hi); padding: 1px 5px; border-radius: 3px;
|
||||||
font: 12px/1.5 ui-monospace, Consolas, "Cascadia Mono", monospace;
|
font: 12px/1.5 ui-monospace, Consolas, "Cascadia Mono", monospace;
|
||||||
color: var(--cyan);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
.mono, .logbox, .fullprompt {
|
.mono, .logbox, .fullprompt {
|
||||||
font-family: ui-monospace, Consolas, "Cascadia Mono", monospace;
|
font-family: ui-monospace, Consolas, "Cascadia Mono", monospace;
|
||||||
@@ -77,44 +82,43 @@ code {
|
|||||||
.muted { color: var(--sub); }
|
.muted { color: var(--sub); }
|
||||||
.right { text-align: right; }
|
.right { text-align: right; }
|
||||||
/* 页面标题里嵌的关键数字(如总积分),给一点强调但不喧宾夺主 */
|
/* 页面标题里嵌的关键数字(如总积分),给一点强调但不喧宾夺主 */
|
||||||
.hl { color: var(--cyan); font-variant-numeric: tabular-nums; }
|
.hl { color: var(--accent); font-variant-numeric: tabular-nums; }
|
||||||
/* 0 积分的记录整行数字变暗,扫一眼就能跳过长尾 */
|
/* 0 积分的记录整行数字变暗,扫一眼就能跳过长尾 */
|
||||||
.tbl td.zero { color: var(--dim); }
|
.tbl td.zero { color: var(--dim); }
|
||||||
|
|
||||||
/* 可访问性:键盘焦点一定要看得见 */
|
/* 可访问性:键盘焦点一定要看得见 */
|
||||||
:focus-visible { outline: 2px solid rgba(34, 211, 238, .65); outline-offset: 2px; }
|
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
|
||||||
/* ---------------- 顶栏 ---------------- */
|
/* ---------------- 顶栏 ---------------- */
|
||||||
.topbar {
|
.topbar {
|
||||||
display: flex; align-items: center; gap: 18px;
|
display: flex; align-items: center; gap: 18px;
|
||||||
padding: 0 22px; height: 58px;
|
padding: 0 22px; height: 56px;
|
||||||
background: rgba(10, 16, 28, .86);
|
background: var(--bg-elev);
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
position: sticky; top: 0; z-index: 50;
|
position: sticky; top: 0; z-index: 50;
|
||||||
}
|
}
|
||||||
.brand { display: flex; align-items: center; gap: 9px; font-size: 14px; letter-spacing: .2px; }
|
.brand { display: flex; align-items: center; gap: 9px; font-size: 14px; }
|
||||||
.brand a { color: var(--text); }
|
.brand a { color: var(--text); }
|
||||||
.brand a:hover { text-decoration: none; color: var(--cyan); }
|
.brand a:hover { text-decoration: none; color: var(--accent); }
|
||||||
.brand .dot {
|
.brand .dot {
|
||||||
width: 9px; height: 9px; border-radius: 50%; background: var(--cyan);
|
width: 8px; height: 8px; border-radius: 2px; background: var(--accent);
|
||||||
box-shadow: 0 0 12px var(--cyan); flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
.brand .ver { color: var(--dim); font-size: 11px; }
|
.brand .ver { color: var(--dim); font-size: 11px; }
|
||||||
|
|
||||||
.topbar nav { display: flex; gap: 3px; margin-left: 6px; overflow-x: auto; }
|
.topbar nav { display: flex; gap: 2px; margin-left: 6px; overflow-x: auto; }
|
||||||
.topbar nav a {
|
.topbar nav a {
|
||||||
padding: 6px 13px; border-radius: var(--r-ctl); color: var(--sub);
|
padding: 6px 12px; border-radius: var(--r-ctl); color: var(--sub);
|
||||||
font-size: 13px; white-space: nowrap; position: relative;
|
font-size: 13px; white-space: nowrap;
|
||||||
}
|
}
|
||||||
.topbar nav a:hover { background: var(--panel-hi); color: var(--text); text-decoration: none; }
|
.topbar nav a:hover { background: var(--panel-hi); color: var(--text); text-decoration: none; }
|
||||||
.topbar nav a.on {
|
.topbar nav a.on { background: var(--accent-dim); color: var(--text); font-weight: 600; }
|
||||||
background: linear-gradient(135deg, rgba(34, 211, 238, .22), rgba(167, 139, 250, .22));
|
|
||||||
color: #fff; font-weight: 600;
|
|
||||||
}
|
|
||||||
.topbar .me { margin-left: auto; display: flex; align-items: center; gap: 10px; flex: 0 0 auto; }
|
.topbar .me { margin-left: auto; display: flex; align-items: center; gap: 10px; flex: 0 0 auto; }
|
||||||
.topbar .who { color: var(--sub); font-size: 12.5px; }
|
.topbar .who { color: var(--sub); font-size: 12.5px; }
|
||||||
.topbar .who b { color: var(--text); font-weight: 600; }
|
.topbar .who b { color: var(--text); font-weight: 600; }
|
||||||
|
/* 用户名同时是「个人中心」入口,所以是 <a>:去掉下划线并给悬浮反馈 */
|
||||||
|
.topbar .who { display: flex; align-items: center; gap: 6px; text-decoration: none; }
|
||||||
|
.topbar .who:hover, .topbar .who:hover b { color: var(--accent); }
|
||||||
|
|
||||||
/* ---------------- 布局 ---------------- */
|
/* ---------------- 布局 ---------------- */
|
||||||
.wrap { max-width: 1480px; margin: 0 auto; padding: 20px 22px 60px; }
|
.wrap { max-width: 1480px; margin: 0 auto; padding: 20px 22px 60px; }
|
||||||
@@ -123,14 +127,9 @@ code {
|
|||||||
gap: 14px; flex-wrap: wrap; margin-bottom: var(--gap);
|
gap: 14px; flex-wrap: wrap; margin-bottom: var(--gap);
|
||||||
}
|
}
|
||||||
.pagehead h1 {
|
.pagehead h1 {
|
||||||
font-size: 20px; margin: 0; font-weight: 700; letter-spacing: .3px;
|
font-size: 19px; margin: 0; font-weight: 600; color: var(--text);
|
||||||
display: flex; align-items: center; gap: 10px;
|
|
||||||
}
|
}
|
||||||
.pagehead h1::before {
|
.pagehead .lead { color: var(--sub); font-size: 12.5px; margin: 5px 0 0; }
|
||||||
content: ""; width: 4px; height: 19px; border-radius: 2px;
|
|
||||||
background: linear-gradient(180deg, var(--cyan), var(--violet)); flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
.pagehead .lead { color: var(--sub); font-size: 12.5px; margin: 4px 0 0 14px; }
|
|
||||||
.pagehead .actions { display: flex; gap: 10px; flex-wrap: wrap; }
|
.pagehead .actions { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||||
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: var(--gap); margin-bottom: var(--gap); }
|
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: var(--gap); margin-bottom: var(--gap); }
|
||||||
.grid3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: var(--gap); margin-bottom: var(--gap); }
|
.grid3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: var(--gap); margin-bottom: var(--gap); }
|
||||||
@@ -142,14 +141,9 @@ code {
|
|||||||
padding: 16px 18px; margin-bottom: var(--gap);
|
padding: 16px 18px; margin-bottom: var(--gap);
|
||||||
}
|
}
|
||||||
.card > h2, .card > .cardhead > h2 {
|
.card > h2, .card > .cardhead > h2 {
|
||||||
font-size: 14px; margin: 0 0 12px; font-weight: 600; letter-spacing: .2px;
|
font-size: 13.5px; margin: 0 0 12px; font-weight: 600; color: var(--text);
|
||||||
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
.card > .cardhead > h2 { margin-bottom: 0; }
|
.card > .cardhead > h2 { margin-bottom: 0; }
|
||||||
.card > h2::before, .card > .cardhead > h2::before {
|
|
||||||
content: ""; width: 3px; height: 13px; border-radius: 2px;
|
|
||||||
background: var(--cyan); flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
.cardhead {
|
.cardhead {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
gap: 12px; flex-wrap: wrap; margin-bottom: 12px;
|
gap: 12px; flex-wrap: wrap; margin-bottom: 12px;
|
||||||
@@ -158,7 +152,7 @@ code {
|
|||||||
.card h3 { font-size: 13px; margin: 18px 0 10px; font-weight: 600; color: var(--text); }
|
.card h3 { font-size: 13px; margin: 18px 0 10px; font-weight: 600; color: var(--text); }
|
||||||
.card h3:first-child { margin-top: 0; }
|
.card h3:first-child { margin-top: 0; }
|
||||||
.hint { color: var(--sub); font-size: 12px; line-height: 1.75; }
|
.hint { color: var(--sub); font-size: 12px; line-height: 1.75; }
|
||||||
.hint a { text-decoration: underline; text-decoration-color: rgba(34, 211, 238, .4); }
|
.hint a { text-decoration: underline; }
|
||||||
.card > .hint:last-child { margin-bottom: 0; }
|
.card > .hint:last-child { margin-bottom: 0; }
|
||||||
.sect-divider { border: 0; border-top: 1px solid var(--line); margin: 18px 0; }
|
.sect-divider { border: 0; border-top: 1px solid var(--line); margin: 18px 0; }
|
||||||
|
|
||||||
@@ -169,18 +163,21 @@ code {
|
|||||||
}
|
}
|
||||||
.kpi {
|
.kpi {
|
||||||
background: var(--panel); border: 1px solid var(--line); border-radius: var(--r-card);
|
background: var(--panel); border: 1px solid var(--line); border-radius: var(--r-card);
|
||||||
padding: 14px 16px; position: relative; overflow: hidden;
|
padding: 14px 16px;
|
||||||
}
|
}
|
||||||
.kpi::after {
|
/* --c 由模板行内给出:只作为一个 8px 的状态点,不做整块着色 */
|
||||||
content: ""; position: absolute; left: 0; top: 0; width: 3px; height: 100%;
|
.kpi > span {
|
||||||
background: var(--c, var(--cyan));
|
color: var(--sub); font-size: 12px; display: flex; align-items: center; gap: 6px;
|
||||||
|
}
|
||||||
|
.kpi > span::before {
|
||||||
|
content: ""; width: 8px; height: 8px; border-radius: 2px; flex: 0 0 auto;
|
||||||
|
background: var(--c, var(--line));
|
||||||
}
|
}
|
||||||
.kpi > span { color: var(--sub); font-size: 12px; display: block; letter-spacing: .2px; }
|
|
||||||
.kpi > b {
|
.kpi > b {
|
||||||
display: block; font-size: 23px; margin: 7px 0 4px; font-weight: 700;
|
display: block; font-size: 22px; margin: 8px 0 4px; font-weight: 600;
|
||||||
font-variant-numeric: tabular-nums; letter-spacing: .3px;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
.kpi > i { color: var(--sub); font-size: 11.5px; font-style: normal; display: block; }
|
.kpi > i { color: var(--sub); font-size: 11.5px; font-style: normal; display: block; line-height: 1.6; }
|
||||||
.kpi > i .delta { color: var(--up); font-variant-numeric: tabular-nums; }
|
.kpi > i .delta { color: var(--up); font-variant-numeric: tabular-nums; }
|
||||||
.kpi > i .delta.dn { color: var(--down); }
|
.kpi > i .delta.dn { color: var(--down); }
|
||||||
|
|
||||||
@@ -188,7 +185,7 @@ code {
|
|||||||
/* 表格必须包在 .tablewrap 里:窄屏时横向滚动,而不是把卡片撑破 */
|
/* 表格必须包在 .tablewrap 里:窄屏时横向滚动,而不是把卡片撑破 */
|
||||||
.tablewrap { overflow-x: auto; margin: 0 -2px; }
|
.tablewrap { overflow-x: auto; margin: 0 -2px; }
|
||||||
.tablewrap::-webkit-scrollbar { height: 8px; }
|
.tablewrap::-webkit-scrollbar { height: 8px; }
|
||||||
.tablewrap::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, .14); border-radius: 4px; }
|
.tablewrap::-webkit-scrollbar-thumb { background: var(--line); border-radius: 4px; }
|
||||||
.tbl { width: 100%; border-collapse: collapse; font-size: 12.5px; }
|
.tbl { width: 100%; border-collapse: collapse; font-size: 12.5px; }
|
||||||
.tbl th, .tbl td {
|
.tbl th, .tbl td {
|
||||||
padding: 9px 10px; text-align: left; border-bottom: 1px solid var(--line-soft);
|
padding: 9px 10px; text-align: left; border-bottom: 1px solid var(--line-soft);
|
||||||
@@ -196,18 +193,17 @@ code {
|
|||||||
}
|
}
|
||||||
.tbl th {
|
.tbl th {
|
||||||
color: var(--sub); font-weight: 500; font-size: 11.5px;
|
color: var(--sub); font-weight: 500; font-size: 11.5px;
|
||||||
white-space: nowrap; letter-spacing: .3px;
|
white-space: nowrap;
|
||||||
position: sticky; top: 0; background: rgba(12, 18, 32, .96);
|
position: sticky; top: 0; background: var(--bg-elev); z-index: 1;
|
||||||
backdrop-filter: blur(4px); z-index: 1;
|
|
||||||
}
|
}
|
||||||
.tbl tbody tr:hover { background: rgba(255, 255, 255, .028); }
|
.tbl tbody tr:hover { background: var(--panel-hi); }
|
||||||
.tbl tbody tr:last-child td { border-bottom: 0; }
|
.tbl tbody tr:last-child td { border-bottom: 0; }
|
||||||
.tbl td.num, .tbl th.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
.tbl td.num, .tbl th.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||||
.tbl td.empty, .tbl td.empty:hover { text-align: center; color: var(--sub); padding: 28px; }
|
.tbl td.empty, .tbl td.empty:hover { text-align: center; color: var(--sub); padding: 28px; background: none; }
|
||||||
.tbl td.px { max-width: 460px; min-width: 220px; }
|
.tbl td.px { max-width: 460px; min-width: 220px; }
|
||||||
.tbl details summary { cursor: pointer; color: var(--sub); }
|
.tbl details summary { cursor: pointer; color: var(--sub); }
|
||||||
.tbl details summary:hover { color: var(--text); }
|
.tbl details summary:hover { color: var(--text); }
|
||||||
.tbl details[open] summary { color: var(--cyan); }
|
.tbl details[open] summary { color: var(--text); }
|
||||||
|
|
||||||
.scroll-y { max-height: 340px; overflow: auto; }
|
.scroll-y { max-height: 340px; overflow: auto; }
|
||||||
|
|
||||||
@@ -220,7 +216,7 @@ code {
|
|||||||
|
|
||||||
.fullprompt {
|
.fullprompt {
|
||||||
margin-top: 9px; padding: 11px 13px; background: var(--panel-dim);
|
margin-top: 9px; padding: 11px 13px; background: var(--panel-dim);
|
||||||
border: 1px solid var(--line); border-radius: 8px;
|
border: 1px solid var(--line); border-radius: var(--r-ctl);
|
||||||
white-space: pre-wrap; word-break: break-word; color: var(--text);
|
white-space: pre-wrap; word-break: break-word; color: var(--text);
|
||||||
font-size: 12px; line-height: 1.75; max-height: 320px; overflow: auto;
|
font-size: 12px; line-height: 1.75; max-height: 320px; overflow: auto;
|
||||||
}
|
}
|
||||||
@@ -229,14 +225,14 @@ code {
|
|||||||
.tag {
|
.tag {
|
||||||
display: inline-block; padding: 1px 7px; border-radius: var(--r-pill);
|
display: inline-block; padding: 1px 7px; border-radius: var(--r-pill);
|
||||||
font-size: 11px; line-height: 17px; white-space: nowrap;
|
font-size: 11px; line-height: 17px; white-space: nowrap;
|
||||||
background: rgba(255, 255, 255, .08); color: var(--sub); vertical-align: 1px;
|
background: var(--panel-hi); color: var(--sub); vertical-align: 1px;
|
||||||
}
|
}
|
||||||
.tag.ok { background: rgba(52, 211, 153, .16); color: var(--green); }
|
.tag.ok { background: rgba(75, 169, 123, .16); color: var(--green); }
|
||||||
.tag.warn { background: rgba(251, 191, 36, .16); color: var(--amber); }
|
.tag.warn { background: rgba(201, 149, 47, .16); color: var(--amber); }
|
||||||
.tag.bad { background: rgba(248, 113, 113, .16); color: var(--red); }
|
.tag.bad { background: rgba(207, 102, 121, .16); color: var(--red); }
|
||||||
.tag.info { background: rgba(96, 165, 250, .16); color: var(--blue); }
|
.tag.info { background: rgba(76, 141, 246, .16); color: var(--blue); }
|
||||||
.tag.accent { background: rgba(167, 139, 250, .18); color: var(--violet); }
|
.tag.accent { background: rgba(139, 126, 200, .16); color: var(--violet); }
|
||||||
.tag.mute { background: rgba(255, 255, 255, .06); color: var(--dim); }
|
.tag.mute { background: var(--panel-hi); color: var(--dim); }
|
||||||
|
|
||||||
.kbd {
|
.kbd {
|
||||||
display: inline-block; padding: 0 6px; border-radius: 4px;
|
display: inline-block; padding: 0 6px; border-radius: 4px;
|
||||||
@@ -249,19 +245,18 @@ code {
|
|||||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||||
padding: 7px 14px; border-radius: var(--r-ctl); border: 1px solid var(--line);
|
padding: 7px 14px; border-radius: var(--r-ctl); border: 1px solid var(--line);
|
||||||
background: var(--panel-hi); color: var(--text); font-size: 12.5px;
|
background: var(--panel-hi); color: var(--text); font-size: 12.5px;
|
||||||
cursor: pointer; transition: background .15s, border-color .15s, box-shadow .15s, filter .15s;
|
cursor: pointer; transition: background .15s, border-color .15s;
|
||||||
white-space: nowrap; text-decoration: none;
|
white-space: nowrap; text-decoration: none;
|
||||||
}
|
}
|
||||||
.btn:hover { background: rgba(255, 255, 255, .11); text-decoration: none; }
|
.btn:hover { background: #232830; text-decoration: none; }
|
||||||
.btn.primary {
|
.btn.primary {
|
||||||
background: linear-gradient(135deg, rgba(34, 211, 238, .26), rgba(167, 139, 250, .26));
|
background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600;
|
||||||
border-color: rgba(34, 211, 238, .42); color: #fff; font-weight: 600;
|
|
||||||
}
|
}
|
||||||
.btn.primary:hover { border-color: var(--cyan); box-shadow: 0 0 14px rgba(34, 211, 238, .22); }
|
.btn.primary:hover { background: #5e99f7; border-color: #5e99f7; }
|
||||||
.btn.ghost { background: transparent; color: var(--sub); }
|
.btn.ghost { background: transparent; color: var(--sub); }
|
||||||
.btn.ghost:hover { color: var(--text); background: var(--panel-hi); }
|
.btn.ghost:hover { color: var(--text); background: var(--panel-hi); }
|
||||||
.btn.danger { border-color: rgba(248, 113, 113, .34); color: var(--red); background: rgba(248, 113, 113, .10); }
|
.btn.danger { border-color: rgba(207, 102, 121, .34); color: var(--red); background: rgba(207, 102, 121, .10); }
|
||||||
.btn.danger:hover { background: rgba(248, 113, 113, .18); }
|
.btn.danger:hover { background: rgba(207, 102, 121, .18); }
|
||||||
.btn.sm { padding: 4px 10px; font-size: 12px; }
|
.btn.sm { padding: 4px 10px; font-size: 12px; }
|
||||||
.btn[disabled], .btn[aria-disabled=true] { opacity: .5; cursor: not-allowed; }
|
.btn[disabled], .btn[aria-disabled=true] { opacity: .5; cursor: not-allowed; }
|
||||||
.btnrow { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
.btnrow { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||||
@@ -270,14 +265,12 @@ form .btn { margin-top: 8px; }
|
|||||||
/* ---------------- 表单 ---------------- */
|
/* ---------------- 表单 ---------------- */
|
||||||
label { display: block; color: var(--sub); font-size: 12px; }
|
label { display: block; color: var(--sub); font-size: 12px; }
|
||||||
label input, label select, label textarea, .inp {
|
label input, label select, label textarea, .inp {
|
||||||
width: 100%; margin-top: 5px; padding: 8px 10px; border-radius: 8px;
|
width: 100%; margin-top: 5px; padding: 8px 10px; border-radius: var(--r-ctl);
|
||||||
background: var(--panel-dim); border: 1px solid var(--line); color: var(--text);
|
background: var(--panel-dim); border: 1px solid var(--line); color: var(--text);
|
||||||
font-size: 13px; line-height: 1.5; outline: none; transition: border-color .15s, background .15s;
|
font-size: 13px; line-height: 1.5; outline: none; transition: border-color .15s;
|
||||||
}
|
|
||||||
label input:hover, label select:hover, label textarea:hover { border-color: rgba(255, 255, 255, .16); }
|
|
||||||
label input:focus, label select:focus, label textarea:focus {
|
|
||||||
border-color: var(--cyan); background: rgba(0, 0, 0, .42);
|
|
||||||
}
|
}
|
||||||
|
label input:hover, label select:hover, label textarea:hover { border-color: #333a44; }
|
||||||
|
label input:focus, label select:focus, label textarea:focus { border-color: var(--accent); }
|
||||||
label textarea {
|
label textarea {
|
||||||
font-family: ui-monospace, Consolas, "Cascadia Mono", monospace;
|
font-family: ui-monospace, Consolas, "Cascadia Mono", monospace;
|
||||||
font-size: 12px; line-height: 1.6; resize: vertical;
|
font-size: 12px; line-height: 1.6; resize: vertical;
|
||||||
@@ -290,7 +283,7 @@ label.row.inline { display: inline-flex; margin-bottom: 0; }
|
|||||||
label.row.inline > span { flex: 0 0 auto; }
|
label.row.inline > span { flex: 0 0 auto; }
|
||||||
label .unit { color: var(--dim); font-size: 11.5px; margin-left: 8px; flex: 0 0 auto; }
|
label .unit { color: var(--dim); font-size: 11.5px; margin-left: 8px; flex: 0 0 auto; }
|
||||||
.field-err { color: var(--red); font-size: 11.5px; margin-top: 4px; }
|
.field-err { color: var(--red); font-size: 11.5px; margin-top: 4px; }
|
||||||
select option { background: #111a2e; color: var(--text); }
|
select option { background: var(--bg-elev); color: var(--text); }
|
||||||
|
|
||||||
.filters { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; }
|
.filters { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; }
|
||||||
.filters label { flex: 1 1 152px; }
|
.filters label { flex: 1 1 152px; }
|
||||||
@@ -314,23 +307,17 @@ select option { background: #111a2e; color: var(--text); }
|
|||||||
font-size: 12.5px; cursor: pointer; transition: .15s; font-family: inherit;
|
font-size: 12.5px; cursor: pointer; transition: .15s; font-family: inherit;
|
||||||
text-decoration: none; white-space: nowrap;
|
text-decoration: none; white-space: nowrap;
|
||||||
}
|
}
|
||||||
.seg a:hover, .seg button:hover { color: var(--text); background: rgba(255, 255, 255, .06); text-decoration: none; }
|
.seg a:hover, .seg button:hover { color: var(--text); text-decoration: none; }
|
||||||
.seg a.on, .seg button.on {
|
.seg a.on, .seg button.on { background: var(--accent-dim); color: var(--text); font-weight: 600; }
|
||||||
background: linear-gradient(135deg, rgba(34, 211, 238, .28), rgba(167, 139, 250, .28));
|
|
||||||
color: #fff; font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------- 分页 ---------------- */
|
/* ---------------- 分页 ---------------- */
|
||||||
.pager { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 14px; align-items: center; }
|
.pager { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 14px; align-items: center; }
|
||||||
.pager a, .pager .cur, .pager .gap {
|
.pager a, .pager .cur, .pager .gap {
|
||||||
padding: 4px 11px; border-radius: 7px; border: 1px solid var(--line);
|
padding: 4px 11px; border-radius: var(--r-ctl); border: 1px solid var(--line);
|
||||||
font-size: 12px; color: var(--sub); min-width: 34px; text-align: center;
|
font-size: 12px; color: var(--sub); min-width: 34px; text-align: center;
|
||||||
}
|
}
|
||||||
.pager a:hover { color: var(--text); border-color: rgba(34, 211, 238, .45); text-decoration: none; }
|
.pager a:hover { color: var(--text); border-color: var(--accent); text-decoration: none; }
|
||||||
.pager .cur {
|
.pager .cur { background: var(--accent); color: #fff; border-color: var(--accent); font-weight: 600; }
|
||||||
background: linear-gradient(135deg, rgba(34, 211, 238, .24), rgba(167, 139, 250, .24));
|
|
||||||
color: #fff; border-color: transparent; font-weight: 600;
|
|
||||||
}
|
|
||||||
.pager .gap { border-color: transparent; color: var(--dim); }
|
.pager .gap { border-color: transparent; color: var(--dim); }
|
||||||
.pager .meta { margin-left: auto; color: var(--sub); font-size: 11.5px; }
|
.pager .meta { margin-left: auto; color: var(--sub); font-size: 11.5px; }
|
||||||
|
|
||||||
@@ -339,18 +326,18 @@ select option { background: #111a2e; color: var(--text); }
|
|||||||
.flash {
|
.flash {
|
||||||
padding: 11px 14px; border-radius: var(--r-ctl); font-size: 12.5px;
|
padding: 11px 14px; border-radius: var(--r-ctl); font-size: 12.5px;
|
||||||
margin-bottom: 8px; border: 1px solid var(--line);
|
margin-bottom: 8px; border: 1px solid var(--line);
|
||||||
background: rgba(34, 211, 238, .10); color: var(--text);
|
background: var(--panel-hi); color: var(--text);
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
.flash.error { background: rgba(248, 113, 113, .12); border-color: rgba(248, 113, 113, .3); }
|
.flash.error { background: rgba(207, 102, 121, .12); border-color: rgba(207, 102, 121, .30); }
|
||||||
.flash.warn { background: rgba(251, 191, 36, .12); border-color: rgba(251, 191, 36, .3); }
|
.flash.warn { background: rgba(201, 149, 47, .12); border-color: rgba(201, 149, 47, .30); }
|
||||||
.flash.ok { background: rgba(52, 211, 153, .12); border-color: rgba(52, 211, 153, .3); }
|
.flash.ok { background: rgba(75, 169, 123, .12); border-color: rgba(75, 169, 123, .30); }
|
||||||
.flash:last-child { margin-bottom: 0; }
|
.flash:last-child { margin-bottom: 0; }
|
||||||
.logbox {
|
.logbox {
|
||||||
background: rgba(0, 0, 0, .40); border: 1px solid var(--line); border-radius: var(--r-ctl);
|
background: var(--panel-dim); border: 1px solid var(--line); border-radius: var(--r-ctl);
|
||||||
padding: 12px 14px; max-height: 430px; overflow: auto; margin: 0;
|
padding: 12px 14px; max-height: 430px; overflow: auto; margin: 0;
|
||||||
font-size: 11.5px; line-height: 1.75; white-space: pre-wrap;
|
font-size: 11.5px; line-height: 1.75; white-space: pre-wrap;
|
||||||
word-break: break-word; color: #c6d3e6;
|
word-break: break-word; color: var(--sub);
|
||||||
}
|
}
|
||||||
.logbox:empty::before { content: "(暂无内容)"; color: var(--dim); }
|
.logbox:empty::before { content: "(暂无内容)"; color: var(--dim); }
|
||||||
.empty-state {
|
.empty-state {
|
||||||
@@ -366,12 +353,12 @@ select option { background: #111a2e; color: var(--text); }
|
|||||||
}
|
}
|
||||||
.login { width: 100%; max-width: 400px; padding: 28px 28px 22px; margin: 0; }
|
.login { width: 100%; max-width: 400px; padding: 28px 28px 22px; margin: 0; }
|
||||||
.login .logo {
|
.login .logo {
|
||||||
width: 44px; height: 44px; border-radius: 13px; margin-bottom: 14px;
|
width: 40px; height: 40px; border-radius: var(--r-ctl); margin-bottom: 14px;
|
||||||
background: linear-gradient(135deg, var(--cyan), var(--violet));
|
background: var(--accent);
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
font-size: 20px; font-weight: 800; color: #061019;
|
font-size: 19px; font-weight: 700; color: #fff;
|
||||||
}
|
}
|
||||||
.login h1 { font-size: 19px; margin: 0 0 5px; }
|
.login h1 { font-size: 19px; margin: 0 0 5px; font-weight: 600; }
|
||||||
.login .hint { margin: 0 0 20px; }
|
.login .hint { margin: 0 0 20px; }
|
||||||
.login label { margin-bottom: 14px; }
|
.login label { margin-bottom: 14px; }
|
||||||
.login .btn { width: 100%; padding: 10px; margin-top: 4px; }
|
.login .btn { width: 100%; padding: 10px; margin-top: 4px; }
|
||||||
@@ -379,13 +366,29 @@ select option { background: #111a2e; color: var(--text); }
|
|||||||
margin: 16px 0 0; padding-top: 14px; border-top: 1px solid var(--line);
|
margin: 16px 0 0; padding-top: 14px; border-top: 1px solid var(--line);
|
||||||
color: var(--dim); font-size: 11.5px; line-height: 1.7;
|
color: var(--dim); font-size: 11.5px; line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
/* 注册页字段比登录页多,卡片给宽一点(沿用 .login 的纵向节奏) */
|
||||||
|
.login.wide { max-width: 470px; }
|
||||||
|
/* 字段说明(如「3~32 位,字母或数字开头」)跟在 label 文字后面 */
|
||||||
|
.login label em.unit { font-style: normal; margin-left: 6px; font-size: 11px; color: var(--dim); }
|
||||||
|
|
||||||
|
/* 验证码:输入框与图片并排一行(.caprow 是**新组件独有**前缀,
|
||||||
|
刻意不叫 .bar —— 页面里 .bar 是筛选条,带着 backdrop-filter 与 margin,
|
||||||
|
撞名会导致整块文字被虚化且被顶高) */
|
||||||
|
.caprow { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.caprow input { flex: 1 1 auto; min-width: 0; letter-spacing: 2px; }
|
||||||
|
/* 图片按 PNG 原始高度显示(150x56,scale=5),不缩放才最清晰 */
|
||||||
|
.capimg {
|
||||||
|
flex: 0 0 auto; height: 56px; width: auto; display: block;
|
||||||
|
border: 1px solid var(--line); border-radius: var(--r-ctl);
|
||||||
|
background: var(--panel-dim); cursor: pointer; transition: .15s;
|
||||||
|
}
|
||||||
|
.capimg:hover { border-color: var(--accent); }
|
||||||
|
|
||||||
/* ---------------- 错误页 ---------------- */
|
/* ---------------- 错误页 ---------------- */
|
||||||
.errpage { text-align: center; padding: 66px 24px; }
|
.errpage { text-align: center; padding: 66px 24px; }
|
||||||
.errpage .code {
|
.errpage .code {
|
||||||
font-size: 52px; font-weight: 800; line-height: 1; margin: 0 0 12px;
|
font-size: 46px; font-weight: 600; line-height: 1; margin: 0 0 14px;
|
||||||
background: linear-gradient(135deg, var(--cyan), var(--violet));
|
color: var(--sub); font-variant-numeric: tabular-nums;
|
||||||
-webkit-background-clip: text; background-clip: text; color: transparent;
|
|
||||||
}
|
}
|
||||||
.errpage p { color: var(--sub); margin: 0 0 20px; }
|
.errpage p { color: var(--sub); margin: 0 0 20px; }
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN" data-page-node-id="jBpPdqHNtyPDgHiurSUDlP">
|
<html lang="zh-CN">
|
||||||
<head data-page-node-id="dVR2hep3JIj1Ezc0ZtFF6H">
|
<head>
|
||||||
<meta charset="UTF-8" data-page-node-id="qqRbAyBRXJtuBQJ1uZTFiM">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" data-page-node-id="hnIdNU8Kp932sqqMuVMJdf">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>WorkBuddy Portal · 积分消耗大屏</title>
|
<title>WorkBuddy Portal · 积分消耗大屏</title>
|
||||||
<link rel="icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' data-page-node-id="AHkV6pmcOA23nBLJEuCsK9"><circle cx='8' cy='8' r='6' fill='%2322d3ee' data-page-node-id="2GTvuDAClmE7KNpisaRV1j"/></svg>">
|
<link rel="icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><circle cx='8' cy='8' r='6' fill='%234c8df6'/></svg>">
|
||||||
<!-- 必须用**绝对**路径:本页由 /dashboard 直接吐出(无尾斜杠),
|
<!-- 必须用**绝对**路径:本页由 /dashboard 直接吐出(无尾斜杠),
|
||||||
若写成相对路径会解析成 /vendor/echarts.min.js → 404,结果整页图表全白。
|
若写成相对路径会解析成 /vendor/echarts.min.js → 404,结果整页图表全白。
|
||||||
静态目录挂载在 /static(见 create_app 的 static_url_path)。 -->
|
静态目录挂载在 /static(见 create_app 的 static_url_path)。 -->
|
||||||
@@ -15,135 +15,133 @@
|
|||||||
明细按窗口裁剪下发(且不含 prompt 全文),页面不再整包搬运整个存档。
|
明细按窗口裁剪下发(且不含 prompt 全文),页面不再整包搬运整个存档。
|
||||||
详见页面脚本里的 loadSources() / refetch()。 -->
|
详见页面脚本里的 loadSources() / refetch()。 -->
|
||||||
<style>
|
<style>
|
||||||
|
/* 与后台 app.css 共用同一套令牌:中性灰阶 + 单一强调色,不用渐变与辉光 */
|
||||||
:root{
|
:root{
|
||||||
--bg:#080d1a; --panel:rgba(255,255,255,.045); --line:rgba(255,255,255,.09);
|
--bg:#0e1013; --bg-elev:#14171b; --panel:#16191e; --panel-hi:#1d2128; --panel-dim:#101318;
|
||||||
--text:#e8edf7; --sub:#8b9bb4; --cyan:#22d3ee; --violet:#a78bfa;
|
--line:#262b33;
|
||||||
--amber:#fbbf24; --green:#34d399; --pink:#f472b6; --blue:#60a5fa;
|
--text:#e4e7eb; --sub:#98a1ac; --dim:#6b7480;
|
||||||
|
--accent:#4c8df6; --accent-dim:rgba(76,141,246,.14);
|
||||||
|
--cyan:#4aa8c0; --violet:#8b7ec8; --amber:#c9952f; --green:#4ba97b;
|
||||||
|
--pink:#c07a9e; --blue:#4c8df6; --red:#cf6679;
|
||||||
}
|
}
|
||||||
*{box-sizing:border-box;margin:0;padding:0}
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
body{
|
body{
|
||||||
background:
|
background:var(--bg);
|
||||||
radial-gradient(1100px 600px at 12% -10%, rgba(34,211,238,.13), transparent 60%),
|
|
||||||
radial-gradient(900px 500px at 100% 0%, rgba(167,139,250,.13), transparent 55%),
|
|
||||||
var(--bg);
|
|
||||||
color:var(--text); font-family:"Microsoft YaHei","PingFang SC",system-ui,-apple-system,sans-serif;
|
color:var(--text); font-family:"Microsoft YaHei","PingFang SC",system-ui,-apple-system,sans-serif;
|
||||||
min-height:100vh; padding:18px 22px 34px;
|
min-height:100vh; padding:18px 22px 34px;
|
||||||
}
|
}
|
||||||
header{display:flex;align-items:flex-end;justify-content:space-between;gap:20px;flex-wrap:wrap;margin-bottom:16px}
|
header{display:flex;align-items:flex-end;justify-content:space-between;gap:20px;flex-wrap:wrap;margin-bottom:16px}
|
||||||
h1{font-size:24px;font-weight:700;letter-spacing:.5px;display:flex;align-items:center;gap:10px}
|
h1{font-size:20px;font-weight:600}
|
||||||
h1::before{content:"";width:5px;height:24px;border-radius:3px;background:linear-gradient(180deg,var(--cyan),var(--violet))}
|
.sub{color:var(--sub);font-size:12.5px;margin-top:5px}
|
||||||
.sub{color:var(--sub);font-size:12.5px;margin-top:4px}
|
.sub code{background:var(--panel-hi);padding:1px 5px;border-radius:3px;color:var(--text);font-size:11.5px}
|
||||||
.sub code{background:rgba(255,255,255,.07);padding:1px 5px;border-radius:4px;color:var(--cyan);font-size:11.5px}
|
|
||||||
/* 大屏是独立静态页,拿不到 Jinja 的导航,所以自己提供回后台的入口 */
|
/* 大屏是独立静态页,拿不到 Jinja 的导航,所以自己提供回后台的入口 */
|
||||||
.navback{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
|
.navback{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
|
||||||
.abtn{
|
.abtn{
|
||||||
display:inline-flex;align-items:center;gap:5px;padding:5px 12px;border-radius:9px;
|
display:inline-flex;align-items:center;gap:5px;padding:5px 12px;border-radius:6px;
|
||||||
border:1px solid var(--line);background:rgba(255,255,255,.05);color:var(--sub);
|
border:1px solid var(--line);background:var(--panel-hi);color:var(--sub);
|
||||||
font-size:12px;text-decoration:none;transition:.15s;
|
font-size:12px;text-decoration:none;transition:.15s;
|
||||||
}
|
}
|
||||||
.abtn:hover{color:var(--text);border-color:rgba(34,211,238,.45);background:rgba(255,255,255,.09);text-decoration:none}
|
.abtn:hover{color:var(--text);border-color:var(--accent);background:#232830;text-decoration:none}
|
||||||
.meta{color:var(--sub);font-size:12px;text-align:right;line-height:1.7}
|
.meta{color:var(--sub);font-size:12px;text-align:right;line-height:1.7}
|
||||||
.meta b{color:var(--cyan);font-weight:600}
|
.meta b{color:var(--text);font-weight:600}
|
||||||
|
|
||||||
.bar{
|
.bar{
|
||||||
display:flex;gap:14px;flex-wrap:wrap;align-items:center;
|
display:flex;gap:14px;flex-wrap:wrap;align-items:center;
|
||||||
background:var(--panel);border:1px solid var(--line);border-radius:12px;
|
background:var(--panel);border:1px solid var(--line);border-radius:8px;
|
||||||
padding:12px 16px;margin-bottom:16px;backdrop-filter:blur(6px);
|
padding:12px 16px;margin-bottom:16px;
|
||||||
}
|
}
|
||||||
.grp{display:flex;align-items:center;gap:8px}
|
.grp{display:flex;align-items:center;gap:8px}
|
||||||
.grp>span{color:var(--sub);font-size:12px;white-space:nowrap}
|
.grp>span{color:var(--sub);font-size:12px;white-space:nowrap}
|
||||||
.seg{display:flex;background:rgba(255,255,255,.05);border:1px solid var(--line);border-radius:9px;overflow:hidden}
|
.seg{display:flex;background:var(--panel-hi);border:1px solid var(--line);border-radius:6px;overflow:hidden}
|
||||||
.seg button{
|
.seg button{
|
||||||
background:transparent;border:0;color:var(--sub);padding:6px 13px;font-size:12.5px;
|
background:transparent;border:0;color:var(--sub);padding:6px 13px;font-size:12.5px;
|
||||||
cursor:pointer;transition:.15s;font-family:inherit;
|
cursor:pointer;transition:.15s;font-family:inherit;
|
||||||
}
|
}
|
||||||
.seg button:hover{color:var(--text);background:rgba(255,255,255,.06)}
|
.seg button:hover{color:var(--text)}
|
||||||
.seg button.on{background:linear-gradient(135deg,rgba(34,211,238,.28),rgba(167,139,250,.28));color:#fff;font-weight:600}
|
.seg button.on{background:var(--accent-dim);color:var(--text);font-weight:600}
|
||||||
select,input[type=date]{
|
select,input[type=date]{
|
||||||
background:rgba(255,255,255,.06);border:1px solid var(--line);color:var(--text);
|
background:var(--panel-dim);border:1px solid var(--line);color:var(--text);
|
||||||
border-radius:8px;padding:6px 10px;font-size:12.5px;font-family:inherit;outline:none;
|
border-radius:6px;padding:6px 10px;font-size:12.5px;font-family:inherit;outline:none;
|
||||||
}
|
}
|
||||||
select option{background:#111a2e;color:var(--text)}
|
select option{background:var(--bg-elev);color:var(--text)}
|
||||||
button.act{
|
button.act{
|
||||||
background:linear-gradient(135deg,rgba(34,211,238,.22),rgba(167,139,250,.22));
|
background:var(--accent);border:1px solid var(--accent);color:#fff;border-radius:6px;
|
||||||
border:1px solid rgba(34,211,238,.35);color:#fff;border-radius:9px;
|
padding:6px 14px;font-size:12.5px;cursor:pointer;font-family:inherit;font-weight:600;transition:.15s;
|
||||||
padding:6px 14px;font-size:12.5px;cursor:pointer;font-family:inherit;transition:.15s;
|
|
||||||
}
|
}
|
||||||
button.act:hover{border-color:var(--cyan);box-shadow:0 0 14px rgba(34,211,238,.25)}
|
button.act:hover{background:#5e99f7;border-color:#5e99f7}
|
||||||
|
|
||||||
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(168px,1fr));gap:14px;margin-bottom:16px}
|
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(168px,1fr));gap:14px;margin-bottom:16px}
|
||||||
.kpi{
|
.kpi{
|
||||||
background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:15px 17px;position:relative;overflow:hidden;
|
background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:15px 17px;
|
||||||
}
|
}
|
||||||
.kpi::after{content:"";position:absolute;left:0;top:0;width:3px;height:100%;background:var(--c,var(--cyan))}
|
.kpi .lb{color:var(--sub);font-size:12px;display:flex;align-items:center;gap:6px}
|
||||||
.kpi .lb{color:var(--sub);font-size:12px;letter-spacing:.3px}
|
.kpi .lb::before{content:"";width:8px;height:8px;border-radius:2px;background:var(--c,var(--line));flex:0 0 auto}
|
||||||
.kpi .vl{font-size:26px;font-weight:700;margin-top:7px;font-variant-numeric:tabular-nums;letter-spacing:.5px}
|
.kpi .vl{font-size:25px;font-weight:600;margin-top:8px;font-variant-numeric:tabular-nums}
|
||||||
.kpi .ex{color:var(--sub);font-size:11.5px;margin-top:5px}
|
.kpi .ex{color:var(--sub);font-size:11.5px;margin-top:5px;line-height:1.6}
|
||||||
|
|
||||||
.grid{display:grid;grid-template-columns:1.45fr 1fr;gap:16px}
|
.grid{display:grid;grid-template-columns:1.45fr 1fr;gap:16px}
|
||||||
.grid.b{grid-template-columns:1fr 1fr;margin-top:16px}
|
.grid.b{grid-template-columns:1fr 1fr;margin-top:16px}
|
||||||
.card{
|
.card{
|
||||||
background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:14px 16px 10px;
|
background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:14px 16px 10px;
|
||||||
}
|
}
|
||||||
.card h2{font-size:14px;font-weight:600;margin-bottom:2px;display:flex;align-items:center;gap:8px}
|
.card h2{font-size:13.5px;font-weight:600;color:var(--text)}
|
||||||
.card h2::before{content:"";width:3px;height:13px;border-radius:2px;background:var(--cyan)}
|
.card .hint{color:var(--sub);font-size:11.5px;margin-bottom:6px;margin-top:2px}
|
||||||
.card .hint{color:var(--sub);font-size:11.5px;margin-bottom:6px}
|
|
||||||
.chart{width:100%;height:290px}
|
.chart{width:100%;height:290px}
|
||||||
.chart.tall{height:330px}
|
.chart.tall{height:330px}
|
||||||
|
|
||||||
table{width:100%;border-collapse:collapse;font-size:12.5px;margin-top:6px}
|
table{width:100%;border-collapse:collapse;font-size:12.5px;margin-top:6px}
|
||||||
th,td{padding:8px 10px;text-align:left;border-bottom:1px solid rgba(255,255,255,.06)}
|
th,td{padding:8px 10px;text-align:left;border-bottom:1px solid var(--line)}
|
||||||
th{color:var(--sub);font-weight:500;font-size:11.5px;letter-spacing:.4px}
|
th{color:var(--sub);font-weight:500;font-size:11.5px}
|
||||||
td.num,th.num{text-align:right;font-variant-numeric:tabular-nums}
|
td.num,th.num{text-align:right;font-variant-numeric:tabular-nums}
|
||||||
tbody tr:hover{background:rgba(255,255,255,.04)}
|
tbody tr:hover{background:var(--panel-hi)}
|
||||||
.dot{display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:7px;vertical-align:middle}
|
.dot{display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:7px;vertical-align:middle}
|
||||||
|
|
||||||
#err{
|
#err{
|
||||||
display:none;background:rgba(248,113,113,.12);border:1px solid rgba(248,113,113,.4);
|
display:none;background:rgba(207,102,121,.12);border:1px solid rgba(207,102,121,.4);
|
||||||
color:#fca5a5;border-radius:12px;padding:14px 16px;margin-bottom:16px;font-size:13px;line-height:1.8;
|
color:#e8b6bf;border-radius:8px;padding:14px 16px;margin-bottom:16px;font-size:13px;line-height:1.8;
|
||||||
}
|
}
|
||||||
#err code{background:rgba(0,0,0,.35);padding:2px 6px;border-radius:4px;color:#fde68a}
|
#err code{background:var(--panel-dim);padding:2px 6px;border-radius:3px;color:var(--text)}
|
||||||
.foot{color:var(--sub);font-size:11.5px;text-align:center;margin-top:22px}
|
.foot{color:var(--dim);font-size:11.5px;text-align:center;margin-top:22px;line-height:1.9}
|
||||||
|
|
||||||
/* ---- 消耗日历(日志视图) ---- */
|
/* ---- 消耗日历(日志视图) ---- */
|
||||||
.calcard{margin-top:16px;padding-bottom:14px}
|
.calcard{margin-top:16px;padding-bottom:14px}
|
||||||
.calhead{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;flex-wrap:wrap}
|
.calhead{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;flex-wrap:wrap}
|
||||||
.calnav{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
.calnav{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||||
.caltitle{min-width:92px;text-align:center;font-size:13px;font-weight:600;color:var(--text)}
|
.caltitle{min-width:92px;text-align:center;font-size:13px;font-weight:600;color:var(--text)}
|
||||||
button.act.ghost{background:rgba(255,255,255,.05);border-color:var(--line);color:var(--sub);padding:5px 11px}
|
button.act.ghost{background:var(--panel-hi);border-color:var(--line);color:var(--sub);padding:5px 11px;font-weight:400}
|
||||||
button.act.ghost:hover{color:var(--text);border-color:rgba(34,211,238,.5);box-shadow:none}
|
button.act.ghost:hover{color:var(--text);border-color:var(--accent);background:#232830}
|
||||||
button.act.ghost:disabled{opacity:.32;cursor:not-allowed}
|
button.act.ghost:disabled{opacity:.32;cursor:not-allowed}
|
||||||
button.act.ghost:disabled:hover{border-color:var(--line);color:var(--sub)}
|
button.act.ghost:disabled:hover{border-color:var(--line);color:var(--sub);background:var(--panel-hi)}
|
||||||
.callegend{display:flex;align-items:center;justify-content:flex-end;gap:5px;color:var(--sub);font-size:11px;margin:4px 0 9px}
|
.callegend{display:flex;align-items:center;justify-content:flex-end;gap:5px;color:var(--sub);font-size:11px;margin:4px 0 9px}
|
||||||
.callegend i{display:inline-block;width:17px;height:11px;border-radius:3px;border:1px solid rgba(255,255,255,.10)}
|
.callegend i{display:inline-block;width:17px;height:11px;border-radius:3px;border:1px solid var(--line)}
|
||||||
.caldow{display:grid;grid-template-columns:repeat(7,1fr);gap:8px;text-align:center;color:var(--sub);
|
.caldow{display:grid;grid-template-columns:repeat(7,1fr);gap:8px;text-align:center;color:var(--sub);
|
||||||
font-size:11px;letter-spacing:.6px;padding-bottom:7px}
|
font-size:11px;padding-bottom:7px}
|
||||||
.calgrid{display:grid;grid-template-columns:repeat(7,1fr);gap:8px}
|
.calgrid{display:grid;grid-template-columns:repeat(7,1fr);gap:8px}
|
||||||
.calcell{position:relative;min-height:72px;border-radius:10px;border:1px solid rgba(255,255,255,.07);
|
.calcell{position:relative;min-height:72px;border-radius:6px;border:1px solid var(--line);
|
||||||
background:rgba(255,255,255,.03);padding:7px 9px 10px;display:flex;flex-direction:column;gap:2px;
|
background:var(--panel);padding:7px 9px 10px;display:flex;flex-direction:column;gap:2px;
|
||||||
overflow:hidden;transition:transform .15s,border-color .15s,box-shadow .15s}
|
overflow:hidden;transition:border-color .15s,background .15s}
|
||||||
.calcell.void{background:transparent;border-color:transparent}
|
.calcell.void{background:transparent;border-color:transparent}
|
||||||
.calcell.in{border-color:rgba(34,211,238,.42)}
|
.calcell.in{border-color:var(--accent)}
|
||||||
.calcell.has{cursor:pointer}
|
.calcell.has{cursor:pointer}
|
||||||
.calcell.has:hover{transform:translateY(-2px);border-color:rgba(34,211,238,.7);box-shadow:0 8px 20px rgba(0,0,0,.38)}
|
.calcell.has:hover{border-color:var(--accent);background:var(--panel-hi)}
|
||||||
.calcell .cd{font-size:11.5px;color:var(--sub);font-variant-numeric:tabular-nums}
|
.calcell .cd{font-size:11.5px;color:var(--sub);font-variant-numeric:tabular-nums}
|
||||||
.calcell.today .cd{color:var(--cyan);font-weight:700}
|
.calcell.today .cd{color:var(--text);font-weight:700}
|
||||||
.calcell .cv{font-size:14px;font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:.2px}
|
.calcell .cv{font-size:14px;font-weight:600;font-variant-numeric:tabular-nums}
|
||||||
.calcell.empty .cv{color:var(--sub);font-weight:500;font-size:13px}
|
.calcell.empty .cv{color:var(--sub);font-weight:400;font-size:13px}
|
||||||
.calcell .cc{font-size:10.5px;color:var(--sub);font-variant-numeric:tabular-nums}
|
.calcell .cc{font-size:10.5px;color:var(--sub);font-variant-numeric:tabular-nums}
|
||||||
.calcell .cbar{position:absolute;left:0;bottom:0;width:100%;height:3px;background:rgba(255,255,255,.05)}
|
.calcell .cbar{position:absolute;left:0;bottom:0;width:100%;height:3px;background:var(--panel-hi)}
|
||||||
.calcell .cbar>i{display:block;height:100%;width:0;border-radius:0 2px 0 0}
|
.calcell .cbar>i{display:block;height:100%;width:0}
|
||||||
.calcell.sel{box-shadow:inset 0 0 0 1.5px var(--cyan)}
|
.calcell.sel{box-shadow:inset 0 0 0 1.5px var(--accent)}
|
||||||
.calcell.peak::after{content:"峰";position:absolute;right:6px;top:5px;background:var(--amber);color:#0a1120;
|
.calcell.peak::after{content:"峰";position:absolute;right:6px;top:5px;background:var(--amber);color:#0e1013;
|
||||||
font-size:9.5px;line-height:14px;padding:0 5px;border-radius:5px;font-weight:700}
|
font-size:9.5px;line-height:14px;padding:0 5px;border-radius:3px;font-weight:700}
|
||||||
.calcell.partial .cc::after{content:" · 残";color:var(--amber)}
|
.calcell.partial .cc::after{content:" · 残";color:var(--amber)}
|
||||||
.calstat{display:flex;gap:22px;flex-wrap:wrap;margin-top:12px;padding-top:12px;border-top:1px solid var(--line);
|
.calstat{display:flex;gap:22px;flex-wrap:wrap;margin-top:12px;padding-top:12px;border-top:1px solid var(--line);
|
||||||
font-size:12px;color:var(--sub)}
|
font-size:12px;color:var(--sub)}
|
||||||
.calstat b{color:var(--text);margin-left:6px;font-variant-numeric:tabular-nums}
|
.calstat b{color:var(--text);margin-left:6px;font-variant-numeric:tabular-nums}
|
||||||
#calTip{position:fixed;z-index:99;pointer-events:none;display:none;max-width:290px;
|
#calTip{position:fixed;z-index:99;pointer-events:none;display:none;max-width:290px;
|
||||||
background:rgba(12,18,32,.96);border:1px solid rgba(255,255,255,.14);border-radius:9px;
|
background:var(--bg-elev);border:1px solid var(--line);border-radius:6px;
|
||||||
padding:9px 12px;font-size:12px;line-height:1.8;color:var(--text);box-shadow:0 12px 34px rgba(0,0,0,.5)}
|
padding:9px 12px;font-size:12px;line-height:1.8;color:var(--text);box-shadow:0 8px 24px rgba(0,0,0,.5)}
|
||||||
#calTip b{color:var(--cyan)}
|
#calTip b{color:var(--text)}
|
||||||
@media(max-width:820px){
|
@media(max-width:820px){
|
||||||
.calcell{min-height:58px;padding:5px 6px 8px}
|
.calcell{min-height:58px;padding:5px 6px 8px}
|
||||||
.calcell .cv{font-size:12px}.calcell .cc{font-size:9.5px}
|
.calcell .cv{font-size:12px}.calcell .cc{font-size:9.5px}
|
||||||
@@ -151,15 +149,15 @@
|
|||||||
@media(max-width:1180px){.grid,.grid.b{grid-template-columns:1fr}}
|
@media(max-width:1180px){.grid,.grid.b{grid-template-columns:1fr}}
|
||||||
|
|
||||||
/* ---- 环比 / 标签 / 迷你条 ---- */
|
/* ---- 环比 / 标签 / 迷你条 ---- */
|
||||||
.dimup{color:var(--amber)} /* 用量上升用琥珀,下降用青色:刻意不用红绿 */
|
.dimup{color:var(--amber)} /* 用量上升用琥珀,下降用蓝:刻意不用红绿 */
|
||||||
.dimdn{color:var(--cyan)}
|
.dimdn{color:var(--accent)}
|
||||||
.ok{color:var(--green)} .bad{color:#f87171}
|
.ok{color:var(--green)} .bad{color:var(--red)}
|
||||||
.tag{display:inline-block;font-size:10px;line-height:15px;padding:0 5px;border-radius:4px;
|
.tag{display:inline-block;font-size:10px;line-height:15px;padding:0 5px;border-radius:3px;
|
||||||
background:rgba(251,191,36,.18);color:var(--amber);margin-left:6px;vertical-align:1px;white-space:nowrap}
|
background:rgba(201,149,47,.18);color:var(--amber);margin-left:6px;vertical-align:1px;white-space:nowrap}
|
||||||
.tag.free{background:rgba(96,165,250,.16);color:var(--blue);margin-left:4px}
|
.tag.free{background:rgba(76,141,246,.16);color:var(--blue);margin-left:4px}
|
||||||
.bar-mini{height:7px;border-radius:4px;background:rgba(255,255,255,.08);overflow:hidden}
|
.bar-mini{height:6px;border-radius:3px;background:var(--panel-hi);overflow:hidden}
|
||||||
.bar-mini>div{height:100%;border-radius:4px;background:linear-gradient(90deg,#22d3ee,#a78bfa)}
|
.bar-mini>div{height:100%;border-radius:3px;background:var(--accent)}
|
||||||
.card .hint b{color:var(--cyan);font-weight:600}
|
.card .hint b{color:var(--text);font-weight:600}
|
||||||
.cardhead{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}
|
.cardhead{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}
|
||||||
|
|
||||||
/* ---- TOP 榜 / 数据源 ---- */
|
/* ---- TOP 榜 / 数据源 ---- */
|
||||||
@@ -172,182 +170,182 @@
|
|||||||
#tblTop td:nth-child(-n+5){white-space:nowrap}
|
#tblTop td:nth-child(-n+5){white-space:nowrap}
|
||||||
#tblTop td.px{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
|
#tblTop td.px{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
|
||||||
color:var(--sub);font-size:11.5px}
|
color:var(--sub);font-size:11.5px}
|
||||||
#tblSrc code{background:rgba(255,255,255,.06);padding:1px 5px;border-radius:4px;
|
#tblSrc code{background:var(--panel-hi);padding:1px 5px;border-radius:3px;
|
||||||
font-size:11px;color:var(--cyan);font-family:Consolas,Monaco,monospace}
|
font-size:11px;color:var(--text);font-family:Consolas,Monaco,monospace}
|
||||||
#srcCheck{margin-top:10px;font-size:11.5px;color:var(--sub);line-height:1.95;
|
#srcCheck{margin-top:10px;font-size:11.5px;color:var(--sub);line-height:1.95;
|
||||||
border-top:1px solid var(--line);padding-top:9px}
|
border-top:1px solid var(--line);padding-top:9px}
|
||||||
.sub code{background:rgba(255,255,255,.06);padding:1px 5px;border-radius:4px;color:var(--cyan);
|
.sub code{background:var(--panel-hi);padding:1px 5px;border-radius:3px;color:var(--text);
|
||||||
font-family:Consolas,Monaco,monospace;font-size:11.5px}
|
font-family:Consolas,Monaco,monospace;font-size:11.5px}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body data-page-node-id="cgE383OQXYMooNAAYOf8Xo">
|
<body>
|
||||||
|
|
||||||
<header data-page-node-id="qNmHyKNLVV83WavBjcUDvZ">
|
<header>
|
||||||
<div data-page-node-id="nvuOyMOnmQg04RZg7ABko0">
|
<div>
|
||||||
<h1 data-page-node-id="bbxwehmAM7hCbojvIUbEn5">WorkBuddy Portal · 积分消耗大屏</h1>
|
<h1>WorkBuddy Portal · 积分消耗大屏</h1>
|
||||||
<div class="sub" data-page-node-id="LgqD86gtDlqqDDFruEcboi">数据来源:本项目 <code>/api</code>(SQLite 正本 <code>data/usage.sqlite</code>),页面按筛选条件实时聚合</div>
|
<div class="sub">数据来源:<code>/api</code>(正本 <code>data/usage.sqlite</code>)</div>
|
||||||
<div class="navback">
|
<div class="navback">
|
||||||
<a class="abtn" href="/">← 返回后台</a>
|
<a class="abtn" href="/">← 返回后台</a>
|
||||||
<a class="abtn" href="/records">数据明细</a>
|
<a class="abtn" href="/records">数据明细</a>
|
||||||
<a class="abtn" href="/logs">日志管理</a>
|
<!-- 日志管理仅管理员可达:角色由 /api/manifest 的 role 字段带回,
|
||||||
|
非管理员时在 boot() 里隐藏,避免给出会 403 的死链 -->
|
||||||
|
<a class="abtn" id="abtnLogs" href="/logs" hidden>日志管理</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="meta" data-page-node-id="4jmZuGBGC8JQmVAE2IOJ7H">
|
<div class="meta">
|
||||||
数据范围 <b id="mRange" data-page-node-id="Hv9sHK3bEHKC2oi5QrSBHx">-</b><br data-page-node-id="loxYsApIQ9pIhv0bpA4S7c">
|
数据范围 <b id="mRange">-</b><br>
|
||||||
共 <b id="mDays" data-page-node-id="j7DQYi30ic8FPEQH2mfPSW">-</b> 天 · <b id="mRows" data-page-node-id="I5ZLhC3ODlkutlGRaRlRKp">-</b> 条请求记录<br data-page-node-id="ecCETqYdR2yF04jFxFPiT8">
|
共 <b id="mDays">-</b> 天 · <b id="mRows">-</b> 条请求记录<br>
|
||||||
数据快照 <b id="mUpdate">-</b>
|
数据快照 <b id="mUpdate">-</b>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div id="err" data-page-node-id="dj4W2yF1bo2dSOqXftYHYb"></div>
|
<div id="err"></div>
|
||||||
|
|
||||||
<div class="bar" data-page-node-id="DdktDg1IO90mtTF9ZuSNB9">
|
<div class="bar">
|
||||||
<div class="grp" data-page-node-id="f4G2ZTZxKEJdVG5RkYwL2Q">
|
<div class="grp">
|
||||||
<span data-page-node-id="6lFVYOo1P7geY0oSXMscUj">日期范围</span>
|
<span>日期范围</span>
|
||||||
<div class="seg" id="segRange" data-page-node-id="ev2oBcItYEQrzl7wiXlzBG">
|
<div class="seg" id="segRange">
|
||||||
<button data-v="day" data-page-node-id="8MbAF6CvYCeQHQ1LFx4L8D">今日</button>
|
<button data-v="day">今日</button>
|
||||||
<button data-v="7d" class="on" data-page-node-id="hiFAf8qWEb3fE6l2MgUbKR">近 7 天</button>
|
<button data-v="7d" class="on">近 7 天</button>
|
||||||
<button data-v="30d" data-page-node-id="N4I8KC6ZmmbwXFqQz4pyBy">近 30 天</button>
|
<button data-v="30d">近 30 天</button>
|
||||||
<button data-v="all" data-page-node-id="esurYqQl7tJrebaUFYae80">全部</button>
|
<button data-v="all">全部</button>
|
||||||
<button data-v="custom" data-page-node-id="LUr2Jzq9MLGadHwepmYg3B">自定义</button>
|
<button data-v="custom">自定义</button>
|
||||||
</div>
|
</div>
|
||||||
<select id="selDay" title="选择具体某一天" style="display:none" data-page-node-id="AXWOgamGumzhgLyFWDtO5F"></select>
|
<select id="selDay" title="选择具体某一天" style="display:none"></select>
|
||||||
<span id="customBox" style="display:none;gap:6px;align-items:center" data-page-node-id="c6BMGtK1rXEGLtPey8eXhK">
|
<span id="customBox" style="display:none;gap:6px;align-items:center">
|
||||||
<input type="date" id="dFrom" data-page-node-id="NZFXC8NZfUMAKOwCeuWi8g"><span style="color:var(--sub)" data-page-node-id="c5MDTzWvPgNNSXYezC5q4d">至</span><input type="date" id="dTo" data-page-node-id="ijDpwRtLUU7VCCFK2s8ad0">
|
<input type="date" id="dFrom"><span style="color:var(--sub)">至</span><input type="date" id="dTo">
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="grp" data-page-node-id="x0nms61tGsgUGorPYjYQnX">
|
<div class="grp">
|
||||||
<span data-page-node-id="OWHQO4wrcghIFuTngJTrgn">统计维度</span>
|
<span>统计维度</span>
|
||||||
<div class="seg" id="segGran" data-page-node-id="mMRb516sRLu1kmhM4ZCXo3">
|
<div class="seg" id="segGran">
|
||||||
<button data-v="auto" class="on" data-page-node-id="9z0JMKlm1sL8Na4FE6eZyX">自动</button>
|
<button data-v="auto" class="on">自动</button>
|
||||||
<button data-v="day" data-page-node-id="fMCwiGyoKGl1zmLVyi3fsz">按天</button>
|
<button data-v="day">按天</button>
|
||||||
<button data-v="week" data-page-node-id="Hpl4cp1jpGMjejxp9zE9L4">按周</button>
|
<button data-v="week">按周</button>
|
||||||
<button data-v="month" data-page-node-id="ebklBG8V2FMGuvwmlUDSDD">按月</button>
|
<button data-v="month">按月</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grp" data-page-node-id="GNZcj0escL1zjzKbpm4wrr">
|
<div class="grp">
|
||||||
<span data-page-node-id="qFpAW08WBrAvUR1rD9pg1r">拆分维度</span>
|
<span>拆分维度</span>
|
||||||
<div class="seg" id="segDim" data-page-node-id="Ke7fl3DhEfgDU4XOqJA4CC">
|
<div class="seg" id="segDim">
|
||||||
<button data-v="model" class="on" data-page-node-id="umLIxwwds01h4xJGMTKJ4N">模型</button>
|
<button data-v="model" class="on">模型</button>
|
||||||
<button data-v="client" data-page-node-id="AmKFjL92JpfCZ34bugR90r">客户端</button>
|
<button data-v="client">客户端</button>
|
||||||
<button data-v="hour" data-page-node-id="PMJKA7oulDfEMrMLmYok3Q">时段</button>
|
<button data-v="hour">时段</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="act" id="btnReload" title="数据由 fetch_usage.py 采集后加工成 dashboard/data/*.json,重新采集后刷新本页即可">刷新页面</button>
|
<button class="act" id="btnReload" title="重新向 /api 取数并重绘本页">刷新页面</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="kpis" id="kpis" data-page-node-id="ezG8rbfBQAhGqE7X7WvjI0"></div>
|
<div class="kpis" id="kpis"></div>
|
||||||
|
|
||||||
<div class="card calcard" data-page-node-id="CalCard0001">
|
<div class="card calcard">
|
||||||
<div class="calhead" data-page-node-id="CalHead0001">
|
<div class="calhead">
|
||||||
<div data-page-node-id="CalHead0002">
|
<div>
|
||||||
<h2 data-page-node-id="CalHead0003">消耗日历 · 日志视图</h2>
|
<h2>消耗日历 · 日志视图</h2>
|
||||||
<div class="hint" id="calHint" data-page-node-id="CalHead0004">按天汇总积分消耗与调用量,色块深浅表示当日强度(相对所选月份峰值);点击有数据的日期可下钻到当日</div>
|
<div class="hint" id="calHint">按天汇总积分消耗与调用量,色块深浅表示当日强度(相对所选月份峰值);点击有数据的日期可下钻到当日</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="calnav" data-page-node-id="CalNav0001">
|
<div class="calnav">
|
||||||
<div class="grp" data-page-node-id="CalNav0002">
|
<div class="grp">
|
||||||
<span data-page-node-id="CalNav0003">着色依据</span>
|
<span>着色依据</span>
|
||||||
<div class="seg" id="segCalMode" data-page-node-id="CalNav0004">
|
<div class="seg" id="segCalMode">
|
||||||
<button data-v="credits" class="on" data-page-node-id="CalNav0005">积分</button>
|
<button data-v="credits" class="on">积分</button>
|
||||||
<button data-v="calls" data-page-node-id="CalNav0006">调用量</button>
|
<button data-v="calls">调用量</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="act ghost" id="calPrev" title="上一月" data-page-node-id="CalNav0007">‹</button>
|
<button class="act ghost" id="calPrev" title="上一月">‹</button>
|
||||||
<span class="caltitle" id="calTitle" data-page-node-id="CalNav0008">-</span>
|
<span class="caltitle" id="calTitle">-</span>
|
||||||
<button class="act ghost" id="calNext" title="下一月" data-page-node-id="CalNav0009">›</button>
|
<button class="act ghost" id="calNext" title="下一月">›</button>
|
||||||
<button class="act ghost" id="calNow" title="跳到最后有数据的月份" data-page-node-id="CalNav0010">最新</button>
|
<button class="act ghost" id="calNow" title="跳到最后有数据的月份">最新</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="callegend" id="calLegend" data-page-node-id="CalLegend01"></div>
|
<div class="callegend" id="calLegend"></div>
|
||||||
<div class="caldow" data-page-node-id="CalDow00001">
|
<div class="caldow">
|
||||||
<span data-page-node-id="CalDow00002">一</span><span data-page-node-id="CalDow00003">二</span><span data-page-node-id="CalDow00004">三</span><span data-page-node-id="CalDow00005">四</span><span data-page-node-id="CalDow00006">五</span><span data-page-node-id="CalDow00007">六</span><span data-page-node-id="CalDow00008">日</span>
|
<span>一</span><span>二</span><span>三</span><span>四</span><span>五</span><span>六</span><span>日</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="calgrid" id="calGrid" data-page-node-id="CalGrid0001"></div>
|
<div class="calgrid" id="calGrid"></div>
|
||||||
<div class="calstat" id="calStat" data-page-node-id="CalStat0001"></div>
|
<div class="calstat" id="calStat"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="calTip" data-page-node-id="CalTip0001"></div>
|
<div id="calTip"></div>
|
||||||
|
|
||||||
<div class="grid" data-page-node-id="Y8L5F0bCd1duWGGhtdnfoI">
|
<div class="grid">
|
||||||
<div class="card" data-page-node-id="9dkepoT2cxSqyqReGkIdsN">
|
<div class="card">
|
||||||
<h2 data-page-node-id="rWK6Yj9FegoLslsQgGDqW6">消耗趋势</h2>
|
<h2>消耗趋势</h2>
|
||||||
<div class="hint" id="trendHint" data-page-node-id="CmcGDzFQGuVCQPHLNjjesn">按选定统计维度展示积分消耗与调用次数</div>
|
<div class="hint" id="trendHint">按选定统计维度展示积分消耗与调用次数</div>
|
||||||
<div class="chart tall" id="cTrend" data-page-node-id="Yl7NmMfhUpKb5C0y9vw5nn"></div>
|
<div class="chart tall" id="cTrend"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card" data-page-node-id="MNLST8ygBBvzlhIUWesmDC">
|
<div class="card">
|
||||||
<div class="cardhead">
|
<div class="cardhead">
|
||||||
<h2 data-page-node-id="6yGT1sOOvBuMJkwikplAeN">维度构成</h2>
|
<h2>维度构成</h2>
|
||||||
<div class="seg" id="segPieMetric" data-page-node-id="PieSeg0001">
|
<div class="seg" id="segPieMetric">
|
||||||
<button data-v="credits" class="on" data-page-node-id="PieSeg0002">按积分</button>
|
<button data-v="credits" class="on">按积分</button>
|
||||||
<button data-v="calls" data-page-node-id="PieSeg0003">按调用量</button>
|
<button data-v="calls">按调用量</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hint" id="pieHint" data-page-node-id="2mTXUjnvf1XD2ZG7LSFM9F">按拆分维度统计积分消耗占比</div>
|
<div class="hint" id="pieHint">按拆分维度统计积分消耗占比</div>
|
||||||
<div class="chart tall" id="cPie" data-page-node-id="WSjIdmCWoCmtoWgRk3rR6a"></div>
|
<div class="chart tall" id="cPie"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid b" data-page-node-id="p5TYzrQeXYclXwXZc0fUrY">
|
<div class="grid b">
|
||||||
<div class="card" data-page-node-id="7DuEunYEvwgw3tJDDfUmRv">
|
<div class="card">
|
||||||
<h2 data-page-node-id="MSJH2nzGRPudHm7yBktel9">维度堆叠分布</h2>
|
<h2>维度堆叠分布</h2>
|
||||||
<div class="hint" id="stackHint" data-page-node-id="a2cMsTQF1SGPs7Z5Irwi0F">各维度在各时间段上的积分消耗堆叠</div>
|
<div class="hint" id="stackHint">各维度在各时间段上的积分消耗堆叠</div>
|
||||||
<div class="chart" id="cStack" data-page-node-id="8GgWmtBxYAkB1vzWEraxTi"></div>
|
<div class="chart" id="cStack"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card" data-page-node-id="GtFQNN1efZkhbOgg4nSwP0">
|
<div class="card">
|
||||||
<h2 data-page-node-id="0kKEgioY6RdyLLMw0E7svo">时段 × 日期热力图</h2>
|
<h2>时段 × 日期热力图</h2>
|
||||||
<div class="hint" id="heatHint" data-page-node-id="sXCbVdfmqJn7E4fJRJbG4f">颜色越亮表示该时段消耗越高</div>
|
<div class="hint" id="heatHint">颜色越亮表示该时段消耗越高</div>
|
||||||
<div class="chart" id="cHeat" data-page-node-id="ZopZiALpWzp3dXaZPJiC7B"></div>
|
<div class="chart" id="cHeat"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid b" data-page-node-id="HistRow0001">
|
<div class="grid b">
|
||||||
<div class="card" data-page-node-id="HistCard001">
|
<div class="card">
|
||||||
<h2 data-page-node-id="HistCard002">单笔消耗分布(长尾)</h2>
|
<h2>单笔消耗分布(长尾)</h2>
|
||||||
<div class="hint" id="histHint" data-page-node-id="HistCard003">按单笔积分分桶</div>
|
<div class="hint" id="histHint">按单笔积分分桶</div>
|
||||||
<div class="chart" id="cHist" data-page-node-id="HistCard004"></div>
|
<div class="chart" id="cHist"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card" data-page-node-id="ScatCard001">
|
<div class="card">
|
||||||
<h2 data-page-node-id="ScatCard002">模型效率散点</h2>
|
<h2>模型效率散点</h2>
|
||||||
<div class="hint" id="scatterHint" data-page-node-id="ScatCard003">请求数 × 积分,气泡大小 = 单次均价</div>
|
<div class="hint" id="scatterHint">请求数 × 积分,气泡大小 = 单次均价</div>
|
||||||
<div class="chart" id="cScatter" data-page-node-id="ScatCard004"></div>
|
<div class="chart" id="cScatter"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid b" data-page-node-id="AJe8K1vlk6fFa7VkHQBfk1">
|
<div class="grid b">
|
||||||
<div class="card" data-page-node-id="uXTFSuEDX2uijM5H6FkoCU">
|
<div class="card">
|
||||||
<h2 data-page-node-id="AuhjUdYBYmEYYT6SEh50T8">维度汇总表</h2>
|
<h2>维度汇总表</h2>
|
||||||
<div class="hint" data-page-node-id="fVNeKG380KfClp6qHRdP6W">按拆分维度聚合,按积分消耗降序</div>
|
<div class="hint">按拆分维度聚合,按积分消耗降序</div>
|
||||||
<div style="max-height:300px;overflow:auto" data-page-node-id="nf3LtPrwJZko43cEaHKMhx"><table id="tblDim" data-page-node-id="4E0T1dD39HCRci06VTahNY"></table></div>
|
<div style="max-height:300px;overflow:auto"><table id="tblDim"></table></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card" data-page-node-id="HTGAo9nJBdjXgrdoBiSqfP">
|
<div class="card">
|
||||||
<h2 data-page-node-id="s0AhZQxvSQlKGY2flPkDeF">每日明细表</h2>
|
<h2>每日明细表</h2>
|
||||||
<div class="hint" data-page-node-id="6CyHLq7WRzpn0PL29I7iJd">当前筛选范围内逐日汇总</div>
|
<div class="hint">当前筛选范围内逐日汇总</div>
|
||||||
<div style="max-height:300px;overflow:auto" data-page-node-id="J1mlkJ3uAd9BNXgCW09pHH"><table id="tblDay" data-page-node-id="VUcijHxm29zvPPCXC5oT7l"></table></div>
|
<div style="max-height:300px;overflow:auto"><table id="tblDay"></table></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid b" data-page-node-id="TopRow000001">
|
<div class="grid b">
|
||||||
<div class="card" data-page-node-id="TopCard0001">
|
<div class="card">
|
||||||
<div class="cardhead">
|
<div class="cardhead">
|
||||||
<h2 data-page-node-id="TopCard0002">TOP 单笔请求</h2>
|
<h2>TOP 单笔请求</h2>
|
||||||
<div class="seg" id="segTopMode" data-page-node-id="TopSeg00001">
|
<div class="seg" id="segTopMode">
|
||||||
<button data-v="range" class="on" data-page-node-id="TopSeg00002">当前筛选</button>
|
<button data-v="range" class="on">当前筛选</button>
|
||||||
<button data-v="global" data-page-node-id="TopSeg00003">全局 TOP200</button>
|
<button data-v="global">全局 TOP200</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hint" id="topHint" data-page-node-id="TopCard0003">按积分降序</div>
|
<div class="hint" id="topHint">按积分降序</div>
|
||||||
<div style="max-height:320px;overflow:auto" data-page-node-id="TopCard0004"><table id="tblTop" data-page-node-id="TopCard0005"></table></div>
|
<div style="max-height:320px;overflow:auto"><table id="tblTop"></table></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card" data-page-node-id="SrcCard0001">
|
<div class="card">
|
||||||
<h2 data-page-node-id="SrcCard0002">数据源</h2>
|
<h2>数据源</h2>
|
||||||
<div class="hint" data-page-node-id="SrcCard0003">数据全部由本项目 Flask 后端经 SQLite 实时聚合(无中间 JSON 层)</div>
|
<div class="hint">数据由本项目 Flask 后端从 SQLite 实时聚合</div>
|
||||||
<div style="max-height:250px;overflow:auto" data-page-node-id="SrcCard0004"><table id="tblSrc" data-page-node-id="SrcCard0005"></table></div>
|
<div style="max-height:250px;overflow:auto"><table id="tblSrc"></table></div>
|
||||||
<div id="srcCheck" data-page-node-id="SrcCard0006"></div>
|
<div id="srcCheck"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="foot" data-page-node-id="ZUGgx25CirIv3OdO9R3ywV">数据链:本项目 Flask 后端(进程内定时采集 → SQLite 正本)→ <code>/api</code> 实时聚合 → 本页渲染 ·
|
<div class="foot">数据链:进程内定时采集 → <code>data/usage.sqlite</code> → <code>/api</code> 实时聚合 → 本页渲染</div>
|
||||||
筛选项变更会按窗口重新取数(明细按需下发,不含 prompt 全文)</div>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function(){
|
(function(){
|
||||||
@@ -365,18 +363,19 @@
|
|||||||
*/
|
*/
|
||||||
const API = "/api";
|
const API = "/api";
|
||||||
|
|
||||||
const PALETTE = ["#22d3ee","#a78bfa","#fbbf24","#34d399","#f472b6","#60a5fa","#fb923c","#4ade80","#c084fc","#f87171"];
|
// 低饱和色板:够区分 10 个维度,又不至于把大屏变成调色板
|
||||||
|
const PALETTE = ["#4c8df6","#4aa8c0","#8b7ec8","#c9952f","#4ba97b","#c07a9e","#7d8896","#6d9fd8","#9a8f7a","#5f8ca8"];
|
||||||
// 维度数可能超过色板长度(实测 12 个模型 > 10 色),超出部分按黄金角补色,避免同色歧义
|
// 维度数可能超过色板长度(实测 12 个模型 > 10 色),超出部分按黄金角补色,避免同色歧义
|
||||||
const dimColor = i => i < PALETTE.length ? PALETTE[i]
|
const dimColor = i => i < PALETTE.length ? PALETTE[i]
|
||||||
: "hsl(" + Math.round((i * 137.508) % 360) + ",70%,62%)";
|
: "hsl(" + Math.round((i * 137.508) % 360) + ",32%,62%)";
|
||||||
// 日历 5 级色阶(沿用页面热力图的 青→紫→粉 渐变语义)
|
// 日历 5 级色阶(与热力图同一套、由深到浅的蓝色阶)
|
||||||
const CAL_LV = [
|
const CAL_LV = [
|
||||||
{bg:"rgba(255,255,255,.03)", bar:"rgba(255,255,255,.07)"},
|
{bg:"transparent", bar:"#1f242b"},
|
||||||
{bg:"rgba(34,211,238,.10)", bar:"#155e75"},
|
{bg:"rgba(76,141,246,.10)", bar:"#22405e"},
|
||||||
{bg:"rgba(34,211,238,.17)", bar:"#0891b2"},
|
{bg:"rgba(76,141,246,.17)", bar:"#2f5b86"},
|
||||||
{bg:"rgba(34,211,238,.25)", bar:"#22d3ee"},
|
{bg:"rgba(76,141,246,.25)", bar:"#4c8df6"},
|
||||||
{bg:"rgba(167,139,250,.30)", bar:"#a78bfa"},
|
{bg:"rgba(201,149,47,.30)", bar:"#c9952f"},
|
||||||
{bg:"rgba(244,114,182,.32)", bar:"#f472b6"}
|
{bg:"rgba(192,122,158,.32)", bar:"#c07a9e"}
|
||||||
];
|
];
|
||||||
// 单笔消耗分布的分桶(0 = 免费,>100 为极端长尾)
|
// 单笔消耗分布的分桶(0 = 免费,>100 为极端长尾)
|
||||||
const HIST_LABELS = ["0(免费)","≤0.1","≤0.5","≤1","≤2","≤5","≤10","≤20","≤50","≤100",">100"];
|
const HIST_LABELS = ["0(免费)","≤0.1","≤0.5","≤1","≤2","≤5","≤10","≤20","≤50","≤100",">100"];
|
||||||
@@ -693,35 +692,35 @@
|
|||||||
|
|
||||||
charts.trend.setOption({
|
charts.trend.setOption({
|
||||||
grid:{left:52,right:56,top:44,bottom:52},
|
grid:{left:52,right:56,top:44,bottom:52},
|
||||||
tooltip:{trigger:"axis",backgroundColor:"rgba(12,18,32,.94)",borderColor:"rgba(255,255,255,.12)",
|
tooltip:{trigger:"axis",backgroundColor:"rgba(20,23,27,.96)",borderColor:"#262b33",
|
||||||
textStyle:{color:"#e8edf7",fontSize:12},
|
textStyle:{color:"#e4e7eb",fontSize:12},
|
||||||
formatter:p=>{
|
formatter:p=>{
|
||||||
const i = p[0].dataIndex, o = g[i];
|
const i = p[0].dataIndex, o = g[i];
|
||||||
return `<b>${o.key}</b>${i===partIdx?`(残日,截至 ${pd.hhmm})`:""}<br>`
|
return `<b>${o.key}</b>${i===partIdx?`(残日,截至 ${pd.hhmm})`:""}<br>`
|
||||||
+ `积分 ${fmt(o.credits)}<br>调用 ${fmt0(o.calls)} 次`;
|
+ `积分 ${fmt(o.credits)}<br>调用 ${fmt0(o.calls)} 次`;
|
||||||
}},
|
}},
|
||||||
legend:{data:["积分消耗","调用次数"],top:6,textStyle:{color:"#8b9bb4",fontSize:11.5},icon:"roundRect",itemWidth:11,itemHeight:11},
|
legend:{data:["积分消耗","调用次数"],top:6,textStyle:{color:"#98a1ac",fontSize:11.5},icon:"roundRect",itemWidth:11,itemHeight:11},
|
||||||
xAxis:{type:"category",data:labels,axisLabel:{color:"#8b9bb4",fontSize:11,rotate:labels.length>16?38:0},
|
xAxis:{type:"category",data:labels,axisLabel:{color:"#98a1ac",fontSize:11,rotate:labels.length>16?38:0},
|
||||||
axisLine:{lineStyle:{color:"rgba(255,255,255,.12)"}},axisTick:{show:false}},
|
axisLine:{lineStyle:{color:"#262b33"}},axisTick:{show:false}},
|
||||||
yAxis:[
|
yAxis:[
|
||||||
{type:"value",name:"积分",nameTextStyle:{color:"#8b9bb4",fontSize:11},
|
{type:"value",name:"积分",nameTextStyle:{color:"#98a1ac",fontSize:11},
|
||||||
splitLine:{lineStyle:{color:"rgba(255,255,255,.06)"}},axisLabel:{color:"#8b9bb4",fontSize:11}},
|
splitLine:{lineStyle:{color:"#1f242b"}},axisLabel:{color:"#98a1ac",fontSize:11}},
|
||||||
{type:"value",name:"次数",nameTextStyle:{color:"#8b9bb4",fontSize:11},
|
{type:"value",name:"次数",nameTextStyle:{color:"#98a1ac",fontSize:11},
|
||||||
splitLine:{show:false},axisLabel:{color:"#8b9bb4",fontSize:11}}
|
splitLine:{show:false},axisLabel:{color:"#98a1ac",fontSize:11}}
|
||||||
],
|
],
|
||||||
dataZoom:labels.length>24?[{type:"slider",height:14,bottom:14,borderColor:"transparent",
|
dataZoom:labels.length>24?[{type:"slider",height:14,bottom:14,borderColor:"transparent",
|
||||||
backgroundColor:"rgba(255,255,255,.05)",fillerColor:"rgba(34,211,238,.18)",
|
backgroundColor:"#1d2128",fillerColor:"rgba(76,141,246,.18)",
|
||||||
handleStyle:{color:"#22d3ee"},textStyle:{color:"#8b9bb4",fontSize:10}}]:[],
|
handleStyle:{color:"#4c8df6"},textStyle:{color:"#98a1ac",fontSize:10}}]:[],
|
||||||
series:[
|
series:[
|
||||||
{name:"积分消耗",type:"bar",barMaxWidth:26,
|
{name:"积分消耗",type:"bar",barMaxWidth:26,
|
||||||
data:g.map((o,i)=> i===partIdx
|
data:g.map((o,i)=> i===partIdx
|
||||||
? {value:+o.credits.toFixed(2), itemStyle:{color:"rgba(34,211,238,.34)",borderColor:"rgba(251,191,36,.6)",
|
? {value:+o.credits.toFixed(2), itemStyle:{color:"rgba(76,141,246,.34)",borderColor:"rgba(201,149,47,.6)",
|
||||||
borderWidth:1,borderType:"dashed",borderRadius:[4,4,0,0]}}
|
borderWidth:1,borderType:"dashed",borderRadius:[4,4,0,0]}}
|
||||||
: +o.credits.toFixed(2)),
|
: +o.credits.toFixed(2)),
|
||||||
itemStyle:{borderRadius:[4,4,0,0],
|
itemStyle:{borderRadius:[4,4,0,0],
|
||||||
color:new echarts.graphic.LinearGradient(0,0,0,1,[{offset:0,color:"#22d3ee"},{offset:1,color:"rgba(34,211,238,.18)"}])}},
|
color:new echarts.graphic.LinearGradient(0,0,0,1,[{offset:0,color:"#4c8df6"},{offset:1,color:"rgba(76,141,246,.18)"}])}},
|
||||||
{name:"调用次数",type:"line",yAxisIndex:1,smooth:true,symbol:"circle",symbolSize:5,
|
{name:"调用次数",type:"line",yAxisIndex:1,smooth:true,symbol:"circle",symbolSize:5,
|
||||||
data:g.map(o=>o.calls),lineStyle:{color:"#a78bfa",width:2},itemStyle:{color:"#a78bfa"}}
|
data:g.map(o=>o.calls),lineStyle:{color:"#c9952f",width:2},itemStyle:{color:"#c9952f"}}
|
||||||
]
|
]
|
||||||
},true);
|
},true);
|
||||||
}
|
}
|
||||||
@@ -738,24 +737,24 @@
|
|||||||
extra:{calls:o.calls, credits:o.credits}
|
extra:{calls:o.calls, credits:o.credits}
|
||||||
}));
|
}));
|
||||||
if(rest.length) items.push({name:"其他 "+rest.length+" 项", value:+restV.toFixed(2),
|
if(rest.length) items.push({name:"其他 "+rest.length+" 项", value:+restV.toFixed(2),
|
||||||
itemStyle:{color:"#64748b"}, extra:{calls:rest.reduce((s,o)=>s+o.calls,0),
|
itemStyle:{color:"#6b7480"}, extra:{calls:rest.reduce((s,o)=>s+o.calls,0),
|
||||||
credits:rest.reduce((s,o)=>s+o.credits,0)}});
|
credits:rest.reduce((s,o)=>s+o.credits,0)}});
|
||||||
$("#pieHint").textContent = "按"+DIM_NAME+(metric==="credits"?"统计积分消耗占比":"统计调用次数占比")
|
$("#pieHint").textContent = "按"+DIM_NAME+(metric==="credits"?"统计积分消耗占比":"统计调用次数占比")
|
||||||
+ (rest.length ? `(TOP ${N} 之外的 ${rest.length} 项已合并为「其他」)` : "");
|
+ (rest.length ? `(TOP ${N} 之外的 ${rest.length} 项已合并为「其他」)` : "");
|
||||||
charts.pie.setOption({
|
charts.pie.setOption({
|
||||||
tooltip:{trigger:"item",backgroundColor:"rgba(12,18,32,.94)",borderColor:"rgba(255,255,255,.12)",
|
tooltip:{trigger:"item",backgroundColor:"rgba(20,23,27,.96)",borderColor:"#262b33",
|
||||||
textStyle:{color:"#e8edf7",fontSize:12},
|
textStyle:{color:"#e4e7eb",fontSize:12},
|
||||||
formatter:p=>{
|
formatter:p=>{
|
||||||
const e = (p.data.extra)||{};
|
const e = (p.data.extra)||{};
|
||||||
return `<b>${p.name}</b><br>积分 ${fmt(e.credits||0)} / 调用 ${fmt0(e.calls||0)} 次<br>`
|
return `<b>${esc(p.name)}</b><br>积分 ${fmt(e.credits||0)} / 调用 ${fmt0(e.calls||0)} 次<br>`
|
||||||
+ `占${metric==="credits"?"积分":"调用"} ${p.percent}%`;
|
+ `占${metric==="credits"?"积分":"调用"} ${p.percent}%`;
|
||||||
}},
|
}},
|
||||||
legend:{type:"scroll",bottom:2,textStyle:{color:"#8b9bb4",fontSize:11},icon:"roundRect",itemWidth:10,itemHeight:10},
|
legend:{type:"scroll",bottom:2,textStyle:{color:"#98a1ac",fontSize:11},icon:"roundRect",itemWidth:10,itemHeight:10},
|
||||||
series:[{
|
series:[{
|
||||||
type:"pie",radius:["46%","70%"],center:["50%","44%"],avoidLabelOverlap:true,
|
type:"pie",radius:["46%","70%"],center:["50%","44%"],avoidLabelOverlap:true,
|
||||||
itemStyle:{borderColor:"#0b1020",borderWidth:2},
|
itemStyle:{borderColor:"#16191e",borderWidth:2},
|
||||||
label:{color:"#c7d2e3",fontSize:11,formatter:"{b}\n{d}%"},
|
label:{color:"#e4e7eb",fontSize:11,formatter:"{b}\n{d}%"},
|
||||||
labelLine:{lineStyle:{color:"rgba(255,255,255,.2)"}},
|
labelLine:{lineStyle:{color:"#3a414b"}},
|
||||||
data:items
|
data:items
|
||||||
}]
|
}]
|
||||||
},true);
|
},true);
|
||||||
@@ -776,6 +775,10 @@
|
|||||||
const metricDim = new Map();
|
const metricDim = new Map();
|
||||||
rows.forEach(x=>{ const d=DIM_KEY(x); metricDim.set(d,(metricDim.get(d)||0)+x.credits); });
|
rows.forEach(x=>{ const d=DIM_KEY(x); metricDim.set(d,(metricDim.get(d)||0)+x.credits); });
|
||||||
const dimList = [...dims].sort((a,b)=>(metricDim.get(b)||0)-(metricDim.get(a)||0));
|
const dimList = [...dims].sort((a,b)=>(metricDim.get(b)||0)-(metricDim.get(a)||0));
|
||||||
|
// 名字保持原样存进 series:图例走 ECharts 的 canvas 渲染(是像素不是 HTML),
|
||||||
|
// 转义反而会把 `&` 画成 `&`。真正需要防的是下面的 tooltip ——
|
||||||
|
// ECharts 的 tooltip 默认 renderMode 是 html,内容按 HTML 插入,
|
||||||
|
// 所以那里必须自己转义(dimList 里是 model / client 名,来自上游接口)。
|
||||||
const series = dimList.map((d,i)=>({
|
const series = dimList.map((d,i)=>({
|
||||||
name:(gran==="hour" ? d : DIM_LABEL(d)), type:"bar", stack:"t", barMaxWidth:30,
|
name:(gran==="hour" ? d : DIM_LABEL(d)), type:"bar", stack:"t", barMaxWidth:30,
|
||||||
itemStyle:{color:dimColor(i)},
|
itemStyle:{color:dimColor(i)},
|
||||||
@@ -785,13 +788,19 @@
|
|||||||
$("#stackHint").textContent = "各"+DIM_NAME+"在时间轴上的积分消耗堆叠(图例按消耗降序;无数据的时间段已补 0)";
|
$("#stackHint").textContent = "各"+DIM_NAME+"在时间轴上的积分消耗堆叠(图例按消耗降序;无数据的时间段已补 0)";
|
||||||
charts.stack.setOption({
|
charts.stack.setOption({
|
||||||
grid:{left:52,right:18,top:38,bottom:44},
|
grid:{left:52,right:18,top:38,bottom:44},
|
||||||
tooltip:{trigger:"axis",axisPointer:{type:"shadow"},backgroundColor:"rgba(12,18,32,.94)",
|
tooltip:{trigger:"axis",axisPointer:{type:"shadow"},backgroundColor:"rgba(20,23,27,.96)",
|
||||||
borderColor:"rgba(255,255,255,.12)",textStyle:{color:"#e8edf7",fontSize:12}},
|
borderColor:"#262b33",textStyle:{color:"#e4e7eb",fontSize:12},
|
||||||
legend:{type:"scroll",top:4,textStyle:{color:"#8b9bb4",fontSize:11},icon:"roundRect",itemWidth:10,itemHeight:10},
|
formatter:ps=>{
|
||||||
|
if(!ps || !ps.length) return "";
|
||||||
|
const head = ps[0].axisValueLabel || ps[0].name || "";
|
||||||
|
return `<b>${esc(head)}</b><br>` + ps.map(
|
||||||
|
p=>`${p.marker}${esc(p.seriesName)}:${fmt(p.value)}`).join("<br>");
|
||||||
|
}},
|
||||||
|
legend:{type:"scroll",top:4,textStyle:{color:"#98a1ac",fontSize:11},icon:"roundRect",itemWidth:10,itemHeight:10},
|
||||||
xAxis:{type:"category",data:bks.map(b=>bucketLabel(b)),
|
xAxis:{type:"category",data:bks.map(b=>bucketLabel(b)),
|
||||||
axisLabel:{color:"#8b9bb4",fontSize:11,rotate:bks.length>12?35:0},
|
axisLabel:{color:"#98a1ac",fontSize:11,rotate:bks.length>12?35:0},
|
||||||
axisLine:{lineStyle:{color:"rgba(255,255,255,.12)"}},axisTick:{show:false}},
|
axisLine:{lineStyle:{color:"#262b33"}},axisTick:{show:false}},
|
||||||
yAxis:{type:"value",splitLine:{lineStyle:{color:"rgba(255,255,255,.06)"}},axisLabel:{color:"#8b9bb4",fontSize:11}},
|
yAxis:{type:"value",splitLine:{lineStyle:{color:"#1f242b"}},axisLabel:{color:"#98a1ac",fontSize:11}},
|
||||||
series:series
|
series:series
|
||||||
},true);
|
},true);
|
||||||
}
|
}
|
||||||
@@ -843,19 +852,19 @@
|
|||||||
|
|
||||||
charts.heat.setOption({
|
charts.heat.setOption({
|
||||||
grid:{left:single?68:78,right:20,top:16,bottom:46},
|
grid:{left:single?68:78,right:20,top:16,bottom:46},
|
||||||
tooltip:{backgroundColor:"rgba(12,18,32,.94)",borderColor:"rgba(255,255,255,.12)",
|
tooltip:{backgroundColor:"rgba(20,23,27,.96)",borderColor:"#262b33",
|
||||||
textStyle:{color:"#e8edf7",fontSize:12},
|
textStyle:{color:"#e4e7eb",fontSize:12},
|
||||||
formatter:p=>`<b>${yData[p.value[1]]}</b> ${hours[p.value[0]]}:00<br>`
|
formatter:p=>`<b>${esc(yData[p.value[1]])}</b> ${hours[p.value[0]]}:00<br>`
|
||||||
+ `消耗 ${fmt(p.value[2])} 积分<br>调用 ${fmt0(p.value[3]||0)} 次`},
|
+ `消耗 ${fmt(p.value[2])} 积分<br>调用 ${fmt0(p.value[3]||0)} 次`},
|
||||||
xAxis:{type:"category",data:xData,splitArea:{show:true,areaStyle:{color:["rgba(255,255,255,.02)","transparent"]}},
|
xAxis:{type:"category",data:xData,splitArea:{show:true,areaStyle:{color:["#14171b","transparent"]}},
|
||||||
axisLabel:{color:"#8b9bb4",fontSize:10},axisLine:{lineStyle:{color:"rgba(255,255,255,.12)"}}},
|
axisLabel:{color:"#98a1ac",fontSize:10},axisLine:{lineStyle:{color:"#262b33"}}},
|
||||||
yAxis:{type:"category",data:yData,axisLabel:{color:"#8b9bb4",fontSize:10},
|
yAxis:{type:"category",data:yData,axisLabel:{color:"#98a1ac",fontSize:10},
|
||||||
axisLine:{lineStyle:{color:"rgba(255,255,255,.12)"}}},
|
axisLine:{lineStyle:{color:"#262b33"}}},
|
||||||
visualMap:{min:0,max:max||1,calculable:false,orient:"horizontal",left:"center",bottom:6,
|
visualMap:{min:0,max:max||1,calculable:false,orient:"horizontal",left:"center",bottom:6,
|
||||||
itemWidth:11,itemHeight:90,textStyle:{color:"#8b9bb4",fontSize:10},
|
itemWidth:11,itemHeight:90,textStyle:{color:"#98a1ac",fontSize:10},
|
||||||
inRange:{color:["#0f2233","#155e75","#0891b2","#22d3ee","#fbbf24","#f472b6"]}},
|
inRange:{color:["#0f2233","#22405e","#2f5b86","#4c8df6","#c9952f","#c07a9e"]}},
|
||||||
series:[{type:"heatmap",data:data,
|
series:[{type:"heatmap",data:data,
|
||||||
itemStyle:{borderColor:"rgba(0,0,0,.25)",borderWidth:.5},
|
itemStyle:{borderColor:"#0e1013",borderWidth:.5},
|
||||||
emphasis:{itemStyle:{borderColor:"#fff",borderWidth:1}}}]
|
emphasis:{itemStyle:{borderColor:"#fff",borderWidth:1}}}]
|
||||||
},true);
|
},true);
|
||||||
}
|
}
|
||||||
@@ -884,31 +893,31 @@
|
|||||||
|
|
||||||
charts.hist.setOption({
|
charts.hist.setOption({
|
||||||
grid:{left:52,right:52,top:38,bottom:52},
|
grid:{left:52,right:52,top:38,bottom:52},
|
||||||
tooltip:{trigger:"axis",backgroundColor:"rgba(12,18,32,.94)",borderColor:"rgba(255,255,255,.12)",
|
tooltip:{trigger:"axis",backgroundColor:"rgba(20,23,27,.96)",borderColor:"#262b33",
|
||||||
textStyle:{color:"#e8edf7",fontSize:12},
|
textStyle:{color:"#e4e7eb",fontSize:12},
|
||||||
formatter:p=>{
|
formatter:p=>{
|
||||||
const i=p[0].dataIndex;
|
const i=p[0].dataIndex;
|
||||||
return `<b>${HIST_LABELS[i]}</b><br>${fmt0(cnt[i])} 笔(占笔数 ${(cnt[i]/tk*100).toFixed(1)}%)<br>`
|
return `<b>${HIST_LABELS[i]}</b><br>${fmt0(cnt[i])} 笔(占笔数 ${(cnt[i]/tk*100).toFixed(1)}%)<br>`
|
||||||
+ `积分 ${fmt(cred[i])}(占 ${share[i]}%)<br>累计积分占比 ${cumShare[i]}%`;
|
+ `积分 ${fmt(cred[i])}(占 ${share[i]}%)<br>累计积分占比 ${cumShare[i]}%`;
|
||||||
}},
|
}},
|
||||||
legend:{data:["笔数","积分占比","累计积分占比"],top:4,textStyle:{color:"#8b9bb4",fontSize:11},
|
legend:{data:["笔数","积分占比","累计积分占比"],top:4,textStyle:{color:"#98a1ac",fontSize:11},
|
||||||
icon:"roundRect",itemWidth:10,itemHeight:10},
|
icon:"roundRect",itemWidth:10,itemHeight:10},
|
||||||
xAxis:{type:"category",data:HIST_LABELS,axisLabel:{color:"#8b9bb4",fontSize:10,rotate:32},
|
xAxis:{type:"category",data:HIST_LABELS,axisLabel:{color:"#98a1ac",fontSize:10,rotate:32},
|
||||||
axisLine:{lineStyle:{color:"rgba(255,255,255,.12)"}},axisTick:{show:false}},
|
axisLine:{lineStyle:{color:"#262b33"}},axisTick:{show:false}},
|
||||||
yAxis:[
|
yAxis:[
|
||||||
{type:"value",name:"笔数",nameTextStyle:{color:"#8b9bb4",fontSize:11},
|
{type:"value",name:"笔数",nameTextStyle:{color:"#98a1ac",fontSize:11},
|
||||||
splitLine:{lineStyle:{color:"rgba(255,255,255,.06)"}},axisLabel:{color:"#8b9bb4",fontSize:11}},
|
splitLine:{lineStyle:{color:"#1f242b"}},axisLabel:{color:"#98a1ac",fontSize:11}},
|
||||||
{type:"value",name:"%",max:100,nameTextStyle:{color:"#8b9bb4",fontSize:11},
|
{type:"value",name:"%",max:100,nameTextStyle:{color:"#98a1ac",fontSize:11},
|
||||||
splitLine:{show:false},axisLabel:{color:"#8b9bb4",fontSize:11}}
|
splitLine:{show:false},axisLabel:{color:"#98a1ac",fontSize:11}}
|
||||||
],
|
],
|
||||||
series:[
|
series:[
|
||||||
{name:"笔数",type:"bar",barMaxWidth:28,data:cnt,
|
{name:"笔数",type:"bar",barMaxWidth:28,data:cnt,
|
||||||
itemStyle:{borderRadius:[4,4,0,0],
|
itemStyle:{borderRadius:[4,4,0,0],
|
||||||
color:new echarts.graphic.LinearGradient(0,0,0,1,[{offset:0,color:"#a78bfa"},{offset:1,color:"rgba(167,139,250,.18)"}])}},
|
color:new echarts.graphic.LinearGradient(0,0,0,1,[{offset:0,color:"#c9952f"},{offset:1,color:"rgba(201,149,47,.18)"}])}},
|
||||||
{name:"积分占比",type:"line",yAxisIndex:1,smooth:true,symbol:"circle",symbolSize:5,data:share,
|
{name:"积分占比",type:"line",yAxisIndex:1,smooth:true,symbol:"circle",symbolSize:5,data:share,
|
||||||
lineStyle:{color:"#fbbf24",width:2},itemStyle:{color:"#fbbf24"}},
|
lineStyle:{color:"#c9952f",width:2},itemStyle:{color:"#c9952f"}},
|
||||||
{name:"累计积分占比",type:"line",yAxisIndex:1,smooth:true,symbol:"none",data:cumShare,
|
{name:"累计积分占比",type:"line",yAxisIndex:1,smooth:true,symbol:"none",data:cumShare,
|
||||||
lineStyle:{color:"#22d3ee",width:1.6,type:"dashed"},itemStyle:{color:"#22d3ee"}}
|
lineStyle:{color:"#4c8df6",width:1.6,type:"dashed"},itemStyle:{color:"#4c8df6"}}
|
||||||
]
|
]
|
||||||
},true);
|
},true);
|
||||||
}
|
}
|
||||||
@@ -927,25 +936,25 @@
|
|||||||
+ "右上角=又多又贵,右下角=多但便宜(免费模型贴在横轴上)";
|
+ "右上角=又多又贵,右下角=多但便宜(免费模型贴在横轴上)";
|
||||||
charts.scatter.setOption({
|
charts.scatter.setOption({
|
||||||
grid:{left:62,right:34,top:34,bottom:46},
|
grid:{left:62,right:34,top:34,bottom:46},
|
||||||
tooltip:{backgroundColor:"rgba(12,18,32,.94)",borderColor:"rgba(255,255,255,.12)",
|
tooltip:{backgroundColor:"rgba(20,23,27,.96)",borderColor:"#262b33",
|
||||||
textStyle:{color:"#e8edf7",fontSize:12},
|
textStyle:{color:"#e4e7eb",fontSize:12},
|
||||||
formatter:p=>{
|
formatter:p=>{
|
||||||
const d = p.data;
|
const d = p.data;
|
||||||
return `<b>${d.name}</b><br>调用 ${fmt0(d.calls)} 次<br>积分 ${fmt(d.credits)}<br>`
|
return `<b>${esc(d.name)}</b><br>调用 ${fmt0(d.calls)} 次<br>积分 ${fmt(d.credits)}<br>`
|
||||||
+ `单次均价 ${fmt(d.avg)}<br>免费调用 ${fmt0(d.free)} 次(${d.calls?(d.free/d.calls*100).toFixed(1):"0"}%)`;
|
+ `单次均价 ${fmt(d.avg)}<br>免费调用 ${fmt0(d.free)} 次(${d.calls?(d.free/d.calls*100).toFixed(1):"0"}%)`;
|
||||||
}},
|
}},
|
||||||
xAxis:{type:"value",name:"调用次数",nameTextStyle:{color:"#8b9bb4",fontSize:11},
|
xAxis:{type:"value",name:"调用次数",nameTextStyle:{color:"#98a1ac",fontSize:11},
|
||||||
splitLine:{lineStyle:{color:"rgba(255,255,255,.06)"}},axisLabel:{color:"#8b9bb4",fontSize:11}},
|
splitLine:{lineStyle:{color:"#1f242b"}},axisLabel:{color:"#98a1ac",fontSize:11}},
|
||||||
yAxis:{type:"value",name:"积分消耗",nameTextStyle:{color:"#8b9bb4",fontSize:11},
|
yAxis:{type:"value",name:"积分消耗",nameTextStyle:{color:"#98a1ac",fontSize:11},
|
||||||
splitLine:{lineStyle:{color:"rgba(255,255,255,.06)"}},axisLabel:{color:"#8b9bb4",fontSize:11}},
|
splitLine:{lineStyle:{color:"#1f242b"}},axisLabel:{color:"#98a1ac",fontSize:11}},
|
||||||
series:[{
|
series:[{
|
||||||
type:"scatter", data:pts,
|
type:"scatter", data:pts,
|
||||||
symbolSize:v=>6 + Math.sqrt((v[2]||0)/maxAvg) * 32,
|
symbolSize:v=>6 + Math.sqrt((v[2]||0)/maxAvg) * 32,
|
||||||
itemStyle:{color:p=>{
|
itemStyle:{color:p=>{
|
||||||
const r = p.data.calls/maxCalls;
|
const r = p.data.calls/maxCalls;
|
||||||
return `rgba(34,211,238,${(0.30 + 0.55*r).toFixed(2)})`;},
|
return `rgba(76,141,246,${(0.30 + 0.55*r).toFixed(2)})`;},
|
||||||
borderColor:"rgba(167,139,250,.85)",borderWidth:1},
|
borderColor:"rgba(201,149,47,.85)",borderWidth:1},
|
||||||
label:{show:true,position:"top",color:"#8b9bb4",fontSize:10,formatter:p=>p.data.name},
|
label:{show:true,position:"top",color:"#98a1ac",fontSize:10,formatter:p=>p.data.name},
|
||||||
emphasis:{itemStyle:{borderColor:"#fff"}}
|
emphasis:{itemStyle:{borderColor:"#fff"}}
|
||||||
}]
|
}]
|
||||||
},true);
|
},true);
|
||||||
@@ -959,7 +968,7 @@
|
|||||||
`<thead><tr><th>${DIM_NAME}</th><th class="num">调用次数</th><th class="num">计费 / 免费</th>
|
`<thead><tr><th>${DIM_NAME}</th><th class="num">调用次数</th><th class="num">计费 / 免费</th>
|
||||||
<th class="num">积分消耗</th><th class="num">占比</th><th class="num">单次均值</th></tr></thead>
|
<th class="num">积分消耗</th><th class="num">占比</th><th class="num">单次均值</th></tr></thead>
|
||||||
<tbody>${g.map((o,i)=>`<tr>
|
<tbody>${g.map((o,i)=>`<tr>
|
||||||
<td><span class="dot" style="background:${dimColor(i)}"></span>${DIM_LABEL(o.key)}</td>
|
<td><span class="dot" style="background:${dimColor(i)}"></span>${esc(DIM_LABEL(o.key))}</td>
|
||||||
<td class="num">${fmt0(o.calls)}</td>
|
<td class="num">${fmt0(o.calls)}</td>
|
||||||
<td class="num">${fmt0(o.billableCalls)} / <span style="color:var(--blue)">${fmt0(o.freeCalls)}</span></td>
|
<td class="num">${fmt0(o.billableCalls)} / <span style="color:var(--blue)">${fmt0(o.freeCalls)}</span></td>
|
||||||
<td class="num">${fmt(o.credits)}</td>
|
<td class="num">${fmt(o.credits)}</td>
|
||||||
@@ -988,7 +997,7 @@
|
|||||||
if(topState.mode==="global"){
|
if(topState.mode==="global"){
|
||||||
const t = (state.src && state.src.top) || [];
|
const t = (state.src && state.src.top) || [];
|
||||||
items = t.slice(0,20).map(x=>({rank:x.rank, t:x.t, model:x.m, client:x.cl, credits:x.c, prompt:x.px}));
|
items = t.slice(0,20).map(x=>({rank:x.rank, t:x.t, model:x.m, client:x.cl, credits:x.c, prompt:x.px}));
|
||||||
$("#topHint").textContent = "全局 TOP 20(来自 top.json,由脚本按积分降序全量计算,不受上方筛选影响)";
|
$("#topHint").textContent = "全部存档的 TOP 20(按积分降序,不受上方筛选影响)";
|
||||||
}else{
|
}else{
|
||||||
items = state.rows.slice().sort((a,b)=>b.credits-a.credits).slice(0,20)
|
items = state.rows.slice().sort((a,b)=>b.credits-a.credits).slice(0,20)
|
||||||
.map((x,i)=>({rank:i+1, t:x.t, model:x.model, client:x.client, credits:x.credits, prompt:x.prompt}));
|
.map((x,i)=>({rank:i+1, t:x.t, model:x.model, client:x.client, credits:x.credits, prompt:x.prompt}));
|
||||||
@@ -1000,8 +1009,8 @@
|
|||||||
<th class="num">积分</th><th>Prompt 摘要</th></tr></thead>
|
<th class="num">积分</th><th>Prompt 摘要</th></tr></thead>
|
||||||
<tbody>${items.map(x=>`<tr>
|
<tbody>${items.map(x=>`<tr>
|
||||||
<td class="num">${x.rank}</td>
|
<td class="num">${x.rank}</td>
|
||||||
<td>${(x.t||"").slice(5,16)}</td>
|
<td>${esc((x.t||"").slice(5,16))}</td>
|
||||||
<td>${x.model}</td><td>${x.client}</td>
|
<td>${esc(x.model)}</td><td>${esc(x.client)}</td>
|
||||||
<td class="num">${fmt(x.credits)}
|
<td class="num">${fmt(x.credits)}
|
||||||
<div class="bar-mini" style="width:46px;display:inline-block;vertical-align:2px;margin-left:6px">
|
<div class="bar-mini" style="width:46px;display:inline-block;vertical-align:2px;margin-left:6px">
|
||||||
<div style="width:${(x.credits/max*100).toFixed(1)}%"></div></div></td>
|
<div style="width:${(x.credits/max*100).toFixed(1)}%"></div></div></td>
|
||||||
@@ -1014,7 +1023,17 @@
|
|||||||
function renderSources(){
|
function renderSources(){
|
||||||
const mf = (state.src && state.src.manifest) || {};
|
const mf = (state.src && state.src.manifest) || {};
|
||||||
const srcs = mf.sources || [];
|
const srcs = mf.sources || [];
|
||||||
const rows = srcs.map(s=>`<tr><td><code>${s.path}</code></td><td>${s.role||"-"}</td>
|
// 服务端对非管理员不下发「数据源」清单(表名 / 库文件属于实现细节),
|
||||||
|
// 这时把表格整块收起来 —— 只隐藏表格本身会留一个只有表头的空壳。
|
||||||
|
const wrap = $("#tblSrc").parentNode;
|
||||||
|
if(wrap){
|
||||||
|
wrap.style.display = srcs.length ? "" : "none";
|
||||||
|
const cap = wrap.previousElementSibling;
|
||||||
|
if(cap) cap.textContent = srcs.length
|
||||||
|
? "数据由本项目 Flask 后端从 SQLite 实时聚合"
|
||||||
|
: "你的数据由服务端实时聚合,口径自检如下";
|
||||||
|
}
|
||||||
|
const rows = srcs.map(s=>`<tr><td><code>${esc(s.path)}</code></td><td>${esc(s.role||"-")}</td>
|
||||||
<td class="num">${fmt0(s.count)}</td><td class="num">${s.bytes? (s.bytes/1024).toFixed(1)+" KB" : "查询"}</td></tr>`).join("");
|
<td class="num">${fmt0(s.count)}</td><td class="num">${s.bytes? (s.bytes/1024).toFixed(1)+" KB" : "查询"}</td></tr>`).join("");
|
||||||
$("#tblSrc").innerHTML =
|
$("#tblSrc").innerHTML =
|
||||||
`<thead><tr><th>数据源</th><th>角色</th><th class="num">条目</th><th class="num">体积</th></tr></thead>
|
`<thead><tr><th>数据源</th><th>角色</th><th class="num">条目</th><th class="num">体积</th></tr></thead>
|
||||||
@@ -1029,11 +1048,10 @@
|
|||||||
$("#srcCheck").innerHTML =
|
$("#srcCheck").innerHTML =
|
||||||
`<div>存档总量(SQLite 正本):${fmt0(t.records||0)} 条 / ${fmt(t.credits||0)} 积分 · `
|
`<div>存档总量(SQLite 正本):${fmt0(t.records||0)} 条 / ${fmt(t.credits||0)} 积分 · `
|
||||||
+ `${(t.days||[]).length} 个活跃日 · ${(t.months||[]).length} 个月</div>`
|
+ `${(t.days||[]).length} 个活跃日 · ${(t.months||[]).length} 个月</div>`
|
||||||
+ (w ? `<div>当前窗口(SQL 聚合):${fmt0(w.records||0)} 条 / ${fmt(w.credits||0)} 积分 | `
|
+ (w ? `<div>当前窗口:SQL 聚合 ${fmt0(w.records||0)} 条 / ${fmt(w.credits||0)} 积分,`
|
||||||
+ `页面按明细实时聚合:${fmt0(state.all.length)} 条 / ${fmt(mine)} 积分 `
|
+ `页面重算 ${fmt0(state.all.length)} 条 / ${fmt(mine)} 积分 `
|
||||||
+ `<span class="${ok?"ok":"bad"}">${ok?"✔ 一致":"✖ 不一致"}</span></div>` : "")
|
+ `<span class="${ok?"ok":"bad"}">${ok?"一致":"不一致"}</span></div>` : "")
|
||||||
+ `<div>口径:明细 / 逐日 / 维度 / TOP 全部由 SQLite 实时计算(无中间 JSON 层) · `
|
+ `<div>Cookie ${h.cookie?"已配置":"<b style='color:#cf6679'>未配置</b>"}`
|
||||||
+ `Cookie ${h.cookie?"已配置":"<b style='color:#f87171'>未配置</b>"}`
|
|
||||||
+ `${h.lastRunAt?` · 最近采集 ${h.lastRunAt}(${h.lastRunStatus||"-"})`:""}</div>`;
|
+ `${h.lastRunAt?` · 最近采集 ${h.lastRunAt}(${h.lastRunStatus||"-"})`:""}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1133,8 +1151,8 @@
|
|||||||
`<b>${d}</b>${(pd && pd.date===d)?`<span class="tag">残日 · 截至 ${pd.hhmm}</span>`:""}<br>`
|
`<b>${d}</b>${(pd && pd.date===d)?`<span class="tag">残日 · 截至 ${pd.hhmm}</span>`:""}<br>`
|
||||||
+ `积分消耗 ${fmt(o.credits)}<br>`
|
+ `积分消耗 ${fmt(o.credits)}<br>`
|
||||||
+ `调用次数 ${fmt0(o.calls)} 次(计费 ${fmt0(o.billableCalls||0)} · 免费 ${fmt0(o.freeCalls||0)})<br>`
|
+ `调用次数 ${fmt0(o.calls)} 次(计费 ${fmt0(o.billableCalls||0)} · 免费 ${fmt0(o.freeCalls||0)})<br>`
|
||||||
+ `TOP 模型 ${tm? tm[0]+"("+fmt(tm[1])+")" : "-"}<br>`
|
+ `TOP 模型 ${tm? esc(tm[0])+"("+fmt(tm[1])+")" : "-"}<br>`
|
||||||
+ `TOP 时段 ${th? th[0]+":00("+fmt(th[1])+")" : "-"}`;
|
+ `TOP 时段 ${th? esc(th[0])+":00("+fmt(th[1])+")" : "-"}`;
|
||||||
this.el.style.display = "block";
|
this.el.style.display = "block";
|
||||||
}
|
}
|
||||||
const w = this.el.offsetWidth, h = this.el.offsetHeight;
|
const w = this.el.offsetWidth, h = this.el.offsetHeight;
|
||||||
@@ -1222,13 +1240,11 @@
|
|||||||
state.all.forEach(x=>{ b[x.client] = (b[x.client]||0) + x.credits; });
|
state.all.forEach(x=>{ b[x.client] = (b[x.client]||0) + x.credits; });
|
||||||
const tot = Object.values(b).reduce((s,v)=>s+v,0)||1;
|
const tot = Object.values(b).reduce((s,v)=>s+v,0)||1;
|
||||||
const parts = Object.entries(b).sort((a,c)=>c[1]-a[1])
|
const parts = Object.entries(b).sort((a,c)=>c[1]-a[1])
|
||||||
.map(([k,v])=>k+" "+(v/tot*100).toFixed(1)+"%").join(" · ");
|
.map(([k,v])=>esc(k)+" "+(v/tot*100).toFixed(1)+"%").join(" · ");
|
||||||
const t = (state.mf && state.mf.totals) || {};
|
const t = (state.mf && state.mf.totals) || {};
|
||||||
$(".sub").innerHTML = "数据来源:本项目 Flask 后端采集云端用量明细 → SQLite("
|
$(".sub").innerHTML = "数据来源:<code>data/usage.sqlite</code>,经 <code>/api</code> 实时聚合;"
|
||||||
+ "<code>data/usage.sqlite</code>),本页经 <code>/api</code> 实时聚合,"
|
+ `本窗口 ${fmt0(state.all.length)} 条 / ${fmt(state.all.reduce((s,x)=>s+x.credits,0))} 积分,`
|
||||||
+ "无中间 JSON 层;本窗口客户端构成:" + parts
|
+ `全量 ${fmt0(t.records||0)} 条 · 客户端构成:` + parts;
|
||||||
+ `(窗口内 ${fmt0(state.all.length)} 条 / ${fmt(state.all.reduce((s,x)=>s+x.credits,0))} 积分,`
|
|
||||||
+ `全量 ${fmt0(t.records||0)} 条)`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 取数 + 渲染:窗口变了先按新窗口拉一次明细,再交给原有的渲染管线
|
// 取数 + 渲染:窗口变了先按新窗口拉一次明细,再交给原有的渲染管线
|
||||||
@@ -1272,6 +1288,9 @@
|
|||||||
$("#err").style.display = "none";
|
$("#err").style.display = "none";
|
||||||
const mf = src.manifest || {};
|
const mf = src.manifest || {};
|
||||||
$("#mUpdate").textContent = mf.generated || "-";
|
$("#mUpdate").textContent = mf.generated || "-";
|
||||||
|
// 管理员入口按角色显隐(大屏是静态页,拿不到 Jinja 上下文)
|
||||||
|
const logsBtn = $("#abtnLogs");
|
||||||
|
if (logsBtn) logsBtn.hidden = (mf.role !== "admin");
|
||||||
initDays();
|
initDays();
|
||||||
bind();
|
bind();
|
||||||
renderScope();
|
renderScope();
|
||||||
|
|||||||
@@ -61,6 +61,12 @@
|
|||||||
var out = {};
|
var out = {};
|
||||||
Array.prototype.forEach.call(form.elements, function (el) {
|
Array.prototype.forEach.call(form.elements, function (el) {
|
||||||
if (!el.name || el.type === "submit" || el.name === "_csrf") return;
|
if (!el.name || el.type === "submit" || el.name === "_csrf") return;
|
||||||
|
// 只读控件必须跳过:disabled 的 input 仍然出现在 form.elements 里,
|
||||||
|
// 若被一起提交,服务端会因为「越权修改只读项」把**整单**拒掉 ——
|
||||||
|
// 表现成「改了 A 却提示 A 不能改」,很难归因。
|
||||||
|
// 注意:fieldset 被 disable 时子元素自身的 disabled 仍是 false,
|
||||||
|
// 所以还要往上找祖先 fieldset。
|
||||||
|
if (el.disabled || (el.closest && el.closest("fieldset[disabled]"))) return;
|
||||||
if (el.type === "checkbox") { out[el.name] = el.checked ? "1" : "0"; return; }
|
if (el.type === "checkbox") { out[el.name] = el.checked ? "1" : "0"; return; }
|
||||||
if (el.type === "radio") { if (el.checked) out[el.name] = el.value; return; }
|
if (el.type === "radio") { if (el.checked) out[el.name] = el.value; return; }
|
||||||
out[el.name] = el.value;
|
out[el.name] = el.value;
|
||||||
@@ -72,7 +78,7 @@
|
|||||||
function hintOf(j) {
|
function hintOf(j) {
|
||||||
if (!j) return "";
|
if (!j) return "";
|
||||||
if (j.error === "cookie_expired") return "\n请到「配置管理」更新 Cookie 与 User-Agent(两者须取自同一次浏览器请求)。";
|
if (j.error === "cookie_expired") return "\n请到「配置管理」更新 Cookie 与 User-Agent(两者须取自同一次浏览器请求)。";
|
||||||
if (j.error === "busy") return "\n已有采集在进行,可稍后重试,或到「日志管理」查看进度。";
|
if (j.error === "busy") return "\n已有采集在进行,可稍后重试;「任务管理」里能看到本次运行的状态。";
|
||||||
if (j.errors && j.errors.length) return "\n" + j.errors.join("\n");
|
if (j.errors && j.errors.length) return "\n" + j.errors.join("\n");
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}备份管理 · {{ project_title }}{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
|
||||||
|
<div class="pagehead">
|
||||||
|
<div>
|
||||||
|
<h1>备份管理</h1>
|
||||||
|
<p class="lead">
|
||||||
|
整库<b>一致性快照</b>:可自动按周期备份、按份数自动清理,也能手工下载与恢复到任意一份
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<span class="tag accent">仅管理员可见</span>
|
||||||
|
<button class="btn primary" id="btnBackupNow" type="button">立即备份</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="collectMsg" class="flash" style="display:none"></div>
|
||||||
|
|
||||||
|
<div class="kpis">
|
||||||
|
<div class="kpi" style="--c:var(--cyan)">
|
||||||
|
<span>归档份数</span><b>{{ info.count }}</b>
|
||||||
|
<i>保留上限 {{ info.keep }} 份</i>
|
||||||
|
</div>
|
||||||
|
<div class="kpi" style="--c:var(--violet)">
|
||||||
|
<span>备份占用</span><b>{{ info.total }}</b>
|
||||||
|
<i>当前库 {{ info.db_bytes }}</i>
|
||||||
|
</div>
|
||||||
|
<div class="kpi" style="--c:{{ 'var(--green)' if info.enabled else 'var(--red)' }}">
|
||||||
|
<span>自动备份</span><b>{{ '已开启' if info.enabled else '已关闭' }}</b>
|
||||||
|
<i>每 {{ info.interval }} 小时一次</i>
|
||||||
|
</div>
|
||||||
|
<div class="kpi" style="--c:var(--blue)">
|
||||||
|
<span>上次 / 下次自动备份</span>
|
||||||
|
<b>{{ info.last_auto[5:16] if info.last_auto != '—' else '还没有' }}</b>
|
||||||
|
<i>{% if info.never_auto %}等待调度线程首轮检查(约 20 秒)
|
||||||
|
{%- elif info.next_auto != '—' %}下次 {{ info.next_auto[5:16] }}
|
||||||
|
{%- else %}未开启{% endif %}</i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flash warn" style="margin-bottom:16px">
|
||||||
|
恢复会用归档内容覆盖当前全部数据(账号、用量、配置)。系统会在恢复前自动备份当前库并保留在列表里,
|
||||||
|
恢复错了可以再恢复到那一份。恢复完成后所有登录会话失效,需要重新登录。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid2">
|
||||||
|
<section class="card">
|
||||||
|
<div class="cardhead">
|
||||||
|
<h2>自动备份设置</h2>
|
||||||
|
<span class="tag accent">实例级</span>
|
||||||
|
</div>
|
||||||
|
<form id="formBackup">
|
||||||
|
{% set b = num_settings %}
|
||||||
|
<label class="row"><span>启用自动备份</span>
|
||||||
|
<select name="backup_enabled">
|
||||||
|
<option value="1" {{ 'selected' if s.backup_enabled != '0' }}>启用</option>
|
||||||
|
<option value="0" {{ 'selected' if s.backup_enabled == '0' }}>关闭</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="row"><span>备份周期</span>
|
||||||
|
<input name="backup_interval_hours" type="number"
|
||||||
|
min="{{ b.backup_interval_hours[0] }}" max="{{ b.backup_interval_hours[1] }}"
|
||||||
|
value="{{ s.backup_interval_hours }}">
|
||||||
|
<em class="unit">{{ b.backup_interval_hours[0] }}~{{ b.backup_interval_hours[1] }} 小时</em></label>
|
||||||
|
<label class="row"><span>保留份数</span>
|
||||||
|
<input name="backup_keep" type="number"
|
||||||
|
min="{{ b.backup_keep[0] }}" max="{{ b.backup_keep[1] }}"
|
||||||
|
value="{{ s.backup_keep }}">
|
||||||
|
<em class="unit">份(超出的自动删最旧的)</em></label>
|
||||||
|
<button class="btn primary" type="submit">保存备份设置</button>
|
||||||
|
<p class="hint">自动备份在调度线程里执行,有采集在跑时让路到下一轮。走 SQLite 在线备份 API,
|
||||||
|
采集写入期间也能拿到一致快照。打完按「保留份数」清理最旧的。</p>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="cardhead">
|
||||||
|
<h2>归档里有什么</h2>
|
||||||
|
<span class="tag mute">安全提示</span>
|
||||||
|
</div>
|
||||||
|
<table class="kv">
|
||||||
|
<tr><th>备份目录</th><td class="mono" style="word-break:break-all">{{ info.dir }}</td></tr>
|
||||||
|
<tr><th>内容</th><td>
|
||||||
|
所有 <code>*.sqlite</code> 的一致性快照<br>
|
||||||
|
<code>instance.json</code>(含两把主密钥)<br>
|
||||||
|
<code>manifest.json</code>(时间、行数、积分、逐文件校验值)
|
||||||
|
</td></tr>
|
||||||
|
<tr><th>为什么带上密钥</th><td>
|
||||||
|
没有 <code>instance.json</code> 里的 <code>cookie_key</code>,归档中的凭证密文就解不开。
|
||||||
|
</td></tr>
|
||||||
|
<tr><th>保管要求</th><td>
|
||||||
|
归档 = 全库数据 + 密钥,下载后请按机密文件保管;它不会进入仓库,也不会被打进镜像。
|
||||||
|
</td></tr>
|
||||||
|
<tr><th>目录独立</th><td>
|
||||||
|
备份目录不在 <code>data/</code> 内,避免容器 <code>down -v</code> 时正本与副本一起被删;
|
||||||
|
容器里挂在独立卷 <code>wb_backups</code> 上。
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="cardhead">
|
||||||
|
<h2>备份列表</h2>
|
||||||
|
<span class="hint">新 → 旧,共 {{ rows|length }} 条(含已丢失的条目)</span>
|
||||||
|
</div>
|
||||||
|
<div class="tablewrap">
|
||||||
|
<table class="tbl" id="backupTable">
|
||||||
|
<thead><tr>
|
||||||
|
<th>文件名</th><th>生成时间</th><th>来源</th><th class="num">大小</th>
|
||||||
|
<th class="num">记录</th><th class="num">积分</th><th class="num">账号</th>
|
||||||
|
<th>库版本</th><th>操作</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in rows %}
|
||||||
|
<tr data-file="{{ r.filename }}" data-exists="{{ 1 if r.exists else 0 }}">
|
||||||
|
<td class="mono sm" style="word-break:break-all">
|
||||||
|
{{ r.filename }}
|
||||||
|
{% if not r.exists %}<br><span class="tag bad">文件已丢失</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="mono sm nowrap">{{ r.created_at or '—' }}</td>
|
||||||
|
<td class="nowrap">
|
||||||
|
<span class="tag {{ 'accent' if r.trigger == 'manual' else ('warn' if r.trigger == 'pre-restore' else 'mute') }}">
|
||||||
|
{{ r.trigger }}</span>
|
||||||
|
{# CLI 造的备份 trigger 与 actor 都是 "cli",印两遍是纯噪音;
|
||||||
|
只在两者不同时(auto/system、manual/张三)才补一行操作者 #}
|
||||||
|
{% if r.actor and r.actor != r.trigger %}
|
||||||
|
<br><span class="muted sm">{{ r.actor }}</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="num nowrap">{{ r.size_h }}</td>
|
||||||
|
<td class="num">{% if r.records %}{{ '{:,}'.format(r.records) }}{% else %}—{% endif %}</td>
|
||||||
|
<td class="num">{% if r.credits %}{{ '%.2f'|format(r.credits) }}{% else %}—{% endif %}</td>
|
||||||
|
<td class="num">{% if r.users %}{{ r.users }}{% else %}—{% endif %}</td>
|
||||||
|
<td class="mono sm">uv={{ r.schema_ver }}</td>
|
||||||
|
<td class="nowrap">
|
||||||
|
{% if r.exists %}
|
||||||
|
<a class="btn sm ghost" href="{{ url_for('api.api_backup_download', filename=r.filename) }}">下载</a>
|
||||||
|
<button class="btn sm" type="button" data-act="restore">恢复</button>
|
||||||
|
<button class="btn sm danger" type="button" data-act="del">删除</button>
|
||||||
|
{% else %}
|
||||||
|
<button class="btn sm ghost" type="button" data-act="forget"
|
||||||
|
title="文件已不在磁盘上,只清掉这条索引记录">移除条目</button>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="9" class="empty">还没有任何备份。点右上角「立即备份」生成第一份。</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p class="hint">「记录 / 积分 / 账号」是归档生成时的计数。恢复前会先校验归档完整性与库结构版本,校验不过不动数据。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||||
|
<script>
|
||||||
|
WBU.bindForm('#formBackup', '/api/settings');
|
||||||
|
|
||||||
|
document.getElementById('btnBackupNow').addEventListener('click', function () {
|
||||||
|
var btn = this, old = btn.textContent;
|
||||||
|
btn.disabled = true; btn.textContent = '备份中…';
|
||||||
|
WBU.post('/api/backups', {}).then(function (j) {
|
||||||
|
if (j.ok === false) { WBU.say(j.message || '备份失败', 'error'); return; }
|
||||||
|
WBU.say(j.message || '备份完成', 'ok');
|
||||||
|
window.setTimeout(function () { location.reload(); }, 1200);
|
||||||
|
}).catch(function (e) {
|
||||||
|
if (String(e.message) !== 'unauthorized') WBU.say('请求失败:' + e.message, 'error');
|
||||||
|
}).finally(function () { btn.disabled = false; btn.textContent = old; });
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('backupTable').addEventListener('click', function (e) {
|
||||||
|
var btn = e.target.closest('button[data-act]');
|
||||||
|
if (!btn) return;
|
||||||
|
var row = btn.closest('tr'), file = row.dataset.file, act = btn.dataset.act;
|
||||||
|
var old = btn.textContent;
|
||||||
|
btn.disabled = true; btn.textContent = '…';
|
||||||
|
|
||||||
|
function restore_(finish) {
|
||||||
|
if (!window.confirm('确定从「' + file + '」恢复吗?\n\n'
|
||||||
|
+ '当前所有账号、用量数据与配置都会被这份归档替换。\n'
|
||||||
|
+ '系统会在替换前自动先备份当前库,恢复错了可以再恢复到那一份。')) {
|
||||||
|
finish(); return;
|
||||||
|
}
|
||||||
|
var inc = window.confirm('是否同时恢复 instance.json(含 Cookie 加密主密钥)?\n\n'
|
||||||
|
+ '点「确定」= 一并恢复(跨机器迁移必须选这个,否则已存的 Cookie 解密不出来)\n'
|
||||||
|
+ '点「取消」= 只恢复数据库(保留本机现有密钥)');
|
||||||
|
WBU.post('/api/backups/' + encodeURIComponent(file) + '/restore',
|
||||||
|
{ include_instance: inc ? '1' : '0' })
|
||||||
|
.then(function (j) {
|
||||||
|
if (j.ok === false) { WBU.say(j.message || '恢复失败', 'error'); return; }
|
||||||
|
WBU.say(j.message || '已恢复', 'ok');
|
||||||
|
window.setTimeout(function () { location.href = '/'; }, 2500);
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
if (String(err.message) !== 'unauthorized') WBU.say('请求失败:' + err.message, 'error');
|
||||||
|
})
|
||||||
|
.finally(finish);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (act === 'restore') {
|
||||||
|
restore_(function () { btn.disabled = false; btn.textContent = old; });
|
||||||
|
} else if (act === 'del') {
|
||||||
|
if (!window.confirm('删除备份文件「' + file + '」?此操作不可撤销。')) {
|
||||||
|
btn.disabled = false; btn.textContent = old; return;
|
||||||
|
}
|
||||||
|
WBU.post('/api/backups/' + encodeURIComponent(file) + '/delete', {})
|
||||||
|
.then(function (j) {
|
||||||
|
if (j.ok === false) { WBU.say(j.message || '删除失败', 'error'); return; }
|
||||||
|
WBU.say(j.message || '已删除', 'ok');
|
||||||
|
window.setTimeout(function () { location.reload(); }, 900);
|
||||||
|
})
|
||||||
|
.catch(function (err) { if (String(err.message) !== 'unauthorized') WBU.say('请求失败:' + err.message, 'error'); })
|
||||||
|
.finally(function () { btn.disabled = false; btn.textContent = old; });
|
||||||
|
} else if (act === 'forget') {
|
||||||
|
WBU.say('该文件已不在磁盘上,删除索引条目请用「清理旧备份」或手工删除数据库行。', 'warn');
|
||||||
|
btn.disabled = false; btn.textContent = old;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="color-scheme" content="dark">
|
<meta name="color-scheme" content="dark">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
<title>{% block title %}{{ project_title }}{% endblock %}</title>
|
<title>{% block title %}{{ project_title }}{% endblock %}</title>
|
||||||
<link rel="icon" href="{{ url_for('static', filename='favicon.svg') }}">
|
<link rel="icon" href="{{ url_for('static', filename='favicon.svg') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}">
|
||||||
@@ -11,7 +12,11 @@
|
|||||||
<body>
|
<body>
|
||||||
{% set nav = active|default('') %}
|
{% set nav = active|default('') %}
|
||||||
{% set on_dash = request.path.startswith('/dashboard') %}
|
{% set on_dash = request.path.startswith('/dashboard') %}
|
||||||
{% if current_user() %}
|
{# 变量名刻意叫 cur 而不是 me:模板里的 {% set %} 会覆盖子模板传入的同名变量,
|
||||||
|
而 current_user() 只含 id/username/display_name/is_admin —— 曾因此让
|
||||||
|
个人中心把 me.created_at 渲染成空(子模板的 me 是完整的用户行)。 #}
|
||||||
|
{% set cur = current_user() %}
|
||||||
|
{% if cur %}
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<span class="dot"></span>
|
<span class="dot"></span>
|
||||||
@@ -24,13 +29,24 @@
|
|||||||
<a href="{{ url_for('views.records') }}" class="{{ 'on' if nav=='records' }}">数据明细</a>
|
<a href="{{ url_for('views.records') }}" class="{{ 'on' if nav=='records' }}">数据明细</a>
|
||||||
<a href="{{ url_for('views.tasks') }}" class="{{ 'on' if nav=='tasks' }}">任务管理</a>
|
<a href="{{ url_for('views.tasks') }}" class="{{ 'on' if nav=='tasks' }}">任务管理</a>
|
||||||
<a href="{{ url_for('views.config_page') }}" class="{{ 'on' if nav=='config' }}">配置管理</a>
|
<a href="{{ url_for('views.config_page') }}" class="{{ 'on' if nav=='config' }}">配置管理</a>
|
||||||
|
{# 日志管理里是实例运行信息(数据库路径 / 账号名 / 来源 IP),仅管理员可见;
|
||||||
|
服务端另有 @admin_required 兜底,这里隐藏只是不给出会 403 的死链。 #}
|
||||||
|
{% if cur.is_admin %}
|
||||||
|
<a href="{{ url_for('views.backups_page') }}" class="{{ 'on' if nav=='backups' }}">备份管理</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if cur.is_admin %}
|
||||||
<a href="{{ url_for('views.logs') }}" class="{{ 'on' if nav=='logs' }}">日志管理</a>
|
<a href="{{ url_for('views.logs') }}" class="{{ 'on' if nav=='logs' }}">日志管理</a>
|
||||||
{% if current_user().is_admin %}
|
{% endif %}
|
||||||
|
{% if cur.is_admin %}
|
||||||
<a href="{{ url_for('views.users_page') }}" class="{{ 'on' if nav=='users' }}">用户管理</a>
|
<a href="{{ url_for('views.users_page') }}" class="{{ 'on' if nav=='users' }}">用户管理</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</nav>
|
</nav>
|
||||||
<div class="me">
|
<div class="me">
|
||||||
<span class="who"><b>{{ current_user().display_name }}</b>{% if current_user().is_admin %} <span class="tag accent">管理员</span>{% endif %}</span>
|
<a class="who" href="{{ url_for('views.profile_page') }}" title="个人中心">
|
||||||
|
<b>{{ cur.display_name }}</b>
|
||||||
|
{% if cur.is_admin %}<span class="tag accent">管理员</span>
|
||||||
|
{% else %}<span class="tag mute">普通账号</span>{% endif %}
|
||||||
|
</a>
|
||||||
{# 退出用 POST + CSRF:GET 型退出会被 <img src="/logout"> 这类请求静默触发 #}
|
{# 退出用 POST + CSRF:GET 型退出会被 <img src="/logout"> 这类请求静默触发 #}
|
||||||
<form method="post" action="{{ url_for('views.logout_post') }}" style="margin:0">
|
<form method="post" action="{{ url_for('views.logout_post') }}" style="margin:0">
|
||||||
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||||
@@ -52,8 +68,7 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="foot">
|
<footer class="foot">
|
||||||
<b>{{ project_title }}</b> · {{ project_name }} · 采集 / 存储 / 呈现三合一 · 数据正本 <code>data/usage.sqlite</code><br>
|
{{ project_title }} · {{ project_name }} · 数据正本 <code>data/usage.sqlite</code>
|
||||||
采集在 Web 进程内按配置时刻执行,无需外部计划任务
|
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -5,24 +5,38 @@
|
|||||||
<div class="pagehead">
|
<div class="pagehead">
|
||||||
<div>
|
<div>
|
||||||
<h1>配置管理</h1>
|
<h1>配置管理</h1>
|
||||||
<p class="lead">凭证、采集参数、维护动作都在这里;所有配置存在数据库,改完立即生效</p>
|
{% if is_admin %}
|
||||||
|
<p class="lead">凭证、采集参数、接口地址与注册策略都在这里;改完立即生效</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="lead">这里维护<b>你自己账号</b>的云端凭证;采集参数与调度由管理员统一设定,只读展示</p>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if s.cookie_broken %}
|
||||||
|
<div class="flash error" style="margin-bottom:16px">
|
||||||
|
已保存的 Cookie <b>无法解密</b>(通常是 <code>data/instance.json</code> 里的
|
||||||
|
<code>cookie_key</code> 被更换或文件丢失)。请重新粘贴一次;在此之前该账号的采集会失败。
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="cardhead">
|
<div class="cardhead">
|
||||||
<h2>云端凭证</h2>
|
<h2>我的云端凭证</h2>
|
||||||
<span class="tag {{ 'ok' if s.cookie_hint else 'bad' }}">{{ '已配置' if s.cookie_hint else '未配置' }}</span>
|
<span class="tag {{ 'ok' if s.cookie_hint else 'bad' }}">{{ '已配置' if s.cookie_hint else '未配置' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
{% if s.cookie_hint %}当前 Cookie:{{ s.cookie_hint }}(页面与接口都不回传明文){% endif %}
|
{% if s.cookie_hint %}当前 Cookie:{{ s.cookie_hint }}
|
||||||
|
{% if s.cookie_at %}({{ s.cookie_at }} 更新){% endif %}。{% endif %}
|
||||||
<br>获取方式:Chrome 打开 <code>https://www.workbuddy.cn/profile/plans-usage</code> → F12 → Network →
|
<br>获取方式:Chrome 打开 <code>https://www.workbuddy.cn/profile/plans-usage</code> → F12 → Network →
|
||||||
任选一个 <code>billing</code> 请求 → 复制 Request Headers 里的 <code>cookie</code> 与 <code>user-agent</code>
|
任选一个 <code>billing</code> 请求 → 复制 Request Headers 的 <code>cookie</code> 与 <code>user-agent</code>
|
||||||
(<b>两者必须取自同一次请求</b>),粘贴到下面。
|
(两者须取自同一次请求)。
|
||||||
|
<br>采集只使用本人凭证,各账号数据互不可见。{% if not is_admin %}普通账号只有这一块可改。{% endif %}
|
||||||
</p>
|
</p>
|
||||||
<form id="formCred">
|
<form id="formCred">
|
||||||
<label class="col">Cookie
|
<label class="col">Cookie
|
||||||
<textarea name="cookie" rows="4" placeholder="留空表示不修改;填 - 表示清空已保存的 Cookie" spellcheck="false"></textarea>
|
<textarea name="cookie" rows="4" autocomplete="off" spellcheck="false"
|
||||||
|
placeholder="留空表示不修改;填 - 表示清空已保存的 Cookie"></textarea>
|
||||||
</label>
|
</label>
|
||||||
<label class="col">User-Agent
|
<label class="col">User-Agent
|
||||||
<textarea name="user_agent" rows="2" spellcheck="false">{{ s.user_agent }}</textarea>
|
<textarea name="user_agent" rows="2" spellcheck="false">{{ s.user_agent }}</textarea>
|
||||||
@@ -33,11 +47,21 @@
|
|||||||
|
|
||||||
<div class="grid2">
|
<div class="grid2">
|
||||||
<section class="card">
|
<section class="card">
|
||||||
|
<div class="cardhead">
|
||||||
<h2>采集参数</h2>
|
<h2>采集参数</h2>
|
||||||
|
{% if is_admin %}
|
||||||
|
<span class="tag accent">实例级 · 对所有账号生效</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="tag mute">只读 · 仅管理员可改</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if is_admin %}
|
||||||
<form id="formCollect">
|
<form id="formCollect">
|
||||||
<label class="row"><span>接口基址</span><input name="api_base" value="{{ s.api_base }}" spellcheck="false"></label>
|
|
||||||
<label class="row"><span>接口路径</span><input name="api_path" value="{{ s.api_path }}" spellcheck="false"></label>
|
|
||||||
{% set b = num_settings %}
|
{% set b = num_settings %}
|
||||||
|
<label class="row"><span>接口基址</span>
|
||||||
|
<input name="api_base" value="{{ s.api_base }}" spellcheck="false"></label>
|
||||||
|
<label class="row"><span>接口路径</span>
|
||||||
|
<input name="api_path" value="{{ s.api_path }}" spellcheck="false"></label>
|
||||||
<label class="row"><span>分页大小</span>
|
<label class="row"><span>分页大小</span>
|
||||||
<input name="page_size" type="number" min="{{ b.page_size[0] }}" max="{{ b.page_size[1] }}" value="{{ s.page_size }}">
|
<input name="page_size" type="number" min="{{ b.page_size[0] }}" max="{{ b.page_size[1] }}" value="{{ s.page_size }}">
|
||||||
<em class="unit">{{ b.page_size[0] }}~{{ b.page_size[1] }} 条/页</em></label>
|
<em class="unit">{{ b.page_size[0] }}~{{ b.page_size[1] }} 条/页</em></label>
|
||||||
@@ -64,8 +88,22 @@
|
|||||||
</label>
|
</label>
|
||||||
<p class="hint">Cookie 就是账号凭证,关掉证书校验等于把它暴露给中间人,非必要不要关。</p>
|
<p class="hint">Cookie 就是账号凭证,关掉证书校验等于把它暴露给中间人,非必要不要关。</p>
|
||||||
<button class="btn primary" type="submit">保存采集参数</button>
|
<button class="btn primary" type="submit">保存采集参数</button>
|
||||||
<p class="hint">整日校验会按天重新拉云端 total 与本地比对,发现缺记录自动补入;设为 0 表示关闭(日常够用)。</p>
|
<p class="hint">整日校验按天比对云端 total 与本地记录并补入缺失项,0 表示关闭。实例级参数,对本机所有账号生效。</p>
|
||||||
</form>
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p class="hint">这些属于整机策略,普通账号只读。</p>
|
||||||
|
<table class="kv">
|
||||||
|
<tr><th>接口基址</th><td class="mono">{{ s.api_base }}</td></tr>
|
||||||
|
<tr><th>接口路径</th><td class="mono">{{ s.api_path }}</td></tr>
|
||||||
|
<tr><th>分页大小</th><td>{{ s.page_size }} 条/页</td></tr>
|
||||||
|
<tr><th>断点回退</th><td>{{ s.rewind_minutes }} 分钟</td></tr>
|
||||||
|
<tr><th>时间漂移容差</th><td>{{ s.drift_tolerance_minutes }} 分钟</td></tr>
|
||||||
|
<tr><th>Prompt 截断</th><td>{{ s.max_prompt }} 字符{% if s.max_prompt == '0' %}(不截断){% endif %}</td></tr>
|
||||||
|
<tr><th>整日校验天数</th><td>{{ s.verify_days }} 天</td></tr>
|
||||||
|
<tr><th>请求超时</th><td>{{ s.timeout }} 秒</td></tr>
|
||||||
|
<tr><th>校验 TLS 证书</th><td>{% if s.ssl_verify != '0' %}<span class="tag ok">校验</span>{% else %}<span class="tag bad">不校验</span>{% endif %}</td></tr>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
@@ -75,7 +113,7 @@
|
|||||||
<label class="col">新密码<input name="new" type="password" autocomplete="new-password"></label>
|
<label class="col">新密码<input name="new" type="password" autocomplete="new-password"></label>
|
||||||
<label class="col">确认新密码<input name="new2" type="password" autocomplete="new-password"></label>
|
<label class="col">确认新密码<input name="new2" type="password" autocomplete="new-password"></label>
|
||||||
<button class="btn primary" type="submit">修改密码</button>
|
<button class="btn primary" type="submit">修改密码</button>
|
||||||
<p class="hint">至少 6 位。修改成功后当前会话仍有效,不必重新登录。</p>
|
<p class="hint">至少 {{ pwd_min }} 位,需包含大写字母、小写字母、数字、符号中的至少两类。修改后当前会话仍有效。</p>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<hr class="sect-divider">
|
<hr class="sect-divider">
|
||||||
@@ -84,18 +122,58 @@
|
|||||||
<button class="btn" type="button" data-maint="fill-prompt"
|
<button class="btn" type="button" data-maint="fill-prompt"
|
||||||
title="把云端仍保留、但本地为空的 User Prompt 补回来">补全缺失 Prompt</button>
|
title="把云端仍保留、但本地为空的 User Prompt 补回来">补全缺失 Prompt</button>
|
||||||
<button class="btn" type="button" data-maint="export-csv"
|
<button class="btn" type="button" data-maint="export-csv"
|
||||||
title="导出与官网 xlsx 同构的全量 CSV 到 data/exports/">导出全量 CSV</button>
|
title="导出与官网 xlsx 同构的 CSV 到 data/exports/">导出我的 CSV</button>
|
||||||
|
{% if is_admin %}
|
||||||
<button class="btn" type="button" data-maint="vacuum"
|
<button class="btn" type="button" data-maint="vacuum"
|
||||||
title="checkpoint + VACUUM,回收删除后的空闲页">整理数据库</button>
|
title="checkpoint + VACUUM,回收删除后的空闲页(整库操作,仅管理员)">整理数据库</button>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">补全 Prompt 需要联网并逐天重拉云端;导出与整理只动本地数据。</p>
|
<p class="hint">补全 Prompt 需要联网并逐天重拉云端;导出与整理只动本地数据。</p>
|
||||||
<div class="btnrow" style="margin-top:14px">
|
<div class="btnrow" style="margin-top:14px">
|
||||||
<a class="btn ghost" href="{{ url_for('views.records_export') }}">按当前明细页筛选导出</a>
|
<a class="btn ghost" href="{{ url_for('views.records_export') }}">按当前明细页筛选导出</a>
|
||||||
{% if current_user().is_admin %}<a class="btn ghost" href="{{ url_for('views.users_page') }}">用户管理</a>{% endif %}
|
<a class="btn ghost" href="{{ url_for('views.profile_page') }}">个人中心</a>
|
||||||
|
{% if is_admin %}<a class="btn ghost" href="{{ url_for('views.users_page') }}">用户管理</a>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if is_admin %}
|
||||||
|
<section class="card">
|
||||||
|
<div class="cardhead">
|
||||||
|
<h2>实例级设置</h2>
|
||||||
|
<span class="tag accent">仅管理员可改,对所有账号生效</span>
|
||||||
|
</div>
|
||||||
|
<form id="formGlobal">
|
||||||
|
{% set b = num_settings %}
|
||||||
|
<label class="row"><span>开放自助注册</span>
|
||||||
|
<select name="allow_register">
|
||||||
|
<option value="1" {{ 'selected' if s.allow_register != '0' }}>允许任何人注册</option>
|
||||||
|
<option value="0" {{ 'selected' if s.allow_register == '0' }}>关闭注册(只能由管理员建号)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="row"><span>同 IP 每日注册上限</span>
|
||||||
|
<input name="register_max_per_ip" type="number"
|
||||||
|
min="{{ b.register_max_per_ip[0] }}" max="{{ b.register_max_per_ip[1] }}"
|
||||||
|
value="{{ s.register_max_per_ip }}">
|
||||||
|
<em class="unit">{{ b.register_max_per_ip[0] }}~{{ b.register_max_per_ip[1] }} 个/天</em></label>
|
||||||
|
<label class="row"><span>验证码策略</span>
|
||||||
|
<select name="captcha_policy">
|
||||||
|
<option value="always" {{ 'selected' if s.captcha_policy == 'always' }}>始终要求(推荐)</option>
|
||||||
|
<option value="adaptive" {{ 'selected' if s.captcha_policy == 'adaptive' }}>仅连续失败后要求(对日常更友好)</option>
|
||||||
|
<option value="off" {{ 'selected' if s.captcha_policy == 'off' }}>关闭(不推荐)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="row"><span>验证码位数</span>
|
||||||
|
<input name="captcha_length" type="number"
|
||||||
|
min="{{ b.captcha_length[0] }}" max="{{ b.captcha_length[1] }}"
|
||||||
|
value="{{ s.captcha_length }}">
|
||||||
|
<em class="unit">4~6 位</em></label>
|
||||||
|
<button class="btn primary" type="submit">保存实例设置</button>
|
||||||
|
<p class="hint">验证码答案只存在服务端 <code>captchas</code> 表,一次性使用、5 分钟过期。关闭会显著放大撞库与批量注册的风险。</p>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
@@ -104,6 +182,8 @@
|
|||||||
WBU.bindForm('#formCred', '/api/settings');
|
WBU.bindForm('#formCred', '/api/settings');
|
||||||
WBU.bindForm('#formCollect', '/api/settings');
|
WBU.bindForm('#formCollect', '/api/settings');
|
||||||
WBU.bindForm('#formPwd', '/api/password', {validate: d => d.new === d.new2 ? null : '两次输入的新密码不一致'});
|
WBU.bindForm('#formPwd', '/api/password', {validate: d => d.new === d.new2 ? null : '两次输入的新密码不一致'});
|
||||||
|
var fg = document.querySelector('#formGlobal');
|
||||||
|
if (fg) WBU.bindForm('#formGlobal', '/api/settings');
|
||||||
WBU.bindMaint('[data-maint]');
|
WBU.bindMaint('[data-maint]');
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -15,9 +15,23 @@
|
|||||||
<label>密码
|
<label>密码
|
||||||
<input name="password" type="password" autocomplete="current-password" required>
|
<input name="password" type="password" autocomplete="current-password" required>
|
||||||
</label>
|
</label>
|
||||||
|
{% if need_captcha %}
|
||||||
|
<label>验证码
|
||||||
|
<span class="caprow">
|
||||||
|
<input name="captcha" maxlength="6" autocomplete="off" spellcheck="false"
|
||||||
|
required placeholder="不区分大小写">
|
||||||
|
{# 点击换一张:URL 带时间戳,避免浏览器复用已被消费的旧图 #}
|
||||||
|
<img class="capimg" alt="图形验证码" title="看不清?点一下换一张"
|
||||||
|
src="{{ url_for('views.captcha_png', purpose='login') }}&t={{ range(1000000)|random }}"
|
||||||
|
onclick="this.src='{{ url_for('views.captcha_png', purpose='login') }}&t=' + Date.now();">
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{% endif %}
|
||||||
<button class="btn primary" type="submit">登 录</button>
|
<button class="btn primary" type="submit">登 录</button>
|
||||||
|
{% if allow_register %}
|
||||||
|
<p class="foot-note">还没有账号?<a href="{{ url_for('views.register') }}">自助注册</a></p>
|
||||||
|
{% endif %}
|
||||||
<p class="foot-note">
|
<p class="foot-note">
|
||||||
首次部署默认账号 <code>admin</code> / <code>admin123</code>,登录后请立即到「配置管理」修改密码。<br>
|
|
||||||
连续输错 {{ max_fails }} 次将锁定 {{ lock_minutes }} 分钟;登录状态保持 {{ session_hours }} 小时。
|
连续输错 {{ max_fails }} 次将锁定 {{ lock_minutes }} 分钟;登录状态保持 {{ session_hours }} 小时。
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div class="pagehead">
|
<div class="pagehead">
|
||||||
<div>
|
<div>
|
||||||
<h1>日志管理</h1>
|
<h1>日志管理</h1>
|
||||||
<p class="lead">采集逐次日志、应用运行日志与操作审计</p>
|
<p class="lead">本机全实例的采集日志、应用运行日志与操作审计(仅管理员可见)</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn" id="btnTail" type="button">刷新应用日志</button>
|
<button class="btn" id="btnTail" type="button">刷新应用日志</button>
|
||||||
@@ -49,12 +49,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="tablewrap">
|
<div class="tablewrap">
|
||||||
<table class="tbl">
|
<table class="tbl">
|
||||||
<thead><tr><th>#</th><th>开始</th><th>触发</th><th>状态</th><th class="num">耗时</th>
|
<thead><tr><th>#</th><th>账号</th><th>开始</th><th>触发</th><th>状态</th><th class="num">耗时</th>
|
||||||
<th class="num">新增</th><th class="num">重复</th><th class="num">冲突</th><th>结论</th><th></th></tr></thead>
|
<th class="num">新增</th><th class="num">重复</th><th class="num">冲突</th><th>结论</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for r in runs %}
|
{% for r in runs %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ r.id }}</td>
|
<td>{{ r.id }}</td>
|
||||||
|
<td>{{ r.uname }}</td>
|
||||||
<td class="mono nowrap">{{ r.started_at[5:] if r.started_at else '—' }}</td>
|
<td class="mono nowrap">{{ r.started_at[5:] if r.started_at else '—' }}</td>
|
||||||
<td><span class="tag {{ 'info' if r.trigger=='schedule' else ('accent' if r.trigger=='startup' else 'mute') }}">{{ r.trigger }}</span></td>
|
<td><span class="tag {{ 'info' if r.trigger=='schedule' else ('accent' if r.trigger=='startup' else 'mute') }}">{{ r.trigger }}</span></td>
|
||||||
<td class="nowrap">
|
<td class="nowrap">
|
||||||
@@ -70,7 +71,7 @@
|
|||||||
<td><a href="{{ url_for('views.logs', run=r.id, status=status or none) }}">详情</a></td>
|
<td><a href="{{ url_for('views.logs', run=r.id, status=status or none) }}">详情</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr><td colspan="10" class="empty">暂无采集日志</td></tr>
|
<tr><td colspan="11" class="empty">暂无采集日志</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -58,8 +58,9 @@
|
|||||||
<tr><th>Cookie</th><td>{% if mf.health.cookie %}<span class="tag ok">已配置</span>{% else %}<span class="tag bad">未配置</span> <a href="{{ url_for('views.config_page') }}">去配置</a>{% endif %}</td></tr>
|
<tr><th>Cookie</th><td>{% if mf.health.cookie %}<span class="tag ok">已配置</span>{% else %}<span class="tag bad">未配置</span> <a href="{{ url_for('views.config_page') }}">去配置</a>{% endif %}</td></tr>
|
||||||
<tr><th>服务器时间</th><td class="mono">{{ sch.now }}</td></tr>
|
<tr><th>服务器时间</th><td class="mono">{{ sch.now }}</td></tr>
|
||||||
</table>
|
</table>
|
||||||
<p class="hint">调度在 Web 进程内运行,不再需要计划任务或外部自动化。所有时刻与开关都在
|
<p class="hint">调度随 Web 进程运行。{% if current_user().is_admin %}时刻与开关在
|
||||||
<a href="{{ url_for('views.tasks') }}">任务管理</a>里改。</p>
|
<a href="{{ url_for('views.tasks') }}">任务管理</a>里改。{% else %}时刻由管理员设定,可在
|
||||||
|
<a href="{{ url_for('views.tasks') }}">任务管理</a>查看或手动采集。{% endif %}</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
@@ -98,7 +99,8 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for r in runs %}
|
{% for r in runs %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="{{ url_for('views.logs', run=r.id) }}">{{ r.id }}</a></td>
|
{# 运行详情在「日志管理」里,而那页仅管理员可达 —— 普通用户不要给死链 #}
|
||||||
|
<td>{% if current_user().is_admin %}<a href="{{ url_for('views.logs', run=r.id) }}">{{ r.id }}</a>{% else %}{{ r.id }}{% endif %}</td>
|
||||||
<td><span class="tag {{ 'info' if r.trigger=='schedule' else ('accent' if r.trigger=='startup' else 'mute') }}">{{ r.trigger }}</span></td>
|
<td><span class="tag {{ 'info' if r.trigger=='schedule' else ('accent' if r.trigger=='startup' else 'mute') }}">{{ r.trigger }}</span></td>
|
||||||
<td class="mono">{{ r.started_at[5:] if r.started_at else '—' }}</td>
|
<td class="mono">{{ r.started_at[5:] if r.started_at else '—' }}</td>
|
||||||
<td class="num">{{ ((r.duration_ms or 0) / 1000) | round(1) }}s</td>
|
<td class="num">{{ ((r.duration_ms or 0) / 1000) | round(1) }}s</td>
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}个人中心 · {{ project_title }}{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
|
||||||
|
<div class="pagehead">
|
||||||
|
<div>
|
||||||
|
<h1>个人中心</h1>
|
||||||
|
<p class="lead">账号 <b>{{ me.username }}</b> · 注册于 {{ (me.created_at or '')[:16] }} ·
|
||||||
|
最近登录 {{ (me.last_login_at or '未登录')[:19] }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<a class="btn" href="{{ url_for('views.config_page') }}">管理我的凭证</a>
|
||||||
|
<a class="btn ghost" href="{{ url_for('views.profile_export') }}"
|
||||||
|
title="导出你的全部用量明细、采集历史、操作审计与有效配置(不含 Cookie 明文)">导出我的全部数据</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kpis">
|
||||||
|
<div class="kpi" style="--c:var(--cyan)">
|
||||||
|
<span>我的记录</span><b>{{ '{:,}'.format(my.n or 0) }}</b>
|
||||||
|
<i>{{ my.d0 or '—' }} ~ {{ my.d1 or '—' }}</i>
|
||||||
|
</div>
|
||||||
|
<div class="kpi" style="--c:var(--violet)">
|
||||||
|
<span>我的积分</span><b>{{ '%.2f'|format(my.c or 0) }}</b>
|
||||||
|
<i>仅统计归属本账号的数据</i>
|
||||||
|
</div>
|
||||||
|
<div class="kpi" style="--c:var(--blue)">
|
||||||
|
<span>采集次数</span><b>{{ '{:,}'.format(runs) }}</b>
|
||||||
|
<i>{% if sch.last %}最近 {{ sch.last.started_at[5:16] if sch.last.started_at else '—' }}{% else %}尚无采集{% endif %}</i>
|
||||||
|
</div>
|
||||||
|
<div class="kpi" style="--c:{{ 'var(--green)' if cred.set and not cred.broken else 'var(--red)' }}">
|
||||||
|
<span>我的 Cookie</span>
|
||||||
|
<b>{% if cred.broken %}无法解密{% elif cred.set %}已配置{% else %}未配置{% endif %}</b>
|
||||||
|
<i>{% if cred.set and not cred.broken %}{{ cred.chars }} 字符,结尾 …{{ cred.tail }}
|
||||||
|
{%- elif cred.broken %}实例密钥被更换,请重新粘贴
|
||||||
|
{%- else %}采集需要本人凭证{% endif %}</i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid2">
|
||||||
|
<section class="card">
|
||||||
|
<h2>修改资料</h2>
|
||||||
|
<form id="formProfile">
|
||||||
|
<label class="row"><span>用户名</span>
|
||||||
|
<input value="{{ me.username }}" disabled spellcheck="false">
|
||||||
|
<em class="unit">登录名不可改</em></label>
|
||||||
|
<label class="row"><span>显示名</span>
|
||||||
|
<input name="display_name" maxlength="64" value="{{ me.display_name or '' }}" spellcheck="false"></label>
|
||||||
|
<label class="row"><span>邮箱</span>
|
||||||
|
<input name="email" type="email" maxlength="128" value="{{ me.email or '' }}" spellcheck="false"></label>
|
||||||
|
<button class="btn primary" type="submit">保存资料</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>修改登录密码</h2>
|
||||||
|
<form id="formPwd">
|
||||||
|
<label class="col">原密码<input name="old" type="password" autocomplete="current-password"></label>
|
||||||
|
<label class="col">新密码<input name="new" type="password" autocomplete="new-password"></label>
|
||||||
|
<label class="col">确认新密码<input name="new2" type="password" autocomplete="new-password"></label>
|
||||||
|
<button class="btn primary" type="submit">修改密码</button>
|
||||||
|
<p class="hint">至少 {{ pwd_min }} 位,需包含大写字母、小写字母、数字、符号中的至少两类。
|
||||||
|
修改成功后其他设备上的登录会立刻失效,本机会话保留。</p>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="cardhead">
|
||||||
|
<h2>导出我的全部数据</h2>
|
||||||
|
<span class="tag mute">数据可携带</span>
|
||||||
|
</div>
|
||||||
|
<p class="hint" style="margin-top:0">
|
||||||
|
下载本账号的用量明细、采集历史、操作审计与有效配置。
|
||||||
|
不含 Cookie 明文,也不含其他账号的数据与实例级运行日志。
|
||||||
|
</p>
|
||||||
|
<div class="btnrow">
|
||||||
|
<a class="btn primary" href="{{ url_for('views.profile_export') }}">导出我的全部数据(zip)</a>
|
||||||
|
<a class="btn ghost" href="{{ url_for('views.records') }}">只导出用量明细(CSV)</a>
|
||||||
|
</div>
|
||||||
|
<p class="hint">两次导出之间至少间隔 10 秒。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="cardhead">
|
||||||
|
<h2>我的采集凭证</h2>
|
||||||
|
<span class="tag {{ 'ok' if cred.set and not cred.broken else ('bad' if not cred.set else 'warn') }}">
|
||||||
|
{{ '正常' if cred.set and not cred.broken else ('未配置' if not cred.set else '需要重配') }}</span>
|
||||||
|
</div>
|
||||||
|
<table class="kv">
|
||||||
|
<tr><th>状态</th><td>
|
||||||
|
{% if cred.broken %}<span class="tag bad">已保存但无法解密</span>,请到「配置管理」重新粘贴
|
||||||
|
{% elif cred.set %}<span class="tag ok">已保存(密文入库)</span>
|
||||||
|
{% else %}<span class="tag bad">未配置</span>{% endif %}
|
||||||
|
</td></tr>
|
||||||
|
<tr><th>字符数 / 尾部</th><td class="mono">{{ cred.chars or 0 }} / {{ ('…' + cred.tail) if cred.tail else '—' }}</td></tr>
|
||||||
|
<tr><th>最后更新</th><td class="mono">{{ cred.at or '—' }}</td></tr>
|
||||||
|
<tr><th>调度</th><td>
|
||||||
|
{% if sch.enabled %}{{ sch.times | join(' · ') or '未设置时刻' }}{% else %}<span class="tag bad">已停用</span>{% endif %}
|
||||||
|
{% if sch.next_run %}· 下次 <span class="mono">{{ sch.next_run }}</span>{% endif %}
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
<p class="hint">
|
||||||
|
凭证以密文入库(主密钥在 <code>data/instance.json</code>),页面与接口都不回传明文。
|
||||||
|
更换请到<a href="{{ url_for('views.config_page') }}">配置管理</a>重新粘贴 Cookie 与 User-Agent。
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||||
|
<script>
|
||||||
|
WBU.bindForm('#formProfile', '/api/profile');
|
||||||
|
WBU.bindForm('#formPwd', '/api/password', {
|
||||||
|
validate: function (d) { return d.new === d.new2 ? null : '两次输入的新密码不一致'; }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -68,9 +68,7 @@
|
|||||||
<button class="btn primary" type="submit">查询</button>
|
<button class="btn primary" type="submit">查询</button>
|
||||||
{% if has_filter %}<a class="btn ghost" href="{{ url_for('views.records') }}">重置</a>{% endif %}
|
{% if has_filter %}<a class="btn ghost" href="{{ url_for('views.records') }}">重置</a>{% endif %}
|
||||||
</form>
|
</form>
|
||||||
<p class="hint">
|
<p class="hint">日期留空表示不限。关键词会同时匹配 User Prompt 与 RequestID,区间越大越慢。</p>
|
||||||
日期留空表示不限。有关键词时会同时匹配 User Prompt 与 RequestID(按天全表扫描,区间越大越慢)。
|
|
||||||
</p>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}注册 · {{ project_title }}{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<div class="loginwrap">
|
||||||
|
<form class="card login wide" method="post" action="{{ url_for('views.register') }}">
|
||||||
|
<div class="logo">W</div>
|
||||||
|
<h1>注册 {{ project_title }}</h1>
|
||||||
|
<p class="hint">注册后请粘贴<strong>你自己账号</strong>的 Cookie —— 采集只使用本人的凭证,各账号数据互相隔离。</p>
|
||||||
|
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
<label>用户名 <em class="unit">3~32 位,字母或数字开头</em>
|
||||||
|
<input name="username" value="{{ username or '' }}" autocomplete="username"
|
||||||
|
autofocus required maxlength="32" spellcheck="false">
|
||||||
|
</label>
|
||||||
|
<label>显示名 <em class="unit">留空则与用户名相同</em>
|
||||||
|
<input name="display_name" value="{{ display_name or '' }}" maxlength="64" autocomplete="nickname">
|
||||||
|
</label>
|
||||||
|
<label>邮箱 <em class="unit">选填,便于日后找回</em>
|
||||||
|
<input name="email" type="email" value="{{ email or '' }}" maxlength="128" autocomplete="email">
|
||||||
|
</label>
|
||||||
|
<label>密码 <em class="unit">至少 {{ pwd_min }} 位,需含两类以上字符</em>
|
||||||
|
<input name="password" type="password" autocomplete="new-password" required>
|
||||||
|
</label>
|
||||||
|
<label>确认密码
|
||||||
|
<input name="password2" type="password" autocomplete="new-password" required>
|
||||||
|
</label>
|
||||||
|
{% if need_captcha %}
|
||||||
|
<label>验证码
|
||||||
|
<span class="caprow">
|
||||||
|
<input name="captcha" maxlength="6" autocomplete="off" spellcheck="false"
|
||||||
|
required placeholder="不区分大小写">
|
||||||
|
<img class="capimg" alt="图形验证码" title="看不清?点一下换一张"
|
||||||
|
src="{{ url_for('views.captcha_png', purpose='register') }}&t={{ range(1000000)|random }}"
|
||||||
|
onclick="this.src='{{ url_for('views.captcha_png', purpose='register') }}&t=' + Date.now();">
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{% endif %}
|
||||||
|
<button class="btn primary" type="submit">注 册</button>
|
||||||
|
<p class="foot-note">已有账号?<a href="{{ url_for('views.login') }}">返回登录</a></p>
|
||||||
|
<p class="foot-note">
|
||||||
|
注册受来源限额与图形验证码双重保护;同一来源每天可注册的账号数由管理员设定。<br>
|
||||||
|
连续输错 {{ max_fails }} 次将锁定 {{ lock_minutes }} 分钟。
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
<div class="pagehead">
|
<div class="pagehead">
|
||||||
<div>
|
<div>
|
||||||
<h1>任务管理</h1>
|
<h1>任务管理</h1>
|
||||||
<p class="lead">调度在 Web 进程内执行,采集互斥由文件锁保证;这里也能手动触发与按区间回填</p>
|
<p class="lead">调度随 Web 进程运行;这里可查看本人运行历史、手动采集与按区间回填</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn primary" id="btnCollect" type="button">立即采集一次</button>
|
<button class="btn primary" id="btnCollect" type="button">立即采集一次</button>
|
||||||
@@ -15,7 +15,11 @@
|
|||||||
|
|
||||||
<div class="grid2">
|
<div class="grid2">
|
||||||
<section class="card">
|
<section class="card">
|
||||||
|
<div class="cardhead">
|
||||||
<h2>采集调度</h2>
|
<h2>采集调度</h2>
|
||||||
|
{% if not can_edit %}<span class="tag mute">只读 · 仅管理员可改</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if can_edit %}
|
||||||
<form id="formTask">
|
<form id="formTask">
|
||||||
<label class="row"><span>启用调度</span>
|
<label class="row"><span>启用调度</span>
|
||||||
<select name="schedule_enabled">
|
<select name="schedule_enabled">
|
||||||
@@ -26,8 +30,8 @@
|
|||||||
<label class="row"><span>每日时刻</span>
|
<label class="row"><span>每日时刻</span>
|
||||||
<input name="schedule_times" value="{{ s_times }}" placeholder="09:00,17:00" spellcheck="false">
|
<input name="schedule_times" value="{{ s_times }}" placeholder="09:00,17:00" spellcheck="false">
|
||||||
</label>
|
</label>
|
||||||
<p class="hint">本地时区,逗号分隔,支持 <code>HH:MM</code>(也可只写 <code>9</code>)。保存后立即生效,
|
<p class="hint">本地时区,逗号分隔,支持 <code>HH:MM</code>(也可只写 <code>9</code>),保存后立即生效。
|
||||||
并会清空当天已执行的槽位标记以便新时刻接管。</p>
|
最多 {{ max_slots }} 个时刻(当前 {{ sch.times|length }} 个),需要更多请先在「配置管理」调大上限。</p>
|
||||||
<label class="row"><span>启动补跑</span>
|
<label class="row"><span>启动补跑</span>
|
||||||
<select name="catch_up">
|
<select name="catch_up">
|
||||||
<option value="1" {{ 'selected' if sch.catch_up }}>开启(错过的时刻在宽限期内补跑)</option>
|
<option value="1" {{ 'selected' if sch.catch_up }}>开启(错过的时刻在宽限期内补跑)</option>
|
||||||
@@ -36,10 +40,20 @@
|
|||||||
</label>
|
</label>
|
||||||
<label class="row"><span>补跑宽限</span>
|
<label class="row"><span>补跑宽限</span>
|
||||||
<input name="catch_up_grace_hours" value="{{ s_grace }}" type="number" min="1" max="168">
|
<input name="catch_up_grace_hours" value="{{ s_grace }}" type="number" min="1" max="168">
|
||||||
<em class="unit">小时(超过就不补,避免开机狂刷)</em>
|
<em class="unit">小时(超过则不补)</em>
|
||||||
</label>
|
</label>
|
||||||
<button class="btn primary" type="submit">保存调度配置</button>
|
<button class="btn primary" type="submit">保存调度配置</button>
|
||||||
|
<p class="hint">时刻是实例级的:本机所有账号在此时刻各自采集自己的数据。</p>
|
||||||
</form>
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<table class="kv">
|
||||||
|
<tr><th>启用调度</th><td>{% if sch.enabled %}<span class="tag ok">已启用</span>{% else %}<span class="tag bad">已停用</span>{% endif %}</td></tr>
|
||||||
|
<tr><th>每日时刻</th><td>{{ sch.times | join(' · ') if sch.times else '—' }}</td></tr>
|
||||||
|
<tr><th>启动补跑</th><td>{% if sch.catch_up %}<span class="tag">开启</span>(宽限 {{ s_grace }} 小时){% else %}关闭{% endif %}</td></tr>
|
||||||
|
<tr><th>下次执行</th><td class="mono">{{ sch.next_run or '—' }}</td></tr>
|
||||||
|
</table>
|
||||||
|
<p class="hint">调度时刻与采集参数由管理员设定,普通账号只读。可在右上角手动采集,或按区间补采本人历史数据。</p>
|
||||||
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
@@ -57,11 +71,13 @@
|
|||||||
<hr class="sect-divider">
|
<hr class="sect-divider">
|
||||||
<h3>历史回填</h3>
|
<h3>历史回填</h3>
|
||||||
<form id="formBackfill">
|
<form id="formBackfill">
|
||||||
<label class="row"><span>起始日期</span><input type="date" name="from" max="{{ sch.now[:10] }}"></label>
|
<label class="row"><span>起始日期</span>
|
||||||
|
<input type="date" name="from" max="{{ sch.now[:10] }}"
|
||||||
|
data-min-days="{{ max_days }}"></label>
|
||||||
<label class="row"><span>结束日期</span><input type="date" name="to" value="{{ sch.now[:10] }}" max="{{ sch.now[:10] }}"></label>
|
<label class="row"><span>结束日期</span><input type="date" name="to" value="{{ sch.now[:10] }}" max="{{ sch.now[:10] }}"></label>
|
||||||
<button class="btn" type="submit">按区间补采</button>
|
<button class="btn" type="submit">按区间补采</button>
|
||||||
<p class="hint">指定区间重新拉取云端明细,已存在的记录按 <code>RequestID</code> 去重,不会重复计入。
|
<p class="hint">按 <code>RequestID</code> 去重,已存在的记录不会重复计入。单次最长 {{ max_days }} 天,
|
||||||
区间越大耗时越长(云端按天分页拉取)。</p>
|
两次采集间隔需 {{ min_gap }} 秒,有任务在跑时不能发起。「立即采集一次」走增量,通常几秒完成。</p>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,7 +112,10 @@
|
|||||||
<td class="num">{{ r.dup }}</td>
|
<td class="num">{{ r.dup }}</td>
|
||||||
<td class="num">{% if r.conflicts %}<span class="tag warn">{{ r.conflicts }}</span>{% else %}0{% endif %}</td>
|
<td class="num">{% if r.conflicts %}<span class="tag warn">{{ r.conflicts }}</span>{% else %}0{% endif %}</td>
|
||||||
<td>{{ r.message or '—' }}</td>
|
<td>{{ r.message or '—' }}</td>
|
||||||
<td><a href="{{ url_for('views.logs', run=r.id) }}">日志</a></td>
|
<td>
|
||||||
|
{% if can_edit %}<a href="{{ url_for('views.logs', run=r.id) }}">日志</a>
|
||||||
|
{% else %}<span class="muted">—</span>{% endif %}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr><td colspan="12" class="empty">暂无运行记录</td></tr>
|
<tr><td colspan="12" class="empty">暂无运行记录</td></tr>
|
||||||
|
|||||||
@@ -5,10 +5,12 @@
|
|||||||
<div class="pagehead">
|
<div class="pagehead">
|
||||||
<div>
|
<div>
|
||||||
<h1>用户管理</h1>
|
<h1>用户管理</h1>
|
||||||
<p class="lead">门户在局域网可访问,因此必须靠账号隔离;这里维护账号、管理员身份与密码</p>
|
<p class="lead">维护账号、状态与管理员身份。用量数据按账号隔离,管理员也看不到别人的用量与凭证</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<span class="tag accent">仅管理员可见</span>
|
<span class="tag accent">仅管理员可见</span>
|
||||||
|
<span class="tag {{ 'ok' if allow_register else 'mute' }}">
|
||||||
|
自助注册:{{ '已开放' if allow_register else '已关闭' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -17,21 +19,24 @@
|
|||||||
<h2>新建账号</h2>
|
<h2>新建账号</h2>
|
||||||
<form id="formNewUser">
|
<form id="formNewUser">
|
||||||
<label class="row"><span>用户名</span>
|
<label class="row"><span>用户名</span>
|
||||||
<input name="username" maxlength="32" placeholder="登录名(≤32 字符)" autocomplete="off" spellcheck="false"></label>
|
<input name="username" maxlength="32" placeholder="3~32 位,字母或数字开头"
|
||||||
|
autocomplete="off" spellcheck="false"></label>
|
||||||
<label class="row"><span>显示名</span>
|
<label class="row"><span>显示名</span>
|
||||||
<input name="display_name" maxlength="64" placeholder="留空则与用户名相同"></label>
|
<input name="display_name" maxlength="64" placeholder="留空则与用户名相同"></label>
|
||||||
|
<label class="row"><span>邮箱</span>
|
||||||
|
<input name="email" type="email" maxlength="128" placeholder="选填"></label>
|
||||||
<label class="row"><span>密码</span>
|
<label class="row"><span>密码</span>
|
||||||
<input name="password" type="password" autocomplete="new-password"></label>
|
<input name="password" type="password" autocomplete="new-password"></label>
|
||||||
<label class="row"><span>确认密码</span>
|
<label class="row"><span>确认密码</span>
|
||||||
<input name="password2" type="password" autocomplete="new-password"></label>
|
<input name="password2" type="password" autocomplete="new-password"></label>
|
||||||
<label class="row"><span>权限</span>
|
<label class="row"><span>权限</span>
|
||||||
<select name="is_admin">
|
<select name="is_admin">
|
||||||
<option value="1">管理员(可管理用户)</option>
|
<option value="0">普通账号(只管自己的凭证与数据)</option>
|
||||||
<option value="0">普通账号(只读数据与日志)</option>
|
<option value="1">管理员(可管理用户与实例设置)</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<button class="btn primary" type="submit">创建账号</button>
|
<button class="btn primary" type="submit">创建账号</button>
|
||||||
<p class="hint">密码至少 6 位、最多 128 位。普通账号不能用本页,也调不动用户管理接口。</p>
|
<p class="hint">密码至少 8 位且需含两类以上字符。无论何种身份,都需要各自配置 Cookie 才能采集。</p>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -42,29 +47,39 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="tablewrap">
|
<div class="tablewrap">
|
||||||
<table class="tbl" id="userTable">
|
<table class="tbl" id="userTable">
|
||||||
<thead><tr><th class="num">ID</th><th>用户名</th><th>显示名</th><th>权限</th>
|
<thead><tr><th class="num">ID</th><th>用户名</th><th>显示名</th><th>权限 / 状态</th>
|
||||||
<th>最后登录</th><th class="num">次数</th><th>操作</th></tr></thead>
|
<th class="num">我的数据</th><th>最后登录</th><th>操作</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for u in users %}
|
{% for u in users %}
|
||||||
<tr data-uid="{{ u.id }}" data-name="{{ u.username }}">
|
<tr data-uid="{{ u.id }}" data-name="{{ u.username }}"
|
||||||
|
data-status="{{ u.status or 'active' }}">
|
||||||
<td class="num muted">{{ u.id }}</td>
|
<td class="num muted">{{ u.id }}</td>
|
||||||
<td class="nowrap"><b>{{ u.username }}</b>
|
<td class="nowrap"><b>{{ u.username }}</b>
|
||||||
{% if u.id == me.id %}<span class="tag accent">当前</span>{% endif %}</td>
|
{% if u.id == me.id %}<span class="tag accent">当前</span>{% endif %}
|
||||||
|
{% if u.email %}<br><span class="muted sm">{{ u.email }}</span>{% endif %}</td>
|
||||||
<td><input class="inp inp-sm" name="display_name" maxlength="64"
|
<td><input class="inp inp-sm" name="display_name" maxlength="64"
|
||||||
value="{{ u.display_name or '' }}" spellcheck="false"></td>
|
value="{{ u.display_name or '' }}" spellcheck="false"></td>
|
||||||
<td>
|
<td class="nowrap">
|
||||||
{# 不能取消自己的管理员身份,所以本人的下拉直接禁用(服务端也会再拦一次) #}
|
{# 不能取消自己的管理员身份,也不能停用自己(服务端也会再拦一次) #}
|
||||||
<select class="inp inp-sm" name="is_admin" {{ 'disabled' if u.id == me.id }}>
|
<select class="inp inp-sm" name="is_admin" {{ 'disabled' if u.id == me.id }}>
|
||||||
<option value="1" {{ 'selected' if u.is_admin }}>管理员</option>
|
<option value="1" {{ 'selected' if u.is_admin }}>管理员</option>
|
||||||
<option value="0" {{ 'selected' if not u.is_admin }}>普通</option>
|
<option value="0" {{ 'selected' if not u.is_admin }}>普通</option>
|
||||||
</select>
|
</select>
|
||||||
|
<select class="inp inp-sm" name="status" {{ 'disabled' if u.id == me.id }}>
|
||||||
|
<option value="active" {{ 'selected' if (u.status or 'active') == 'active' }}>启用</option>
|
||||||
|
<option value="disabled" {{ 'selected' if u.status == 'disabled' }}>停用</option>
|
||||||
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td class="mono sm nowrap">{{ u.last_login_at or '—' }}</td>
|
<td class="num nowrap">{{ '{:,}'.format(u.recs or 0) }} 条
|
||||||
<td class="num">{{ u.login_count }}</td>
|
<br><span class="muted sm">{{ '%.2f'|format(u.credits or 0) }} 分</span></td>
|
||||||
|
<td class="mono sm nowrap">{{ (u.last_login_at or '—')[:16] }}
|
||||||
|
{% if u.last_login_ip %}<br><span class="muted">{{ u.last_login_ip }}</span>{% endif %}</td>
|
||||||
<td class="nowrap">
|
<td class="nowrap">
|
||||||
<button class="btn sm" type="button" data-act="save">保存</button>
|
<button class="btn sm" type="button" data-act="save">保存</button>
|
||||||
<button class="btn sm ghost" type="button" data-act="pwd">改密</button>
|
<button class="btn sm ghost" type="button" data-act="pwd">改密</button>
|
||||||
{% if u.id != me.id %}
|
{% if u.id != me.id %}
|
||||||
|
<button class="btn sm ghost" type="button" data-act="toggle">
|
||||||
|
{{ '停用' if (u.status or 'active') == 'active' else '启用' }}</button>
|
||||||
<button class="btn sm danger" type="button" data-act="del">删除</button>
|
<button class="btn sm danger" type="button" data-act="del">删除</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
@@ -75,14 +90,17 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">「改密」会依次询问新密码与确认;管理员不能取消自己的管理员身份,任何人也不能删除自己。</p>
|
<p class="hint">
|
||||||
|
「停用」立即失效,数据与 Cookie 保留;「删除」不可逆,会连同用量数据与 Cookie 一起删除。
|
||||||
|
管理员不能取消自己的管理员身份,也不能停用或删除自己。
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="cardhead">
|
<div class="cardhead">
|
||||||
<h2>用户操作审计</h2>
|
<h2>账号操作审计</h2>
|
||||||
<span class="hint">最近 20 条</span>
|
<span class="hint">最近 20 条(含注册与登录失败)</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="tablewrap scroll-y">
|
<div class="tablewrap scroll-y">
|
||||||
<table class="tbl">
|
<table class="tbl">
|
||||||
@@ -139,20 +157,31 @@
|
|||||||
if (act === 'save') {
|
if (act === 'save') {
|
||||||
var fields = {};
|
var fields = {};
|
||||||
fields.display_name = row.querySelector('[name=display_name]').value;
|
fields.display_name = row.querySelector('[name=display_name]').value;
|
||||||
var sel = row.querySelector('[name=is_admin]');
|
var adm = row.querySelector('[name=is_admin]');
|
||||||
if (sel && !sel.disabled) fields.is_admin = sel.value;
|
if (adm && !adm.disabled) fields.is_admin = adm.value;
|
||||||
|
var st = row.querySelector('[name=status]');
|
||||||
|
if (st && !st.disabled) fields.status = st.value;
|
||||||
done(WBU.post('/api/users/' + uid, fields));
|
done(WBU.post('/api/users/' + uid, fields));
|
||||||
} else if (act === 'pwd') {
|
} else if (act === 'pwd') {
|
||||||
var p1 = window.prompt('为用户「' + name + '」设置新密码(至少 6 位)');
|
var p1 = window.prompt('为用户「' + name + '」设置新密码(至少 8 位,需含两类字符)');
|
||||||
if (p1 === null) { btn.disabled = false; btn.textContent = old; return; }
|
if (p1 === null) { btn.disabled = false; btn.textContent = old; return; }
|
||||||
var p2 = window.prompt('再输入一次新密码以确认');
|
var p2 = window.prompt('再输入一次新密码以确认');
|
||||||
if (p2 === null) { btn.disabled = false; btn.textContent = old; return; }
|
if (p2 === null) { btn.disabled = false; btn.textContent = old; return; }
|
||||||
if (p1 !== p2) { WBU.say('两次输入的密码不一致', 'warn'); btn.disabled = false; btn.textContent = old; return; }
|
if (p1 !== p2) { WBU.say('两次输入的密码不一致', 'warn'); btn.disabled = false; btn.textContent = old; return; }
|
||||||
done(WBU.post('/api/users/' + uid, { password: p1, password2: p2 }));
|
done(WBU.post('/api/users/' + uid, { password: p1, password2: p2 }));
|
||||||
} else if (act === 'del') {
|
} else if (act === 'toggle') {
|
||||||
if (!window.confirm('确定删除用户「' + name + '」?该操作不可撤销(其历史审计记录会保留)。')) {
|
var cur = row.dataset.status === 'active';
|
||||||
|
var next = cur ? 'disabled' : 'active';
|
||||||
|
if (!window.confirm(cur ? ('停用「' + name + '」?其数据与 Cookie 会保留,但无法登录。')
|
||||||
|
: ('启用「' + name + '」?'))) {
|
||||||
btn.disabled = false; btn.textContent = old; return;
|
btn.disabled = false; btn.textContent = old; return;
|
||||||
}
|
}
|
||||||
|
done(WBU.post('/api/users/' + uid, { status: next }));
|
||||||
|
} else if (act === 'del') {
|
||||||
|
var keep = window.confirm('确定删除用户「' + name + '」?\n\n'
|
||||||
|
+ '点「确定」= 连同其用量数据与 Cookie 一起删除(推荐)\n'
|
||||||
|
+ '点「取消」= 取消本次删除');
|
||||||
|
if (!keep) { btn.disabled = false; btn.textContent = old; return; }
|
||||||
done(WBU.post('/api/users/' + uid + '/delete', {}));
|
done(WBU.post('/api/users/' + uid + '/delete', {}));
|
||||||
} else {
|
} else {
|
||||||
btn.disabled = false; btn.textContent = old;
|
btn.disabled = false; btn.textContent = old;
|
||||||
|
|||||||
@@ -1,70 +1,244 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
# Copyright (c) 2026 Wang Chuanli
|
||||||
|
|
||||||
"""页面路由(Jinja 模板)。
|
"""页面路由(Jinja 模板)。
|
||||||
|
|
||||||
分工:
|
分工:
|
||||||
/ 概览(KPI + 入口)
|
/ 概览(KPI + 入口)
|
||||||
/dashboard ECharts 交互大屏(独立静态页,登录后可达,数据走 /api/bundle)
|
/dashboard ECharts 交互大屏(独立静态页,登录后可达,数据走 /api/bundle)
|
||||||
/tasks 任务管理:调度开关/时刻、手动触发、运行历史
|
|
||||||
/config 配置管理:Cookie / UA / 采集参数 / 改密码
|
|
||||||
/logs 日志管理:采集逐次明细 + 应用日志尾部
|
|
||||||
/records 数据明细:分页、筛选、搜索、导出
|
/records 数据明细:分页、筛选、搜索、导出
|
||||||
|
/tasks 任务管理:运行历史、手动采集、补采(**调度配置仅管理员可改**)
|
||||||
|
/config 配置管理:本人的 Cookie / UA(**其余参数仅管理员可改**)
|
||||||
|
/logs 日志管理(**仅管理员**)
|
||||||
|
/profile 个人中心:资料、密码、凭证状态
|
||||||
|
/users 用户管理(仅管理员)
|
||||||
|
/register 自助注册(受 allow_register 开关约束)
|
||||||
|
/captcha.png 图形验证码
|
||||||
|
|
||||||
|
**多用户约定(两条)**
|
||||||
|
|
||||||
|
1. **数据作用域**:所有数据类页面都只取 `current_user()["id"]` 那份数据;
|
||||||
|
管理员在「用户管理」里能看到账号列表,但**看不到别人的用量与凭证**。
|
||||||
|
|
||||||
|
2. **写权限**:普通用户只能写 `config.USER_EDITABLE_KEYS`(本人的 cookie /
|
||||||
|
user_agent),其余配置(调度时刻、采集参数、接口地址、注册策略)都归
|
||||||
|
管理员。页面上的 disabled / 隐藏只是「不给误导性按钮」,真正的闸门在
|
||||||
|
`@admin_required` 与 `config.writable_by()`,两端共用一个判断。
|
||||||
"""
|
"""
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
|
||||||
from flask import (Blueprint, current_app, flash, jsonify, redirect, render_template,
|
from flask import (Blueprint, current_app, flash, jsonify, redirect, render_template,
|
||||||
request, send_from_directory, url_for)
|
request, send_from_directory, session, url_for)
|
||||||
|
|
||||||
from .. import collect, config, db, query, scheduler
|
from .. import backup, collect, config, db, query, scheduler, security
|
||||||
from ..security import (admin_required, clear_fail, current_user, is_locked, lock_left,
|
from ..security import (admin_required, current_user, is_admin, login_required,
|
||||||
login_ok, login_required, login_session, logout_session,
|
safe_next)
|
||||||
note_fail, safe_next)
|
|
||||||
|
|
||||||
bp = Blueprint("views", __name__)
|
bp = Blueprint("views", __name__)
|
||||||
|
|
||||||
|
|
||||||
def _ip():
|
def _ip():
|
||||||
return request.headers.get("X-Forwarded-For", request.remote_addr or "").split(",")[0].strip()
|
"""客户端地址。**全站统一走 security.client_ip()**。
|
||||||
|
|
||||||
|
原来这里直接取 `X-Forwarded-For` 的第 0 段,等于把「来源 IP」交给请求方
|
||||||
|
自己申报:验证码出图限速、注册配额、登录锁定三道 IP 防线会一起失效。
|
||||||
|
具体取法与开关见 security.client_ip 的注释。
|
||||||
|
"""
|
||||||
|
return security.client_ip()
|
||||||
|
|
||||||
|
|
||||||
|
def _uid():
|
||||||
|
"""当前账号 id。调用方必须已过 @login_required。"""
|
||||||
|
u = current_user()
|
||||||
|
return u["id"] if u else 0
|
||||||
|
|
||||||
|
|
||||||
|
def _shift(days):
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
return (datetime.now() + timedelta(days=days)).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 验证码 ----------------
|
||||||
|
@bp.get("/captcha.png")
|
||||||
|
def captcha_png():
|
||||||
|
"""生成一张验证码。答案只写进 captchas 表,会话里只记 id。"""
|
||||||
|
purpose = (request.args.get("purpose") or "login").strip().lower()
|
||||||
|
if purpose not in ("login", "register"):
|
||||||
|
purpose = "login"
|
||||||
|
if not security.captcha_fetch_allowed(_ip()):
|
||||||
|
return "验证码请求过于频繁,请稍后再试", 429
|
||||||
|
try:
|
||||||
|
png = security.issue_captcha(db.get_db(), purpose)
|
||||||
|
except sqlite3.Error:
|
||||||
|
return "验证码服务暂不可用", 503
|
||||||
|
resp = current_app.response_class(png, mimetype="image/png")
|
||||||
|
# 必须禁缓存:否则浏览器复用旧图,而服务端那张已经被消费掉了,
|
||||||
|
# 表现为「图没变但怎么输都错」。
|
||||||
|
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
||||||
|
resp.headers["Pragma"] = "no-cache"
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
# ---------------- 登录 ----------------
|
# ---------------- 登录 ----------------
|
||||||
def _login_ctx(**kw):
|
def _login_ctx(conn=None, **kw):
|
||||||
"""登录页共用的上下文:锁定阈值/会话时长都从配置读,避免模板里写死数字。"""
|
"""登录页共用的上下文:锁定阈值/会话时长都从配置读,避免模板里写死数字。
|
||||||
|
|
||||||
|
`need_captcha` 与 `allow_register` 也在这里补齐 —— 登录页与注册页
|
||||||
|
必须对「要不要验证码」保持一致,否则会出现「页面没给输入框、
|
||||||
|
服务端却在校验」的死循环。
|
||||||
|
"""
|
||||||
kw.setdefault("max_fails", config.MAX_LOGIN_FAILS)
|
kw.setdefault("max_fails", config.MAX_LOGIN_FAILS)
|
||||||
kw.setdefault("lock_minutes", config.LOGIN_LOCK_MINUTES)
|
kw.setdefault("lock_minutes", config.LOGIN_LOCK_MINUTES)
|
||||||
kw.setdefault("session_hours", config.SESSION_HOURS)
|
kw.setdefault("session_hours", config.SESSION_HOURS)
|
||||||
|
kw.setdefault("pwd_min", config.PASSWORD_MIN)
|
||||||
|
if conn is not None:
|
||||||
|
kw.setdefault("need_captcha", security.captcha_required(conn, _ip(), kw.get("username") or ""))
|
||||||
|
kw.setdefault("allow_register", security.register_allowed(conn))
|
||||||
return kw
|
return kw
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/login", methods=["GET", "POST"])
|
@bp.route("/login", methods=["GET", "POST"])
|
||||||
def login():
|
def login():
|
||||||
nxt = request.values.get("next") or ""
|
nxt = request.values.get("next") or ""
|
||||||
|
conn = db.get_db()
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
ip = _ip()
|
ip = _ip()
|
||||||
if is_locked(ip):
|
|
||||||
n = lock_left(ip)
|
|
||||||
flash("登录失败次数过多,请 %d 秒后再试" % n, "error")
|
|
||||||
return render_template("login.html", **_login_ctx(next_url=nxt)), 429
|
|
||||||
username = (request.form.get("username") or "").strip()
|
username = (request.form.get("username") or "").strip()
|
||||||
pwd = request.form.get("password") or ""
|
pwd = request.form.get("password") or ""
|
||||||
conn = db.get_db()
|
|
||||||
user = login_ok(conn, username, pwd)
|
# 先记一次「尝试」(含成功)。只按失败计数会被「慢慢撞」绕过:
|
||||||
|
# 攻击者只要把失败次数控制在阈值以下就能无限试。
|
||||||
|
security.note_try(ip)
|
||||||
|
left = security.auth_locked(ip, username)
|
||||||
|
if left:
|
||||||
|
reason = security.auth_block_reason(ip, username)
|
||||||
|
security.audit_login_fail(conn, username,
|
||||||
|
"已限速(%s),剩余 %d 秒" % (reason, left), ip)
|
||||||
|
flash(security.auth_block_message(reason, left), "error")
|
||||||
|
return render_template("login.html",
|
||||||
|
**_login_ctx(conn, next_url=nxt, username=username,
|
||||||
|
need_captcha=True)), 429
|
||||||
|
|
||||||
|
# 验证码**先于**口令校验:否则攻击者可以拿「密码对不对」当信号,
|
||||||
|
# 在解验证码之前就把字典跑完。
|
||||||
|
need_cap = security.captcha_required(conn, ip, username)
|
||||||
|
if need_cap and not security.consume_captcha(conn, "login", request.form.get("captcha")):
|
||||||
|
n = security.note_auth_fail(ip, username)
|
||||||
|
security.audit_login_fail(conn, username, "验证码错误(第 %d 次)" % n, ip)
|
||||||
|
flash("验证码不正确或已过期,请重新输入", "error")
|
||||||
|
return render_template("login.html",
|
||||||
|
**_login_ctx(conn, username=username, next_url=nxt,
|
||||||
|
need_captcha=True)), 400
|
||||||
|
|
||||||
|
user, err = security.login_ok(conn, username, pwd)
|
||||||
if user is None:
|
if user is None:
|
||||||
n = note_fail(ip)
|
n = security.note_auth_fail(ip, username)
|
||||||
db.audit(conn, "login_failed", username, "第 %d 次失败" % n, ip)
|
security.audit_login_fail(conn, username, err + "(第 %d 次)" % n, ip)
|
||||||
flash("用户名或密码不正确(剩余尝试 %d 次)" % max(0, config.MAX_LOGIN_FAILS - n), "error")
|
# 不再报「剩余 N 次」:用户名维度的计数已经在攻击者手里了,
|
||||||
|
# 报出来的数字会变成「还差几次就能把这个人锁住」的倒计时。
|
||||||
|
flash("%s(本来源连续失败 %d 次)" % (err, n), "error")
|
||||||
# 必须把 next 显式回填:失败后 request.args 为空,
|
# 必须把 next 显式回填:失败后 request.args 为空,
|
||||||
# 若模板从 request.args 取值会导致跳转目标丢失(历史 bug)。
|
# 若模板从 request.args 取值会导致跳转目标丢失(历史 bug)。
|
||||||
return render_template("login.html", **_login_ctx(username=username, next_url=nxt)), 401
|
return render_template("login.html",
|
||||||
clear_fail(ip)
|
**_login_ctx(conn, username=username, next_url=nxt,
|
||||||
login_session(user)
|
need_captcha=True)), 401
|
||||||
db.audit(conn, "login", username, "登录成功", ip)
|
security.clear_auth_fail(ip, username)
|
||||||
return redirect(safe_next(nxt, url_for("views.overview")))
|
security.login_session(user)
|
||||||
|
conn.execute("UPDATE users SET last_login_ip=? WHERE id=?", (ip, user["id"]))
|
||||||
|
db.audit(conn, "login", username, "登录成功", ip, user["id"])
|
||||||
|
target = safe_next(nxt, "")
|
||||||
|
if not target:
|
||||||
|
# 新注册 / 还没配凭证 → 直接带到配置页,少一步摸索
|
||||||
|
cred = db.secret_state(conn, "cookie", user["id"])
|
||||||
|
target = url_for("views.config_page") if not cred["set"] else url_for("views.overview")
|
||||||
|
return redirect(target)
|
||||||
|
|
||||||
if current_user():
|
if current_user():
|
||||||
return redirect(url_for("views.overview"))
|
return redirect(url_for("views.overview"))
|
||||||
return render_template("login.html", **_login_ctx(next_url=nxt))
|
return render_template("login.html", **_login_ctx(conn, next_url=nxt))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 注册 ----------------
|
||||||
|
def _register_ctx(**kw):
|
||||||
|
kw.setdefault("max_fails", config.MAX_LOGIN_FAILS)
|
||||||
|
kw.setdefault("lock_minutes", config.LOGIN_LOCK_MINUTES)
|
||||||
|
kw.setdefault("pwd_min", config.PASSWORD_MIN)
|
||||||
|
return kw
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/register", methods=["GET", "POST"])
|
||||||
|
def register():
|
||||||
|
conn = db.get_db()
|
||||||
|
if not security.register_allowed(conn):
|
||||||
|
return render_template("error.html", code=403,
|
||||||
|
message="管理员已关闭自助注册,请联系管理员开通账号"), 403
|
||||||
|
if current_user():
|
||||||
|
return redirect(url_for("views.overview"))
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
ip = _ip()
|
||||||
|
username = (request.form.get("username") or "").strip()
|
||||||
|
display = (request.form.get("display_name") or "").strip()[:64]
|
||||||
|
email = (request.form.get("email") or "").strip()[:128]
|
||||||
|
pwd = request.form.get("password") or ""
|
||||||
|
pwd2 = request.form.get("password2") or ""
|
||||||
|
ctx = _register_ctx(username=username, display_name=display, email=email,
|
||||||
|
need_captcha=True)
|
||||||
|
|
||||||
|
security.note_try(ip)
|
||||||
|
left = security.auth_locked(ip, username)
|
||||||
|
if left:
|
||||||
|
reason = security.auth_block_reason(ip, username)
|
||||||
|
flash(security.auth_block_message(reason, left), "error")
|
||||||
|
return render_template("register.html", **ctx), 429
|
||||||
|
|
||||||
|
# 注册一律要验证码:这是唯一能让陌生人写库的入口
|
||||||
|
if not security.consume_captcha(conn, "register", request.form.get("captcha")):
|
||||||
|
security.note_auth_fail(ip, username)
|
||||||
|
db.audit(conn, "register_rejected", username or "-", "验证码错误", ip)
|
||||||
|
flash("验证码不正确或已过期,请重新输入", "error")
|
||||||
|
return render_template("register.html", **ctx), 400
|
||||||
|
|
||||||
|
ok, n, limit = security.register_quota(conn, ip)
|
||||||
|
if not ok:
|
||||||
|
db.audit(conn, "register_rejected", username or "-",
|
||||||
|
"同 IP 当日注册数已达上限 %d" % limit, ip)
|
||||||
|
flash("同一来源每天最多注册 %d 个账号,请明天再试或联系管理员" % limit, "error")
|
||||||
|
return render_template("register.html", **ctx), 429
|
||||||
|
|
||||||
|
err = security.username_problem(username) or security.password_problem(pwd, pwd2, username)
|
||||||
|
if err:
|
||||||
|
security.note_auth_fail(ip, username)
|
||||||
|
db.audit(conn, "register_rejected", username or "-", err, ip)
|
||||||
|
flash(err, "error")
|
||||||
|
return render_template("register.html", **ctx), 400
|
||||||
|
if db.user_by_name(conn, username):
|
||||||
|
# 用户名唯一性本来就暴露(注册时要查重),这里如实告知
|
||||||
|
flash("用户名已被占用,请换一个", "error")
|
||||||
|
return render_template("register.html", **ctx), 400
|
||||||
|
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO users(username,password_hash,display_name,email,is_admin,status,"
|
||||||
|
" created_at,register_ip) VALUES(?,?,?,?,0,'active',?,?)",
|
||||||
|
(username, security.hash_password(pwd), display or username, email or None,
|
||||||
|
db.now_str(), ip))
|
||||||
|
uid = cur.lastrowid
|
||||||
|
db.audit(conn, "register", username, "自助注册成功(账号 #%d)" % uid, ip, uid)
|
||||||
|
# 注册即登录:少一次输密码,也顺手把会话建立起来
|
||||||
|
row = db.user_by_id(conn, uid)
|
||||||
|
security.login_session(row)
|
||||||
|
security.clear_auth_fail(ip, username)
|
||||||
|
flash("注册成功。请粘贴你自己账号的 Cookie —— 采集只使用本人的凭证。", "ok")
|
||||||
|
return redirect(url_for("views.config_page"))
|
||||||
|
|
||||||
|
return render_template("register.html", **_register_ctx(need_captcha=True))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/logout")
|
@bp.post("/logout")
|
||||||
@@ -73,8 +247,8 @@ def logout_post():
|
|||||||
"""退出登录改为 POST + CSRF:GET 型退出会被 <img src> 这类请求静默触发。"""
|
"""退出登录改为 POST + CSRF:GET 型退出会被 <img src> 这类请求静默触发。"""
|
||||||
u = current_user()
|
u = current_user()
|
||||||
if u:
|
if u:
|
||||||
db.audit(db.get_db(), "logout", u["username"], "", _ip())
|
db.audit(db.get_db(), "logout", u["username"], "", _ip(), u["id"])
|
||||||
logout_session()
|
security.logout_session()
|
||||||
flash("已退出登录", "ok")
|
flash("已退出登录", "ok")
|
||||||
return redirect(url_for("views.login"))
|
return redirect(url_for("views.login"))
|
||||||
|
|
||||||
@@ -93,29 +267,25 @@ def logout():
|
|||||||
@login_required
|
@login_required
|
||||||
def overview():
|
def overview():
|
||||||
conn = db.get_db()
|
conn = db.get_db()
|
||||||
mf = query.manifest(conn)
|
uid = _uid()
|
||||||
t = query.totals(conn)
|
mf = query.manifest(conn, uid)
|
||||||
|
t = query.totals(conn, uid)
|
||||||
today = db.now_str()[:10]
|
today = db.now_str()[:10]
|
||||||
st = query.summary(conn, today, today)
|
st = query.summary(conn, uid, today, today)
|
||||||
d30 = query.summary(conn, _shift(-29), today)
|
d30 = query.summary(conn, uid, _shift(-29), today)
|
||||||
# 昨日对比:昨日整日 vs 今日(残日),让「今天偏少」有参照
|
# 昨日对比:昨日整日 vs 今日(残日),让「今天偏少」有参照
|
||||||
y = _shift(-1)
|
y = _shift(-1)
|
||||||
yest = query.summary(conn, y, y)
|
yest = query.summary(conn, uid, y, y)
|
||||||
dims = query.dims(conn)
|
dims = query.dims(conn, uid)
|
||||||
# 注意:这里的 SQL 必须把模板用到的列都选出来(模板渲染 r.fetched,
|
# 注意:这里的 SQL 必须把模板用到的列都选出来(模板渲染 r.fetched,
|
||||||
# 少选一列并不会报错,只会静默渲染成空白 —— 历史 bug)。
|
# 少选一列并不会报错,只会静默渲染成空白 —— 历史 bug)。
|
||||||
runs = conn.execute(
|
runs = conn.execute(
|
||||||
"SELECT id,trigger,status,started_at,duration_ms,fetched,added,dup,total,conflicts,message"
|
"SELECT id,trigger,status,started_at,duration_ms,fetched,added,dup,total,conflicts,message"
|
||||||
" FROM collect_runs ORDER BY id DESC LIMIT 8").fetchall()
|
" FROM collect_runs WHERE user_id=? ORDER BY id DESC LIMIT 8", (uid,)).fetchall()
|
||||||
return render_template("overview.html", mf=mf, totals=t, today_stat=st, stat30=d30,
|
return render_template("overview.html", mf=mf, totals=t, today_stat=st, stat30=d30,
|
||||||
yesterday=yest, yday=y,
|
yesterday=yest, yday=y,
|
||||||
models=dims["model"][:8], clients=dims["client"],
|
models=dims["model"][:8], clients=dims["client"],
|
||||||
runs=runs, sch=_sch_info(conn), active="overview")
|
runs=runs, sch=_sch_info(conn, uid), active="overview")
|
||||||
|
|
||||||
|
|
||||||
def _shift(days):
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
return (datetime.now() + timedelta(days=days)).strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------- 大屏(独立 ECharts 页)----------------
|
# ---------------- 大屏(独立 ECharts 页)----------------
|
||||||
@@ -130,16 +300,17 @@ def dashboard():
|
|||||||
|
|
||||||
|
|
||||||
# ---------------- 任务管理 ----------------
|
# ---------------- 任务管理 ----------------
|
||||||
def _sch_info(conn):
|
def _sch_info(conn, uid):
|
||||||
sch = scheduler.get_scheduler()
|
sch = scheduler.get_scheduler()
|
||||||
nxt = scheduler.next_run_at(conn)
|
nxt = scheduler.next_run_at(conn, uid)
|
||||||
last = conn.execute("SELECT * FROM collect_runs ORDER BY id DESC LIMIT 1").fetchone()
|
last = conn.execute("SELECT * FROM collect_runs WHERE user_id=? ORDER BY id DESC LIMIT 1",
|
||||||
|
(uid,)).fetchone()
|
||||||
return {
|
return {
|
||||||
"running": sch.running,
|
"running": sch.running,
|
||||||
"enabled": db.get_bool(conn, "schedule_enabled", True),
|
"enabled": db.get_bool(conn, "schedule_enabled", True, uid),
|
||||||
"times": scheduler.slots(conn),
|
"times": scheduler.slots(conn, uid),
|
||||||
"next_run": nxt.strftime("%Y-%m-%d %H:%M:%S") if nxt else None,
|
"next_run": nxt.strftime("%Y-%m-%d %H:%M:%S") if nxt else None,
|
||||||
"catch_up": db.get_bool(conn, "catch_up", True),
|
"catch_up": db.get_bool(conn, "catch_up", True, uid),
|
||||||
"interval": sch.interval,
|
"interval": sch.interval,
|
||||||
"last": dict(last) if last else None,
|
"last": dict(last) if last else None,
|
||||||
"lock": os.path.exists(collect.LOCK_PATH),
|
"lock": os.path.exists(collect.LOCK_PATH),
|
||||||
@@ -151,16 +322,24 @@ def _sch_info(conn):
|
|||||||
@login_required
|
@login_required
|
||||||
def tasks():
|
def tasks():
|
||||||
conn = db.get_db()
|
conn = db.get_db()
|
||||||
|
uid = _uid()
|
||||||
page = _int_arg("page", 1, 1, 10 ** 6)
|
page = _int_arg("page", 1, 1, 10 ** 6)
|
||||||
size = 20
|
size = 20
|
||||||
total = conn.execute("SELECT COUNT(*) FROM collect_runs").fetchone()[0]
|
total = conn.execute("SELECT COUNT(*) FROM collect_runs WHERE user_id=?",
|
||||||
runs = conn.execute("SELECT * FROM collect_runs ORDER BY id DESC LIMIT ? OFFSET ?",
|
(uid,)).fetchone()[0]
|
||||||
(size, (page - 1) * size)).fetchall()
|
runs = conn.execute("SELECT * FROM collect_runs WHERE user_id=? ORDER BY id DESC"
|
||||||
s = db.get_settings(conn)
|
" LIMIT ? OFFSET ?", (uid, size, (page - 1) * size)).fetchall()
|
||||||
|
s = db.get_settings(conn, uid=uid)
|
||||||
pages = max(1, (total + size - 1) // size)
|
pages = max(1, (total + size - 1) // size)
|
||||||
return render_template("tasks.html", runs=runs, sch=_sch_info(conn),
|
# 三道闸门的当前取值,直接交给页面:前端据此提前禁用/限位,
|
||||||
|
# 而不是让用户填完了再吃一个 400/409/429。
|
||||||
|
return render_template("tasks.html", runs=runs, sch=_sch_info(conn, uid),
|
||||||
s_times=s.get("schedule_times") or "",
|
s_times=s.get("schedule_times") or "",
|
||||||
s_grace=s.get("catch_up_grace_hours") or "12",
|
s_grace=s.get("catch_up_grace_hours") or "12",
|
||||||
|
max_slots=s.get("max_schedule_slots_per_day") or "6",
|
||||||
|
max_days=collect.max_range_days(conn),
|
||||||
|
min_gap=collect.min_interval_seconds(conn),
|
||||||
|
can_edit=is_admin(),
|
||||||
page=page, pages=pages, total=total,
|
page=page, pages=pages, total=total,
|
||||||
page_window=_page_window(page, pages),
|
page_window=_page_window(page, pages),
|
||||||
active="tasks")
|
active="tasks")
|
||||||
@@ -188,73 +367,130 @@ def _page_window(page, pages, span=9):
|
|||||||
@login_required
|
@login_required
|
||||||
def config_page():
|
def config_page():
|
||||||
conn = db.get_db()
|
conn = db.get_db()
|
||||||
s = db.get_settings(conn)
|
uid = _uid()
|
||||||
|
s = db.get_settings(conn, uid=uid)
|
||||||
for k in [k for k in list(s) if config.is_internal_key(k)]:
|
for k in [k for k in list(s) if config.is_internal_key(k)]:
|
||||||
s.pop(k, None)
|
s.pop(k, None)
|
||||||
cookie = (s.pop("cookie", "") or "")
|
# 加密键在 get_settings 里已经被置空,这里补上「状态摘要」给页面显示
|
||||||
s["cookie_hint"] = ("%d 字符,结尾 …%s" % (len(cookie), cookie[-16:])) if cookie else ""
|
st = db.secret_state(conn, "cookie", uid)
|
||||||
return render_template("config.html", s=s, sch=_sch_info(conn),
|
s["cookie_hint"] = ("%d 字符,结尾 …%s" % (st["chars"], st["tail"])) if st["set"] else ""
|
||||||
|
s["cookie_broken"] = st["broken"]
|
||||||
|
s["cookie_at"] = st["at"]
|
||||||
|
return render_template("config.html", s=s, sch=_sch_info(conn, uid),
|
||||||
secret_keys=config.SECRET_KEYS,
|
secret_keys=config.SECRET_KEYS,
|
||||||
num_settings=config.NUM_SETTINGS,
|
num_settings=config.NUM_SETTINGS,
|
||||||
|
pwd_min=config.PASSWORD_MIN,
|
||||||
|
is_admin=is_admin(),
|
||||||
|
global_keys=config.GLOBAL_KEYS,
|
||||||
active="config")
|
active="config")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 个人中心 ----------------
|
||||||
|
@bp.get("/profile")
|
||||||
|
@login_required
|
||||||
|
def profile_page():
|
||||||
|
conn = db.get_db()
|
||||||
|
u = current_user()
|
||||||
|
row = db.user_by_id(conn, u["id"])
|
||||||
|
cred = db.secret_state(conn, "cookie", u["id"])
|
||||||
|
my = conn.execute(
|
||||||
|
"SELECT COUNT(*) n, COALESCE(SUM(credits),0) c, MIN(day) d0, MAX(day) d1"
|
||||||
|
" FROM usage_records WHERE user_id=?", (u["id"],)).fetchone()
|
||||||
|
runs = conn.execute("SELECT COUNT(*) FROM collect_runs WHERE user_id=?",
|
||||||
|
(u["id"],)).fetchone()[0]
|
||||||
|
return render_template("profile.html", me=dict(row), cred=cred, my=dict(my),
|
||||||
|
runs=runs, sch=_sch_info(conn, u["id"]),
|
||||||
|
pwd_min=config.PASSWORD_MIN, active="profile")
|
||||||
|
|
||||||
|
|
||||||
# ---------------- 用户管理 ----------------
|
# ---------------- 用户管理 ----------------
|
||||||
@bp.get("/users")
|
@bp.get("/users")
|
||||||
@admin_required
|
@admin_required
|
||||||
def users_page():
|
def users_page():
|
||||||
conn = db.get_db()
|
conn = db.get_db()
|
||||||
users = conn.execute(
|
users = conn.execute(
|
||||||
"SELECT id,username,display_name,is_admin,created_at,last_login_at,login_count"
|
"SELECT u.id,u.username,u.display_name,u.email,u.is_admin,u.status,u.created_at,"
|
||||||
" FROM users ORDER BY id").fetchall()
|
" u.register_ip,u.last_login_at,u.last_login_ip,u.login_count,"
|
||||||
|
" (SELECT COUNT(*) FROM usage_records r WHERE r.user_id=u.id) recs,"
|
||||||
|
" (SELECT COALESCE(SUM(credits),0) FROM usage_records r WHERE r.user_id=u.id) credits"
|
||||||
|
" FROM users u ORDER BY u.id").fetchall()
|
||||||
audits = conn.execute("SELECT * FROM audit_log WHERE action LIKE 'user%'"
|
audits = conn.execute("SELECT * FROM audit_log WHERE action LIKE 'user%'"
|
||||||
|
" OR action IN ('register','register_rejected','password','login_failed')"
|
||||||
" ORDER BY id DESC LIMIT 20").fetchall()
|
" ORDER BY id DESC LIMIT 20").fetchall()
|
||||||
return render_template("users.html", users=users, audits=audits,
|
return render_template("users.html", users=users, audits=audits,
|
||||||
me=current_user(), active="users")
|
me=current_user(), allow_register=db.get_bool(
|
||||||
|
conn, "allow_register", True),
|
||||||
|
active="users")
|
||||||
|
|
||||||
|
|
||||||
# ---------------- 日志管理 ----------------
|
# ---------------- 日志管理(仅管理员) ----------------
|
||||||
@bp.get("/logs")
|
@bp.get("/logs")
|
||||||
@login_required
|
@admin_required
|
||||||
def logs():
|
def logs():
|
||||||
|
"""日志管理 —— **仅管理员**。
|
||||||
|
|
||||||
|
这一页同时呈现「全实例采集日志」「进程级应用日志」「全实例操作审计」,
|
||||||
|
都是实例运行信息(会带数据库路径、账号名、采集区间、来源 IP)。
|
||||||
|
普通账号不该读到这些:他们要看自己的采集历史走「任务管理」,
|
||||||
|
那页只查本人的数据。服务端用 @admin_required 兜底,导航里也会隐藏入口。
|
||||||
|
"""
|
||||||
conn = db.get_db()
|
conn = db.get_db()
|
||||||
run_id = request.args.get("run")
|
run_id = request.args.get("run")
|
||||||
detail = None
|
detail = None
|
||||||
if run_id and str(run_id).isdigit():
|
if run_id and str(run_id).isdigit():
|
||||||
detail = conn.execute("SELECT * FROM collect_runs WHERE id=?", (int(run_id),)).fetchone()
|
detail = conn.execute("SELECT * FROM collect_runs WHERE id=?",
|
||||||
|
(int(run_id),)).fetchone()
|
||||||
|
|
||||||
status = request.args.get("status") or ""
|
status = request.args.get("status") or ""
|
||||||
w, p = ("WHERE status = ?", [status]) if status in ("ok", "warn", "error", "running") else ("", [])
|
if status in ("ok", "warn", "error", "running"):
|
||||||
# 操作审计:按动作筛选 + 分页(原来只能看最近 40 条,等于不可查)
|
w, p = "WHERE status = ?", [status]
|
||||||
|
else:
|
||||||
|
status, w, p = "", "", []
|
||||||
|
|
||||||
act = request.args.get("act") or ""
|
act = request.args.get("act") or ""
|
||||||
aw, ap = ("WHERE action = ?", [act]) if act else ("", [])
|
ap = [act] if act else []
|
||||||
|
aw_sql = "WHERE action = ?" if act else ""
|
||||||
|
|
||||||
apage = _int_arg("apage", 1, 1, 10 ** 6)
|
apage = _int_arg("apage", 1, 1, 10 ** 6)
|
||||||
asize = 20
|
asize = 20
|
||||||
atotal = conn.execute("SELECT COUNT(*) FROM audit_log %s" % aw, ap).fetchone()[0]
|
atotal = conn.execute("SELECT COUNT(*) FROM audit_log %s" % aw_sql, ap).fetchone()[0]
|
||||||
audits = conn.execute("SELECT * FROM audit_log %s ORDER BY id DESC LIMIT ? OFFSET ?" % aw,
|
audits = conn.execute("SELECT * FROM audit_log %s ORDER BY id DESC LIMIT ? OFFSET ?" % aw_sql,
|
||||||
ap + [asize, (apage - 1) * asize]).fetchall()
|
ap + [asize, (apage - 1) * asize]).fetchall()
|
||||||
# 注意传的是 sqlite3.Row 列表而不是纯字符串列表:模板要用 a[0]=动作、a[1]=次数,
|
# 注意传的是 sqlite3.Row 列表而不是纯字符串列表:模板要用 a[0]=动作、a[1]=次数,
|
||||||
# 若在这里就用推导式取 r[0],模板里的 a[0] 会变成「字符串的第一个字符」。
|
# 若在这里就用推导式取 r[0],模板里的 a[0] 会变成「字符串的第一个字符」。
|
||||||
actions = conn.execute(
|
actions = conn.execute(
|
||||||
"SELECT action, COUNT(*) n FROM audit_log GROUP BY action ORDER BY n DESC, action").fetchall()
|
"SELECT action, COUNT(*) n FROM audit_log GROUP BY action ORDER BY n DESC, action").fetchall()
|
||||||
|
|
||||||
page = _int_arg("page", 1, 1, 10 ** 6)
|
page = _int_arg("page", 1, 1, 10 ** 6)
|
||||||
size = 30
|
size = 30
|
||||||
total = conn.execute("SELECT COUNT(*) FROM collect_runs %s" % w, p).fetchone()[0]
|
total = conn.execute("SELECT COUNT(*) FROM collect_runs %s" % w, p).fetchone()[0]
|
||||||
runs = conn.execute("SELECT id,trigger,status,started_at,duration_ms,fetched,added,dup,total,"
|
# 带上账号名:这是实例级视图,一行没有归属人根本没法读
|
||||||
"conflicts,exit_code,message FROM collect_runs %s"
|
runs = conn.execute("SELECT r.id,r.user_id,COALESCE(u.username,'—') AS uname,"
|
||||||
" ORDER BY id DESC LIMIT ? OFFSET ?" % w, p + [size, (page - 1) * size]).fetchall()
|
" r.trigger,r.status,r.started_at,r.duration_ms,r.fetched,r.added,"
|
||||||
|
" r.dup,r.total,r.conflicts,r.exit_code,r.message"
|
||||||
|
" FROM collect_runs r LEFT JOIN users u ON u.id=r.user_id %s"
|
||||||
|
" ORDER BY r.id DESC LIMIT ? OFFSET ?" % w,
|
||||||
|
p + [size, (page - 1) * size]).fetchall()
|
||||||
apages = max(1, (atotal + asize - 1) // asize)
|
apages = max(1, (atotal + asize - 1) // asize)
|
||||||
return render_template("logs.html", runs=runs, detail=detail, audits=audits,
|
return render_template("logs.html", runs=runs, detail=detail, audits=audits,
|
||||||
actions=actions, act=act, apage=apage, apages=apages, atotal=atotal,
|
actions=actions, act=act, apage=apage, apages=apages, atotal=atotal,
|
||||||
apage_window=_page_window(apage, apages, span=7),
|
apage_window=_page_window(apage, apages, span=7),
|
||||||
page=page, pages=max(1, (total + size - 1) // size), total=total,
|
page=page, pages=max(1, (total + size - 1) // size), total=total,
|
||||||
page_window=_page_window(page, max(1, (total + size - 1) // size)),
|
page_window=_page_window(page, max(1, (total + size - 1) // size)),
|
||||||
status=status,
|
status=status, active="logs")
|
||||||
active="logs")
|
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/logs/tail")
|
@bp.get("/logs/tail")
|
||||||
@login_required
|
@login_required
|
||||||
def logs_tail():
|
def logs_tail():
|
||||||
|
"""应用日志尾部(进程级,所有账号看到的是同一份)。
|
||||||
|
|
||||||
|
只对管理员开放:日志里会打印数据库路径、账号名等运行信息,
|
||||||
|
没有理由让任意注册用户读到整个实例的运行轨迹。
|
||||||
|
"""
|
||||||
|
if not is_admin():
|
||||||
|
return jsonify({"ok": False, "error": "forbidden",
|
||||||
|
"message": "应用日志仅管理员可查看"}), 403
|
||||||
n = _int_arg("lines", 200, 10, 2000)
|
n = _int_arg("lines", 200, 10, 2000)
|
||||||
path = config.APP_LOG
|
path = config.APP_LOG
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
@@ -286,6 +522,7 @@ def _day_args():
|
|||||||
@login_required
|
@login_required
|
||||||
def records():
|
def records():
|
||||||
conn = db.get_db()
|
conn = db.get_db()
|
||||||
|
uid = _uid()
|
||||||
frm, to = _day_args()
|
frm, to = _day_args()
|
||||||
model = request.args.get("model") or None
|
model = request.args.get("model") or None
|
||||||
client = request.args.get("client") or None
|
client = request.args.get("client") or None
|
||||||
@@ -293,10 +530,10 @@ def records():
|
|||||||
order = request.args.get("order") or "ts_desc"
|
order = request.args.get("order") or "ts_desc"
|
||||||
page = _int_arg("page", 1, 1, 10 ** 6)
|
page = _int_arg("page", 1, 1, 10 ** 6)
|
||||||
size = _int_arg("size", 50, 10, query.MAX_PAGE_SIZE)
|
size = _int_arg("size", 50, 10, query.MAX_PAGE_SIZE)
|
||||||
data = query.records_page(conn, frm, to, model=model, client=client, q=q,
|
data = query.records_page(conn, uid, frm, to, model=model, client=client, q=q,
|
||||||
page=page, size=size, order=order)
|
page=page, size=size, order=order)
|
||||||
# 只算一次 dims:query.dims() 内部有 3 条 GROUP BY,重复调用纯属浪费
|
# 只算一次 dims:query.dims() 内部有 3 条 GROUP BY,重复调用纯属浪费
|
||||||
d = query.dims(conn)
|
d = query.dims(conn, uid)
|
||||||
models = [r["name"] for r in d["model"]]
|
models = [r["name"] for r in d["model"]]
|
||||||
clients = [r["name"] for r in d["client"]]
|
clients = [r["name"] for r in d["client"]]
|
||||||
# 注意:不要把含 "items" 键的 dict 直接交给模板——Jinja 的属性查找会先命中
|
# 注意:不要把含 "items" 键的 dict 直接交给模板——Jinja 的属性查找会先命中
|
||||||
@@ -318,6 +555,8 @@ def records_export():
|
|||||||
数据用 query.iter_records 流式取,不把整个结果集读进内存。
|
数据用 query.iter_records 流式取,不把整个结果集读进内存。
|
||||||
"""
|
"""
|
||||||
from flask import Response
|
from flask import Response
|
||||||
|
u = current_user()
|
||||||
|
uid = u["id"]
|
||||||
frm, to = _day_args()
|
frm, to = _day_args()
|
||||||
model = request.args.get("model") or None
|
model = request.args.get("model") or None
|
||||||
client = request.args.get("client") or None
|
client = request.args.get("client") or None
|
||||||
@@ -337,7 +576,7 @@ def records_export():
|
|||||||
# 自己开一条连接,并在流结束时关掉。
|
# 自己开一条连接,并在流结束时关掉。
|
||||||
own = db.connect()
|
own = db.connect()
|
||||||
try:
|
try:
|
||||||
for r in query.iter_records(own, frm, to, model=model, client=client,
|
for r in query.iter_records(own, uid, frm, to, model=model, client=client,
|
||||||
q=q, order=order):
|
q=q, order=order):
|
||||||
buf.seek(0)
|
buf.seek(0)
|
||||||
buf.truncate(0)
|
buf.truncate(0)
|
||||||
@@ -347,14 +586,206 @@ def records_export():
|
|||||||
finally:
|
finally:
|
||||||
own.close()
|
own.close()
|
||||||
|
|
||||||
name = "usage_%s_%s.csv" % (frm or "all", to or db.now_str()[:10])
|
# 文件名带账号名:多人导出到同一目录时不会互相覆盖。
|
||||||
resp = Response(gen(), mimetype="text/csv; charset=utf-8",
|
# 名字必须过 safe_filename —— 响应头里拼一个含引号或 CR/LF 的用户名
|
||||||
headers={"Content-Disposition": 'attachment; filename="%s"' % name})
|
# 就是响应头注入(username 并不总是注册正则的产物,见 security.safe_filename)。
|
||||||
|
name = "usage_%s_%s_%s.csv" % (security.safe_filename(u["username"], fallback="u%d" % uid),
|
||||||
|
frm or "all", to or db.now_str()[:10])
|
||||||
|
resp = Response(gen(), mimetype="text/csv; charset=utf-8")
|
||||||
|
resp.headers["Content-Disposition"] = security.content_disposition(name)
|
||||||
# 导出可能很慢,避免 nginx 之类的前置代理先缓冲整个响应体
|
# 导出可能很慢,避免 nginx 之类的前置代理先缓冲整个响应体
|
||||||
resp.headers["X-Accel-Buffering"] = "no"
|
resp.headers["X-Accel-Buffering"] = "no"
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 备份管理(仅管理员) ----------------
|
||||||
|
@bp.get("/backups")
|
||||||
|
@admin_required
|
||||||
|
def backups_page():
|
||||||
|
"""备份管理 —— **仅管理员**。
|
||||||
|
|
||||||
|
这一页能下载整库归档、也能把整库恢复回某个时刻,权限等价于
|
||||||
|
「拿到所有人的数据并覆盖它」,所以必须是管理员专属:页面用
|
||||||
|
@admin_required,接口层另有同样的一层。
|
||||||
|
"""
|
||||||
|
conn = db.get_db()
|
||||||
|
backup.sync_index(conn) # 磁盘才是事实来源,进页面对一次账
|
||||||
|
rows = backup.listing(conn)
|
||||||
|
last_auto = backup.last_auto_at(conn)
|
||||||
|
nxt = backup.next_auto_at(conn)
|
||||||
|
info = {
|
||||||
|
"dir": config.BACKUP_DIR,
|
||||||
|
"count": len([r for r in rows if r["exists"]]),
|
||||||
|
"total": backup.human(backup.total_bytes(conn)),
|
||||||
|
"enabled": db.get_bool(conn, "backup_enabled", True),
|
||||||
|
"interval": db.get_int(conn, "backup_interval_hours", 24),
|
||||||
|
"keep": db.get_int(conn, "backup_keep", 7),
|
||||||
|
"last_auto": last_auto or "—",
|
||||||
|
# 从来没有跑过自动备份时,next_auto_at() 返回的是**当前时间**
|
||||||
|
# (语义是「马上就轮到它」)。直接印成时间会让人以为那是个已经过去的
|
||||||
|
# 计划点,所以这里区分成「还没跑过」与「下次某时刻」两种显示。
|
||||||
|
"next_auto": nxt.strftime("%Y-%m-%d %H:%M:%S") if (nxt and last_auto) else "—",
|
||||||
|
"never_auto": not last_auto,
|
||||||
|
"db_bytes": backup.human(os.path.getsize(config.SQLITE_PATH)
|
||||||
|
if os.path.exists(config.SQLITE_PATH) else 0),
|
||||||
|
}
|
||||||
|
s = db.get_settings(conn)
|
||||||
|
return render_template("backups.html", rows=rows, info=info, s=s,
|
||||||
|
num_settings=config.NUM_SETTINGS, active="backups")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 个人数据导出(每个账号都能导自己的) ----------------
|
||||||
|
def _zip_stream(buf, filename, mimetype="application/zip"):
|
||||||
|
"""把已生成好的临时缓冲流给浏览器,并在流结束后关掉它。"""
|
||||||
|
from flask import Response
|
||||||
|
|
||||||
|
def gen():
|
||||||
|
try:
|
||||||
|
buf.seek(0)
|
||||||
|
while True:
|
||||||
|
chunk = buf.read(65536)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
yield chunk
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
buf.close()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
|
||||||
|
resp = Response(gen(), mimetype=mimetype)
|
||||||
|
resp.headers["Content-Disposition"] = security.content_disposition(filename)
|
||||||
|
resp.headers["X-Accel-Buffering"] = "no"
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/profile/export")
|
||||||
|
@login_required
|
||||||
|
def profile_export():
|
||||||
|
"""导出「我的全部数据」。
|
||||||
|
|
||||||
|
这是普通账号的数据可携带出口,所以**只含本人的数据**,且
|
||||||
|
**绝不含 Cookie 明文**(只写「有没有配、多少字符、什么时候更新的」)。
|
||||||
|
归档里放使用记录、采集历史、本人审计与本人配置四份,另加一份说明。
|
||||||
|
"""
|
||||||
|
u = current_user()
|
||||||
|
uid = u["id"]
|
||||||
|
ok, wait = security.action_allowed("export:%d" % uid, 10)
|
||||||
|
if not ok:
|
||||||
|
return render_template("error.html", code=429,
|
||||||
|
message="导出太频繁了,请 %d 秒后再试" % wait), 429
|
||||||
|
|
||||||
|
conn = db.get_db()
|
||||||
|
buf = tempfile.SpooledTemporaryFile(max_size=16 * 1024 * 1024)
|
||||||
|
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
||||||
|
# ---- 1. 使用记录(与官网 xlsx、CSV 导出同构的列)----
|
||||||
|
with z.open("使用记录.csv", "w") as f:
|
||||||
|
f.write("\ufeff".encode("utf-8"))
|
||||||
|
b = io.StringIO()
|
||||||
|
w = csv.writer(b, lineterminator="\r\n")
|
||||||
|
w.writerow(collect.FIELDS)
|
||||||
|
f.write(b.getvalue().encode("utf-8"))
|
||||||
|
for r in query.iter_records(conn, uid, None, None):
|
||||||
|
b.seek(0)
|
||||||
|
b.truncate(0)
|
||||||
|
w.writerow([r["request_id"], "%.2f" % r["credits"], r["prompt"] or "",
|
||||||
|
r["model"], r["client"], r["ts"]])
|
||||||
|
f.write(b.getvalue().encode("utf-8"))
|
||||||
|
|
||||||
|
# ---- 2. 采集历史 ----
|
||||||
|
with z.open("采集历史.csv", "w") as f:
|
||||||
|
f.write("\ufeff".encode("utf-8"))
|
||||||
|
b = io.StringIO()
|
||||||
|
w = csv.writer(b, lineterminator="\r\n")
|
||||||
|
w.writerow(["id", "触发方式", "状态", "开始", "结束", "耗时ms",
|
||||||
|
"窗口起", "窗口止", "云端返回", "新增", "重复", "存档总数",
|
||||||
|
"冲突", "结论"])
|
||||||
|
for r in conn.execute(
|
||||||
|
"SELECT id,trigger,status,started_at,finished_at,duration_ms,win_from,"
|
||||||
|
"win_to,fetched,added,dup,total,conflicts,message FROM collect_runs"
|
||||||
|
" WHERE user_id=? ORDER BY id", (uid,)):
|
||||||
|
b.seek(0)
|
||||||
|
b.truncate(0)
|
||||||
|
w.writerow(list(r))
|
||||||
|
f.write(b.getvalue().encode("utf-8"))
|
||||||
|
|
||||||
|
# ---- 3. 本人相关的操作审计 ----
|
||||||
|
with z.open("操作审计.csv", "w") as f:
|
||||||
|
f.write("\ufeff".encode("utf-8"))
|
||||||
|
b = io.StringIO()
|
||||||
|
w = csv.writer(b, lineterminator="\r\n")
|
||||||
|
w.writerow(["时间", "操作者", "动作", "说明", "来源 IP"])
|
||||||
|
for r in conn.execute(
|
||||||
|
"SELECT at,actor,action,detail,ip FROM audit_log"
|
||||||
|
" WHERE user_id=? ORDER BY id", (uid,)):
|
||||||
|
b.seek(0)
|
||||||
|
b.truncate(0)
|
||||||
|
w.writerow(list(r))
|
||||||
|
f.write(b.getvalue().encode("utf-8"))
|
||||||
|
|
||||||
|
# ---- 4. 账号与有效配置(凭证只回状态)----
|
||||||
|
row = db.user_by_id(conn, uid)
|
||||||
|
st = db.secret_state(conn, "cookie", uid)
|
||||||
|
cfg = db.get_settings(conn, uid=uid)
|
||||||
|
for k in [k for k in list(cfg) if config.is_internal_key(k)]:
|
||||||
|
cfg.pop(k, None)
|
||||||
|
cfg.pop("cookie", None)
|
||||||
|
snaps = {k: v for k, v in cfg.items()
|
||||||
|
if not (isinstance(v, str) and len(v) > 200)}
|
||||||
|
payload = {
|
||||||
|
"导出时间": db.now_str(),
|
||||||
|
"程序版本": _app_version(),
|
||||||
|
"账号": {
|
||||||
|
"id": row["id"], "用户名": row["username"],
|
||||||
|
"显示名": row["display_name"], "邮箱": row["email"],
|
||||||
|
"角色": "管理员" if row["is_admin"] else "普通账号",
|
||||||
|
"状态": row["status"], "注册时间": row["created_at"],
|
||||||
|
"注册来源 IP": row["register_ip"],
|
||||||
|
"最后登录": row["last_login_at"], "登录次数": row["login_count"],
|
||||||
|
},
|
||||||
|
"数据量": {
|
||||||
|
"记录条数": conn.execute("SELECT COUNT(*) FROM usage_records"
|
||||||
|
" WHERE user_id=?", (uid,)).fetchone()[0],
|
||||||
|
"积分合计": round(conn.execute(
|
||||||
|
"SELECT COALESCE(SUM(credits),0) FROM usage_records"
|
||||||
|
" WHERE user_id=?", (uid,)).fetchone()[0], 2),
|
||||||
|
},
|
||||||
|
"凭证状态": {
|
||||||
|
"Cookie": ("已配置 %d 字符,尾部 …%s" % (st["chars"], st["tail"])) if st["set"]
|
||||||
|
else ("无法解密" if st["broken"] else "未配置"),
|
||||||
|
"说明": "出于安全考虑,导出文件里不含 Cookie 明文;如需迁移请到「配置管理」重新粘贴。",
|
||||||
|
},
|
||||||
|
"有效配置": snaps,
|
||||||
|
}
|
||||||
|
with z.open("我的账号与配置.json", "w") as f:
|
||||||
|
f.write(json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8"))
|
||||||
|
|
||||||
|
with z.open("说明.txt", "w") as f:
|
||||||
|
f.write(("本归档是账号「%s」在本站的全部数据副本。\n\n"
|
||||||
|
"包含:\n"
|
||||||
|
" 使用记录.csv —— 你的全部用量明细(与官网导出同构)\n"
|
||||||
|
" 采集历史.csv —— 你的采集任务运行历史\n"
|
||||||
|
" 操作审计.csv —— 与你账号相关的操作记录\n"
|
||||||
|
" 我的账号与配置.json —— 账号信息与对你有有效的配置\n\n"
|
||||||
|
"不包含:Cookie 明文、任何他人的数据、实例级运行日志。\n"
|
||||||
|
"导出时间:%s\n程序版本:%s\n"
|
||||||
|
% (u["username"], db.now_str(), _app_version())).encode("utf-8"))
|
||||||
|
|
||||||
|
# 用户名先收敛再拼名字:老库里可能存在不符合注册正则的账号名,
|
||||||
|
# 里面若带反斜杠,content_disposition 为了防路径会把它之后的整段截掉,
|
||||||
|
# 结果文件名退化成 "x_2026-09-18.zip"。先收敛就不会走到那条截断路径。
|
||||||
|
name = "my-data_%s_%s.zip" % (security.safe_filename(u["username"], fallback="u%d" % uid),
|
||||||
|
db.now_str()[:10])
|
||||||
|
db.audit(conn, "export_self", u["username"], "导出个人全部数据(%s)" % name,
|
||||||
|
_ip(), uid)
|
||||||
|
return _zip_stream(buf, name)
|
||||||
|
|
||||||
|
|
||||||
|
def _app_version():
|
||||||
|
from .. import __version__
|
||||||
|
return __version__
|
||||||
|
|
||||||
|
|
||||||
# ---------------- 兼容旧地址 ----------------
|
# ---------------- 兼容旧地址 ----------------
|
||||||
@bp.get("/index.html")
|
@bp.get("/index.html")
|
||||||
def legacy_index():
|
def legacy_index():
|
||||||
|
|||||||