7 次代码提交
作者 SHA1 备注 提交日期
wangchuanli f36149efc3 feat(安全): 对外暴露面加固 + 界面去 AI 化(v1.5.0)
界面(去 AI 味):
- 大屏页清除 114 处生成器残留属性 data-page-node-id
- 视觉系统改回工程控制台风格:去 radial/linear-gradient、去辉光、
  去标题前彩色装饰条,改为中性灰阶 + 单一蓝色强调色;KPI 色条改状态点
- 精简各页说教式长提示;修掉 profile.html 泄漏到页面上的 Markdown 星号
- 删除登录页过时的「默认账号 admin / admin123」提示(1.4.0 起已无默认口令)

安全与隐私(按「将会被公网访问」收口):
- 内部异常只回 8 位事件号,完整堆栈进服务端日志(web/api.py::_internal)
- 导出文件名收敛:防响应头注入与路径穿越;manage.py passwd 补用户名校验
- 登录对不存在的账号也走一次哑哈希,抹平用户名枚举的时序差异
- /api/* 读接口限速 240 次 / 60 秒 / 账号(挡住循环调 /api/bundle)
- 进程 umask 0077 + 目录 0700 / 文件 0600:对话正文与主密钥的落盘权限
- 表名与库文件路径只对管理员下发;大屏页所有数据插值转义
- --debug 只允许绑定回环地址;新增 Permissions-Policy 与 413 处理器

文档:
- DEPLOYMENT 新增第十三节「安全与隐私基线」;迁移表补 1.4.0 → 1.5.0 行
- SECURITY 更新支持范围、新增「信息泄漏收敛」小节与上线检查项
- .codebuddy/ 加入 .gitignore(助手工作记忆不进仓库)

版本:1.4.0 → 1.5.0(无库结构变更,user_version 仍为 4)
验证:python tools/smoke.py → ok=264 fail=0;python tools/check_docs.py → 0 处问题
2026-09-18 11:13:17 +08:00
wangchuanli 3751dffef9 feat: 新增备份恢复与公网加固
- 新增备份管理页与 API:在线快照、自动周期备份、按份数清理、下载、一键恢复(恢复前自动兜底)
- 新增 /profile/export,普通用户可导出本人全部数据(不含 Cookie 明文)
- 修复 X-Forwarded-For 可伪造导致三道 IP 防线失效,统一走 client_ip() 取客户端地址
- 取消 admin123 硬编码默认口令,留空则生成随机初始口令并仅打印一次
- .dockerignore 排除 backups/ 并加构建期断言,防止密钥随镜像分发
- 新增会话版本号,改密/停用/删除及恢复备份后其他会话立即失效
- 新增容器资源上限、采集跨度硬顶 31 天、重操作最小间隔与并发 409
- 新增访问日志、HSTS 条件下发、口令黑名单、验证码抗模板匹配、instance.json 0600
- 版本号升至 1.4.0,同步更新 README、SECURITY、.env.example 与 compose 配置
2026-09-18 08:46:34 +08:00
wangchuanli 1bf961f6b3 feat(权限): 收敛普通账号写权限至本人凭证
将调度时刻、采集参数等实例级配置收归管理员,普通账号仅可维护本人 Cookie 与 User-Agent。
新增 config.writable_by 作为唯一写权限入口,set_setting 强制全局键落到 user_id=0,
消除「管理员改了只有自己生效」的静默缺陷。新增 tools/check_docs.py 文档自检,
smoke 断言扩至 215 项、check_live 扩至 122 项并支持普通账号越权验收,
忽略 backups/、data/*.bak* 与 legacy-v1/,版本升至 v1.3.0。
2026-09-18 08:46:00 +08:00
wangchuanli df7db3582e feat(multi-user): 多用户化 + 凭证加密 + 自助注册与图形验证码
数据隔离
- settings / usage_records 主键改为 (user_id, key) / (user_id, request_id),
  索引一律以 user_id 打头;collect_runs / audit_log 增加 user_id
- query / collect / scheduler 全链路把 uid 作为 conn 之后的第一个位置参数且无默认值
  (漏传直接 TypeError,不会退化成「返回全量」)
- 配置三级回落 个人→实例→DEFAULTS;NO_FALLBACK_KEYS={cookie,user_agent} 不回落

凭证保密
- 新增 workbuddy_portal/crypto.py:手写 ChaCha20(RFC8439 §2.3) + HMAC-SHA256
  encrypt-then-MAC,零第三方依赖;主密钥 cookie_key 与 SECRET_KEY 分键位存放
- get_secret() 是取明文的唯一通道;get_settings() 把加密键置空;
  secret_state() 只回 {set,chars,tail,broken};升级时自动加密历史明文

注册与验证码
- 新增 /register 与 workbuddy_portal/captcha.py(手写 PNG + 点阵字模 + 干扰线)
- 验证码答案只存服务端表、不进 session,一次性、5 分钟过期、按 purpose 隔离
- allow_register / register_max_per_ip / captcha_policy / captcha_length 四个实例级开关
- 失败限速改为 IP + 用户名双维度;停用账号每请求回查、立即失效

页面
- 新增 /profile(个人中心)与注册页;登录页加验证码与自助注册入口
- /config 增加凭证状态、cookie_broken 告警、实例级设置区;/users 增加邮箱/状态与启停

修复
- base.html 顶层 {% set me %} 覆盖子模板同名变量,导致个人中心「注册于」渲染为空
- WB_COOKIE_SECURE 未写进 compose 的 environment,在 .env 里设了不生效
- 「修改登录密码」提示写「至少 6 位」,与实际策略(≥8 位 + 两类字符)不符
- 「用户管理」删除说明写「可勾选保留」,与页面实际行为不符
- 注册页与 flash 文案里的 **强调** Markdown 字面量

验证与文档
- smoke.py 99 → 165 项断言(多用户隔离 / 凭证保密 / 注册与验证码 / 3 条防回归)
- check_live.py 56 → 83 项断言(新增注册 / 验证码 / 安全响应头一节)
- demo_data.py 造两个账号;shots.py 自动过验证码、重出 11 张截图
- README / SECURITY / ARCHITECTURE / API / DEPLOYMENT / USER-GUIDE / FAQ / CHANGELOG / CONTRIBUTING 同步
2026-09-15 17:32:35 +08:00
wangchuanli 23799b4ea5 feat(oss): 补齐开源声明体系(MIT + 第三方声明 + 贡献/安全/行为准则)
* LICENSE              —— MIT
  * THIRD-PARTY-NOTICES  —— 依赖清单、再分发合规说明(含随仓库分发的
                            Apache ECharts 5.6.0 / Apache-2.0)与自查清单
  * CONTRIBUTING.md      —— 开发环境、五层验证、必须遵守的不变量、提交规范
  * SECURITY.md          —— 漏洞私有报告渠道、已有措施、已知非目标
  * CODE_OF_CONDUCT.md   —— 改编自 Contributor Covenant 2.1
  * .github/             —— Bug 报告 / 功能建议表单 + PR 模板
  * .editorconfig        —— 与 .gitattributes 保持一致
  * 全部 Python / Shell 源文件加 SPDX-License-Identifier: MIT 头
  * README 增加「开源与许可」章节与许可标识
2026-09-14 16:15:26 +08:00
wangchuanli 9469a61bbc chore(privacy): 文档与截图改用合成示例数据,移除真实 Prompt 与统计口径
原截图里含有不该公开的内容:
  * docs/images/02-records.png —— 真实 Prompt 全文、请求 ID、真实模型名
  * docs/images/05-logs.png    —— 本机路径与 Windows 用户名
  * docs/images/04-config.png  —— Cookie 尾串
  * 01/03/07/08                —— 真实用量分布与日期

做法:新增 tools/demo_data.py 生成完全合成的示例库(假模型名 demo-*、
通用 Prompt、偏斜的积分分布、RFC 5737 文档专用网段的审计 IP),
在容器里跑它并以 /app 路径截图,再按原规格(1400px + 调色板量化)替换。
同时把 docs/API.md 与 docs/DEPLOYMENT.md 示例响应里的真实模型名与真实
统计数字(1665 条 / 8513.36 积分等)换成示例口径。

顺带修正 .gitignore:只写 data/*.sqlite 会漏掉子目录,补 data/**/*.sqlite
等规则并忽略 data/demo/。
2026-09-14 16:15:23 +08:00
wangchuanli 342da56d4d fix(docker): .dockerignore 漏掉嵌套 __pycache__,字节码混进镜像
只写 `__pycache__/` 时 Docker 仅匹配上下文根目录下的同名目录,
`tools/__pycache__` 等嵌套目录会被原样 COPY 进镜像(实测镜像里确实存在)。
补上 `**/__pycache__/` 与 `**/*.py[cod]`。
2026-09-14 16:15:21 +08:00
共修改 76 个文件,包含 11467 行新增和 1880 行删除
+18
查看文件
@@ -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/
+33
查看文件
@@ -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
+52 -3
查看文件
@@ -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
+83
查看文件
@@ -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
+8
查看文件
@@ -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
+46
查看文件
@@ -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)已同步更新
+31
查看文件
@@ -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
+59
查看文件
@@ -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」部分。
+160
查看文件
@@ -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 里问,比写完再返工更省事。
+15 -4
查看文件
@@ -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"]
+21
查看文件
@@ -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.
+331 -106
查看文件
@@ -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/images/01-overview.png) ![概览](docs/images/01-overview.png)
> 其余页面截图见 [用户手册](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/)
+266
查看文件
@@ -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` 应当**直接报错退出**
+78
查看文件
@@ -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,同步更新本文档中的版本号
- [ ] 若新增了第三方文件到仓库,在此登记其许可证
- [ ] 若将本项目的界面截图用于宣传,确认其中不含真实业务数据
+91
查看文件
@@ -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` 一个。
+4
查看文件
@@ -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
+51
查看文件
@@ -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:
+10 -2
查看文件
@@ -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 ----------
+3
查看文件
@@ -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 即认为
+155 -49
查看文件
@@ -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`。
+212 -22
查看文件
@@ -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 层跑绿。**
+481 -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-绑定挂载的坑容器打不开数据库)。
### 安全 ### 安全
+1071 -297
查看文件
文件差异内容过多而无法显示 加载差异
+204 -19
查看文件
@@ -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 或托管环境里:
+661 -153
查看文件
文件差异内容过多而无法显示 加载差异
二进制
查看文件
二进制文件未显示。

之前

宽度:  |  高度:  |  大小: 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

+480 -61
查看文件
@@ -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
+338
查看文件
@@ -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 图片:![alt](目标)
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())
+262 -24
查看文件
@@ -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))
+344
查看文件
@@ -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())
+3
查看文件
@@ -1,4 +1,7 @@
#!/bin/sh #!/bin/sh
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Wang Chuanli
# ============================================================================= # =============================================================================
# 用 Gitea Access Token 一次性完成:推代码 + 推镜像 # 用 Gitea Access Token 一次性完成:推代码 + 推镜像
# #
+147 -34
查看文件
@@ -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)
+641 -45
查看文件
@@ -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)
+27 -1
查看文件
@@ -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("内部错误")
+640
查看文件
@@ -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
+317
查看文件
@@ -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())
+3
查看文件
@@ -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
+170 -73
查看文件
@@ -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
+301 -28
查看文件
@@ -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
+177
查看文件
@@ -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]
+433 -39
查看文件
@@ -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 集成 ----------------
+99 -62
查看文件
@@ -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
+56 -23
查看文件
@@ -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
+78 -17
查看文件
@@ -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);
+608 -81
查看文件
@@ -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)
+3
查看文件
@@ -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
+521 -112
查看文件
@@ -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 表;一次性使用,校验后立即删除。",
})
+126 -123
查看文件
@@ -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; }
+300 -281
查看文件
@@ -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),
// 转义反而会把 `&` 画成 `&amp;`。真正需要防的是下面的 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();
+7 -1
查看文件
@@ -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 "";
} }
+223
查看文件
@@ -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 %}
+20 -5
查看文件
@@ -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>
+93 -13
查看文件
@@ -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 -1
查看文件
@@ -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>
+4 -3
查看文件
@@ -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>
+5 -3
查看文件
@@ -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>
+119
查看文件
@@ -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 %}
+1 -3
查看文件
@@ -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 %}
+27 -8
查看文件
@@ -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>
+50 -21
查看文件
@@ -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;
+509 -78
查看文件
@@ -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():