chore: 项目定名为 workbuddy-portal,容器化并补齐文档体系

## 项目定名
- 目录 wb_usage_portal → workbuddy-portal
- Python 包 wb_usage → workbuddy_portal(含 session cookie 名)
- 界面品牌统一为 WorkBuddy Portal;项目标识收敛到 config 单一来源

## 容器化
- Dockerfile:多阶段构建,依赖层与源码解耦;非 root(uid 1000);内置健康检查
- docker-compose.yml:单服务 + 绑定挂载 data/logs + 日志轮转 + TZ
- docker/entrypoint.sh:幂等初始化 → exec serve(LF 行尾,已由 .gitattributes 锁定)
- docker/healthcheck.py:纯标准库探活 /login(slim 镜像无 curl)
- .dockerignore / .env.example;数据目录可用 WB_DATA_DIR 等环境变量覆盖

## 文档
- docs/USER-GUIDE.md    用户使用手册(含 9 张真实界面截图)
- docs/DEPLOYMENT.md    部署运维(Docker / 裸机 / 反代 / 备份 / 推 Gitea 注册表)
- docs/ARCHITECTURE.md  架构与设计说明(含已知坑与红线、验证体系)
- docs/API.md           接口参考(路径 / 参数 / 返回结构 / 错误码)
- docs/FAQ.md           常见问题;docs/CHANGELOG.md 变更日志

## 修复缺陷(8)
1. /records/export 必然 500:生成器在请求上下文销毁后才迭代,改用自建连接
2. 大屏页图表全白:相对路径把 echarts.min.js 解析成 /vendor/... → 404
3. /users 500:路由已注册但模板缺失
4. 明细页日期筛选失效:视图传 f.frm、模板读 f.from
5. 配置页维护按钮全死:调用了不存在的 WBU.bindMaint()
6. 审计只能看最近 40 条:LIMIT 写死
7. 明细页多跑一条无用 SELECT:day_list() 取了没人用
8. 登录页锁定阈值未从配置注入

## 安全加固
- 新增 safe_next():拒绝 //evil.com 等协议相对 URL 的开放重定向
- 缺 CSRF 的写请求统一 400
- 默认开启云端 HTTPS 证书校验(ssl_verify=1);Cookie 是账号凭证
- 登录失败计数表加上限与 TTL
- /logout 拆分为 POST(执行) + GET(仅提示),防 <img src=/logout> 静默退出
- settings 内部簿记键 slot:* 读写两侧过滤,不再从 /api/settings 泄漏

## 内部质量与工具
- 设置项写时校验 + 读时兜底,杜绝「一个手滑的数字让采集整个跑不起来」
- 全局 ValueError → 400:手写 query string 不再暴露 500 页面
- CSV 导出改 csv.writer 流式写入(原手工拼串,字段含逗号会串列)
- bundle 明细加 20000 上限并回传 recordsTotal/recordsTruncated,不静默丢数据
- tools/smoke.py 离线回归 99 项;tools/check_live.py 真实 HTTP 56 项
- tools/shots.py Playwright 逐页截图 + JS 报错收集

## 验证
- compileall 通过;smoke 99/99;对容器实例 check_live 56/56;截图 0 JS 报错
- 容器内采集实测成功(trigger=startup 补跑:新增 11 条)
这个提交包含在:
2026-09-14 14:55:50 +08:00
当前提交 86631ae7ab
共修改 58 个文件,包含 9409 行新增和 0 行删除
+29
查看文件
@@ -0,0 +1,29 @@
# 构建上下文排除项:镜像里只要源码与配置,不要数据、日志、缓存、版本库
.git
.gitignore
.gitattributes
.github
# 数据正本与运行产物(容器里由挂载卷提供)
data/
logs/
# Python 缓存
__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
# 本机开发/测试产物
.idea/
.vscode/
*.log
# 容器自身文件
Dockerfile
.dockerignore
docker-compose*.yml
.env
.env.*
!.env.example
+29
查看文件
@@ -0,0 +1,29 @@
# =============================================================================
# docker-compose 环境变量样例:复制成 .env 后按需修改(.env 已被 .gitignore 排除)
# cp .env.example .env
# =============================================================================
# ---------- 监听 ----------
# 宿主机绑定地址:0.0.0.0 = 局域网可访问;127.0.0.1 = 只允许本机
WB_BIND=0.0.0.0
WB_PORT=8848
TZ=Asia/Shanghai
# ---------- 首个管理员(只在数据库为空时生效)----------
# 强烈建议:首次启动前就设好,避免用默认的 admin/admin123 暴露在局域网上
WB_ADMIN_USER=admin
WB_ADMIN_PASSWORD=
# ---------- 调度 ----------
# 一个容器一份调度。只有跑多副本时才把除第一份之外的都设成 1。
WB_DISABLE_SCHEDULER=0
# ---------- 可选:启动时自动导入 ----------
# 1 = 尝试从挂载进来的编辑器 settings.json 读取 codebuddyUsage.* 写入数据库
WB_IMPORT_CREDS=0
# 指向容器内路径;配合 docker-compose.yml 里注释掉的挂载项使用
WB_IMPORT_XLSX=
# ---------- 镜像名(推送 Gitea 注册表时用)----------
# WB_IMAGE=git.iwali.top/wangchuanli/workbuddy-portal:latest
# WB_IMAGE=git.iwali.top/wangchuanli/workbuddy-portal:1.1.0
+36
查看文件
@@ -0,0 +1,36 @@
# 统一换行符。
# 关键:容器入口脚本必须是 LF —— 带 CRLF 的 .sh 在 Linux 里会报
# "exec format error" / "no such file or directory",且极难猜。
# 这里把整个仓库一律按 LF 签出(服务器/容器导向的项目,Windows 本机开发也没问题)。
* text=auto eol=lf
*.sh text eol=lf
*.py text eol=lf
*.js text eol=lf
*.css text eol=lf
*.html text eol=lf
*.sql text eol=lf
*.md text eol=lf
*.txt text eol=lf
*.json text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.svg text eol=lf
*.toml text eol=lf
*.cfg text eol=lf
Dockerfile text eol=lf
.gitattributes text eol=lf
.gitignore text eol=lf
# 二进制:不要做任何换行/编码转换
*.sqlite binary
*.sqlite-wal binary
*.sqlite-shm binary
*.xlsx binary
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.woff binary
*.woff2 binary
+48
查看文件
@@ -0,0 +1,48 @@
# =============================================================================
# 注意:Git 的 .gitignore **不支持行尾注释**——规则后面跟 # 注释会让整行变成
# 一个永远匹配不上的模式(曾经因此把 data/instance.json 误入库)。
# 注释一律单独占一行。
# =============================================================================
# ---- 数据与运行产物:正本不进版本库(体积大、含凭证衍生物)----
data/*.sqlite
data/*.sqlite-wal
data/*.sqlite-shm
# 含 secret_key,泄露等于会话签名密钥外泄,绝不可提交
data/instance.json
data/exports/*.csv
# 界面截图(tools/shots.py 生成的临时产物;手册配图在 docs/images/)
data/shots/
# ---- 日志 ----
logs/*
# ---- 保留目录本身 ----
# docker-compose 是绑定挂载,宿主机目录必须先存在,
# 否则 Docker 会以 root 自动创建,Linux 上会引发「unable to open database file」
!data/.gitkeep
!data/exports/.gitkeep
!logs/.gitkeep
# ---- Python ----
__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
# ---- 编辑器 / 系统 ----
.idea/
.vscode/
*.swp
.DS_Store
Thumbs.db
# ---- 部署本地配置(含明文密码,只提交 .env.example)----
.env
.env.local
.env.*.local
compose.override.yml
+73
查看文件
@@ -0,0 +1,73 @@
# syntax=docker/dockerfile:1
# =============================================================================
# workbuddy-portal —— 多阶段构建
# 构建层:只做「装依赖」,与源码解耦(改业务代码不会触发重装依赖)
# 运行层:slim 基础镜像 + 独立 venv + 非 root 用户
# =============================================================================
# ---------- Stage 1: 依赖 ----------
FROM python:3.13-slim AS builder
ENV PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
# 单独建 venv:运行时整目录拷过去,镜像里不留 pip 缓存与编译工具
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
WORKDIR /build
COPY requirements.txt ./
RUN pip install --upgrade pip \
&& pip install -r requirements.txt
# ---------- Stage 2: 运行 ----------
FROM python:3.13-slim AS runtime
LABEL org.opencontainers.image.title="WorkBuddy Portal" \
org.opencontainers.image.description="WorkBuddy 积分用量采集 / 存储 / 呈现一体化门户" \
org.opencontainers.image.version="1.1.0" \
org.opencontainers.image.source="http://git.iwali.top/wangchuanli/workbuddy-portal"
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONIOENCODING=utf-8 \
TZ=Asia/Shanghai \
PATH="/opt/venv/bin:$PATH" \
WB_DATA_DIR=/app/data \
WB_LOG_DIR=/app/logs \
WB_HOST=0.0.0.0 \
WB_PORT=8848
# tzdata:日志与「每日 09:00 / 17:00」的调度槽位都依赖本地时区
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends tzdata ca-certificates; \
rm -rf /var/lib/apt/lists/*; \
ln -snf "/usr/share/zoneinfo/${TZ}" /etc/localtime; \
echo "${TZ}" > /etc/timezone; \
groupadd --gid 1000 app; \
useradd --uid 1000 --gid app --create-home --shell /usr/sbin/nologin app
COPY --from=builder /opt/venv /opt/venv
WORKDIR /app
# --chown 让非 root 用户能读写挂载卷之外的文件;.dockerignore 已挡掉数据与日志
COPY --chown=app:app . .
RUN set -eux; \
chmod +x /app/docker/entrypoint.sh /app/docker/healthcheck.py; \
mkdir -p /app/data /app/logs; \
chown -R app:app /app/data /app/logs; \
python -c "import workbuddy_portal, flask, waitress; print('deps ok', flask.__version__)"
USER app
EXPOSE 8848
VOLUME ["/app/data", "/app/logs"]
HEALTHCHECK --interval=30s --timeout=6s --start-period=20s --retries=3 \
CMD ["python", "/app/docker/healthcheck.py"]
ENTRYPOINT ["/app/docker/entrypoint.sh"]
CMD ["serve"]
+256
查看文件
@@ -0,0 +1,256 @@
# WorkBuddy Portal
> **workbuddy-portal** —— WorkBuddy 积分用量「采集 / 存储 / 呈现」一体化门户
一个独立部署的 Python / Flask 应用:把账号云端的用量明细按时采集下来、按 `request_id`
去重存档,再以「**管理后台**(配置 / 任务 / 日志 / 明细)+ **ECharts 交互大屏**」两种形态呈现。
**不依赖任何外部计划任务或自动化**——调度线程就跑在 Web 进程里。
| | |
|---|---|
| 语言 / 框架 | Python 3.11+ · Flask 3 · Jinja2 · 纯标准库 `urllib` 采集 |
| 存储 | SQLite(WAL),单文件正本 `data/usage.sqlite` |
| 前端 | 服务端渲染后台 + 独立 ECharts 大屏(离线自带的 `echarts.min.js`) |
| 部署 | Docker Compose / 裸机 `waitress`;镜像可推 Gitea 容器注册表 |
| 鉴权 | 全站登录 + CSRF + 角色(管理员 / 普通用户),凭证存库、页面只回掩码 |
| 版本 | v1.1.0 |
**目录**:[核心特性](#核心特性) · [架构](#架构一图) · [快速开始](#快速开始) · [命令一览](#命令一览) ·
[页面一览](#页面一览) · [接口一览](#接口一览) · [文档导航](#文档导航) · [安全须知](#安全须知)
---
## 核心特性
| 能力 | 说明 |
|---|---|
| **增量采集** | 按 `MAX(ts)` 断点续采 + 回退窗口;主键 `ON CONFLICT` 去重,冲突时以「更早的本地时间」为准 |
| **进程内调度** | 每天固定时刻(默认 `09:00,17:00`)由内置线程触发;支持**启动补跑**(程序没开时错过的时刻,开机后在宽限期内补上) |
| **单写者保证** | 文件锁 `data/collect.lock` 让「调度 / 页面手动触发 / CLI」三处不并发写 SQLite;僵尸锁 30 分钟可抢占 |
| **全量存档** | 不随官网导出窗口过期而丢数据;官网 xlsx 丢失约 22% 的 `Prompt`,可用 `fill-prompt` 回补 |
| **大屏去中间层** | 大屏直接走 `/api`,按当前筛选窗口实时聚合;左侧多取等长一段用于算环比,窗口不变不重复请求 |
| **可观测** | 每次采集落一条 `collect_runs`(含 `[warn]`/`[error]` 逐行原文);另有操作审计与登录审计 |
| **一键备份** | 正本就是宿主机上的一个 `.sqlite` 文件,拷走即可;`manage.py vacuum` 回收空闲页 |
---
## 架构一图
```
┌──────────────── workbuddy-portal(单进程)────────────────┐
云端用量接口 │ │
/billing/meter/ │ scheduler.py ──┐ │
get-user-request- │ (20s 轮询槽位) │ │
usage │ ▼ │
▲ │ collect.py ─ 文件锁 collect.lock ─ 去重 upsert ─▶ SQLite │
│ │ ▲ data/usage.sqlite(WAL) │
└───────────┼──────┘ ▲ │
client.py(urllib)│ │ │
│ query.py(聚合全部下推 SQL) │
│ ▲ ▲ │
│ web/views.py ──────┘ └──── web/api.py│
│ (Jinja 后台) (JSON) │
└───────────────┬───────────────────────────┬──────────────┘
▼ ▼
/ /records /tasks /dashboard(ECharts 大屏)
/config /logs /users
```
四层职责:
| 层 | 位置 | 说明 |
|---|---|---|
| 采集 | `workbuddy_portal/collect.py` + `scheduler.py` | 纯 `urllib` 调云端;断点、去重、锁、导入导出 |
| 存储 | `workbuddy_portal/db.py` + `schema.sql` | SQLite WAL,单写者,运行期配置也在库里(`settings` 表) |
| 聚合 | `workbuddy_portal/query.py` | `daily / dims / top / records / summary / bundle`,全部下推 SQL |
| 呈现 | `workbuddy_portal/web/` | Jinja 后台(`views.py`)+ JSON API(`api.py`)+ 静态大屏 |
---
## 快速开始
### 方式一:Docker Compose(推荐)
```bash
git clone http://git.iwali.top/wangchuanli/workbuddy-portal.git
cd workbuddy-portal
cp .env.example .env # 至少设好 WB_ADMIN_PASSWORD
# 编辑 .env: WB_ADMIN_PASSWORD=一个足够强的密码
docker compose up -d --build
docker compose logs -f # Ctrl-C 退出日志跟踪,容器继续跑
```
打开 `http://<本机IP>:8848` → 用 `.env` 里设的账号登录 → 去「配置管理」粘贴 Cookie。
> 数据落在宿主机 `./data/`、日志落在 `./logs/`,`docker compose down` 不会删数据。
> **Linux 宿主机**上首次运行可能要 `sudo chown -R 1000:1000 ./data ./logs`(容器内以 uid 1000 运行)。
### 方式二:裸机 Python
```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. **改密码**——局域网可访问,默认密码等于没锁门(「配置管理 → 修改密码」)。
2. **填 Cookie**——「配置管理 → 凭证」,否则采集只会记一条 `cookie_expired`。
获取方式见 [用户手册](docs/USER-GUIDE.md#三获取并填写-cookie)。
3. **确认调度时刻**——「任务管理」里把 `09:00,17:00` 改成你的习惯时刻,保存即生效。
---
## 命令一览
统一入口是 `manage.py`(Docker 里同样可用:`docker compose exec portal python manage.py stats`)。
| 命令 | 作用 |
|---|---|
| `init` | 初始化数据库(幂等)。`--user` / `--password` 指定首个管理员 |
| `serve` | 启动 Web。`--host` `--port` `--debug` `--no-scheduler` |
| `collect` | 执行一次增量采集后退出(不想开 Web 时可挂系统计划任务) |
| `migrate-csv [文件]` | 从旧版 CSV 存档导入(默认自动探测旧项目路径) |
| `import-xlsx <文件>` | 合入官网「用量明细-导出」的 xlsx |
| `import-creds` | 从 VSCode / Cursor / Trae 的 `settings.json` 读取 `codebuddyUsage.*` 写入数据库 |
| `fill-prompt` | 回补缺失的 `User Prompt`(官网导出会丢约 22%) |
| `export-csv [路径]` | 导出与官网 xlsx 同构的 CSV(默认 `data/exports/`) |
| `vacuum` | `wal_checkpoint(TRUNCATE)` + `VACUUM`,回收空闲页、压缩 WAL |
| `stats` | 存档概况 + 模型维度表 + 最近采集(不联网) |
| `status` | 调度开关 / 下次执行 / Cookie 状态 / 最近采集 |
| `passwd <用户> [新密码]` | 重置或创建登录账号 |
### 自检工具
| 脚本 | 层 | 说明 |
|---|---|---|
| `tools/smoke.py` | 离线回归 | `test_client` 对真实库全页面只读渲染,**99 项断言**(历史缺陷防回归 ①~⑭、CSV 列、class↔CSS 对账、静态资源逐个 200),**不需要先起服务** |
| `tools/check_live.py` | 真实 HTTP | 对运行中的服务走真实链路(登录 → CSRF → 各页面 → 各 API → 导出 → 安全项),**56 项断言**,基本只读 |
| `tools/shots.py` | 界面实检 | Playwright 登录后逐页截图并收集 console / pageerror,产物在 `data/shots/` |
```bash
python tools/smoke.py # 离线,随时可跑
python manage.py serve --port 8849 --no-scheduler # 另开一个终端
python tools/check_live.py --base http://127.0.0.1:8849 # 真实 HTTP
python tools/shots.py --base http://127.0.0.1:8849 --full # 逐页截图
```
> `smoke.py` 会写少量 `audit_log` 审计行(被拒的配置写入也留痕),不动业务数据;
> `check_live.py` 只读,但登录成功会更新 `users.last_login_at` / `login_count`。
---
## 页面一览
| 路径 | 作用 |
|---|---|
| `/` | **概览**:KPI(含今日 vs 昨日整日)、采集健康度、调度状态、模型 TOP、最近采集 |
| `/dashboard` | **ECharts 交互大屏**(独立静态页):日历热力图、趋势、维度分布、单笔 TOP,支持区间/维度/指标联动 |
| `/records` | **数据明细**:快捷区间、日期/模型/客户端/关键词筛选、排序、分页、展开 Prompt、导出 CSV |
| `/tasks` | **任务管理**:调度开关与时刻、启动补跑、按区间补采、运行历史 |
| `/config` | **配置管理**:Cookie / UA、采集参数、TLS 校验、修改密码、维护动作(回补 Prompt / 导出 / 整理库) |
| `/logs` | **日志管理**:逐次采集详情(含 `[warn]`/`[error]` 原文)、状态筛选、应用日志、操作审计 |
| `/users` | **用户管理**(仅管理员):新建账号、改显示名/权限/密码、删除、用户操作审计 |
![概览](docs/images/01-overview.png)
> 其余页面截图见 [用户手册](docs/USER-GUIDE.md)。
---
## 接口一览
全部需要登录(`/api/*` 未登录返回 `401` JSON);写接口另需 CSRF(请求头 `X-CSRF-Token`,
页面已注入 `window.WB_CSRF`)。完整参数说明见 [docs/API.md](docs/API.md)。
| 方法 | 路径 | 作用 |
|---|---|---|
| GET | `/api/manifest` | 存档总量、日期区间、存活日清单、数据源、健康状态 |
| GET | `/api/bundle` | 大屏一次取齐:全量 `daily` + 窗口 `dims`/`top`/`records`/`totals` |
| GET | `/api/summary` | KPI + 环比(前一段不在存档内则不给假数字) |
| GET | `/api/daily` | 逐日聚合(含每日分模型、24 时段) |
| GET | `/api/dims` | 模型 / 客户端 / 时段汇总 |
| GET | `/api/top` | 单笔消耗榜(唯一带 Prompt 摘要的接口) |
| GET | `/api/records` · `/api/records/<id>` | 明细分页 / 单条详情 |
| GET | `/api/runs` · `/api/runs/<id>` | 采集运行历史 / 单次详情(含逐行日志) |
| GET | `/api/status` | 调度状态、下次执行、互斥锁、最近采集 |
| GET | `/api/audit` | 操作审计分页 + 可选动作清单 |
| POST | `/api/collect` | 手动触发采集(可指定区间补采) |
| POST | `/api/maintenance/<action>` | `fill-prompt` \| `export-csv` \| `vacuum` \| `recount` |
| GET/POST | `/api/settings` | 读 / 写配置(非法值 `400` 并列出全部错误) |
| POST | `/api/password` | 修改自己的登录密码 |
| GET/POST | `/api/users` · `/api/users/<id>` | 用户管理(仅管理员) |
| GET | `/logs/tail` · `/records/export` | 应用日志尾部 / 按筛选流式导出 CSV |
---
## 目录结构
```
workbuddy-portal/
├── manage.py 统一 CLI(唯一入口)
├── requirements.txt
├── Dockerfile 多阶段构建(依赖层 / 运行层)
├── docker-compose.yml 单服务编排(数据绑定挂载)
├── .env.example 环境变量样例
├── docker/
│ ├── entrypoint.sh 幂等初始化 → exec serve(LF 行尾)
│ └── healthcheck.py 标准库健康检查(免登录页 /login)
├── docs/ 文档(见下)
├── tools/
│ ├── smoke.py 离线回归(99 项断言)
│ ├── check_live.py 真实 HTTP 验收(56 项断言)
│ └── shots.py Playwright 逐页截图 + JS 报错收集
└── workbuddy_portal/
├── __init__.py create_app:配置 / 日志 / 蓝图 / 错误页 / 启动调度
├── config.py 路径、项目标识、默认值、写时校验
├── db.py SQLite 连接、schema、settings 读写、审计
├── schema.sql 表结构
├── security.py 密码哈希、session、CSRF、失败限速、safe_next、角色
├── client.py 云端接口(urllib)+ 编辑器凭证读取
├── collect.py 增量采集 / 去重入库 / 互斥锁 / xlsx 导入 / CSV 导出
├── scheduler.py 进程内调度线程(槽位去重 + 启动补跑)
├── query.py SQL 聚合层
└── web/
├── views.py 页面路由
├── api.py JSON API
├── templates/ base / login / overview / tasks / config / logs / records / users / error
└── static/
├── css/app.css 统一设计令牌
├── js/app.js 带 CSRF 的请求、表单与维护动作绑定
├── favicon.svg
└── dashboard/index.html ECharts 大屏(独立页)
```
---
## 文档导航
| 文档 | 面向 | 内容 |
|---|---|---|
| [docs/USER-GUIDE.md](docs/USER-GUIDE.md) | **使用者** | 用户使用手册:登录、各页面操作、Cookie 获取、导出、常见操作 |
| [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) | 运维 | 部署与运维:Docker、裸机、反向代理、备份恢复、升级回滚、推镜像到 Gitea、排错 |
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | 开发 | 架构与设计说明:数据模型、调度与锁、聚合边界、安全模型、设计取舍 |
| [docs/API.md](docs/API.md) | 开发 / 集成 | 接口参考:路径、参数、返回结构、错误码 |
| [docs/FAQ.md](docs/FAQ.md) | 所有人 | 常见问题:采集为空、Cookie 失效、时区、性能、权限 |
| [docs/CHANGELOG.md](docs/CHANGELOG.md) | 所有人 | 变更日志 |
---
## 安全须知
局域网可访问 ⇒ 以下每一条都必要:
- **必须改默认密码**;给只读同事发普通账号(`is_admin=0`),不要共用管理员。
- **Cookie 就是账号凭证**:只以掩码回显,存库不外传;默认开启 TLS 证书校验(`ssl_verify=1`),
仅在自签 / 企业代理场景临时关闭。
- **CSRF 全站校验**,退出登录也是 `POST`(GET 型退出能被 `<img src="/logout">` 静默触发)。
- **开放重定向防护**:登录跳转的 `next` 只接受站内相对路径,`//evil.com` 这类协议相对 URL 一律回落到 `/`。
- **登录限速**:同 IP 连续失败 5 次锁定 10 分钟;失败计数表有上限与 TTL。
- **不进版本库的文件**:`data/instance.json`(含 `secret_key`)、`data/usage.sqlite`、`logs/`、`.env`(含明文密码)。
查看文件
查看文件
+52
查看文件
@@ -0,0 +1,52 @@
# =============================================================================
# workbuddy-portal —— 单服务部署(采集 / 存储 / 呈现都在同一个进程里)
#
# docker compose up -d --build 本机构建并启动
# docker compose logs -f 跟踪日志
# docker compose down 停止(数据留在 ./data,不会丢)
#
# 设计取舍:
# * 刻意只有**一个**服务:SQLite 是单写者,调度线程也在 Web 进程内,
# 多副本只会带来锁竞争与重复采集,所以不做横向扩展。
# * data/ 与 logs/ 用**绑定挂载**而非命名卷:正本就是宿主机上的
# data/usage.sqlite,备份就是拷目录,宿主机上的 manage.py 也能直接读同一份数据。
# =============================================================================
name: workbuddy-portal
services:
portal:
# 默认就用 Gitea 注册表里的名字,构建完即可直接 push,不用再补 tag
image: ${WB_IMAGE:-git.iwali.top/wangchuanli/workbuddy-portal:latest}
build:
context: .
dockerfile: Dockerfile
container_name: workbuddy-portal
restart: unless-stopped
init: true # tini 接管 PID 1:docker stop 能干净地传到 python
ports:
- "${WB_BIND:-0.0.0.0}:${WB_PORT:-8848}:8848"
environment:
TZ: ${TZ:-Asia/Shanghai} # 决定「每日 09:00 / 17:00」调度与日志时间戳
WB_HOST: 0.0.0.0
WB_PORT: "8848"
WB_ADMIN_USER: ${WB_ADMIN_USER:-admin}
WB_ADMIN_PASSWORD: ${WB_ADMIN_PASSWORD:-}
WB_DISABLE_SCHEDULER: ${WB_DISABLE_SCHEDULER:-0}
WB_IMPORT_CREDS: ${WB_IMPORT_CREDS:-0}
WB_IMPORT_XLSX: ${WB_IMPORT_XLSX:-}
volumes:
- ./data:/app/data # 数据正本 + 导出 + secret_key
- ./logs:/app/logs # 应用日志(滚动 2 MB × 3)
# 可选:把编辑器配置挂进来,配合 WB_IMPORT_CREDS=1 自动接管 cookie
# - ${WB_EDITOR_SETTINGS:-./nonexistent.json}:/mnt/editor-settings.json:ro
healthcheck:
test: ["CMD", "python", "/app/docker/healthcheck.py"]
interval: 30s
timeout: 6s
start_period: 20s
retries: 3
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
+48
查看文件
@@ -0,0 +1,48 @@
#!/bin/sh
# =============================================================================
# workbuddy-portal 容器入口
# 1) 幂等初始化数据库(建表 + 默认配置 + 首个管理员)
# 2) 可选的凭证 / 历史数据导入
# 3) exec 交给 manage.py serve —— 调度线程就在这个进程里,不另起进程
# 所有开关都用环境变量控制,见 .env.example
# =============================================================================
set -eu
ADMIN_USER="${WB_ADMIN_USER:-admin}"
ADMIN_PASSWORD="${WB_ADMIN_PASSWORD:-}"
HOST="${WB_HOST:-0.0.0.0}"
PORT="${WB_PORT:-8848}"
log() { echo "[entrypoint] $*"; }
log "workbuddy-portal 启动:data=${WB_DATA_DIR:-/app/data} logs=${WB_LOG_DIR:-/app/logs} 监听 ${HOST}:${PORT} TZ=${TZ:-未设置}"
# ---------- 1. 初始化(幂等:users 非空时不会重建管理员)----------
if [ -n "${ADMIN_PASSWORD}" ]; then
python manage.py init --user "${ADMIN_USER}" --password "${ADMIN_PASSWORD}"
else
python manage.py init --user "${ADMIN_USER}"
log "未设置 WB_ADMIN_PASSWORD —— 首次部署的默认密码是 admin123,请登录后立刻修改"
fi
# ---------- 2. 可选:从挂载进来的 VSCode/Cursor/Trae settings.json 接管 cookie 与 UA ----------
if [ "${WB_IMPORT_CREDS:-0}" = "1" ]; then
log "尝试从编辑器配置导入 cookie / User-Agent"
python manage.py import-creds || log "[warn] import-creds 未成功,跳过(后续可在「配置管理」手工粘贴)"
fi
# ---------- 3. 可选:一次性导入官网导出的 xlsx ----------
if [ -n "${WB_IMPORT_XLSX:-}" ]; then
if [ -f "${WB_IMPORT_XLSX}" ]; then
log "导入 xlsx:${WB_IMPORT_XLSX}"
python manage.py import-xlsx "${WB_IMPORT_XLSX}" || log "[warn] import-xlsx 失败,跳过"
else
log "[warn] WB_IMPORT_XLSX 指向的文件不存在:${WB_IMPORT_XLSX}"
fi
fi
# ---------- 4. 交接给 Web(含进程内调度)----------
# 单写者约束:一个容器只允许一个调度线程,所以本镜像刻意不做多副本横向扩展。
# 真要跑多副本时,除第一个外全部设 WB_DISABLE_SCHEDULER=1。
log "启动 Web 服务(waitress)…"
exec python manage.py serve --host "${HOST}" --port "${PORT}"
+22
查看文件
@@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
"""容器健康检查。
只用标准库:slim 镜像里没有 curl。`/login` 是唯一免登录页面,拿到 200 即认为
Web 进程已就绪(不用 /api/*,那些未登录只会返回 401,反而会误判为不健康)。
必须显式清空代理:容器里若继承了宿主的 HTTP_PROXY,127.0.0.1 也会被拦。
"""
import os
import sys
import urllib.request
PORT = os.environ.get("WB_PORT", "8848")
URL = "http://127.0.0.1:%s/login" % PORT
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
try:
with opener.open(URL, timeout=4) as r:
sys.exit(0 if r.status == 200 else 1)
except Exception as exc: # noqa: BLE001 —— 健康检查只关心成败
print("healthcheck failed: %s" % exc, file=sys.stderr)
sys.exit(1)
+412
查看文件
@@ -0,0 +1,412 @@
# 接口参考(API)
> 面向**开发 / 集成**。所有接口都在 `/api` 前缀下,返回 JSON。
**通用约定**
| 项 | 说明 |
|---|---|
| 认证 | 全部需要登录。`/api/*` 未登录返回 **401** JSON(页面则跳登录页) |
| CSRF | **写接口**(`POST`)需带 `X-CSRF-Token` 头,或表单域 `_csrf`;缺失 / 错误返回 **400** |
| 日期参数 | `from` / `to`,`YYYY-MM-DD`。也容忍 `YYYY/MM/DD`、带时间的写法;起止写反会自动交换 |
| 非法参数 | 无法识别时返回 **400** 且带人话说明(如 `参数 from 不是合法日期:abc(正确写法 2026-09-08)`),**不会 500** |
| 分页 | `page`(默认 1)+ `size`(默认 50,上限 500) |
| 权限 | 标「管理员」的接口非管理员访问返回 **403** |
| 时区 | 所有日期口径按服务端本地时区(Docker 由 `TZ` 决定) |
**错误响应形状**
```json
{ "ok": false, "error": "bad_request", "message": "参数 from 不是合法日期:abc(正确写法 2026-09-08)" }
```
| `error` | HTTP | 含义 |
|---|---|---|
| `bad_request` | 400 | 参数不合法 / 缺 CSRF / 业务校验失败(`message` 说明原因) |
| `unauthorized` | 401 | 未登录 |
| `forbidden` | 403 | 已登录但权限不足 |
| `not_found` | 404 | 接口或资源不存在 |
| `busy` | 409 | 已有采集在跑(单写者约束) |
| `cookie_expired` | 401 | 云端 Cookie 失效,需去「配置管理」更新 |
| `api` | 502 | 云端接口异常 |
| `internal` | 500 | 服务端异常 |
---
## 读接口
### GET `/api/manifest`
存档总览。无损、代价小,适合做探活与元数据展示。
```json
{
"schema": 4,
"generated": "2026-09-14 14:52:20",
"archive": "workbuddy-portal",
"producer": "workbuddy-portal(Flask + SQLite)",
"note": "...",
"totals": {
"records": 1665, "credits": 8513.36, "calls": 1086,
"freeCalls": 579, "billableCalls": 507,
"models": 12, "clients": 3,
"first": "2026-08-10 00:00:00", "last": "2026-09-14 14:51:00",
"topCredits": 319.5
},
"months": ["2026-08", "2026-09"],
"sources": [{ "path": "usage.sqlite", "role": "primary", "count": 1665, "bytes": 1234567 }],
"focusDay": "2026-09-14",
"health": { "cookie": true, "lastRunAt": "2026-09-14 14:51:28", "lastRunStatus": "ok" }
}
```
### GET `/api/bundle`
**大屏页专用**:一次取齐所有需要的数据,避免切页时多次往返。
| 参数 | 必填 | 默认 | 说明 |
|---|---|---|---|
| `from` / `to` | 否 | 全量 | 筛选窗口 |
| `topN` | 否 | 30 | 「单笔 TOP」返回条数(1~1000) |
```json
{
"manifest": { ... 同上 ... },
"daily": [
{ "d": "2026-09-08", "c": 1423.5, "k": 88, "fc": 40, "bc": 48,
"m": { "deepseek-v4-flash": 800.2, "glm-5.3-flash": 623.3 },
"h": [0,0,0,0,0,0,0,0,0,12.5, ...] }
],
"dims": { "model": [...], "client": [...], "hour": [...] },
"top": [ { "id": "...", "c": 319.5, "m": "kimi-k3-1", "cl": "VSCode", "t": "2026-09-12 15:04:00", "px": "摘要…" } ],
"records": [ { "id": "...", "c": 5.78, "m": "...", "cl": "...", "t": "...", "px": "..." } ],
"recordsTotal": 1665,
"recordsCap": 20000,
"recordsTruncated": false,
"totals": { ... },
"window": { "from": "...", "to": "...", "days": 7 }
}
```
**关键语义**(改动前务必先读 [架构说明](ARCHITECTURE.md#五聚合层边界在哪)):
| 字段 | 范围 | 说明 |
|---|---|---|
| `daily` | **全量** | 不受 `from`/`to` 影响(约 200 B/天),供日历与日期轴 |
| `top` | **全局** | 不受窗口影响,否则排名会随筛选跳动 |
| `dims` / `records` / `totals` | 随窗口 | 筛选真正影响的部分 |
| `records` | 有上限核心集 | 超过 `recordsCap` 时只发**最新 N 条**,`recordsTruncated=true` |
> **字段名是短键**(`d/c/k/m/cl/t/px`),沿用旧版 `dashboard/data/*.json` 的契约,
> 大屏页的渲染代码依赖它。`/api/records` 用的是可读全名。**两套契约不要互相「统一」。**
### GET `/api/summary`
KPI + 环比。`from`/`to` 缺省时自动取全量区间。
```json
{
"records": 428, "credits": 2145.6, "calls": 300,
"freeCalls": 120, "billableCalls": 180,
"firstDay": "2026-09-08", "lastDay": "2026-09-14", "days": 7,
"models": 9, "clients": 2,
"first": "...", "last": "...",
"window": { "from": "2026-09-08", "to": "2026-09-14", "days": 7 },
"avgPerCall": 7.15,
"prev": { ... 上一段等长窗口的同样结构 ... },
"delta": { "credits": 12.3, "calls": -4.1, "window": { "from": "...", "to": "..." } },
"partial": { "date": "2026-09-14", "hhmm": "14:52" }
}
```
| 字段 | 说明 |
|---|---|
| `prev` | 紧邻的前一段**等长**窗口。若该段不在存档内,其值为空/零——**不给假数字** |
| `delta` | 相对 `prev` 的变化百分比 |
| `partial` | 窗口末尾那天是「今天」,数据尚未走完,据此提示 |
### GET `/api/daily`
| 参数 | 说明 |
|---|---|
| `from` / `to` | 筛选窗口 |
```json
{ "days": [ { "d": "2026-09-08", "c": 1423.5, "k": 88, "fc": 40, "bc": 48,
"m": {...}, "h": [24 个元素] } ] }
```
`h` 是 24 个时段的积分数组,索引即小时。
### GET `/api/dims`
| 参数 | 默认 | 说明 |
|---|---|---|
| `dim` | 全部 | `model` / `client` / `hour`,只返回该维度 |
```json
{
"model": [ { "name": "deepseek-v4-flash", "credits": 3784.86, "calls": 234, "avg": 16.17, "free": 0 } ],
"client": [ { "name": "VSCode", ... } ],
"hour": [ { "name": "14", ... } ]
}
```
### GET `/api/top`
单笔消耗榜。**唯一会返回 Prompt 摘要的列表接口。**
| 参数 | 默认 | 说明 |
|---|---|---|
| `from` / `to` | 全量 | 筛选窗口 |
| `n` | 50 | 返回条数(1~1000) |
```json
[ { "id": "…", "c": 319.5, "m": "kimi-k3-1", "cl": "VSCode",
"t": "2026-09-12 15:04:00", "px": "截断后的 Prompt 摘要" } ]
```
### GET `/api/records`
明细分页。**字段用可读全名**(与导出、Jinja 表格一致)。
| 参数 | 默认 | 说明 |
|---|---|---|
| `from` / `to` | 全量 | 日期窗口 |
| `model` / `client` | — | 精确匹配 |
| `q` | — | 在 Prompt 里模糊匹配 |
| `page` | 1 | 页码 |
| `size` | 50 | 每页条数(1~500) |
| `order` | `ts_desc` | `ts_desc` / `ts_asc` / `credits_desc` 等 |
| `lean` | — | `1` = 不返回 Prompt 全文(省约 80% 体积) |
```json
{
"items": [ { "request_id": "…", "credits": 5.78, "model": "…",
"client": "…", "ts": "2026-09-14 14:20:00", "prompt": "…" } ],
"total": 1665, "page": 1, "size": 50, "pages": 34,
"window": { "from": "...", "to": "..." }
}
```
### GET `/api/records/<request_id>`
单条详情,返回整行字段(`SELECT *`)。不存在返回 404 `{"ok":false,"message":"记录不存在"}`。
### GET `/api/runs` · GET `/api/runs/<id>`
| 参数 | 默认 | 说明 |
|---|---|---|
| `limit` | 50 | 返回条数(1~500) |
`/api/runs` → `{ "items": [ {id, trigger, status, started_at, finished_at, duration_ms, win_from, win_to, fetched, added, dup, total, conflicts, exit_code, message} ] }`
`/api/runs/<id>` → 单次详情,**额外含 `detail`**(逐行日志原文)。
### GET `/api/status`
```json
{
"server_time": "2026-09-14 14:52:20",
"scheduler": { "enabled": true, "times": ["09:00","17:00"], "next": "2026-09-14 17:00:00" },
"running_runs": 0,
"last_run": { "id": 8, "trigger": "startup", "status": "ok", "started_at": "...", "message": "..." },
"cookie_set": true
}
```
### GET `/api/audit`
操作审计分页。
| 参数 | 默认 | 说明 |
|---|---|---|
| `action` | — | 按动作精确筛选(值取自返回的 `actions`) |
| `page` / `size` | 1 / 50 | 分页(`size` 上限 500) |
```json
{
"total": 120, "page": 1, "size": 50, "pages": 3,
"actions": ["collect", "login", "login_failed", "settings", "settings_rejected", "user_create", ...],
"items": [ { "id": 300, "at": "2026-09-14 14:52:20", "actor": "admin",
"action": "login", "detail": "登录成功", "ip": "127.0.0.1" } ]
}
```
### GET `/api/settings`
读配置。**`cookie` 只回掩码**,绝不回明文;内部簿记键(`slot:*`)不返回。
```json
{
"values": { "page_size": "200", "schedule_times": "09:00,17:00", ... },
"cookie_hint": "4054 字符,…09db9a660825",
"num_settings": { "page_size": [20, 1000, "条/页"], ... },
"bool_settings": ["schedule_enabled", "catch_up"]
}
```
### GET `/api/users`(管理员)
```json
{ "items": [ { "id": 1, "username": "admin", "display_name": "管理员",
"is_admin": 1, "created_at": "...", "last_login_at": "...", "login_count": 12 } ] }
```
> **口令散列永不出现在响应里。**
### GET `/logs/tail`
应用日志尾部。
| 参数 | 默认 | 说明 |
|---|---|---|
| `lines` | 200 | 行数(自动钳制;非法值回落默认而非 500) |
### GET `/records/export`
按当前筛选**流式**导出 CSV(不受分页上限约束)。
| 参数 | 说明 |
|---|---|
| `from` / `to` / `model` / `client` / `q` / `order` | 同 `/api/records` |
响应:`text/csv; charset=utf-8`,带 **UTF-8 BOM**(Excel 双击不乱码),
表头与官网导出 xlsx 同构:`RequestID, 积分消耗, User Prompt, 模型, 客户端, 时间`。
---
## 写接口
> 全部需要 `X-CSRF-Token`(页面里读 `window.WB_CSRF`)。
### POST `/api/collect`
手动触发采集(同步执行,页面等待结果)。
请求体(都可选):
```json
{ "from": "2026-09-01", "to": "2026-09-07" }
```
| 场景 | 响应 |
|---|---|
| 成功 | `200 {"ok": true, "result": {"message": "新增 11 条,重复 6 条,存档共 1665 条", ...}}` |
| 已有采集在跑 | `409 {"ok": false, "error": "busy", "message": "..."}` |
| Cookie 失效 | `401 {"ok": false, "error": "cookie_expired", "message": "..."}` |
| 云端异常 | `502 {"ok": false, "error": "api", "message": "..."}` |
| 日期不合法 | `400 {"ok": false, "error": "bad_request", "message": "起始日期不合法:..."}` |
### POST `/api/maintenance/<action>`
把 CLI 维护动作搬到页面。
| `action` | 作用 |
|---|---|
| `fill-prompt` | 从云端回补缺失的 Prompt |
| `export-csv` | 全量导出 CSV 到 `data/exports/` |
| `vacuum` | `wal_checkpoint(TRUNCATE)` + `VACUUM` |
| `recount` | 重新统计并返回当前条数 |
未知动作返回 **404**。成功返回 `{"ok": true, "message": "..."}`。
### POST `/api/settings`
写配置。**逐项校验**,一次返回全部错误。
请求体:`{ "page_size": "300", "schedule_times": "08:00,12:00,18:00", ... }`
| 场景 | 响应 |
|---|---|
| 全部合法 | `200 {"ok": true, "changed": ["page_size"], "ignored": []}` |
| 有非法值 | `400 {"ok": false, "error": "invalid", "errors": ["page_size 需在 20 ~ 1000 条/页 之间"]}` |
要点:
- `cookie` **留空 = 不修改**(不会把已有 Cookie 清掉);
- 未知键被忽略并在 `ignored` 里列出,**不会被写成任意键**;
- 内部键(`slot:*`)被忽略;
- 每次拒绝都会写一条 `settings_rejected` 审计。
### POST `/api/password`
修改**自己**的密码。
```json
{ "old": "旧密码", "new": "新密码", "new2": "新密码" }
```
校验:非空、两次一致、长度上限 128。旧密码错误返回 400。
### POST `/api/users`(管理员)
```json
{ "username": "viewer", "display_name": "只读同事", "password": "…", "is_admin": false }
```
### POST `/api/users/<id>`(管理员)
```json
{ "display_name": "新名字", "is_admin": true, "password": "可选,重置密码" }
```
### POST `/api/users/<id>/delete`(管理员)
删除账号。**三重护栏**(服务端强制):
1. 不能取消自己的管理员身份;
2. 不能删除自己;
3. 至少保留一个账号。
违反返回 `400`。
---
## 集成示例
### curl(读接口)
```bash
# 1) 登录拿会话 + CSRF(登录页的 window.WB_CSRF)
curl -s -c /tmp/cj --noproxy '*' http://127.0.0.1:8848/login \
| grep -o 'window.WB_CSRF = "[^"]*"'
CSRF=$(curl -s -c /tmp/cj --noproxy '*' http://127.0.0.1:8848/login | sed -n 's/.*WB_CSRF = "\([^"]*\)".*/\1/p')
curl -s -b /tmp/cj -c /tmp/cj --noproxy '*' -X POST \
-d "username=admin&password=admin123&_csrf=$CSRF" http://127.0.0.1:8848/login -o /dev/null
# 2) 调接口
curl -s -b /tmp/cj --noproxy '*' "http://127.0.0.1:8848/api/summary?from=2026-09-08&to=2026-09-14"
```
> `--noproxy '*'` 是本机环境的坑:默认代理会把 `127.0.0.1` 也拦成 502。
### Python(写接口,注意 CSRF)
```python
import http.cookiejar, json, re, urllib.parse, urllib.request
BASE = "http://127.0.0.1:8848"
cj = http.cookiejar.CookieJar()
op = urllib.request.build_opener(urllib.request.ProxyHandler({}),
urllib.request.HTTPCookieProcessor(cj))
# 拿到 CSRF(登录页内联注入)
html = op.open(BASE + "/login").read().decode()
csrf = re.search(r'window\.WB_CSRF = "([^"]+)"', html).group(1)
# 登录
op.open(urllib.request.Request(
BASE + "/login",
data=urllib.parse.urlencode({"username": "admin", "password": "admin123",
"_csrf": csrf}).encode()))
# 触发采集(写接口必须带 X-CSRF-Token)
req = urllib.request.Request(BASE + "/api/collect", data=b"{}", method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("X-CSRF-Token", csrf)
print(json.loads(op.open(req).read().decode()))
```
> **登录成功会重置会话与 CSRF 令牌**——登录后要重新读一次页面的 `window.WB_CSRF`,
> 否则写接口会返回 400。
+382
查看文件
@@ -0,0 +1,382 @@
# 架构与设计说明
> 面向**开发 / 维护者**。解释这个系统为什么长这样,以及改动时不能碰的红线。
**目录**
- [一、分层与数据流](#一分层与数据流)
- [二、数据模型](#二数据模型)
- [三、采集:断点、去重、锁](#三采集断点去重锁)
- [四、调度:为什么不用 APScheduler](#四调度为什么不用-apscheduler)
- [五、聚合层:边界在哪](#五聚合层边界在哪)
- [六、呈现层:两套界面共用一套令牌](#六呈现层两套界面共用一套令牌)
- [七、安全模型](#七安全模型)
- [八、配置系统:写时校验 + 读时兜底](#八配置系统写时校验--读时兜底)
- [九、已知坑与红线](#九已知坑与红线)
- [十、验证体系](#十验证体系)
---
## 一、分层与数据流
```
client.py 纯 urllib 调云端;读编辑器 settings.json 取凭证
│
▼
collect.py 断点续采 → 文件锁 → 规范化 → 分批 upsert;导入/导出也在这
│ ▲
│ │ scheduler.py 只是「到点调 collect.sync()」
▼
db.py + schema.sql SQLite(WAL),单写者;settings 表兼作运行期配置
│
▼
query.py 全部聚合下推 SQL:daily / dims / top / records / summary / bundle / manifest
│
├──▶ web/views.py Jinja 后台(7 个页面)
└──▶ web/api.py JSON(大屏 + 页面异步调用)
```
**关键点:没有中间 JSON 层。** 旧版本是「脚本 → CSV → 预生成 JSON → 大屏」,
任何一次查询变化都要重新跑生成器。现在聚合全部下推 SQL,页面与接口共享同一个 `query` 层,
口径不可能不一致。
---
## 二、数据模型
```sql
usage_records(
request_id TEXT PRIMARY KEY, -- 云端请求 ID,去重靠它
ts TEXT NOT NULL, -- 本地时间戳 'YYYY-MM-DD HH:MM:SS'
day TEXT NOT NULL, -- 派生字段:便于按天聚合与建索引
hour INTEGER NOT NULL, -- 派生字段:0-23,供时段分布
model TEXT, client TEXT,
credits REAL NOT NULL,
prompt TEXT, -- 可截断(max_prompt)
first_seen TEXT, last_seen TEXT,
cloud_ts TEXT -- 云端原始时间,用于漂移检测
)
collect_runs(
id INTEGER PRIMARY KEY, trigger TEXT, status TEXT,
started_at, finished_at, duration_ms,
win_from, win_to, -- 本次扫描窗口
fetched, added, dup, total, conflicts,
exit_code, message, detail -- detail 存逐行日志原文
)
settings(key PRIMARY KEY, value, updated_at) -- cookie / 调度 / 采集参数 / 与 slot:HH:MM 簿记
users(id, username UNIQUE, password_hash, display_name, is_admin, created_at, last_login_at, login_count)
audit_log(id, at, actor, action, detail, ip)
```
索引:`day`、`(day,hour)`、`(model,day)`、`(client,day)`、`credits DESC`、`ts`。
覆盖了「按天」「按天+时段」「模型/客户端 × 天」「单笔 TOP」「时间排序」五类热点查询。
### 为什么 `day`/`hour` 是冗余列
`substr(ts,1,10)` 这类函数表达式无法走索引。把它们物化成列 + 索引,聚合查询从全表扫描
变成索引扫描。写入时多算一次,读的时候省下 N 次。
### 为什么 `settings` 表兼作簿记
调度槽位去重需要一个「今天 09:00 已经跑过了」的持久标记,正好复用
`settings(key='slot:2026-09-14T09:00', value='done')`。这类键用前缀 `slot:` 标记为
**内部键**:`/api/settings` 读写两侧都过滤掉(`config.is_internal_key()`),
用户不会在配置页看到它们,也无法通过接口写入任意键。
---
## 三、采集:断点、去重、锁
### 断点续采
```python
since = 本地 MAX(ts) - rewind_minutes # 回退几分钟,容忍云端写入延迟
rows = client.fetch_range(since, now) # 分页拉取
```
回退的意义:云端记录可能比本地时间晚落库,卡在边界上的记录会被漏掉。
默认回退 2 分钟,重复拉到已有记录由去重兜住——**宁可重复拉,不可漏**。
### 去重:`ON CONFLICT` + 取更早的时间
```sql
INSERT INTO usage_records(...) VALUES(...)
ON CONFLICT(request_id) DO UPDATE SET
ts = CASE WHEN excluded.ts < usage_records.ts THEN excluded.ts ELSE usage_records.ts END,
...
```
同一条请求可能被多次拉到(断点回退 + 区间补采重叠)。冲突时**以更早的本地 `ts` 为准**,
因为后拉到的可能已经越过了跨日边界。
### 单写者:文件锁
```
collect._Lock() → data/collect.lock (O_CREAT|O_EXCL)
```
三处调用者共享这把锁:调度线程、页面手动触发(`POST /api/collect`)、CLI(`manage.py collect`)。
拿到锁失败 → 抛 `collect.Busy` → API 返回 **409**,页面提示「采集正在进行中」。
锁文件含时间戳,**超过 30 分钟视为僵尸锁可抢占**(进程被 kill 时锁不会自己释放)。
> **为什么不用数据库锁**:SQLite 的写锁是「事务级」的,而一次采集可能持续几分钟
> 且分多个事务提交。用文件锁把「整个采集流程」串起来,比用事务锁正确得多。
### 分批提交
`upsert()` 每 200 行一个显式事务(`BEGIN` / `COMMIT`),并带重入保护:
```python
own_tx = not conn.in_transaction # 已在事务里就别再 BEGIN(会报错)
```
这样中途失败只回滚当前一批,已入库的不受影响。
---
## 四、调度:为什么不用 APScheduler
需求只有「每天几个固定时刻」。一个 20 秒轮询的线程就够:
```python
while True:
now = datetime.now()
for slot in due_slots(now): # 今天该跑但没跑过的时刻
if not already_done(slot): # 靠 settings 里的 slot: 键去重
mark_done(slot)
collect.sync(trigger="schedule")
sleep(20)
```
自研换来三件事**外部调度器给不了**:
1. **启动补跑**:程序没开时错过的时刻,启动后检查「今天已过的时刻」,
在宽限期(`catch_up_grace_hours`,默认 12 小时)内补采。
2. **与 CLI 共享同一把锁**:手动 `manage.py collect` 不会和调度撞车。
3. **零额外依赖**:少一个包,少一类版本冲突。
注意事项:
- `WERKZEUG_RUN_MAIN` 守卫:`--debug` 下 reloader 会 fork 子进程,只允许子进程起调度。
- `WB_DISABLE_SCHEDULER=1` 关掉调度(多副本时给除第一份外的实例用)。
- 轮询间隔 20 秒是折中:时刻精度 ±20 秒足够,且几乎不占 CPU。
---
## 五、聚合层:边界在哪
`query.py` 是唯一的聚合出口。几个刻意的设计:
| 函数 | 边界 | 为什么 |
|---|---|---|
| `manifest()` | 全量 | 存档总览,供页面显示「数据范围」「活跃天数」 |
| `bundle()` 的 `daily` | **全量**(约 200 B/天) | 大屏的日历与日期轴需要完整日期序列 |
| `bundle()` 的 `top` | **全局** | 大屏的「单笔 TOP」不该随窗口变(否则排名会跳) |
| `bundle()` 的 `dims/records/totals` | 随窗口 | 这才是筛选真正影响的部分 |
| `bundle()` 的 `records` | **有上限核心集** | 见下 |
| `summary()` | 窗口 + 上一段 | 环比;前一段不在存档内时**不给假数字**,明确标记 |
### `bundle` 为什么要设上限
大屏是「数据进浏览器 → 控件联动 → 即时重绘」的模型,必须把数据一次性下发。
但全量明细可能有几十万条,直接塞进 JSON 会把浏览器打死。
所以:
```python
BUNDLE_RECORDS_CAP = 20000
```
超过上限时只发**最新的 N 条**,同时返回 `recordsTotal` / `recordsCap` / `recordsTruncated`,
页面据此提示「明细表只显示最近 N 条,完整数据请到数据明细页」。**不静默丢数据**是关键。
### 日期归一化
`norm_day()` 容忍 `2026/09/08`、`2026-09-08 12:00:00`、`T` 分隔;
`norm_window()` 还会自动交换写反的起止。**任何手写 query string 都不该让接口 500。**
---
## 六、呈现层:两套界面共用一套令牌
- **Jinja 后台**:`web/templates/*.html` + `web/static/css/app.css`
- **ECharts 大屏**:`web/static/dashboard/index.html`(单文件,内联样式)
两者**共用同一套设计令牌**(色板 / 圆角 / 间距 / 字号)。大屏是独立静态页,
因为它需要完全自由的布局与 canvas 尺寸,套进导航框架反而受限。
### 大屏页的所有权边界
大屏页有 13 个渲染函数,**只允许改数据层,不允许改渲染逻辑**。原因:
渲染函数经过 Node DOM stub 工装验证(断言「页面聚合 == 独立算出的聚合」),
改动它们会让验证失效。
### 返回值形状契约
大屏页沿用旧版 `dashboard/data/*.json` 的**短键**:
```
daily: d(day) c(credits) k(calls) f(first) b(build) m(models) h(hours[24])
records: id c(credits) m(model) cl(client) t(ts) px(prompt)
```
而 `/api/records`(供 Jinja 表格与导出)用**可读全名**:
`request_id / credits / model / client / ts / prompt`。
这不是不一致,是**两套消费方的契约不同**:改短键要大屏重写,改全名要模板重写。
**不要试图「统一」它们。**
---
## 七、安全模型
### 认证
| 项 | 做法 |
|---|---|
| 密码存储 | `pbkdf2:sha256:200000`(Werkzeug 实现) |
| 会话 | Flask 签名 cookie `workbuddy_portal_sid`,HttpOnly + SameSite=Lax,12 小时 |
| 密钥持久化 | `data/instance.json` 的 `secret_key`,重启不踢人 |
| 失败限速 | 同 IP 连续 5 次失败锁定 10 分钟;计数表有上限(4096 个 IP)与 TTL(1 小时) |
### 授权
`@login_required`(`/api/*` 未登录返回 401 JSON,页面跳登录)+
`@admin_required`(403)两层。`/users` 与 `/api/users*` 全部要管理员。
内置护栏(服务端强制,前端只是提前提示):不能取消自己的管理员身份、不能删自己、至少留一个账号。
### CSRF
`before_request` 统一校验:`X-CSRF-Token` 头或 `_csrf` 表单域。
**退出登录也走 POST**——GET 型退出能被 `<img src="/logout">` 静默触发。
### 开放重定向
登录跳转的 `next` 只接受站内相对路径。`security.safe_next()` 拒绝:
- `//evil.com`(**协议相对 URL**,浏览器会当成 `http://evil.com`)
- `/\evil.com`、含 `\` 的
- `http://...` / `https://...` 绝对地址
- 含 CR/LF 的(防 header 注入)
### 凭证
- Cookie 只回掩码(`cookie_hint`),页面与接口都不回明文;保存时留空 = 不覆盖。
- 默认 `ssl_verify=1`:Cookie 就是账号凭证,不该在无校验的 TLS 上裸奔。
- 内部簿记键(`slot:*`)读写两侧都过滤。
### 参数
所有查询参数在入口归一化 / 钳制。非法值返回 400(带人话说明)或回落默认,
**绝不 500**——全局 `ValueError` 处理器兜住 `strptime` / `int()` 这类异常。
---
## 八、配置系统:写时校验 + 读时兜底
历史 bug:配置页是自由文本框,把 `page_size` 敲成 `abc` 后,
采集在 `int()` 处抛 `ValueError` 整个跑不起来。现在的双保险:
**写时校验**(`config.normalize_setting`)
```python
NUM_SETTINGS = {"page_size": (20, 1000, "条/页"), ...} # 范围 + 单位
BOOL_SETTINGS = {"schedule_enabled", "catch_up"}
```
非法值 → `400 {"error":"invalid","errors":[...]}`,一次列出**全部**错误,并写审计 `settings_rejected`。
**读时兜底**
```python
page_size = db.get_int(conn, "page_size", 200) # 任何异常都回落默认值
```
采集路径上**不允许出现裸 `int(s.get(...))`**。数值还会按 `NUM_SETTINGS` 的范围再钳一次。
---
## 九、已知坑与红线
### 流式响应里不能复用 `db.get_db()`
Flask 在 `full_dispatch_request()` 返回 `app_iter` **之后**就 pop 请求上下文
(`teardown_appcontext` → `close_db` 关掉 `g.db`),WSGI 服务器**才开始**迭代生成器。
于是生成器一读库就报 `Cannot operate on a closed database`。
**规则**:凡 `Response(gen())` / `stream_with_context` 场景,生成器内部要 `db.connect()`
自建连接并 `finally` 关闭。(`/records/export` 就是这么修的。)
### `send_from_directory` 吐的单层路由,页内资源必须写绝对路径
`/dashboard` 没有尾斜杠,页内 `src="vendor/echarts.min.js"` 会被解析成
`/vendor/echarts.min.js` → 404 → **整页图表全白**。静态挂载点是 `/static`,
所以写 `/static/dashboard/vendor/echarts.min.js`。
这个 bug 曾经躲过「状态码断言」和「真实 HTTP」两层测试,只有浏览器截图才抓到。
现在 `tools/smoke.py` 有专门一节:抓页面里所有 `src`/`href` 资源引用逐个断言 200
(断言前先剥掉 HTML 注释,否则注释里的示例路径会被误判)。
### Jinja 里避开 `dict` 的方法名
模板中 `a.items` / `a.keys` / `a.get` / `a.values` / `a.update` / `a.pop` / `a.copy`
会命中**方法**而不是数据(属性查找优先于下标)。视图里把这类值拆成独立变量传。
同类坑:**视图里不要把 `fetchall()` 的 Row 列表用列表推导扁平化成字符串列表**
——模板写 `a[0]` 会变成「取字符串第一个字符」,**而且不报错**。
### 同一文件不能连续并行编辑
同一轮响应里对同一文件发多个编辑会互相覆盖(都返回成功,只有最后一个落盘)。
改同一文件必须串行,改完回读确认。
### 新增组件用带前缀的独有类名
`class="bar"` 撞上页面已有的筛选条 `.bar`(带 `backdrop-filter: blur(6px)`),
会让整块文字被静默虚化。新组件一律用带前缀的类名(如 `.calcell .cbar`)。
`smoke.py` 里有「页面 class ∩ `app.css` 选择器」差集断言兜这类问题。
### 前端日期运算不要用 `toISOString().slice(0,10)`
GMT+8 下 `new Date("2026-08-15T00:00:00")` 的 UTC 时刻是前一天 16:00,
取出来就少一天,「加一天」变成「减一天」,循环跑飞。
用 `getFullYear/getMonth/getDate` 拼本地串。
### ECharts 热力图 `data` 是「一个坐标一个点」
同坐标重复 push 会**互相覆盖而非累加**。要展示格子合计,必须先在 JS 里按 `(x,y)` 聚合再 push。
### 其它
- 传给模板的「带下标的行」直接传 `fetchall()` 的 Row 列表,不要先扁平化。
- 本机 chromium 截图要用同版本二进制初始化过的 profile 目录;
Playwright 驱动与本机浏览器版本会错位,需显式传 `executable_path`。
- `urllib` / `curl` 会走本机代理,把 `127.0.0.1` 也拦成 502——
探测本地服务要 `build_opener(ProxyHandler({}), ...)` 或 `--noproxy '*'`。
---
## 十、验证体系
五层,按代价从低到高。**前两层已固化成脚本,改完必须跑。**
| 层 | 手段 | 抓什么 |
|---|---|---|
| 1 | 独立聚合对账(直读 CSV 不走 `query.py`) | 口径错、少算。热力图要**逐格**比,历史上出过「同格覆盖少算 84%」 |
| 2 | `tools/smoke.py`(99 项断言,离线) | 模板残留、历史缺陷防回归 ①~⑭、静态资源 404、class↔CSS 对账 |
| 3 | `tools/check_live.py`(56 项断言,真实 HTTP) | `test_client` 覆盖不到的:waitress、端口、cookie 往返、开放重定向、CSRF |
| 4 | Node DOM stub + `vm.runInContext` 跑大屏真实脚本 | 「页面聚合 == 独立算出的聚合」、切区间只发一次请求 |
| 5 | `tools/shots.py`(Playwright 截图 + console/pageerror) | **界面层**。本轮最有价值的 bug(大屏全白)只有它抓到 |
```bash
python tools/smoke.py # 1~2 层,随时跑
python manage.py serve --port 8849 --no-scheduler # 另开终端
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 层
```
**改动前先读 [九、已知坑与红线](#九已知坑与红线),改完先把第 2 层跑绿。**
+128
查看文件
@@ -0,0 +1,128 @@
# 变更日志
本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/):`主版本.次版本.修订号`。
- **主版本**:不兼容的变更(数据库迁移需手工介入、接口契约变化)
- **次版本**:向后兼容的功能新增
- **修订号**:向后兼容的缺陷修复
---
## [1.1.0] — 2026-09-14
**主题:项目定名 `workbuddy-portal` · 容器化 · 文档体系**
### 新增
- **Docker 化**:多阶段 `Dockerfile`(依赖层与运行层分离,改业务代码不触发重装依赖)、
`docker-compose.yml`(单服务、数据绑定挂载、健康检查、日志轮转)、
`docker/entrypoint.sh`(幂等初始化 → exec 交接)、`docker/healthcheck.py`(纯标准库)、
`.dockerignore`、`.env.example`
- **容器环境变量**:`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`
- **文档体系** `docs/`:
[用户使用手册](USER-GUIDE.md)(含 9 张界面截图)、
[部署与运维指南](DEPLOYMENT.md)(含推镜像到 Gitea 注册表的完整流程)、
[架构与设计说明](ARCHITECTURE.md)、
[接口参考](API.md)、
[常见问题](FAQ.md)
- **`.gitattributes`**:强制 `*.sh` / `Dockerfile` / 各类源码为 LF
(带 CRLF 的 `.sh` 在容器里会报 `exec format error`,极难定位)
### 变更
- **项目定名**:`wb_usage_portal` → **`workbuddy-portal`**;
Python 包 `wb_usage` → **`workbuddy_portal`**;会话 cookie
`wb_usage_sid` → `workbuddy_portal_sid`(升级后需要重新登录)
- **界面品牌**统一为 **WorkBuddy Portal**(此前为「WorkBuddy 用量门户」)
- 项目标识收敛到 `config.PROJECT_NAME` / `PROJECT_TITLE` / `PROJECT_DESC` 单一来源,
模板通过 `app_name` 等上下文变量引用,不再多处硬编码
- 数据 / 日志目录支持环境变量覆盖(`WB_DATA_DIR` / `WB_LOG_DIR` / `WB_DB`)
- 版本号 1.0.0 → **1.1.0**
- 大屏页标题改为「WorkBuddy Portal · 积分消耗大屏」
### 修复
| # | 症状 | 根因 |
|---|---|---|
| 1 | `/records/export` **必然 500** | 生成器在请求上下文销毁后才被迭代,复用 `db.get_db()` 撞「数据库已关闭」。改为生成器内自建连接 |
| 2 | **大屏页图表全白** | `/dashboard` 无尾斜杠,`src="vendor/echarts.min.js"` 被解析成 `/vendor/…` → 404 |
| 3 | `/users` 500 | 路由已注册但 `users.html` 不存在 |
| 4 | 明细页日期筛选失效 | 视图传 `f.frm`、模板读 `f.from`;导出链接拼 `?frm=` 而接口只认 `from` |
| 5 | 配置页 3 个维护按钮全死 | 模板调 `WBU.bindMaint()`,`app.js` 里没有该函数 |
| 6 | 审计只能看最近 40 条 | `LIMIT 40` 写死 |
| 7 | 明细页多跑一条无用 `SELECT` | `day_list()` 取了没人用 |
| 8 | 登录页锁定阈值写死 | 未从配置注入 |
> **#2 值得单独一提**:前两层测试都没抓到(离线断言只看状态码 + `<html>`,
> 真实 HTTP 只看状态码),是加上 Playwright 截图后才发现的。
> 据此给 `tools/smoke.py` 补了「页面所有 `src`/`href` 资源引用逐个断言 200」一节。
### 安全
- 新增 `security.safe_next()`:登录跳转的 `next` 拒绝 `//evil.com`(协议相对 URL)、
`/\evil.com`、绝对地址与含 CR/LF 的值
- 缺 CSRF 的写请求统一 400(此前部分路径漏检)
- 默认**开启**云端 HTTPS 证书校验(`ssl_verify=1`)。Cookie 是账号凭证,不该裸奔
- 登录失败计数表加上限(4096 个 IP)与 TTL(1 小时),防内存被大量来源 IP 撑爆
- `/logout` 拆分为 POST(执行)+ GET(仅提示),防 `<img src="/logout">` 静默退出
- `settings` 的内部簿记键(`slot:*`)读写两侧都过滤,不再从 `/api/settings` 泄漏
### 内部质量
- 设置项**写时校验 + 读时兜底**:`config.normalize_setting()`(范围 + 单位,非法值 400
并列出全部错误)+ 采集路径全面改用 `db.get_int()`,杜绝「一个手滑的数字让采集整个跑不起来」
- 全局 `ValueError` → 400 处理器:手写 query string 不再能把 500 页面暴露出去
- 日期归一化 `norm_day()` / `norm_window()`(容忍 `2026/09/08`、带时间、起止写反)
- CSV 导出改用 `csv.writer` 流式写入(此前手工拼字符串,`model`/`client` 含逗号会串列)
- `bundle` 明细加下限(`BUNDLE_RECORDS_CAP = 20000`),并返回
`recordsTotal` / `recordsCap` / `recordsTruncated`,不静默丢数据
- 轮询日志尾部改用 `collections.deque(maxlen=n)`,不再把整个文件读进内存
- 修掉一条非法 CSS 声明 `font: 13px/1.5 inherit`(简写里 `inherit` 不能当字族,
整条被浏览器丢弃,输入框一直用默认字体)
### 工具
- 新增 `tools/smoke.py`:**离线回归 99 项断言**(全页面只读渲染 + 模板残留 +
历史缺陷防回归 ①~⑭ + 静态资源逐个 200 + CSV 列 + 页面 class ↔ `app.css` 选择器对账),
不需要先起服务
- `tools/check_live.py` 扩充到 **56 项断言**,新增用户管理 / 审计 / 流式导出 /
开放重定向 / CSRF 四节
- 新增 `tools/shots.py`:Playwright 登录后逐页截图 + 收集 console / pageerror
(内建可执行文件探测,规避驱动与本机浏览器版本错位)
---
## [1.0.0] — 2026-09-14
**主题:从「脚本 + CSV + 静态大屏」演化为独立可部署的门户**
### 新增
- 独立 Flask 应用:`create_app` 工厂 + `views` / `api` 双蓝图
- SQLite(WAL)作为**唯一数据正本**,主键去重、断点续采、聚合全部下推 SQL
- 进程内调度线程(20 秒轮询 + 槽位去重 + 启动补跑),**不再依赖外部计划任务**
- 单写者文件锁 `data/collect.lock`(含 30 分钟僵尸锁抢占)
- 登录鉴权(`pbkdf2:sha256:200000`)、全站 CSRF、同 IP 失败限速
- 后台页面:概览 / 数据明细 / 任务管理 / 配置管理 / 日志管理
- 独立 ECharts 交互大屏 `/dashboard`(离线自带 echarts,不依赖 CDN)
- CLI:`init` `serve` `collect` `migrate-csv` `import-xlsx` `import-creds`
`fill-prompt` `export-csv` `stats` `status` `passwd` `vacuum`
- 从旧版 CSV / 官网 xlsx / 编辑器设置导入的迁移通道
- 从 VSCode / Cursor / Trae 的 `settings.json` 接管 Cookie 与 User-Agent
### 变更
- 数据正本从 CSV 改为 SQLite;CSV 降级为导出物(`data/exports/`)
- 采集从外部脚本改入 Web 进程;删除全部外部自动化与计划任务
- 运行期配置(Cookie、调度、采集参数)从文件搬进数据库,由后台页面维护
---
## 版本对照
| 版本 | 数据正本 | 调度 | 部署 | 鉴权 |
|---|---|---|---|---|
| 1.1.0 | SQLite(WAL) | 进程内 | Docker Compose / 裸机 | 登录 + CSRF + 角色 |
| 1.0.0 | SQLite(WAL) | 进程内 | 裸机 | 登录 + CSRF |
| < 1.0 | CSV 文件 | 外部计划任务 | 脚本 | 无(仅局域网) |
+475
查看文件
@@ -0,0 +1,475 @@
# 部署与运维指南
> 面向**运维 / 部署者**。从零到跑起来,以及跑起来之后的备份、升级、排错。
**目录**
- [一、部署方式怎么选](#一部署方式怎么选)
- [二、Docker Compose 部署](#二docker-compose-部署)
- [三、裸机部署](#三裸机部署)
- [四、反向代理与 HTTPS](#四反向代理与-https)
- [五、把镜像推到 Gitea 注册表](#五把镜像推到-gitea-注册表)
- [六、备份与恢复](#六备份与恢复)
- [七、升级与回滚](#七升级与回滚)
- [八、日常巡检](#八日常巡检)
- [九、排错](#九排错)
- [十、配置项速查](#十配置项速查)
---
## 一、部署方式怎么选
| 场景 | 建议 |
|---|---|
| 有 Docker(NAS / 服务器 / 本机 Docker Desktop) | **Docker Compose**,最省事,升级只需换镜像 |
| 不想装 Docker,或要跑在 Windows 上用系统计划任务兜底 | 裸机 Python + `waitress` |
| 想给多人访问 | 任一种方式 + 反向代理(加 HTTPS 更稳) |
> **不要横向扩展**。SQLite 是单写者,调度线程也在 Web 进程内,
> 多副本只会带来锁竞争和重复采集。这个服务天然是单实例的。
---
## 二、Docker Compose 部署
### 2.1 前置
- Docker Engine 20.10+ / Docker Desktop(含 Compose v2)
- 至少 200 MB 磁盘(镜像 152 MB + 数据)
### 2.2 步骤
```bash
git clone http://git.iwali.top/wangchuanli/workbuddy-portal.git
cd workbuddy-portal
cp .env.example .env
vi .env # 至少设置 WB_ADMIN_PASSWORD
docker compose up -d --build
docker compose ps # 等 STATUS 变成 (healthy)
docker compose logs -f
```
浏览器打开 `http://<服务器IP>:8848`。
### 2.3 `.env` 主要变量
| 变量 | 默认 | 说明 |
|---|---|---|
| `WB_BIND` | `0.0.0.0` | 宿主机绑定地址。只想本机访问就设 `127.0.0.1` |
| `WB_PORT` | `8848` | 宿主机端口 |
| `TZ` | `Asia/Shanghai` | **影响「每日 09:00/17:00」与所有日期口径** |
| `WB_ADMIN_USER` | `admin` | 首个管理员用户名(只在库为空时生效) |
| `WB_ADMIN_PASSWORD` | 空 | 首个管理员密码。**留空会用 `admin123`**,务必显式设置 |
| `WB_DISABLE_SCHEDULER` | `0` | `1` = 不启动调度线程(只跑手动采集) |
| `WB_IMPORT_CREDS` | `0` | `1` = 启动时尝试从挂载的编辑器配置导入 Cookie |
### 2.4 数据落点
| 容器内 | 宿主机 | 内容 |
|---|---|---|
| `/app/data` | `./data` | `usage.sqlite`(正本)、`instance.json`(secret_key)、`exports/` |
| `/app/logs` | `./logs` | `app.log`(滚动 2 MB × 3) |
**绑定挂载**而非命名卷,是为了:备份就是拷目录;宿主机上的 `manage.py` 能直接读同一份数据。
> **Linux 宿主机首次运行**若报 `unable to open database file`,
> 是宿主目录属主与容器内 uid 1000 不一致:
> ```bash
> sudo chown -R 1000:1000 ./data ./logs
> ```
### 2.5 常用命令
```bash
docker compose ps
docker compose logs -f --tail=100
docker compose restart
docker compose down # 停并删容器,数据保留
docker compose up -d --build # 改完代码重新构建
# 在容器里跑 CLI(同一个数据卷)
docker compose exec portal python manage.py stats
docker compose exec portal python manage.py status
docker compose exec portal python manage.py passwd admin 新密码
docker compose exec portal python manage.py vacuum
docker compose exec portal python manage.py collect # 手动采集一次
```
> **本机调试**(Docker Desktop on Windows)已验证:
> 绑定挂载上的 SQLite(WAL)读写正常,调度补跑、采集、导出、CSV 流式下载都可用。
---
## 三、裸机部署
### 3.1 Windows
```bat
git clone http://git.iwali.top/wangchuanli/workbuddy-portal.git
cd workbuddy-portal
py -3 -m venv .venv
.venv\Scripts\pip install -r requirements.txt
.venv\Scripts\python manage.py init --user admin --password 你的强密码
.venv\Scripts\python manage.py serve
```
开机自启用「任务计划程序」:触发器「计算机启动时」,操作
`<项目路径>\.venv\Scripts\python.exe`,参数 `manage.py serve`,起始位置设为项目目录。
### 3.2 Linux
```bash
git clone http://git.iwali.top/wangchuanli/workbuddy-portal.git
cd workbuddy-portal
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/python manage.py init --user admin --password 你的强密码
```
`/etc/systemd/system/workbuddy-portal.service`:
```ini
[Unit]
Description=WorkBuddy Portal
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=workbuddy
WorkingDirectory=/opt/workbuddy-portal
Environment=TZ=Asia/Shanghai
ExecStart=/opt/workbuddy-portal/.venv/bin/python manage.py serve --host 0.0.0.0 --port 8848
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now workbuddy-portal
sudo systemctl status workbuddy-portal
journalctl -u workbuddy-portal -f
```
> **不要**用 `manage.py collect` + cron 替代内置调度,除非你确实想让调度留在外部
> (那种情况下 Web 端要加 `--no-scheduler`,避免和 cron 抢锁——虽然文件锁会保证正确性,
> 但会白跑一次)。
---
## 四、反向代理与 HTTPS
前面挂 nginx 时要注意两点,否则会踩坑:
```nginx
server {
listen 443 ssl;
server_name portal.example.com;
ssl_certificate /etc/ssl/certs/portal.crt;
ssl_certificate_key /etc/ssl/private/portal.key;
location / {
proxy_pass http://127.0.0.1:8848;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# 必须透传:登录失败限速按真实 IP 计数,否则所有请求都算到代理头上
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 导出 CSV 已带 X-Accel-Buffering: no,这里关掉代理缓冲才能边查边吐
proxy_buffering off;
proxy_read_timeout 300s; # 采集/导出可能跑几分钟
}
}
```
排错要点:
| 现象 | 原因 |
|---|---|
| 登录限速「误伤」所有人 | 没透传 `X-Forwarded-For` |
| 导出 CSV 要等很久才出第一个字节 | 没关 `proxy_buffering` |
| 手动采集走到 504 | `proxy_read_timeout` 太短 |
---
## 五、把镜像推到 Gitea 注册表
Gitea 自带容器注册表(`registry/2.0`)。目标是 `git.iwali.top/wangchuanli/workbuddy-portal`。
### 5.1 准备:本机允许 HTTP 注册表
Gitea 走的是 **HTTP**,Docker 默认只允许 HTTPS。Docker Desktop:**Settings → Docker Engine**,
在 `daemon.json` 里加:
```json
{
"insecure-registries": ["git.iwali.top"]
}
```
`Apply & Restart`。Linux 上同理改 `/etc/docker/daemon.json` 后 `sudo systemctl restart docker`。
> 这是**内网自托管服务**的常规做法。若 Gitea 前面有带证书的 Caddy/nginx,
> 用 `https://` 地址即可,不必开 insecure。
### 5.2 登录
```bash
docker login git.iwali.top -u wangchuanli
# 密码用 Personal Access Token(Gitea「设置 → 应用 → 生成令牌」,
# 勾选 write:package;不要用网页登录密码)
```
### 5.3 构建并推送
```bash
# 镜像名默认已经是注册表地址(见 docker-compose.yml 的 image: 字段)
docker compose build
# 打上语义化版本标签
docker tag git.iwali.top/wangchuanli/workbuddy-portal:latest \
git.iwali.top/wangchuanli/workbuddy-portal:1.1.0
docker push git.iwali.top/wangchuanli/workbuddy-portal:latest
docker push git.iwali.top/wangchuanli/workbuddy-portal:1.1.0
```
### 5.4 在另一台机器上拉取运行
```bash
docker pull git.iwali.top/wangchuanli/workbuddy-portal:1.1.0
# 不 clone 仓库也能跑:只写一个 compose 文件
cat > docker-compose.yml <<'YAML'
services:
portal:
image: git.iwali.top/wangchuanli/workbuddy-portal:1.1.0
restart: unless-stopped
ports: ["8848:8848"]
environment:
TZ: Asia/Shanghai
volumes:
- ./data:/app/data
- ./logs:/app/logs
YAML
docker compose up -d
```
### 5.5 验证远端
```bash
docker manifest inspect git.iwali.top/wangchuanli/workbuddy-portal:1.1.0
# 或
curl -s -u wangchuanli:TOKEN \
http://git.iwali.top/api/v1/packages/wangchuanli?type=container
```
---
## 六、备份与恢复
### 6.1 备份什么
| 文件 | 重要性 | 说明 |
|---|---|---|
| `data/usage.sqlite` | ★★★ | **数据正本**,丢了要重新采集,且官网窗口外的数据永久丢失 |
| `data/usage.sqlite-wal` / `-shm` | ★★★ | WAL 模式下未 checkpoint 的数据在这里,**要一起拷** |
| `data/instance.json` | ★★ | 含 `secret_key`,丢了所有人都要重新登录(数据不受影响) |
| `data/exports/*.csv` | ★ | 导出快照,可再生 |
| `logs/` | ☆ | 排错用,可再生 |
`.env` 不在里面——它含密码,**单独用密码管理器保管**。
### 6.2 备份命令
```bash
# 方式 A:先 checkpoint 再拷(推荐,最干净)
docker compose exec portal python -c "
from workbuddy_portal import db
c = db.connect(); c.execute('PRAGMA wal_checkpoint(TRUNCATE)')
"
# Windows 宿主机
copy data\usage.sqlite D:\backup\usage-%DATE%.sqlite
# Linux 宿主机
cp data/usage.sqlite ~/backup/usage-$(date +%F).sqlite
# 方式 B:直接整体拷(含 -wal / -shm)
docker compose stop portal
tar czf backup-$(date +%F).tar.gz data/
docker compose start portal
# 方式 C:逻辑导出(跨版本最安全)
docker compose exec portal python manage.py export-csv /app/data/exports
```
建议方式 A 配合计划任务每天跑一次;每季度用方式 C 出一份逻辑快照。
### 6.3 恢复
```bash
docker compose down
cp ~/backup/usage-2026-09-14.sqlite data/usage.sqlite
rm -f data/usage.sqlite-wal data/usage.sqlite-shm # 关键:清掉旧 WAL
docker compose up -d
docker compose exec portal python manage.py stats # 核对条数
```
> **别把旧库和旧 WAL 混着用**。WAL 里记的是相对旧库的增量,配错会损坏数据。
---
## 七、升级与回滚
### Docker
```bash
git pull
docker compose build
docker compose up -d # 重建容器,数据在挂载卷里不受影响
docker compose exec portal python manage.py stats
```
回滚:把 `.env` 里的 `WB_IMAGE` 指回旧版本标签,然后
```bash
docker compose up -d --no-build
```
### 裸机
```bash
git pull
.venv/bin/pip install -r requirements.txt
sudo systemctl restart workbuddy-portal
```
### 升级前
1. **先备份**(见第六节)——`schema.sql` 用的是 `CREATE TABLE IF NOT EXISTS`,
加表加索引是安全的,但改列需要手工迁移,所以备份是唯一保险。
2. 看一眼 [CHANGELOG](CHANGELOG.md) 有没有破坏性变更。
---
## 八、日常巡检
| 频率 | 做什么 |
|---|---|
| 每天 | 打开「概览」看「采集健康」;确认今天有采集记录 |
| 每周 | 「日志管理」按 `warn` / `error` 筛一遍,看有没有 TLS 或解密类告警 |
| 每月 | 确认 Cookie 没过期(「配置管理」看提示);跑一次备份恢复演练 |
| 每季度 | `manage.py vacuum`;出一份全量 CSV 归档;检查磁盘占用 |
一键体检:
```bash
docker compose exec portal python manage.py status
docker compose exec portal python manage.py stats
```
---
## 九、排错
### 容器起来了但页面打不开
```bash
docker compose ps # 看 STATUS 是否 (healthy)
docker compose logs --tail=100
```
| 症状 | 排查 |
|---|---|
| `STATUS` 是 `Restarting` | 看日志里的 Python traceback;多半是 `data/` 权限或端口冲突 |
| `unhealthy` 但 `Up` | 健康检查打 `/login` 失败;`docker compose exec portal python /app/docker/healthcheck.py` 看具体报错 |
| 端口占用 | 改 `.env` 的 `WB_PORT`,如 `18848:8848` |
| 宿主机能访问、局域网不能 | `WB_BIND` 是不是被改成 `127.0.0.1` 了;防火墙有没有放行 |
### `exec format error` / `no such file or directory`(entrypoint)
`docker/entrypoint.sh` 被 CRLF 污染了。仓库里有 `.gitattributes` 强制 `*.sh` 为 LF;
若手工传过文件,执行:
```bash
python -c "p='docker/entrypoint.sh';d=open(p,'rb').read();open(p,'wb').write(d.replace(b'\r\n',b'\n'))"
```
### 数据库相关
| 报错 | 原因 / 处理 |
|---|---|
| `unable to open database file` | 目录属主不对:`sudo chown -R 1000:1000 ./data` |
| `database is locked` | 有另一个写进程(另一个容器?宿主机上的 CLI?);等它跑完 |
| `disk I/O error` | 挂载文件系统不支持 SQLite 的锁语义。改用本地盘或 Docker 命名卷 |
### 采集相关
| 报错 | 处理 |
|---|---|
| `cookie_expired` / `401` | 重新获取 Cookie 填进「配置管理」,然后按区间补采 |
| `TLS` / `SSLError` | 企业代理 / 自签证书场景,临时把 `ssl_verify` 设为 `0`;否则保持开启 |
| 返回 `409 busy` | 正常——已有采集在跑,等它结束 |
| 新增一直是 0 | 看「抓取」条数:>0 说明都是已存在的(正常);=0 说明云端该时段确实没数据 |
### 时区不对导致日期错位
```bash
docker compose exec portal date # 应该输出 CST / +0800
```
若不是,检查 `.env` 的 `TZ=Asia/Shanghai`,改完 `docker compose up -d` 重建容器
(`TZ` 是环境变量,`restart` 不生效)。
### 想临时关掉自动采集
「任务管理 → 取消勾选『启用调度』→ 保存」。或者
```bash
echo "WB_DISABLE_SCHEDULER=1" >> .env && docker compose up -d
```
---
## 十、配置项速查
调度与采集参数都在数据库里,**改完立即生效、不用重启**(页面「任务管理 / 配置管理」可改,
也可以直接改表):
| 键 | 默认 | 说明 |
|---|---|---|
| `schedule_enabled` | `1` | 调度总开关 |
| `schedule_times` | `09:00,17:00` | 每日时刻,逗号分隔,本地时区 |
| `catch_up` | `1` | 启动补跑开关 |
| `catch_up_grace_hours` | `12` | 补跑宽限期(小时) |
| `page_size` | `200` | 采集单页条数(20~1000) |
| `rewind_minutes` | `2` | 断点回退分钟数(0~120) |
| `drift_tolerance_minutes` | `5` | 云端时间漂移告警阈值(0~720) |
| `max_prompt` | `2048` | Prompt 入库截断长度,0 = 不截断 |
| `verify_days` | `0` | 采集后整日校验天数(0~90) |
| `timeout` | `30` | HTTP 超时秒数(5~300) |
| `ssl_verify` | `1` | 校验云端 HTTPS 证书 |
| `api_base` / `api_path` | 官方地址 | 接口地址(走镜像/代理时改) |
| `cookie` | 空 | 账号凭证(页面只回掩码) |
| `user_agent` | Chrome UA | 与 Cookie 同源更稳 |
**写错的值会在保存时被拒绝**并给出原因,不会污染配置。
环境变量(启动期,改了要重建容器):
| 变量 | 说明 |
|---|---|
| `TZ` | 时区,影响所有日期口径 |
| `WB_HOST` / `WB_PORT` | 容器内监听地址 / 端口 |
| `WB_DATA_DIR` / `WB_LOG_DIR` / `WB_DB` | 数据 / 日志 / 库文件路径覆盖 |
| `WB_DISABLE_SCHEDULER` | `1` = 不启动调度线程 |
| `WB_ADMIN_USER` / `WB_ADMIN_PASSWORD` | 首个管理员(仅库为空时生效) |
| `WB_IMPORT_CREDS` / `WB_IMPORT_XLSX` | 启动时自动导入 |
+285
查看文件
@@ -0,0 +1,285 @@
# 常见问题(FAQ)
按「现象」分类。每条都给出**原因**和**动作**,不用从头排查。
---
## 一、部署
### Q:`docker compose up` 报端口被占用
```
Error response from daemon: Ports are not available: exposing port TCP 0.0.0.0:8848
```
改 `.env` 里的 `WB_PORT`,比如 `WB_PORT=18848`,然后 `docker compose up -d`。
容器内始终监听 8848,只改宿主映射即可。
### Q:容器起来了但局域网访问不了
1. `.env` 的 `WB_BIND` 是不是被设成了 `127.0.0.1`(那只允许本机);
2. 服务器防火墙有没有放行该端口;
3. `docker compose ps` 的 `PORTS` 是不是 `0.0.0.0:8848->8848/tcp`。
### Q:容器 `unhealthy` 但 `Up`
```bash
docker compose exec portal python /app/docker/healthcheck.py
```
健康检查打的是 `/login`(唯一免登录页),拿到 200 才算健康。
若失败,看 `docker compose logs` 有没有 Python traceback。
### Q:`unable to open database file`
Linux 宿主机上宿主目录属主与容器内 uid 1000 不一致:
```bash
sudo chown -R 1000:1000 ./data ./logs
docker compose restart
```
### Q:`database is locked` / `disk I/O error`
| 报错 | 原因 |
|---|---|
| `database is locked` | 有另一个写者(另一个容器实例?宿主机上同时在跑 `manage.py collect`?)。等它结束——文件锁会串行化,但 SQLite 层面的写冲突仍会短暂报错 |
| `disk I/O error` | 挂载的文件系统不支持 SQLite 需要的锁语义(某些 NFS / 网络盘)。改用本地盘或 Docker 命名卷 |
### Q:`exec format error` 或 `no such file or directory`(关于 entrypoint.sh)
`docker/entrypoint.sh` 被 CRLF 污染了。仓库 `.gitattributes` 已强制 `*.sh` 为 LF;
若手工传过文件:
```bash
python -c "p='docker/entrypoint.sh';d=open(p,'rb').read();open(p,'wb').write(d.replace(b'\r\n',b'\n'))"
```
### Q:能不能跑多个副本做高可用
**不要。** 三个理由:SQLite 是单写者;调度线程在 Web 进程内;文件锁只在本机有效。
真要跑多副本,除第一份外全部设 `WB_DISABLE_SCHEDULER=1`,但写冲突依然存在。
这个服务的正确扩展方式是「升级到更强的单机」,不是横向加副本。
---
## 二、采集
### Q:采集报 `cookie_expired` / `unauthorized`
Cookie 过期。重新获取(见 [用户手册 3.2](USER-GUIDE.md#32-拿-cookie-的两种办法)),
填进「配置管理 → 凭证」,保存后按区间补采。
> Cookie 通常是浏览器会话级,**关掉浏览器可能就失效**。从已登录浏览器复制时勾选「保持登录」。
### Q:采集成功但「新增 0 条」
大概率正常。看那一次的 `抓取` 条数:
| 抓取 | 新增 | 判断 |
|---|---|---|
| > 0 | 0 | 云端返回的都是库里已有的(断点回退窗口重叠)——**正常** |
| 0 | 0 | 该时段云端确实没有记录——**正常** |
| > 0 | > 0 | 正常采集到新数据 |
### Q:返回 409 `busy`
已有采集在跑。文件锁 `data/collect.lock` 保证同时只有一个采集。
到「任务管理 → 运行历史」看它是否还在 `running`,等结束再操作。
### Q:TLS / SSLError
企业代理或自签证书场景。两种处理:
1. **推荐**:把企业根证书装进系统信任链;
2. **临时**:「配置管理」把 `ssl_verify` 设为 `0`。
> Cookie 就是账号凭证,关掉证书校验等于把它暴露在中间人面前。**只在受控内网临时用。**
### Q:日志里出现「云端时间比本地早」告警
云端记录的 `cloud_ts` 比本地 `ts` 早超过 `drift_tolerance_minutes`(默认 5 分钟)。
影响不大,但可能意味着:
- 服务端时钟不准 → 校时;
- 云端写入有延迟 → 适当调大 `rewind_minutes`(比如 5)。
### Q:想让采集更频繁 / 更稀疏
「任务管理 → 每日时刻」改成任意逗号分隔的时刻,如 `08:30,12:30,18:00,22:00`。
保存即生效,不用重启。时刻按**本地时区**解释。
---
## 三、数据与日期
### Q:日期差一天 / 跨日数据落到相邻日期
时区问题。所有日期按服务端本地时区计算。
```bash
docker compose exec portal date # 期望:CST / +0800
docker compose exec portal python -c "from workbuddy_portal import db; print(db.now_str())"
```
若不是,检查 `.env` 的 `TZ=Asia/Shanghai`,然后 **`docker compose up -d` 重建**
(`TZ` 是环境变量,`restart` 不生效)。
### Q:导出的 CSV 在 Excel 里乱码
本系统导出的文件带 **UTF-8 BOM**,双击不会乱码。若乱码,先确认你打开的是
从「导出 CSV」拿到的文件,而不是用记事本另存过的版本。
### Q:大屏的「单笔 TOP」为什么切了日期区间也不变
**设计如此。** `top` 是**全局**的:如果跟着窗口变,排名会随筛选跳动,反而看不出长期最贵的那几条。
想要窗口内的排行,用「数据明细」按积分降序 + 日期筛选。
### Q:大屏的 `daily`(日历/趋势)为什么不受区间影响
也是设计如此:`daily` 是全量(约 200 B/天),日历与日期轴需要完整日期序列。
真正跟随窗口的是 `dims` / `totals` / 明细表。
### Q:明细表提示「只显示最近 N 条」
大屏下发的明细有上限(`recordsCap = 20000`)。超过时只发**最新 N 条**并置
`recordsTruncated=true`,页面据此提示。完整数据请到「数据明细」页筛选或导出。
---
## 四、界面
### Q:页面能开但图表全白
1. `Ctrl+F5` 强刷清缓存;
2. 开浏览器控制台看有没有资源 404;
3. 「日志管理 → 应用日志」看有没有异常栈。
> 历史上有过 `/dashboard` 下相对路径把 `echarts.min.js` 解析成 `/vendor/...` 导致
> 整页全白的问题,已在 `tools/smoke.py` 里加了「页面所有 `src`/`href` 资源逐个断言 200」防回归。
### Q:某块样式突然失效 / 文字发虚
多半是类名撞了全局样式。本项目约定:**新组件用带前缀的独有类名**
(如 `.calcell .cbar`,而不是含糊的 `.bar`)。
`smoke.py` 里有「页面 class ∩ `app.css` 选择器」差集断言,跑一遍就能发现异常类名。
### Q:登录后跳转目标丢了
历史 bug,已修。现在 `next` 参数在 GET/POST 两条路径上都正确回填。
注意开放重定向防护会拒绝站外目标:`//evil.com`、`/\evil.com`、`https://evil.com`
一律回落到 `/`——这是**预期行为**。
---
## 五、账号与权限
### Q:忘记管理员密码
```bash
# 裸机
python manage.py passwd admin 新密码
# Docker
docker compose exec portal python manage.py passwd admin 新密码
```
不传新密码时会用默认的 `admin123`——**别这么干**。
### Q:怎么给同事开只读账号
「用户管理 → 新建账号」,**不要勾**「管理员」。
普通用户能看所有页面、能导出、能触发采集,但看不到「用户管理」且访问 `/users` 返回 403。
### Q:不小心把自己降级 / 删掉自己了
做不到。服务端有三条护栏:不能取消自己的管理员身份、不能删除自己、至少保留一个账号。
### Q:所有人被踢下线了
`SECRET_KEY` 变了。它存在 `data/instance.json`。这个文件丢了/被删了就会重新生成,
所有会话失效(**数据不受影响**)。恢复办法:从备份里找回 `instance.json`,或让大家重新登录。
---
## 六、运维
### Q:备份怎么做最稳
```bash
# 1) 先 checkpoint,把 WAL 落进主库
docker compose exec portal python -c "
from workbuddy_portal import db
db.connect().execute('PRAGMA wal_checkpoint(TRUNCATE)')"
# 2) 拷走
cp data/usage.sqlite ~/backup/usage-$(date +%F).sqlite
```
或者整体 `tar` 掉 `data/`(含 `-wal` / `-shm`)——**别把新库配旧 WAL 用**,那会损坏数据。
### Q:数据库文件越来越大
```bash
docker compose exec portal python manage.py vacuum
```
或在「配置管理 → 维护动作 → 整理数据库」点一下。
作用是 `wal_checkpoint(TRUNCATE)` + `VACUUM`,回收删除后的空闲页并压缩 WAL。
### Q:升级会不会丢数据
不会。数据在宿主机的 `data/`(绑定挂载),`docker compose up -d --build` 只重建容器。
但**升级前依然要备份**:`schema.sql` 用 `CREATE TABLE IF NOT EXISTS`,
加表加索引安全,**改列需要手工迁移**。
### Q:日志在哪、怎么滚动
| 位置 | 内容 |
|---|---|
| `logs/app.log`(挂载到宿主机) | 应用日志,滚动 2 MB × 3 |
| `docker compose logs` | 容器 stdout(entrypoint + waitress) |
| 页面「日志管理」 | 采集逐行日志 + 应用日志尾部 + 操作审计 |
### Q:想改采集的接口地址(走镜像/代理)
「配置管理」里改 `api_base` 与 `api_path`。改了之后记得同步确认 Cookie 是该域下的有效凭证。
---
## 七、开发
### Q:改完代码怎么验证
**五层,前两层必须跑绿**:
```bash
python tools/smoke.py # 离线回归 99 项
python manage.py serve --port 8849 --no-scheduler # 另开终端
python tools/check_live.py --base http://127.0.0.1:8849 # 真实 HTTP 56 项
python tools/shots.py --base http://127.0.0.1:8849 --full # 界面截图 + JS 报错
```
详见 [架构说明 · 验证体系](ARCHITECTURE.md#十验证体系)。
### Q:`ModuleNotFoundError: No module named 'flask'`
选错解释器了。依赖装在项目的 venv 或托管环境里:
```bash
python -c "import flask, sys; print(sys.executable, flask.__version__)"
```
报这个错说明当前 `python` 不是装了依赖的那个。
### Q:改模板后页面没变
本项目 `TEMPLATES_AUTO_RELOAD=True`,模板改动通常立即生效。
若是静态资源(CSS/JS)被浏览器缓存,`Ctrl+F5`。
### Q:写了个自定义接口结果 500,但日志只看到异常栈
先看是不是**流式响应**(`Response(gen())` / `stream_with_context`):
Flask 在返回 `app_iter` 之后就关掉了请求上下文里的连接,
生成器里若复用 `db.get_db()` 会报 `Cannot operate on a closed database`。
正确做法是在生成器内部 `db.connect()` 自建连接并 `finally` 关闭。
详见 [架构说明 · 已知坑](ARCHITECTURE.md#九已知坑与红线)。
+413
查看文件
@@ -0,0 +1,413 @@
# WorkBuddy Portal 用户使用手册
> 面向**使用者**(不是开发者)。读完这份就能独立完成日常操作:
> 登录 → 看用量 → 配置采集 → 查明细 → 导数据 → 处理常见异常。
**目录**
- [一、这个系统是做什么的](#一这个系统是做什么的)
- [二、登录与账号](#二登录与账号)
- [三、获取并填写 Cookie](#三获取并填写-cookie)
- [四、概览页:一眼看清家底](#四概览页一眼看清家底)
- [五、用量大屏:交互式分析](#五用量大屏交互式分析)
- [六、数据明细页:查、筛、导](#六数据明细页查筛导)
- [七、任务管理页:定时与补采](#七任务管理页定时与补采)
- [八、配置管理页:参数与维护](#八配置管理页参数与维护)
- [九、日志管理页:出问题先看这里](#九日志管理页出问题先看这里)
- [十、用户管理页(仅管理员)](#十用户管理页仅管理员)
- [十一、常见任务速查](#十一常见任务速查)
- [十二、常见问题](#十二常见问题)
---
## 一、这个系统是做什么的
它把 WorkBuddy 账号的**积分用量明细**自动采集下来,存成一份**永久全量存档**,并提供查询与可视化。
为什么不直接看官网?官网只给一段时间窗口的明细,过期就查不到了;导出的 xlsx 还会丢掉
约 22% 的 `User Prompt` 内容。本系统把数据落到自己的库里,**只增不减**,随时能翻旧账。
一次典型的日常是:
```
每天 09:00 / 17:00 系统自动采集(你什么都不用做)
↓
你想看看进度 → 打开「概览」看今天用了多少
想深挖 → 打开「用量大屏」按模型/客户端/时段切
要找某条记录 → 「数据明细」搜索 + 展开 Prompt
要拿给别人 → 「数据明细」→ 导出 CSV
```
---
## 二、登录与账号
打开 `http://<部署机器IP>:8848`,会看到登录页。
![登录页](images/00-login.png)
| 项目 | 说明 |
|---|---|
| 默认账号 | `admin` / `admin123`(**只有数据库里一个账号都没有时**才会创建) |
| 登录保持 | 12 小时 |
| 失败限制 | 同一 IP 连续错 5 次,锁定 10 分钟 |
| 退出 | 右上角「退出」(走 POST,防被恶意链接静默触发) |
> ⚠️ **首次部署请立刻改密码**:系统是给局域网访问的,默认密码等于没锁门。
> 改法:「配置管理 → 修改密码」,或命令行 `python manage.py passwd admin 新密码`。
### 权限差别
| 能力 | 管理员 | 普通用户 |
|---|---|---|
| 看概览 / 大屏 / 明细 / 任务 / 配置 / 日志 | ✅ | ✅ |
| 手动触发采集、补采、改配置 | ✅ | ✅ |
| 导出 CSV | ✅ | ✅ |
| **用户管理**(建号 / 改权限 / 删号) | ✅ | ❌(导航里不显示,直接访问返回 403) |
> 给只读同事发普通账号即可,没必要共用管理员。
---
## 三、获取并填写 Cookie
**没有 Cookie,采集一定失败。** 这是首次部署唯一的必要手工步骤。
### 3.1 为什么要 Cookie
采集是直接调账号的用量接口,云端用 Cookie 认人。Cookie 是账号凭证,所以它:
- 存在数据库里,页面上**只回显掩码**(如 `a1b2…f9`);
- 不会被任何接口以明文返回。
### 3.2 拿 Cookie 的两种办法
**办法 A:让程序自己从编辑器设置里读(最省事)**
如果你平时用 VSCode / Cursor / Trae 登录过 WorkBuddy,Cookie 已经在本机设置里:
```bash
python manage.py import-creds
```
它会去读编辑器 `settings.json` 里的 `codebuddyUsage.*` 字段,写进数据库。
Docker 部署时对应 `WB_IMPORT_CREDS=1`(需要把设置文件挂进容器)。
**办法 B:手工复制(一定可行)**
1. 浏览器打开并登录 WorkBuddy 官网;
2. 按 `F12` 打开开发者工具 → 切到 **Network(网络)** 标签;
3. 刷新页面,随便点一个发往 `workbuddy.cn` 的请求;
4. 在 **Request Headers(请求标头)** 里找到 `Cookie:` 一行;
5. **整行值**复制下来(很长,通常几千字符,要复制完整);
6. 到本系统「配置管理 → 凭证 → Cookie」,粘贴,保存。
> 顺手把 User-Agent 也填成同一个浏览器的 UA,成功率高一些。
### 3.3 验证 Cookie 是否有效
保存后到「任务管理 → 立即采集一次」,然后看「日志管理」最新一条:
| 日志里看到 | 含义 | 怎么办 |
|---|---|---|
| `新增 N 条` 或 `无新增(已是最新)` | ✅ 正常 | — |
| `cookie_expired` / `401` / `403` | Cookie 过期了 | 重新执行 3.2 |
| `TLS` / `SSLError` | 证书校验失败 | 见「十二、常见问题」 |
---
## 四、概览页:一眼看清家底
![概览页](images/01-overview.png)
从上到下四块:
**1. KPI 卡片(6 个)**
| 卡片 | 含义 |
|---|---|
| 存档总量 | 库里一共多少条记录(只增不减) |
| 累计积分 | 全部记录的积分合计 |
| 活跃天数 | 有记录的自然日数量 |
| 今日积分 | 今天(本地时区)已消耗 |
| 今日 vs 昨日 | 今日与**昨日整日**对比,带涨跌幅 |
| 采集健康 | 最近若干次采集的成功 / 失败情况 |
**2. 今日 vs 昨日整日**
注意「昨日」是**完整一天**,而「今日」还在进行中——上午看数字偏低是正常的,
该跟昨天的**同一时段**比才有意义(大屏页能做这个对比)。
**3. 调度状态**
显示调度开关、下次执行时刻、上次采集结果。这里显示「已停用」时采集不会自动跑,
到「任务管理」把调度打开。
**4. 模型 TOP + 最近采集**
按模型看积分消耗排行;下方是最近几次采集的触发方式(手动 / 调度 / 启动补跑)、
耗时、抓取条数、新增条数、去重条数。
---
## 五、用量大屏:交互式分析
左侧导航点「**用量大屏**」,或直接访问 `/dashboard`。
![大屏首页](images/07-dashboard.png)
大屏是独立的交互页,顶部可切**时间区间**(今日 / 近 7 天 / 近 30 天 / 自定义),
所有图表联动重绘。主要图表:
| 图 | 看什么 |
|---|---|
| 日历热力图 | 哪几天用得猛(颜色越深越多)。**注意:格子颜色是「该日合计」**,不是单条 |
| 趋势折线 | 按天的积分走势,判断是否在加速 |
| 维度分布 | 按**模型**或**客户端**拆分的占比 |
| 时段分布 | 24 小时里集中在哪些时段(配合判断是否有脚本在跑) |
| 单笔 TOP | 最贵的单次请求,含 Prompt 摘要——最值得优化成本的地方 |
![大屏交互](images/08-dashboard-interact.png)
> 页面左上角有「← 返回后台」等入口,随时能回管理后台。
> 大屏的数据是**按当前筛选窗口实时取**的,不是预生成的静态图——切区间会重新请求。
**怎么用它省钱**:先看「单笔 TOP」抓出最贵的请求类型,再看「时段分布」判断是不是
某个自动化任务在固定时间跑,最后用「数据明细」把那一批记录导出来逐条分析。
---
## 六、数据明细页:查、筛、导
![数据明细页](images/02-records.png)
### 6.1 筛选条件
| 条件 | 说明 |
|---|---|
| 快捷区间 | 「今日 / 近 7 天 / 近 30 天 / 全部」一键填日期 |
| 日期 | `起` / `止`,留空表示不限 |
| 模型 | 下拉,来自库里实际出现过的模型 |
| 客户端 | 下拉,来源客户端标识 |
| 关键词 | 在 `Prompt` 正文里模糊匹配 |
| 每页条数 | 20 ~ 500 |
| 排序 | 时间倒序 / 正序、积分从高到低等 |
> 日期写错格式(如 `abc`、`2026-13-99`)不会白屏,系统会忽略非法值并提示。
### 6.2 看单条的完整 Prompt
列表默认**不显示 Prompt 全文**(它占数据体积约 80%)。点行首的「展开」看该条的完整 Prompt。
想批量看就导出 CSV。
### 6.3 导出 CSV
点「导出 CSV」,会把**当前筛选条件下的全部记录**(不是当前页)流式导出,
文件名形如 `usage_2026-09-01_2026-09-14.csv`。
- 编码为 **UTF-8 带 BOM**,Excel 双击直接打开不乱码;
- 列与官网导出的 xlsx **完全同构**:`requestId, credits, prompt, model, client, requestTime`;
- 数据量大时也是边查边吐,不会把服务器内存吃满。
---
## 七、任务管理页:定时与补采
![任务管理页](images/03-tasks.png)
### 7.1 调度设置
| 项 | 说明 |
|---|---|
| 启用调度 | 总开关。关掉后只有手动采集会跑 |
| 每日时刻 | 逗号分隔的本地时刻,如 `09:00,17:00`。**保存即生效,不用重启** |
| 启动补跑 | 打开后,程序启动时会把今天已错过、且还在宽限期内的时刻补采一次 |
| 补跑宽限期 | 超过多少小时就不补了(默认 12 小时) |
> 调度线程在 Web 进程内,所以「关掉 Web」等于「关掉调度」。
> 如果偶尔忘了开机,靠「启动补跑」把错过的时刻补回来。
### 7.2 手动采集
- **立即采集一次**:按断点续采,最常用的按钮。
- **按区间补采**:填 `起` / `止`,把这几天重新扫一遍。
用途:换了 Cookie 之后回补漏掉的日期;或怀疑某天数据不全时重扫。
重扫**不会产生重复**——主键去重,已存在的记录按「更早的本地时间」保留。
> **同一时刻只能有一个采集在跑**。重复点击会返回「忙碌」提示,这是设计如此
> (SQLite 是单写者,并发只会互相拖慢)。等它跑完再点。
### 7.3 运行历史
每次采集都留一条记录:触发方式、状态、耗时、抓取/新增/去重条数、退出码。
点「详情」看这一次的**逐行日志原文**,包括 `[warn]` 和 `[error]`。
---
## 八、配置管理页:参数与维护
![配置管理页](images/04-config.png)
### 8.1 凭证
| 字段 | 说明 |
|---|---|
| Cookie | 采集用的账号凭证。**只回显掩码**;留空保存 = 不修改(不会被清空) |
| User-Agent | 与拿 Cookie 的浏览器保持一致更稳 |
### 8.2 采集参数
| 参数 | 默认 | 范围 | 说明 |
|---|---|---|---|
| `api_base` | `https://www.workbuddy.cn` | — | 接口基址(镜像 / 代理时改) |
| `api_path` | `/billing/meter/get-user-request-usage` | — | 接口路径 |
| `page_size` | 200 | 20 ~ 1000 | 单页条数。调大能减少请求次数,但单次更慢 |
| `rewind_minutes` | 2 | 0 ~ 120 | 断点回退分钟数。避免云端写入延迟导致漏数据 |
| `drift_tolerance_minutes` | 5 | 0 ~ 720 | 云端时间比本地早超过该值才告警 |
| `max_prompt` | 2048 | 0 ~ 20000 | `Prompt` 入库截断长度,`0` = 不截断 |
| `verify_days` | 0 | 0 ~ 90 | 每次采集后做整日完整性校验的天数,`0` = 关 |
| `timeout` | 30 | 5 ~ 300 | 单次 HTTP 超时(秒) |
| `ssl_verify` | 1(开) | — | 校验云端 HTTPS 证书。**只在自签/企业代理场景才关** |
> **写错的值会被当场拒绝**并提示原因,不会污染配置(历史版本会因为一个手滑的数字
> 让采集整个跑不起来)。范围外的数、非数字都会在保存时被拦下。
### 8.3 维护动作
| 按钮 | 作用 | 何时用 |
|---|---|---|
| 补全 Prompt | 把缺失的 `Prompt` 从云端回补 | 从官网 xlsx 导入过数据后(xlsx 丢约 22%) |
| 导出全量 CSV | 全量导出到 `data/exports/` | 归档 / 交接 |
| 整理数据库 | `wal_checkpoint` + `VACUUM` | 删过数据后回收空间,或 WAL 文件偏大时 |
这些动作**耗时且会占用写权限**,所以有二次确认。执行期间不要重复点击。
### 8.4 修改密码
填「当前密码 / 新密码 / 确认新密码」。改完当前会话仍然有效,其他会话需要重新登录。
---
## 九、日志管理页:出问题先看这里
![日志管理页](images/05-logs.png)
三个区块:
**1. 采集运行历史**(可翻页 + 按状态筛 `ok` / `warn` / `error` / `running`)
每行可展开看**逐行日志原文**——排错时最有用的一块。
**2. 应用日志尾部**
Web 进程自身的日志(启动、异常栈、调度动作)。默认展示尾部若干行。
**3. 操作审计**
谁在什么时候做了什么:登录、登录失败、改配置、触发采集、导出、建号删号……
可按**动作**筛选,支持翻页。
> 排错顺序建议:操作审计(有没有人动过) → 采集历史(采集本身成不成功) → 应用日志(程序有没有异常)。
---
## 十、用户管理页(仅管理员)
![用户管理页](images/06-users.png)
| 操作 | 说明 |
|---|---|
| 新建账号 | 填用户名 / 显示名 / 密码,可勾选管理员 |
| 改显示名 | 行内直接改,保存即生效 |
| 改权限 | 管理员 ↔ 普通用户 |
| 改密码 | 给忘了密码的同事重置 |
| 删除 | 删除账号 |
内置三条护栏(前端和后端都拦):
1. **不能取消自己的管理员身份**(防止把自己锁在门外);
2. **不能删除自己**;
3. **至少要保留一个账号**(防止系统变成没人能登录)。
---
## 十一、常见任务速查
| 我想… | 怎么做 |
|---|---|
| 立刻采集一次 | 任务管理 → 立即采集一次 |
| 回补某几天的数据 | 任务管理 → 按区间补采,填起止日期 |
| 换 Cookie | 配置管理 → 凭证 → 粘贴新 Cookie → 保存 → 回补最近几天 |
| 导出某段时间的数据给别人 | 数据明细 → 选日期 → 导出 CSV |
| 导出全量存档 | 配置管理 → 维护动作 → 导出全量 CSV |
| 找出最贵的请求 | 用量大屏 → 单笔 TOP |
| 看某条请求的完整 Prompt | 数据明细 → 该行「展开」 |
| 给同事开只读账号 | 用户管理 → 新建账号,**不勾**管理员 |
| 同事忘记密码 | 用户管理 → 该行「改密码」 |
| 把数据备份走 | 拷 `data/usage.sqlite`(连同 `-wal`/`-shm`),或导出全量 CSV |
| 关掉自动采集 | 任务管理 → 关「启用调度」 |
| 改采集时刻 | 任务管理 → 每日时刻,如 `08:30,12:30,18:00` → 保存 |
| 系统变慢了 | 配置管理 → 整理数据库;再不行看「十二」 |
---
## 十二、常见问题
### 采集报 `cookie_expired` / `unauthorized`
Cookie 过期。重新按 [3.2](#32-拿-cookie-的两种办法) 拿一份新 Cookie 填进去。
Cookie 有效期通常是浏览器会话级别,**关掉浏览器可能就失效了**——建议用
「办法 B」从已登录的浏览器里复制时,勾选「保持登录」。
### 采集成功但「新增 0 条」
大概率是**正常的**:断点续采意味着没有新请求时确实没有新增。
看「日志管理」里那一次的 `抓取` 条数:
- `抓取 > 0,新增 = 0` → 云端返回的都是库里已存在的,正常;
- `抓取 = 0` → 该时段云端确实没有记录。
### 日期看起来差一天
所有日期都按**部署机器的本地时区**(容器里由 `TZ` 决定,默认 `Asia/Shanghai`)计算。
如果服务器时区不是东八区,跨日的数据会落到相邻日期上。
Docker 部署请确认 `TZ=Asia/Shanghai`;裸机部署确认系统时区。
### 导出的 CSV 在 Excel 里中文乱码
不会——导出已经带 UTF-8 BOM。如果乱码,先确认你打开的是本系统导出的文件,
而不是手工用记事本另存过的版本。
### 提示「采集正在进行中」
同一时刻只允许一个采集(SQLite 单写者)。等当前这次跑完再操作,
在「任务管理 → 运行历史」里能看到它是否还在 `running`。
### 页面能打开但图表空白
1. 强制刷新(`Ctrl+F5`)清掉旧缓存;
2. 检查浏览器控制台有没有资源 404;
3. 到「日志管理 → 应用日志」看有没有异常栈。
### 关掉浏览器后调度还在跑吗
在的。调度在**服务端进程**里,和浏览器无关。要停就去「任务管理」关调度开关,
或停掉服务。
### 忘记管理员密码
在部署机器上执行:
```bash
python manage.py passwd admin 新密码 # 裸机
docker compose exec portal python manage.py passwd admin 新密码 # Docker
```
### 数据会丢吗
正本是宿主机上的 `data/usage.sqlite`。`docker compose down` **不会删数据**;
只有显式 `docker compose down -v` 或手动删目录才会。
定期拷走这个文件(连同 `-wal` / `-shm`)就是完整备份。
### 能不能同时开多个采集进程
不能,也没必要。SQLite 单写者 + 文件锁的设计就是为了避免并发写。
真要跑多副本,除第一份外都要设 `WB_DISABLE_SCHEDULER=1`,
且只有一份能安全写——所以**不要**横向扩展这个服务。
---
更多技术细节见 [架构与设计说明](ARCHITECTURE.md)、[部署与运维指南](DEPLOYMENT.md)、
[接口参考](API.md)。
二进制
查看文件
二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 93 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 170 KiB

二进制
查看文件
二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 616 KiB

二进制
查看文件
二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 164 KiB

二进制
查看文件
二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 140 KiB

二进制
查看文件
二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 218 KiB

二进制
查看文件
二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 98 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 397 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 397 KiB

查看文件
+317
查看文件
@@ -0,0 +1,317 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""WorkBuddy Portal —— 统一命令行入口。
采集 / 存储 / 呈现三件事都由本项目承担,不再依赖外部计划任务或自动化。
常用:
python manage.py init 初始化数据库(建表 + 默认配置 + 管理员)
python manage.py serve 启动 Web(0.0.0.0:8848,进程内含调度线程)
python manage.py serve --port 9000 --debug 开发模式(reloader 下调度只启动一份)
python manage.py collect 执行一次增量采集并退出(可用于外部计划任务)
python manage.py migrate-csv [文件] 从旧版 CSV 存档导入(默认自动探测路径)
python manage.py import-xlsx <文件> 从官网导出的 xlsx 合入
python manage.py fill-prompt 补全缺失的 User Prompt
python manage.py export-csv [路径] 导出与官网同构的 CSV
python manage.py stats 只看存档概况,不联网
python manage.py passwd <用户名> [新密码] 重置登录密码
python manage.py status 查看调度与最近采集状态
"""
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from workbuddy_portal import client, collect, config, db, query, scheduler # noqa: E402
def _p(*a):
print(*a)
def cmd_init(args):
db.init_db(admin_user=args.user, admin_password=args.password)
conn = db.connect()
try:
n = query.totals(conn)
_p("数据库已就绪:%s" % config.SQLITE_PATH)
_p(" 存档 %d 条 / %.2f 积分 / %d 个活跃日" % (n["records"], n["credits"], n["days"]))
_p(" 管理员:%s" % args.user)
finally:
conn.close()
def cmd_serve(args):
from workbuddy_portal import create_app
app = create_app(start_scheduler=not args.no_scheduler)
host = args.host or config.DEFAULT_HOST
port = args.port or config.DEFAULT_PORT
if args.debug:
app.run(host=host, port=port, debug=True)
return
try:
from waitress import serve
_p("生产模式(waitress)监听 http://%s:%d" % (host, port))
serve(app, host=host, port=port, threads=8, ident="workbuddy-portal")
except ImportError:
_p("[warn] 未安装 waitress,回退到 Flask 内置服务器(生产建议 pip install waitress)")
app.run(host=host, port=port, threaded=True)
def cmd_collect(args):
db.init_db(create_admin=False)
try:
r = collect.run_sync(trigger="cli")
except collect.Busy as e:
_p("[busy] %s" % e)
return 1
except collect.ApiError as e:
_p("[error] %s" % e)
return 3 if e.cookie_expired else 5
for line in r["lines"]:
_p(line)
return 0
def cmd_migrate_csv(args):
db.init_db(create_admin=False)
path = args.path
if not path:
for c in config.LEGACY_CSV_CANDIDATES:
if os.path.exists(c):
path = c
break
if not path:
_p("[error] 找不到旧存档 CSV,请显式指定路径")
return 2
conn = db.connect()
try:
collect.migrate_from_csv(conn, path, log=_p)
n = query.totals(conn)
_p("当前存档:%d 条 / %.2f 积分 / %s ~ %s" % (n["records"], n["credits"],
n["firstDay"], n["lastDay"]))
finally:
conn.close()
return 0
def cmd_import_xlsx(args):
db.init_db(create_admin=False)
conn = db.connect()
try:
collect.import_xlsx(conn, args.path, log=_p)
finally:
conn.close()
def cmd_fill_prompt(args):
db.init_db(create_admin=False)
conn = db.connect()
try:
collect.fill_prompt(conn, log=_p)
finally:
conn.close()
def cmd_export_csv(args):
db.init_db(create_admin=False)
conn = db.connect()
try:
path, n = collect.export_csv(conn, args.path)
_p("已导出 %d 条 -> %s" % (n, path))
finally:
conn.close()
def cmd_stats(args):
db.init_db(create_admin=False)
conn = db.connect()
try:
t = query.totals(conn)
if not t["records"]:
_p("存档为空,先跑 python manage.py migrate-csv 或 manage.py collect")
return
_p("存档:%d 条 / %.2f 积分 / %d 个活跃日(%s ~ %s)"
% (t["records"], t["credits"], t["days"], t["firstDay"], t["lastDay"]))
_p("计费调用 %d · 免费调用 %d · 模型 %d · 客户端 %d"
% (t["billableCalls"], t["freeCalls"], t["models"], t["clients"]))
_p("")
_p("%-24s %8s %12s %10s %8s" % ("模型", "调用", "积分", "单次均价", "免费占比"))
for m in query.dims(conn)["model"]:
_p("%-24s %8d %12.2f %10.2f %7.0f%%"
% (m["name"], m["calls"], m["credits"], m["avgPerCall"], m["freeRate"] * 100))
runs = conn.execute("SELECT id,trigger,status,started_at,added,dup,total,message"
" FROM collect_runs ORDER BY id DESC LIMIT 5").fetchall()
if runs:
_p("")
_p("最近采集:")
for r in runs:
_p(" #%d %s %s +%d/%d → %d %s"
% (r["id"], r["started_at"], r["status"], r["added"], r["dup"],
r["total"], r["message"] or ""))
finally:
conn.close()
def cmd_import_creds(args):
"""把 VSCode 设置里的 cookie / userAgent 接管进数据库(一次性迁移用)。"""
db.init_db(create_admin=False)
conn = db.connect()
try:
found = client.read_vscode_creds()
if not found:
_p("[error] 没找到 VSCode 系编辑器的 settings.json")
return 2
hit = None
for path, cookie, ua, note in found:
_p(" %-70s %s" % (path, note))
if cookie and hit is None:
hit = (cookie, ua)
if not hit:
_p("[error] 这些文件里都没有 codebuddyUsage.cookie,请到「配置管理」页手工粘贴")
return 2
cookie, ua = hit
db.set_setting(conn, "cookie", cookie)
if ua:
db.set_setting(conn, "user_agent", ua)
db.audit(conn, "import_creds", "cli", "从 VSCode 设置导入凭证(%d 字符)" % len(cookie), "127.0.0.1")
_p("已导入 Cookie(%d 字符)与 User-Agent(%s)" % (len(cookie), "有" if ua else "无"))
finally:
conn.close()
return 0
def cmd_passwd(args):
db.init_db(create_admin=False)
from workbuddy_portal.security import hash_password
conn = db.connect()
try:
row = conn.execute("SELECT id FROM users WHERE username=?", (args.user,)).fetchone()
pwd = args.password or "admin123"
if row:
conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(pwd), row["id"]))
_p("已重置 %s 的密码" % args.user)
else:
conn.execute("INSERT INTO users(username,password_hash,display_name,is_admin,created_at)"
" VALUES(?,?,?,1,?)", (args.user, hash_password(pwd), args.user, db.now_str()))
_p("已创建用户 %s" % args.user)
_p("新密码:%s" % pwd)
finally:
conn.close()
def cmd_status(args):
db.init_db(create_admin=False)
conn = db.connect()
try:
_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 "不存在"))
last = conn.execute("SELECT * FROM collect_runs ORDER BY id DESC LIMIT 1").fetchone()
if last:
_p("最近采集:#%d %s %s %s" % (last["id"], last["started_at"], last["status"],
last["message"] or ""))
else:
_p("最近采集:无")
_p("(注意:调度线程只在 manage.py serve 进程内运行)")
finally:
conn.close()
def cmd_vacuum(args):
"""整理数据库:checkpoint WAL + VACUUM 回收空间。"""
db.init_db(create_admin=False)
conn = db.connect()
try:
before = os.path.getsize(config.SQLITE_PATH) if os.path.exists(config.SQLITE_PATH) else 0
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
conn.execute("VACUUM")
conn.execute("PRAGMA optimize")
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("存档 %d 条记录" % collect.record_count(conn))
finally:
conn.close()
return 0
def _human(n):
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 main():
ap = argparse.ArgumentParser(description="WorkBuddy Portal(workbuddy-portal)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__)
sub = ap.add_subparsers(dest="cmd")
s = sub.add_parser("init", help="初始化数据库")
s.add_argument("--user", default="admin")
s.add_argument("--password", default=None)
s.set_defaults(func=cmd_init)
s = sub.add_parser("serve", help="启动 Web 服务")
s.add_argument("--host", default=None)
s.add_argument("--port", type=int, default=None)
s.add_argument("--debug", action="store_true")
s.add_argument("--no-scheduler", action="store_true", help="不启动进程内调度线程")
s.set_defaults(func=cmd_serve)
s = sub.add_parser("collect", help="执行一次增量采集")
s.set_defaults(func=cmd_collect)
s = sub.add_parser("migrate-csv", help="从旧版 CSV 导入")
s.add_argument("path", nargs="?")
s.set_defaults(func=cmd_migrate_csv)
s = sub.add_parser("import-xlsx", help="从官网 xlsx 导入")
s.add_argument("path")
s.set_defaults(func=cmd_import_xlsx)
s = sub.add_parser("fill-prompt", help="补全缺失的 User Prompt")
s.set_defaults(func=cmd_fill_prompt)
s = sub.add_parser("export-csv", help="导出 CSV")
s.add_argument("path", nargs="?")
s.set_defaults(func=cmd_export_csv)
s = sub.add_parser("stats", help="存档概况")
s.set_defaults(func=cmd_stats)
s = sub.add_parser("import-creds", help="从 VSCode 设置导入 cookie / UA 到数据库")
s.set_defaults(func=cmd_import_creds)
s = sub.add_parser("passwd", help="重置 / 创建登录账号")
s.add_argument("user")
s.add_argument("password", nargs="?")
s.set_defaults(func=cmd_passwd)
s = sub.add_parser("status", help="调度与最近采集状态")
s.set_defaults(func=cmd_status)
s = sub.add_parser("vacuum", help="整理数据库(checkpoint + VACUUM)")
s.set_defaults(func=cmd_vacuum)
args = ap.parse_args()
if not getattr(args, "func", None):
ap.print_help()
return 0
config.ensure_dirs()
return args.func(args) or 0
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
_p("\n已中断")
sys.exit(130)
+11
查看文件
@@ -0,0 +1,11 @@
# WorkBuddy Portal —— 运行依赖
# 说明:sqlite3 是 Python 标准库,无需安装。
Flask>=3.0 # Web 框架(Jinja 模板 + 蓝图)
waitress>=3.0 # Windows 上的生产级 WSGI(可选,缺失时回退 Flask 内置服务器)
openpyxl>=3.1 # 仅 --import-xlsx 需要
# 刻意不引入:
# APScheduler —— 调度只有「每天固定时刻」这一种需求,手写线程更少依赖、
# 更容易做「启动补跑」与「与 CLI 共享文件锁」
# requests —— 采集是纯 urllib(见 workbuddy_portal/client.py),少一个依赖
+297
查看文件
@@ -0,0 +1,297 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""端到端验收:对**运行中的**服务发真实 HTTP 请求,走完整登录/CSRF/API 链路。
与 tests 里用 Flask test_client 的冒烟测试互补——这里验证的是「真的起起来了、
真的能登录、真的能取到数」,适合部署到局域网后随手跑一遍。
用法:
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 -u admin -p 你的密码
python tools/check_live.py --from 2026-09-08 --to 2026-09-14
退出码:0 全通过;1 有失败项(会打印失败清单)。
注意:脚本会读取窗口数据但**不写库**(不触发采集、不改配置),可安全反复运行。
"""
from __future__ import annotations
import argparse
import http.cookiejar
import json
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime
OK = 0
FAIL = 0
FAILS: list[str] = []
def _d(s: str):
"""把 YYYY-MM-DD 解析成本地 datetime(不用 date.fromisoformat 之外的时区处理)。"""
return datetime.strptime(s, "%Y-%m-%d")
def chk(name: str, cond: bool, extra: str = "") -> None:
global OK, FAIL
if cond:
OK += 1
print(" [OK] %s %s" % (name, extra))
else:
FAIL += 1
FAILS.append(name)
print(" [FAIL] %s %s" % (name, extra))
class _NoRedirect(urllib.request.HTTPRedirectHandler):
"""不要自动跟随 302 —— 检查跳转目标本身是否安全时必须看到原始 Location。"""
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
class Live:
def __init__(self, base: str, timeout: int = 20):
self.base = base.rstrip("/")
self.timeout = timeout
# 关键:显式清空代理,否则本机代理会把 127.0.0.1 也拦成 502
self.cj = http.cookiejar.CookieJar()
self.op = urllib.request.build_opener(
urllib.request.ProxyHandler({}),
urllib.request.HTTPCookieProcessor(self.cj),
)
self.op.addheaders = [("User-Agent", "workbuddy-portal-check/1.1")]
# 不跟随跳转的 opener:共用同一个 cookie jar,保证是同一会话
self.op_nr = urllib.request.build_opener(
urllib.request.ProxyHandler({}),
urllib.request.HTTPCookieProcessor(self.cj),
_NoRedirect,
)
self.op_nr.addheaders = [("User-Agent", "workbuddy-portal-check/1.1")]
def get(self, path: str):
try:
r = self.op.open(urllib.request.Request(self.base + path), timeout=self.timeout)
return r.status, r.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", "replace")
def post(self, path: str, data: dict, csrf: str | None = None, as_json: bool = False):
if as_json:
body, ct = json.dumps(data).encode(), "application/json"
else:
body, ct = urllib.parse.urlencode(data).encode(), "application/x-www-form-urlencoded"
req = urllib.request.Request(self.base + path, data=body, method="POST")
req.add_header("Content-Type", ct)
if csrf:
req.add_header("X-CSRF-Token", csrf)
try:
r = self.op.open(req, timeout=self.timeout)
return r.status, r.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", "replace")
def post_raw(self, path: str, data: dict):
"""表单 POST 且**不跟随**跳转,返回 (status, Location)。"""
body = urllib.parse.urlencode(data).encode()
req = urllib.request.Request(self.base + path, data=body, method="POST")
req.add_header("Content-Type", "application/x-www-form-urlencoded")
try:
r = self.op_nr.open(req, timeout=self.timeout)
return r.status, r.headers.get("Location")
except urllib.error.HTTPError as e:
return e.code, e.headers.get("Location")
def jget(self, path: str) -> dict:
st, body = self.get(path)
return json.loads(body) if st == 200 else {}
def run(L: Live, user: str, pwd: str, frm: str, to: str) -> None:
print("== 1. 未登录访问受保护资源 ==")
st, body = L.get("/")
chk("GET / 未登录落登录页", st == 200 and "登录" in body, "status=%s" % st)
for p in ("/api/summary", "/api/bundle", "/api/manifest"):
st, _ = L.get(p)
chk("GET %-14s 未登录=401" % p, st == 401, "status=%s" % st)
print("== 2. 登录(含 CSRF) ==")
st, html = L.get("/login")
m = re.search(r'name="_csrf"\s+value="([^"]+)"', html)
chk("登录页含 CSRF 隐藏域", bool(m))
st, _ = L.post("/login", {"username": user, "password": pwd,
"_csrf": m.group(1) if m else ""})
chk("登录成功", st in (200, 302), "status=%s" % st)
st, html = L.get("/")
chk("登录后 GET / 到概览", st == 200 and "概览" in html, "len=%d" % len(html))
print("== 3. 后台页面均可达 ==")
for p, kw in [("/", "概览"), ("/tasks", "任务"), ("/config", "配置"),
("/logs", "日志"), ("/records", "记录")]:
st, html = L.get(p)
chk("GET %-10s" % p, st == 200 and kw in html, "status=%s len=%d" % (st, len(html)))
print("== 4. 登录后写操作仍需 CSRF ==")
st, _ = L.post("/api/collect", {}, csrf=None, as_json=True)
chk("POST /api/collect 缺 CSRF=400", st == 400, "status=%s" % st)
print("== 5. 结构与数值 ==")
mf = L.jget("/api/manifest")
chk("manifest 含 health/archive/totals/sources",
all(k in mf for k in ("health", "archive", "totals", "sources")))
src = (mf.get("sources") or [{}])[0]
chk("manifest 存档条数与数据源一致",
mf["totals"]["records"] == src.get("count"),
"records=%s src=%s" % (mf["totals"]["records"], src.get("count")))
sm = L.jget("/api/summary?from=%s&to=%s" % (frm, to))
want_days = (_d(to) - _d(frm)).days + 1
chk("summary 窗口天数正确", sm.get("window", {}).get("days") == want_days,
"window=%s 期望 %d 天" % (sm.get("window"), want_days))
chk("summary 窗口内有记录", (sm.get("records") or 0) > 0, "records=%s" % sm.get("records"))
chk("summary 环比 prev 存在", bool(sm.get("prev")),
"prev=%s~%s" % ((sm.get("prev") or {}).get("firstDay"), (sm.get("prev") or {}).get("lastDay")))
chk("summary avgPerCall 自洽",
not sm.get("calls") or abs(sm["avgPerCall"] - round(sm["credits"] / sm["calls"], 4)) < 1e-6)
bd = L.jget("/api/bundle?from=%s&to=%s" % (frm, to))
chk("bundle 顶层键齐全",
{"manifest", "daily", "dims", "top", "records", "totals", "window"} <= set(bd),
"keys=%s" % list(bd.keys()))
recs, daily = bd.get("records", []), bd.get("daily", [])
rsum = round(sum(float(x["c"]) for x in recs), 2)
tsum = round(float(bd.get("totals", {}).get("credits", 0)), 2)
print(" 窗口 %d 条,records 求和 %.2f ;totals.credits %.2f" % (len(recs), rsum, tsum))
chk("records 求和 == totals.credits", abs(rsum - tsum) < 0.005, "diff=%.4f" % (rsum - tsum))
chk("records 求和 == summary.credits",
abs(rsum - float(sm.get("credits", 0))) < 0.005,
"diff=%.4f" % (rsum - float(sm.get("credits", 0))))
chk("daily 为全量(多于窗口天数,供日历/日期轴)", len(daily) > want_days,
"daily=%d 天 > 窗口 %d 天" % (len(daily), want_days))
chk("daily 全量求和 == 存档总额",
abs(round(sum(float(x["c"]) for x in daily), 2)
- round(float(mf["totals"]["credits"]), 2)) < 0.005)
chk("逐日 h[24] 求和 == 当日 c",
all(abs(round(sum(d["h"]), 2) - round(d["c"], 2)) < 0.005 for d in daily))
chk("dims.hour 补齐 24 槽", len(bd.get("dims", {}).get("hour", [])) == 24)
chk("dims.model 非空", len(bd.get("dims", {}).get("model", [])) > 0)
top = bd.get("top", [])
chk("top 榜按积分降序",
all(top[i]["c"] >= top[i + 1]["c"] for i in range(len(top) - 1)), "n=%d" % len(top))
print("== 6. 明细分页/筛选/排序 ==")
rj = L.jget("/api/records?page=1&size=5")
chk("分页返回 5 条", len(rj.get("items", [])) == 5,
"total=%s pages=%s" % (rj.get("total"), rj.get("pages")))
chk("分页 total 与存档一致", rj.get("total") == mf["totals"]["records"])
chk("分页字段为可读全名", "request_id" in (rj.get("items") or [{}])[0])
chk("分页页码自洽",
rj.get("pages") == max(1, (rj.get("total", 0) + rj.get("size", 1) - 1) // rj.get("size", 1)))
r2 = L.jget("/api/records?page=2&size=5")
chk("第 2 页与第 1 页不重叠",
set(x["request_id"] for x in r2.get("items", [])).isdisjoint(
set(x["request_id"] for x in rj.get("items", []))))
models = bd.get("dims", {}).get("model", [])
if models:
mn = models[0]["name"]
rf = L.jget("/api/records?page=1&size=5&model=" + urllib.parse.quote(mn))
chk("按模型筛选生效", all(x["model"] == mn for x in rf.get("items", [])),
"model=%s total=%s" % (mn, rf.get("total")))
ro = L.jget("/api/records?page=1&size=10&order=credits_desc")
chk("按积分降序生效",
all(ro["items"][i]["credits"] >= ro["items"][i + 1]["credits"]
for i in range(len(ro.get("items", [])) - 1)))
print("== 7. 凭据不外泄 ==")
stj = L.jget("/api/settings")
chk("settings 无 cookie 明文字段", "cookie" not in stj, "keys=%s" % list(stj.keys()))
chk("settings 仅回 cookie_hint 掩码",
bool(stj.get("cookie_hint")) and len(str(stj.get("cookie_hint"))) < 200,
"hint=%s" % stj.get("cookie_hint"))
chk("配置页 HTML 不含 cookie 明文", "eyJ" not in L.get("/config")[1])
print("== 8. 错误处理 ==")
for p in ("/api/nope", "/nope"):
st, _ = L.get(p)
chk("GET %-12s =404" % p, st == 404, "status=%s" % st)
for p in ("/api/summary?from=abc&to=def", "/api/daily?from=2026-13-99"):
st, _ = L.get(p)
chk("GET %-32s 非法日期=400" % p, st == 400, "status=%s" % st)
print("== 9. 新增能力:用户管理 / 审计 / 流式导出 ==")
st, html = L.get("/users")
chk("GET /users 管理员可达", st == 200 and "用户管理" in html, "status=%s" % st)
chk("用户管理页不回传口令散列", "pbkdf2:" not in html)
au = L.jget("/api/audit?size=5")
chk("GET /api/audit 结构完整",
all(k in au for k in ("total", "page", "size", "pages", "actions", "items")),
"keys=%s" % list(au.keys()))
chk("审计条目带 actor/action/at",
not au.get("items") or {"actor", "action", "at"} <= set(au["items"][0]),
"n=%d" % len(au.get("items", [])))
st, csv_body = L.get("/records/export?from=%s&to=%s" % (frm, to))
chk("GET /records/export=200", st == 200, "status=%s" % st)
chk("导出带 UTF-8 BOM(Excel 不乱码)", csv_body.startswith("\ufeff"))
lines = [x for x in csv_body.lstrip("\ufeff").split("\r\n") if x]
chk("导出表头为官网同构列",
lines and lines[0] == "RequestID,积分消耗,User Prompt,模型,客户端,时间",
"header=%s" % (lines[0] if lines else None))
chk("导出行数 == 窗口记录数 + 表头", len(lines) == (sm.get("records") or 0) + 1,
"csv=%d 记录=%s" % (len(lines), sm.get("records")))
r1 = L.jget("/api/records?page=1&size=1&from=%s&to=%s" % (frm, to))
first_id = ((r1.get("items") or [{}])[0]).get("request_id")
chk("导出与明细同源同序(首行 == 明细首条)",
len(lines) > 1 and bool(first_id) and first_id in lines[1],
"api=%s csv=%s" % (first_id, (lines[1][:40] if len(lines) > 1 else None)))
print("== 10. 安全:开放重定向与凭证外泄 ==")
L2 = Live(L.base) # 全新会话,避免已登录被直跳
st, html = L2.get("/login")
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 被拒(不出现协议相对跳转)",
st == 302 and "evil.com" not in (loc or "") and not (loc or "").startswith("//"),
"status=%s Location=%s" % (st, loc))
L3 = Live(L.base)
st, html = L3.get("/login")
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",
"status=%s Location=%s" % (st, loc))
st, loc = L3.post_raw("/login", {"username": user, "password": pwd, "_csrf": "wrong"})
chk("错误 CSRF 的登录 POST=400", st == 400, "status=%s" % st)
st, body = L.get("/logout")
chk("GET /logout 不执行退出(仅提示)", st == 200 and "退出" in body, "status=%s" % st)
st, html = L.get("/")
chk("GET /logout 后仍处于登录态", st == 200 and "概览" in html, "status=%s" % st)
def main() -> int:
ap = argparse.ArgumentParser(description="对运行中的用量门户做端到端验收")
ap.add_argument("--base", default="http://127.0.0.1:8848", help="服务地址")
ap.add_argument("-u", "--user", default="admin", help="登录用户名")
ap.add_argument("-p", "--password", default="admin123", 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("--timeout", type=int, default=20)
a = ap.parse_args()
print("目标:%s 窗口:%s ~ %s\n" % (a.base, a.frm, a.to))
run(Live(a.base, a.timeout), a.user, a.password, a.frm, a.to)
print("\nRESULT: ok=%d fail=%d" % (OK, FAIL))
if FAILS:
print("失败项:%s" % "、".join(FAILS))
return 1 if FAIL else 0
if __name__ == "__main__":
sys.exit(main())
+152
查看文件
@@ -0,0 +1,152 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""界面实检:登录后逐页截图,用来目视确认「统一美化」是否真的落地。
用法:
python manage.py serve --port 8849 --no-scheduler # 另开一个终端
python tools/shots.py --base http://127.0.0.1:8849
python tools/shots.py --full # 整页长图(默认只截首屏)
产物:data/shots/*.png(已被 .gitignore 之外的目录,可直接删)。
为什么不用 headless chrome 直出:本项目的页面都要登录态,
`--screenshot` 无法注入会话 Cookie,所以必须用 Playwright 走一次真实登录。
"""
from __future__ import annotations
import argparse
import os
import sys
BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, BASE)
PAGES = [
("login", "/login", "登录页"),
("overview", "/", "概览"),
("records", "/records", "数据明细"),
("tasks", "/tasks", "任务管理"),
("config", "/config", "配置管理"),
("logs", "/logs", "日志管理"),
("users", "/users", "用户管理"),
("dashboard", "/dashboard", "用量大屏"),
]
def _find_browser() -> str | None:
"""找一个可用的 Chromium 可执行文件。
Playwright 的驱动版本与本地已下载的浏览器版本常常错位(例如驱动要
chromium_headless_shell-1234 而本机只有 -1228),此时 launch() 会直接报
「Executable doesn't exist」。这里按优先级探测,命中就显式传 executable_path。
"""
import glob
home = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
pats = [
os.path.join(home, "ms-playwright", "chromium-*", "chrome-win64", "chrome.exe"),
os.path.join(home, "ms-playwright", "chromium_headless_shell-*",
"chrome-headless-shell-win64", "chrome-headless-shell.exe"),
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
]
for p in pats:
hits = sorted(glob.glob(p))
if hits:
return hits[-1]
return None
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--base", default="http://127.0.0.1:8849")
ap.add_argument("-u", "--user", default="admin")
ap.add_argument("-p", "--password", default="admin123")
ap.add_argument("--out", default=os.path.join(BASE, "data", "shots"))
ap.add_argument("--full", action="store_true", help="截整页长图")
ap.add_argument("--browser", default="", help="显式指定 chrome/msedge 可执行文件")
ap.add_argument("--width", type=int, default=1440)
ap.add_argument("--height", type=int, default=900)
a = ap.parse_args()
from playwright.sync_api import sync_playwright
exe = a.browser or _find_browser()
os.makedirs(a.out, exist_ok=True)
problems = []
with sync_playwright() as pw:
try:
br = pw.chromium.launch(executable_path=exe) if exe else pw.chromium.launch()
except Exception as e: # noqa: BLE001
print("[FAIL] 启动浏览器失败:%s" % e)
print(" 可用 --browser 显式指定,或用 `playwright install chromium` 装齐。")
return 1
print("浏览器:%s" % (exe or "playwright 默认"))
ctx = br.new_context(viewport={"width": a.width, "height": a.height},
device_scale_factor=2, locale="zh-CN")
page = ctx.new_page()
errors = []
page.on("console", lambda m: errors.append(m.text) if m.type == "error" else None)
page.on("pageerror", lambda e: errors.append(str(e)))
# 1) 先截未登录的登录页
page.goto(a.base + "/login", wait_until="networkidle")
page.screenshot(path=os.path.join(a.out, "00-login.png"), full_page=a.full)
print("[ok] 00-login.png")
# 2) 登录
page.fill('input[name=username]', a.user)
page.fill('input[name=password]', a.password)
page.click('button[type=submit]')
page.wait_for_load_state("networkidle")
if "/login" in page.url:
print("[FAIL] 登录失败,后续截图无意义")
br.close()
return 1
# 3) 逐页截图
for i, (slug, path, label) in enumerate(PAGES[1:], start=1):
errors.clear()
page.goto(a.base + path, wait_until="networkidle")
page.wait_for_timeout(900) # 等 ECharts / 表格渲染稳下来
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) 大屏页再点几个交互,确认控件联动不炸
page.goto(a.base + "/dashboard", wait_until="networkidle")
page.wait_for_timeout(1200)
for sel in ["#segRange button", ".seg button"]:
btns = page.query_selector_all(sel)
if len(btns) > 1:
errors.clear()
btns[1].click()
page.wait_for_timeout(900)
page.screenshot(path=os.path.join(a.out, "08-dashboard-interact.png"),
full_page=a.full)
js_err = [e for e in errors if "favicon" not in e.lower()]
print("[ok] 08-dashboard-interact.png 点击 %s 第 2 项%s"
% (sel, "" if not js_err else " [JS错误] %s" % js_err[:2]))
if js_err:
problems.append("大屏交互: %s" % js_err[:2])
break
br.close()
print("\n截图目录:%s" % a.out)
if problems:
print("发现问题:")
for p in problems:
print(" - " + p)
return 1
print("RESULT: 全页面截图完成,无 JS 报错")
return 0
if __name__ == "__main__":
sys.exit(main())
+318
查看文件
@@ -0,0 +1,318 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""离线回归:用 Flask test_client 对**真实库**做全页面只读渲染 + 缺陷防回归断言。
与 tools/check_live.py 的分工:
* check_live.py 对运行中的服务发真实 HTTP,验「起没起来、登录/CSRF/API 通不通」
* smoke.py(本脚本)不发网络请求,直接把请求灌进 WSGI 应用,
因此能覆盖到「页面模板渲染是否正确」,且不需要先起服务、不需要密码。
覆盖内容:
1. 全页面渲染(含 /users,需管理员身份)——模板报错会直接暴露成 500
2. 模板未渲染残留(HTML 里出现 {{ / {% 说明有变量名写错)
3. 历史缺陷防回归(见下 REGRESSIONS)
4. CSV 导出可被标准 csv 解析、列数一致
5. 页面 HTML 里的 class 与 app.css 的选择器做差集(抓类名拼写错误)
用法:
cd workbuddy-portal
python tools/smoke.py
退出码:0 全通过;1 有失败项。
"""
from __future__ import annotations
import csv
import io
import json
import os
import re
import sys
BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, BASE)
OK = 0
FAIL = 0
FAILS: list[str] = []
NOTES: list[str] = []
def chk(name: str, cond: bool, extra: str = "") -> bool:
global OK, FAIL
if cond:
OK += 1
print(" [OK] %s %s" % (name, extra))
else:
FAIL += 1
FAILS.append(name)
print(" [FAIL] %s %s" % (name, extra))
return bool(cond)
def note(msg: str) -> None:
NOTES.append(msg)
print(" [note] %s" % msg)
def login(cli, admin=True):
"""注入会话绕过登录:GET 不触发 CSRF,因此可直接测页面渲染。"""
with cli.session_transaction() as s:
s["uid"] = 1
s["uname"] = "admin" if admin else "viewer"
s["dname"] = "管理员" if admin else "只读账号"
s["adm"] = 1 if admin else 0
s["_csrf"] = "smoke-csrf-token"
def page(cli, path, method="GET", **kw):
r = getattr(cli, method.lower())(path, **kw)
return r.status_code, r.get_data(as_text=True)
def run() -> None:
from workbuddy_portal import create_app, db, query
print("== 0. 构建应用 ==")
app = create_app(start_scheduler=False, do_init_db=False)
app.config["WTF_CSRF_ENABLED"] = False
n_routes = len([r for r in app.url_map.iter_rules()])
chk("create_app 成功", app is not None)
chk("路由数量 >= 35", n_routes >= 35, "routes=%d" % n_routes)
# ---------------- 1. 未登录 ----------------
print("== 1. 未登录:受保护页应跳登录、API 应 401 ==")
with app.test_client() as cli:
for p in ("/", "/records", "/tasks", "/config", "/logs", "/users"):
st, _ = page(cli, p)
chk("GET %-10s 未登录=302" % p, st == 302, "status=%s" % st)
for p in ("/api/summary", "/api/users", "/api/settings", "/api/audit"):
st, _ = page(cli, p)
chk("GET %-14s 未登录=401" % p, st == 401, "status=%s" % st)
st, html = page(cli, "/login")
chk("登录页含 CSRF 隐藏域", 'name="_csrf"' in html)
# ---------------- 2. 管理员:全页面渲染 ----------------
print("== 2. 管理员:全页面渲染 ==")
with app.test_client() as cli:
login(cli, admin=True)
pages = [
("/", "概览"), ("/records", "数据明细"), ("/tasks", "任务管理"),
("/config", "配置管理"), ("/logs", "日志管理"), ("/users", "用户管理"),
("/dashboard", "<html"),
]
for p, kw in pages:
st, html = page(cli, p)
ok = chk("GET %-10s 200" % p, st == 200, "status=%s len=%d" % (st, len(html)))
if ok:
chk(" └ 含关键字 %s" % kw, kw in html)
chk(" └ 无模板残留 {{ / {%%", "{{" not in html and "{%" not in html)
chk(" └ 含导航栏", "topbar" in html or p == "/dashboard")
st, html = page(cli, "/users")
chk("用户管理页列出账号", 'data-uid=' in html, "含行内编辑按钮")
chk("用户管理页含新建表单", 'id="formNewUser"' in html)
chk("用户管理页含审计表", "用户操作审计" in html)
# 配置页的维护按钮 + 大屏回后台入口
st, cfg = page(cli, "/config")
chk("配置页含维护按钮组", cfg.count("data-maint=") >= 3, "n=%d" % cfg.count("data-maint="))
chk("配置页含 TLS 校验下拉", 'name="ssl_verify"' in cfg)
st, rec = page(cli, "/records")
chk("明细页含快捷区间", 'data-range="today"' in rec and 'data-range="30d"' in rec)
chk("明细页表格包在 .tablewrap", "tablewrap" in rec)
# ---------------- 2b. 静态资源引用可解析 ----------------
print("== 2b. 页面引用的静态资源全部可达 ==")
asset_re = re.compile(r"\.(?:js|css|svg|png|jpe?g|gif|webp|ico|woff2?)(?:\?|$)", re.I)
with app.test_client() as cli:
login(cli, admin=True)
for p in ("/", "/records", "/tasks", "/config", "/logs", "/users", "/dashboard"):
_, html = page(cli, p)
# 先剥掉 HTML 注释:注释里常写示例路径(src="vendor/x.js"),
# 不剥会把示例当真实引用误报。
html = re.sub(r"<!--.*?-->", "", html, flags=re.S)
refs = set(re.findall(r'src="([^"]+)"', html))
refs |= {h for h in re.findall(r'href="([^"]+)"', html) if asset_re.search(h)}
bad, n = [], 0
for r in sorted(refs):
if r.startswith(("data:", "http:", "https:", "//", "#")):
continue
target = r
if not r.startswith("/"): # 相对路径按该页 URL 解析(曾因此 404)
target = (p if p.endswith("/") else p.rsplit("/", 1)[0] + "/") + r
n += 1
ast, _ = page(cli, target)
if ast != 200:
bad.append("%s -> %s(%s)" % (r, target, ast))
chk("%-11s 资源引用全部 200" % p, not bad,
("坏引用=%s" % bad) if bad else "%d 个引用" % n)
# ---------------- 3. 非管理员:权限边界 ----------------
print("== 3. 非管理员:/users 必须 403,导航不出现该入口 ==")
with app.test_client() as cli:
login(cli, admin=False)
st, html = page(cli, "/users")
chk("GET /users 非管理员=403", st == 403, "status=%s" % st)
st, _ = page(cli, "/api/users")
chk("GET /api/users 非管理员=403", st == 403, "status=%s" % st)
st, _ = page(cli, "/api/users")
st, html = page(cli, "/")
chk("概览导航不含「用户管理」", "用户管理" not in html)
for p in ("/", "/records", "/tasks", "/logs"):
st, _ = page(cli, p)
chk("GET %-10s 非管理员=200" % p, st == 200, "status=%s" % st)
# ---------------- 4. 历史缺陷防回归 ----------------
print("== 4. 历史缺陷防回归 ==")
with app.test_client() as cli:
login(cli, admin=True)
# ① 非法日期曾 500
st, body = page(cli, "/api/summary?from=abc&to=def")
chk("① /api/summary 非法日期=400", st == 400, "status=%s" % st)
chk(" └ 返回 JSON 错误体", '"ok": false' in body.replace('":', '": '))
# ② /tasks 非法页码曾 500
st, _ = page(cli, "/tasks?page=abc")
chk("② /tasks?page=abc=200", st == 200, "status=%s" % st)
# ③ 日志尾部非法行数曾 500
st, _ = page(cli, "/logs/tail?lines=abc")
chk("③ /logs/tail?lines=abc=200", st == 200, "status=%s" % st)
# ④ 内部簿记键 slot:* 曾泄漏到 /api/settings
st, body = page(cli, "/api/settings")
chk("④ /api/settings 无 slot:* 键", "slot:" not in body, "status=%s" % st)
# ⑤ 概览「云端」列曾因 SQL 少选列而恒为空
st, ov = page(cli, "/")
chk("⑤ 概览含「云端」列", "云端" in ov)
# ⑥ 明细页日期回填:模板曾读 f.from,导致输入框永远为空
st, rec = page(cli, "/records?from=2026-09-08&to=2026-09-10")
chk("⑥ 明细页回填 from", 'value="2026-09-08"' in rec)
chk("⑥ 明细页回填 to", 'value="2026-09-10"' in rec)
# ⑦ 导出链接必须带规范参数名 from(模板内部用 frm,拼 URL 时要换回来)
m = re.search(r'href="(/records/export[^"]*)"', rec)
chk("⑦ 导出链接存在", bool(m))
if m:
chk("⑦ 导出链接带 from=", "from=2026-09-08" in m.group(1), m.group(1))
chk("⑦ 导出链接带 to=", "to=2026-09-10" in m.group(1))
# ⑧ 导出接口本身也要认 from/to
st, csv_body = page(cli, "/records/export?from=2026-09-08&to=2026-09-10")
chk("⑧ GET /records/export=200", st == 200, "status=%s" % st)
rows = list(csv.reader(io.StringIO(csv_body.lstrip("\ufeff"))))
chk("⑧ CSV 至少含表头", len(rows) >= 1, "rows=%d" % len(rows))
chk("⑧ CSV 列数一致",
len({len(r) for r in rows if r}) == 1,
"列数集合=%s" % sorted({len(r) for r in rows if r}))
chk("⑧ CSV 表头为官方同构列",
rows and rows[0] == ["RequestID", "积分消耗", "User Prompt", "模型", "客户端", "时间"],
"header=%s" % (rows[0] if rows else None))
# ⑨ 非法设置必须在写入时被拒(曾让采集崩掉)
st, body = page(cli, "/api/settings", method="POST", json={"page_size": "abc"},
headers={"X-CSRF-Token": "smoke-csrf-token"})
chk("⑨ 非法设置写入=400", st == 400, "status=%s" % st)
st, body = page(cli, "/api/settings", method="POST", json={"slot:09:00": "x"},
headers={"X-CSRF-Token": "smoke-csrf-token"})
chk("⑨ 内部键被忽略而非写入",
st == 200 and "slot:09:00" in (json.loads(body).get("ignored") or []),
"status=%s body=%s" % (st, body[:140]))
# ⑩ 维护动作
st, _ = page(cli, "/api/maintenance/nope", method="POST", json={},
headers={"X-CSRF-Token": "smoke-csrf-token"})
chk("⑩ 未知维护动作=404", st == 404, "status=%s" % st)
st, body = page(cli, "/api/maintenance/recount", method="POST", json={},
headers={"X-CSRF-Token": "smoke-csrf-token"})
chk("⑩ recount=200", st == 200, "status=%s body=%s" % (st, body[:90]))
# ⑪ 非管理员调用户管理 API
st, _ = page(cli, "/api/users/1/delete", method="POST", json={},
headers={"X-CSRF-Token": "smoke-csrf-token"})
chk("⑪ 删除自己=400(不允许)", st == 400, "status=%s" % st)
# ⑫ CSRF 缺失必须 400
st, _ = page(cli, "/api/settings", method="POST", json={"max_prompt": "100"})
chk("⑫ 缺 CSRF=400", st == 400, "status=%s" % st)
# ⑬ 分页/筛选参数非法不能 500
for p in ("/logs?page=abc", "/logs?apage=abc", "/records?page=abc&size=abc",
"/records?from=2026-13-99", "/logs?status=%27%20OR%201=1--"):
st, _ = page(cli, p)
chk("⑬ GET %-30s =200" % p, st == 200, "status=%s" % st)
# ⑭ 审计筛选:只返回指定动作,且计数与筛选一致
st, lg = page(cli, "/logs")
chk("⑭ 日志页含操作审计筛选", "操作审计" in lg and "seg" in lg)
m = re.search(r'href="/logs\?act=([^"&]+)', lg)
if chk("⑭ 审计筛选有可选动作", bool(m), "act=%s" % (m.group(1) if m else "无")):
act = m.group(1)
st, lg2 = page(cli, "/logs?act=" + act)
chk("⑭ 带 act=%s 仍 200" % act, st == 200, "status=%s" % st)
# 从审计表的 id 处切片:前面采集表里的 trigger 也用了 tag mute,不能混入
i = lg2.find('id="auditTable"')
tail = lg2[i:] if i >= 0 else ""
chk("⑭ 审计表存在", i >= 0)
tags = re.findall(r'<td><span class="tag mute">([^<]+)</span></td>', tail)
others = sorted({t for t in tags if t != act})
chk("⑭ 审计筛选结果不含其他动作", not others and bool(tags),
"命中=%d 混入=%s" % (len(tags), others))
# ---------------- 5. 数据自洽 ----------------
print("== 5. 数据自洽(只读) ==")
with app.test_client() as cli:
login(cli, admin=True)
mf = json.loads(page(cli, "/api/manifest")[1])
src = (mf.get("sources") or [{}])[0]
chk("manifest 存档条数 == 数据源条数",
mf["totals"]["records"] == src.get("count"),
"%s vs %s" % (mf["totals"]["records"], src.get("count")))
sm = json.loads(page(cli, "/api/summary")[1])
chk("summary 全量 calls == 存档条数",
sm.get("calls") == mf["totals"]["records"],
"calls=%s records=%s" % (sm.get("calls"), mf["totals"]["records"]))
chk("summary 全量 credits 自洽",
abs(float(sm.get("credits", 0)) - float(mf["totals"]["credits"])) < 0.005,
"%s vs %s" % (sm.get("credits"), mf["totals"]["credits"]))
d = query.daily(db.get_db())
chk("daily 逐日积分求和 == 存档总额",
abs(round(sum(float(x["c"]) for x in d), 2)
- round(float(mf["totals"]["credits"]), 2)) < 0.005)
chk("daily 逐日 h[24] 求和 == 当日积分",
all(abs(round(sum(x["h"]), 2) - round(x["c"], 2)) < 0.005 for x in d))
note("存档 %s 条 / %s 积分 / %d 天" % (mf["totals"]["records"],
mf["totals"]["credits"], len(d)))
# ---------------- 6. class 名与 CSS 选择器对账 ----------------
print("== 6. 页面 class 与 app.css 选择器对账 ==")
css = open(os.path.join(BASE, "workbuddy_portal", "web", "static", "css", "app.css"),
encoding="utf-8").read()
css_classes = set(re.findall(r"\.([A-Za-z][\w-]*)", css))
with app.test_client() as cli:
login(cli, admin=True)
used: set[str] = set()
for p in ("/", "/records", "/tasks", "/config", "/logs", "/users", "/login"):
if p == "/login":
with app.test_client() as c2:
html = page(c2, p)[1]
else:
html = page(cli, p)[1]
for m in re.findall(r'class="([^"]*)"', html):
used.update(t for t in m.split() if t)
# 允许的无样式类:JS 钩子、第三方/语义标记
allow = {"no-js", "on", "cur", "gap", "meta", "unit", "field-err"}
missing = sorted(c for c in used - css_classes - allow)
chk("无「用了但 CSS 里不存在」的类名", not missing, "缺失=%s" % missing if missing else "")
def main() -> int:
print("工程目录:%s\n" % BASE)
try:
run()
except Exception as e: # noqa: BLE001
import traceback
traceback.print_exc()
print("\n[FATAL] 脚本本身异常:%s" % e)
return 1
print("\nRESULT: ok=%d fail=%d" % (OK, FAIL))
if NOTES:
print("备注:")
for n in NOTES:
print(" - " + n)
if FAILS:
print("失败项:\n - " + "\n - ".join(FAILS))
return 1 if FAIL else 0
if __name__ == "__main__":
sys.exit(main())
+123
查看文件
@@ -0,0 +1,123 @@
# -*- coding: utf-8 -*-
"""WorkBuddy Portal —— 独立 Flask 项目。
一个程序管全部:
* 采集:进程内调度线程按配置的时刻调用云端接口,增量写入 SQLite
* 存储:SQLite 单文件正本(WAL),去重靠主键,聚合下推 SQL
* 呈现:Jinja 负责配置/任务/日志/明细;ECharts 大屏独立成页,数据走 /api/*
* 鉴权:局域网可访问 ⇒ 必须有登录,cookie 等凭证存数据库由后台页面维护
用法:
python manage.py init 初始化数据库(建表 + 默认配置 + 管理员)
python manage.py serve 启动 Web(默认 0.0.0.0:8848,含调度线程)
python manage.py collect 执行一次增量采集(CLI,不影响 Web)
...
"""
import logging
import os
from datetime import timedelta
from logging.handlers import RotatingFileHandler
from flask import Flask, jsonify, render_template, request
from . import config, db, security
__version__ = "1.1.0"
PROJECT_NAME = config.PROJECT_NAME
def _setup_logging(app):
config.ensure_dirs()
handler = RotatingFileHandler(config.APP_LOG, maxBytes=2 * 1024 * 1024,
backupCount=3, encoding="utf-8")
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)-7s [%(name)s] %(message)s"))
handler.setLevel(logging.INFO)
root = logging.getLogger()
if not any(isinstance(h, RotatingFileHandler) and getattr(h, "baseFilename", "") == handler.baseFilename
for h in root.handlers):
root.addHandler(handler)
root.setLevel(logging.INFO)
app.logger.info("启动 %s v%s(db=%s)", __name__, __version__, config.SQLITE_PATH)
def create_app(start_scheduler=True, do_init_db=True, **overrides):
app = Flask(__name__,
template_folder="web/templates",
static_folder="web/static",
static_url_path="/static")
app.config.update(
SECRET_KEY=config.secret_key(),
PERMANENT_SESSION_LIFETIME=timedelta(hours=config.SESSION_HOURS),
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
SESSION_COOKIE_NAME="workbuddy_portal_sid",
MAX_CONTENT_LENGTH=4 * 1024 * 1024,
TEMPLATES_AUTO_RELOAD=True,
SEND_FILE_MAX_AGE_DEFAULT=0,
)
app.config.update(overrides)
app.json.ensure_ascii = False
app.json.sort_keys = False
if do_init_db:
db.init_db()
_setup_logging(app)
security.init_app(app)
from .web import register as register_web
register_web(app)
@app.teardown_appcontext
def _close(exc=None):
db.close_db(exc)
# ---- 错误处理 ----
@app.errorhandler(404)
def _404(e):
if request.path.startswith("/api/"):
return jsonify({"ok": False, "error": "not_found", "message": "接口不存在"}), 404
return render_template("error.html", code=404, message="页面不存在"), 404
@app.errorhandler(400)
def _400(e):
if request.path.startswith("/api/"):
return jsonify({"ok": False, "error": "bad_request", "message": str(e)}), 400
return render_template("error.html", code=400, message=str(e)), 400
@app.errorhandler(403)
def _403(e):
if request.path.startswith("/api/"):
return jsonify({"ok": False, "error": "forbidden", "message": "没有权限"}), 403
return render_template("error.html", code=403, message="没有权限"), 403
@app.errorhandler(500)
def _500(e):
app.logger.exception("内部错误")
if request.path.startswith("/api/"):
return jsonify({"ok": False, "error": "internal", "message": "服务器内部错误"}), 500
return render_template("error.html", code=500, message="服务器内部错误"), 500
@app.errorhandler(ValueError)
def _value_error(e):
"""参数解析类异常统一归 400(例如 base64/日期/整数解析失败)。
没有这层的话,一个手写的 query string 就能把 500 页面暴露出去。
"""
app.logger.warning("参数错误:%s", e)
if request.path.startswith("/api/"):
return jsonify({"ok": False, "error": "bad_request", "message": str(e)}), 400
return render_template("error.html", code=400, message=str(e)), 400
@app.context_processor
def _inject():
return {"app_version": __version__, "nav_active": "",
"project_name": config.PROJECT_NAME,
"project_title": config.PROJECT_TITLE,
"project_desc": config.PROJECT_DESC}
if start_scheduler:
from . import scheduler
scheduler.start_from_app(app)
return app
+191
查看文件
@@ -0,0 +1,191 @@
# -*- coding: utf-8 -*-
"""云端用量接口客户端(纯 urllib,不依赖 requests/浏览器)。
接口:POST {api_base}/billing/meter/get-user-request-usage
body {"startTime":"YYYY-MM-DD HH:MM:SS","endTime":"...","pageNum":1,"pageSize":200}
响应 data.total=区间调用总数;data.data[]: requestId/credit/model/client/requestTime/input/inputTrunc
鉴权只靠 Cookie + User-Agent,两者都从数据库 settings 读(后台页面维护)。
"""
import json
import ssl
import urllib.error
import urllib.request
from datetime import timedelta
class ApiError(Exception):
"""接口 / 凭证类异常。cookie_expired=True 时表示需要更新凭证。"""
def __init__(self, msg, cookie_expired=False):
super().__init__(msg)
self.cookie_expired = cookie_expired
def _ssl_context(verify=True):
ctx = ssl.create_default_context()
if not verify:
# 仅在用户显式设置 ssl_verify=0 时走到这里:cookie 就是账号凭证,
# 关掉校验等于把凭证暴露给中间人,所以默认永远是校验的。
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
def _call(api_base, path, body, cookie, ua, timeout, ssl_verify=True):
req = urllib.request.Request(api_base + path, data=json.dumps(body).encode(), method="POST")
for k, v in {
"accept": "application/json, text/plain, */*",
"content-type": "application/json",
"cookie": cookie,
"origin": api_base,
"referer": api_base + "/profile/plans-usage",
"x-client-platform": "web",
"user-agent": ua,
}.items():
req.add_header(k, v)
try:
with urllib.request.urlopen(req, timeout=timeout,
context=_ssl_context(ssl_verify)) as r:
return json.loads(r.read().decode("utf-8"))
except ssl.SSLError as e:
raise ApiError("TLS_ERROR: 云端证书校验失败(%s)。若本机有自签/企业代理,"
"请在「配置管理」把 ssl_verify 设为 0 并自行承担风险。" % e)
except urllib.error.HTTPError as e:
if e.code in (401, 403):
raise ApiError("COOKIE_EXPIRED: Cookie 已失效或被拒绝(HTTP %d)" % e.code,
cookie_expired=True)
raise ApiError("HTTP_%d" % e.code)
except urllib.error.URLError as e:
raise ApiError("网络请求失败:%s" % e)
except (ValueError, OSError) as e:
raise ApiError("响应解析失败:%s" % e)
def fetch_range(start_dt, end_dt, cookie, ua, api_base, api_path,
page_size=200, timeout=30, max_pages=100, log=None,
ssl_verify=True):
"""按天切分拉取,返回 (明细列表, {日期: 云端 total})。
按天切分是为了拿到「整日 total」——增量窗口的 total 只是区间值,不能用于完整性比对。
"""
rows, totals = [], {}
day, last = start_dt.date(), end_dt.date()
while day <= last:
d = day.strftime("%Y-%m-%d")
s = start_dt.strftime("%Y-%m-%d %H:%M:%S") if day == start_dt.date() else d + " 00:00:00"
e = end_dt.strftime("%Y-%m-%d %H:%M:%S") if day == last else d + " 23:59:59"
body = {"startTime": s, "endTime": e, "pageNum": 1, "pageSize": page_size}
total, got = None, 0
while True:
js = _call(api_base, api_path, body, cookie, ua, timeout, ssl_verify=ssl_verify)
if js.get("code") != 0:
raise ApiError("API_ERROR: %s" % js.get("msg"))
dta = js.get("data") or {}
if total is None:
total = dta.get("total", 0)
batch = dta.get("data") or []
rows.extend(batch)
got += len(batch)
if not batch or got >= (total or 0) or body["pageNum"] >= max_pages:
break
body["pageNum"] += 1
totals[d] = total or 0
if log:
log(" %s 云端 %s 条" % (d, total if total is not None else "-"))
day += timedelta(days=1)
return rows, totals
def strip_jsonc(text):
"""去掉 JSONC 里的 // 与 /* */ 注释(VSCode settings.json 常见)。
注意:`/* */` 的扫描必须按字符前移 1(不是 2),并且要连收尾的 `*/` 一起跳过,
否则会把 `*/` 残留进结果,或(当收尾的 `*` 落在奇数下标时)永远匹配不到终止符。
"""
out, i, n = [], 0, len(text)
in_str = esc = False
while i < n:
c = text[i]
if in_str:
out.append(c)
if esc:
esc = False
elif c == "\\":
esc = True
elif c == '"':
in_str = False
i += 1
continue
if c == '"':
in_str = True
out.append(c)
i += 1
continue
if c == "/" and i + 1 < n and text[i + 1] == "/":
while i < n and text[i] != "\n":
i += 1
continue # 保留换行,行号不漂移
if c == "/" and i + 1 < n and text[i + 1] == "*":
i += 2
while i < n and not (text[i] == "*" and i + 1 < n and text[i + 1] == "/"):
i += 1
i += 2 if i + 1 < n else 0 # 跳过收尾的 */
continue
out.append(c)
i += 1
return "".join(out)
def read_vscode_creds():
"""从 VSCode 设置里读 codebuddyUsage.cookie / userAgent(一次性接管用)。
原脚本靠这个读凭证;迁移到本项目的 settings 表后只作为「导入来源」。
"""
import glob
import json as _json
import os
cands = []
appdata = os.environ.get("APPDATA", "")
home = os.path.expanduser("~")
if appdata:
cands += [os.path.join(appdata, "Code", "User", "settings.json"),
os.path.join(appdata, "Code - Insiders", "User", "settings.json"),
os.path.join(appdata, "Cursor", "User", "settings.json"),
os.path.join(appdata, "Trae", "User", "settings.json")]
cands += glob.glob(os.path.join(home, "AppData", "Roaming", "*", "User", "settings.json"))
seen, out = set(), []
for p in cands:
if p in seen or not os.path.exists(p):
continue
seen.add(p)
try:
cfg = _json.loads(strip_jsonc(open(p, encoding="utf-8").read()))
except Exception as e: # noqa: BLE001
out.append((p, None, None, "解析失败:%s" % e))
continue
cookie = (cfg.get("codebuddyUsage.cookie") or "").strip()
ua = (cfg.get("codebuddyUsage.userAgent") or "").strip()
out.append((p, cookie, ua, "ok" if cookie else "无 codebuddyUsage.cookie"))
return out
def normalize(r, max_prompt=2048):
"""云端明细 -> 入库字段。"""
ts = (r.get("requestTime") or "")[:19]
prompt = r.get("input") or r.get("inputTrunc") or ""
prompt = " ".join(str(prompt).split()) # 折叠换行
if max_prompt and len(prompt) > max_prompt:
prompt = prompt[:max_prompt]
try:
credit = float(r.get("credit") or 0)
except (TypeError, ValueError):
credit = 0.0
return {
"request_id": (r.get("requestId") or "").strip(),
"ts": ts,
"model": (str(r.get("model") or "-").strip() or "-"),
"client": (str(r.get("client") or "-").strip() or "-"),
"credits": round(credit, 2),
"prompt": prompt,
}
+449
查看文件
@@ -0,0 +1,449 @@
# -*- coding: utf-8 -*-
"""采集主流程:云端增量 -> SQLite(去重、断点、漂移校验、运行记录)。
与原 fetch_usage.py 的差别:
* 存档正本从 CSV 换成 SQLite,去重由 `ON CONFLICT(request_id)` 承担
* 断点由 `SELECT MAX(ts)` 承担,不再需要全量读入内存
* 每次运行落一条 collect_runs 记录,页面据此展示任务历史与日志
* 采集互斥用文件锁,保证「单写者」——SQLite 只允许一个写进程
"""
import csv
import os
import time
from datetime import datetime, timedelta
from . import client, config, db
from .client import ApiError
FIELDS = ["RequestID", "积分消耗", "User Prompt", "模型", "客户端", "时间"]
LOCK_PATH = os.path.join(config.DATA_DIR, "collect.lock")
LOCK_STALE_SECONDS = 30 * 60 # 锁超过 30 分钟视为僵尸锁,可抢占
class Busy(Exception):
"""已有采集在跑。"""
# ---------------- 互斥锁 ----------------
class _Lock:
def __init__(self, path=LOCK_PATH):
self.path = path
self.fd = None
def __enter__(self):
config.ensure_dirs()
if os.path.exists(self.path):
try:
age = time.time() - os.path.getmtime(self.path)
except OSError:
age = 0
if age < LOCK_STALE_SECONDS:
raise Busy("已有采集任务正在运行(锁文件 %s,%.0f 秒前创建)"
% (self.path, age))
os.remove(self.path) # 僵尸锁
try:
self.fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
os.write(self.fd, ("%d %s" % (os.getpid(), db.now_str())).encode())
except FileExistsError:
raise Busy("已有采集任务正在运行")
return self
def __exit__(self, *a):
try:
if self.fd is not None:
os.close(self.fd)
except OSError:
pass
self.fd = None
try:
os.remove(self.path)
except OSError:
pass
# ---------------- 运行记录 ----------------
def start_run(conn, trigger):
cur = conn.execute("INSERT INTO collect_runs(trigger,status,started_at) VALUES(?,?,?)",
(trigger, "running", db.now_str()))
return cur.lastrowid
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=?"]
vals = [status, db.now_str()]
for f in fields[1:]:
if f in kw:
sets.append("%s=?" % f)
vals.append(kw[f])
vals.append(run_id)
conn.execute("UPDATE collect_runs SET %s WHERE id=?" % ",".join(sets), vals)
# ---------------- 入库 ----------------
_UPSERT = """
INSERT INTO usage_records(request_id,ts,day,hour,model,client,credits,prompt,
first_seen,last_seen,cloud_ts)
VALUES(:request_id,:ts,:day,:hour,:model,:client,:credits,:prompt,:first_seen,:last_seen,:cloud_ts)
ON CONFLICT(request_id) DO UPDATE SET
last_seen = excluded.last_seen,
cloud_ts = excluded.cloud_ts,
ts = CASE WHEN excluded.ts <> '' AND excluded.ts < usage_records.ts
THEN excluded.ts ELSE usage_records.ts END,
day = CASE WHEN excluded.ts <> '' AND excluded.ts < usage_records.ts
THEN excluded.day ELSE usage_records.day END,
hour = CASE WHEN excluded.ts <> '' AND excluded.ts < usage_records.ts
THEN excluded.hour ELSE usage_records.hour END,
prompt = CASE WHEN COALESCE(usage_records.prompt,'') = ''
THEN excluded.prompt ELSE usage_records.prompt END,
model = CASE WHEN COALESCE(usage_records.model,'') IN ('','-')
THEN excluded.model ELSE usage_records.model END,
client = CASE WHEN COALESCE(usage_records.client,'') IN ('','-')
THEN excluded.client ELSE usage_records.client END
"""
def _row_dict(n):
ts = (n.get("ts") or "").strip()[:19]
hh = ts[11:13]
return {
"request_id": n["request_id"],
"ts": ts,
"day": ts[:10],
"hour": int(hh) if hh.isdigit() else 0,
"model": n.get("model") or "-",
"client": n.get("client") or "-",
"credits": n.get("credits") or 0.0,
"prompt": n.get("prompt") or "",
"first_seen": db.now_str(),
"last_seen": db.now_str(),
"cloud_ts": ts,
}
def upsert(conn, normalized, drift_tolerance=5, log=None):
"""按 request_id 去重写入。返回 (added, dup, conflicts) 与逐行警告。
每 200 条一个事务(连接是 autocommit,不显式 BEGIN 的话每条 INSERT 都要
单独 fsync)。upsert 本身幂等,所以按块提交是安全的。
"""
recs = []
for n in normalized:
if not n.get("request_id") or not n.get("ts"):
continue
recs.append(_row_dict(n))
added = dup = 0
conflicts = []
for i in range(0, len(recs), 200):
chunk = recs[i:i + 200]
ids = [r["request_id"] for r in chunk]
ph = ",".join("?" * len(ids))
own_tx = not conn.in_transaction
if own_tx:
conn.execute("BEGIN")
try:
old = {x["request_id"]: x["ts"] for x in
conn.execute("SELECT request_id,ts FROM usage_records WHERE request_id IN (%s)" % ph, ids)}
for r in chunk:
prev = old.get(r["request_id"])
if prev is None:
added += 1
else:
dup += 1
try:
delta = (datetime.strptime(prev, "%Y-%m-%d %H:%M:%S")
- datetime.strptime(r["ts"], "%Y-%m-%d %H:%M:%S")).total_seconds()
except (ValueError, TypeError):
delta = 0
if delta > drift_tolerance * 60:
conflicts.append((r["request_id"], prev, r["ts"]))
conn.executemany(_UPSERT, chunk)
if own_tx:
conn.execute("COMMIT")
except Exception:
if own_tx:
conn.execute("ROLLBACK")
raise
if conflicts and log:
log("[warn] %d 条 RequestID 相同且云端开始时间早于本地超过 %d 分钟(保留本地最早时间,未覆盖):"
% (len(conflicts), drift_tolerance))
for rid, t_old, t_new in conflicts[:10]:
log(" %s: 本地 %s / 云端 %s" % (rid, t_old, t_new))
if len(conflicts) > 10:
log(" ... 另有 %d 条" % (len(conflicts) - 10))
return added, dup, conflicts
def record_count(conn):
return conn.execute("SELECT COUNT(*) FROM usage_records").fetchone()[0]
def last_ts(conn):
return conn.execute("SELECT MAX(ts) FROM usage_records").fetchone()[0]
# ---------------- 主同步 ----------------
def sync(conn, trigger="manual", from_dt=None, to_dt=None, verify_days=None,
do_write=True, log=None):
"""增量同步。
trigger: manual | schedule | cli | startup(写进 collect_runs 便于区分来源)
返回 result dict;异常时抛出 ApiError(调用方决定如何展示)。
"""
lines = []
def _log(msg):
lines.append(msg)
if log:
log(msg)
s = db.get_settings(conn)
cookie = (s.get("cookie") or "").strip() or os.environ.get("WB_COOKIE", "").strip()
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_path = s.get("api_path") or config.API_PATH
# 一律走 db.get_int/get_float:settings 表的值由后台页面自由输入,
# 直接 int() 会让一个手滑的字符把整条采集链路打断(历史 bug)。
page_size = db.get_int(conn, "page_size", 200)
rewind = db.get_int(conn, "rewind_minutes", 2)
drift = db.get_int(conn, "drift_tolerance_minutes", 5)
max_prompt = db.get_int(conn, "max_prompt", 0)
timeout = db.get_int(conn, "timeout", 30)
ssl_verify = db.get_bool(conn, "ssl_verify", True)
if verify_days is None:
verify_days = db.get_int(conn, "verify_days", 0)
# 夹到合法区间,避免历史脏数据(如超大的 page_size)把云端打爆
lo, hi, _ = config.NUM_SETTINGS["page_size"]
page_size = max(lo, min(hi, page_size))
run_id = start_run(conn, trigger) if do_write else None
t0 = time.time()
base = {"win_from": None, "win_to": None, "fetched": 0,
"added": 0, "dup": 0, "conflicts": 0}
if not cookie:
msg = "未配置 Cookie,请到「配置管理」页粘贴,或设置环境变量 WB_COOKIE"
_log("[error] " + msg)
if run_id:
finish_run(conn, run_id, "error", exit_code=2, message=msg,
duration_ms=int((time.time() - t0) * 1000), detail="\n".join(lines), **base)
raise ApiError(msg)
total_before = record_count(conn)
tail = last_ts(conn)
now = datetime.now()
_log("存档:%d 条%s" % (total_before, (",最后记录 " + tail) if tail else "(空)"))
# 断点 = 本地最后一条记录时间(回退 rewind 分钟防边界遗漏)
if from_dt:
start = from_dt
elif tail:
start = datetime.strptime(tail, "%Y-%m-%d %H:%M:%S") - timedelta(minutes=rewind)
else:
start = now - timedelta(days=30)
_log("存档为空,默认回填最近 30 天")
end = to_dt or now
if start >= end:
start = end - timedelta(minutes=rewind)
_log("同步区间:%s ~ %s" % (start.strftime("%Y-%m-%d %H:%M:%S"),
end.strftime("%Y-%m-%d %H:%M:%S")))
try:
raw, _totals = client.fetch_range(start, end, cookie, ua, api_base, api_path,
page_size=page_size, timeout=timeout,
log=_log, ssl_verify=ssl_verify)
except ApiError as e:
msg = str(e)
_log("[error] " + msg)
if run_id:
finish_run(conn, run_id, "error", exit_code=3 if e.cookie_expired else 5,
message=msg, duration_ms=int((time.time() - t0) * 1000),
detail="\n".join(lines), win_from=start.strftime("%Y-%m-%d %H:%M:%S"),
win_to=end.strftime("%Y-%m-%d %H:%M:%S"), **{k: v for k, v in base.items()
if k not in ("win_from", "win_to")})
raise
new_rows = [client.normalize(r, max_prompt=max_prompt) for r in raw]
_log("云端返回:%d 条" % len(raw))
added, dup, conflicts = upsert(conn, new_rows, drift_tolerance=drift, log=_log)
# 整日完整性校验(默认关闭;用于排查缺记录)
if verify_days > 0:
days = [r["day"] for r in conn.execute(
"SELECT DISTINCT day FROM usage_records ORDER BY day DESC LIMIT ?", (verify_days,))]
_log("完整性校验:最近 %d 天" % len(days))
for d in sorted(days):
local = conn.execute("SELECT COUNT(*) FROM usage_records WHERE day=?", (d,)).fetchone()[0]
d0 = datetime.strptime(d, "%Y-%m-%d")
try:
raw2, t2 = client.fetch_range(d0, d0.replace(hour=23, minute=59, second=59),
cookie, ua, api_base, api_path,
page_size=page_size, timeout=timeout,
ssl_verify=ssl_verify)
except ApiError as e:
_log(" %s 校验失败:%s" % (d, e))
continue
cloud = t2.get(d, 0)
if local < cloud:
a2, _, _ = upsert(conn, [client.normalize(r, max_prompt=max_prompt) for r in raw2],
drift_tolerance=drift)
added += a2
_log(" %s:云端 %d / 本地 %d → 补入 %d 条" % (d, cloud, local, a2))
else:
_log(" %s:云端 %d / 本地 %d OK" % (d, cloud, local))
total_after = record_count(conn)
status = "warn" if conflicts else "ok"
msg = "新增 %d 条,重复 %d 条,存档共 %d 条" % (added, dup, total_after)
_log("RESULT: added=%d dup=%d total=%d" % (added, dup, total_after))
if run_id:
finish_run(conn, run_id, status,
duration_ms=int((time.time() - t0) * 1000),
win_from=start.strftime("%Y-%m-%d %H:%M:%S"),
win_to=end.strftime("%Y-%m-%d %H:%M:%S"),
fetched=len(raw), added=added, dup=dup, total=total_after,
conflicts=len(conflicts), exit_code=0, message=msg,
detail="\n".join(lines))
return {"status": status, "added": added, "dup": dup, "fetched": len(raw),
"total": total_after, "conflicts": len(conflicts),
"win_from": start.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}
def run_sync(trigger="manual", **kw):
"""带锁的同步入口(供 CLI / 调度器 / 页面手动触发共用)。"""
with _Lock():
conn = db.thread_conn()
return sync(conn, trigger=trigger, **kw)
# ---------------- 补全 / 导入 / 导出 ----------------
def fill_prompt(conn, log=print):
"""补全缺失的 User Prompt(官网导出的 xlsx 会丢约 22%,云端仍保留)。"""
s = db.get_settings(conn)
cookie = (s.get("cookie") or "").strip() or os.environ.get("WB_COOKIE", "").strip()
ua = (s.get("user_agent") or "").strip()
max_prompt = db.get_int(conn, "max_prompt", 0)
if not cookie:
raise ApiError("未配置 Cookie")
todo = conn.execute("SELECT request_id, day FROM usage_records "
"WHERE COALESCE(prompt,'')='' ORDER BY day").fetchall()
if not todo:
log("没有缺失的 User Prompt")
return 0
days = sorted({r["day"] for r in todo})
log("待补全 %d 条,分布在 %d 天" % (len(todo), len(days)))
pool = {}
for d in days:
d0 = datetime.strptime(d, "%Y-%m-%d")
raw, _ = client.fetch_range(d0, d0.replace(hour=23, minute=59, second=59),
cookie, ua, s.get("api_base") or config.API_BASE,
s.get("api_path") or config.API_PATH,
page_size=db.get_int(conn, "page_size", 200),
timeout=db.get_int(conn, "timeout", 30),
ssl_verify=db.get_bool(conn, "ssl_verify", True))
for r in raw:
rid = (r.get("requestId") or "").strip()
if rid:
pool[rid] = client.normalize(r, max_prompt=max_prompt)["prompt"]
log(" %s 云端 %d 条" % (d, len(raw)))
n = 0
for row in todo:
p = pool.get(row["request_id"], "")
if p:
conn.execute("UPDATE usage_records SET prompt=? WHERE request_id=?", (p, row["request_id"]))
n += 1
left = conn.execute("SELECT COUNT(*) FROM usage_records WHERE COALESCE(prompt,'')=''").fetchone()[0]
log("补全 %d 条,仍为空 %d 条" % (n, left))
return n
def import_xlsx(conn, path, log=print):
"""从官网「用量明细 - 导出」的 xlsx 合入(按 request_id 去重)。"""
try:
import openpyxl
except ImportError:
raise ApiError("需要 openpyxl:pip install openpyxl")
s = db.get_settings(conn)
max_prompt = db.get_int(conn, "max_prompt", 0)
wb = openpyxl.load_workbook(path, read_only=True, data_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)]
idx = {n: i for i, n in enumerate(header)}
def col(*names):
for n in names:
if n in idx:
return idx[n]
raise ApiError("xlsx 缺少列 %s,实际表头:%s" % (names, header))
i_rid, i_cr, i_px = col("RequestID"), col("积分消耗"), col("User Prompt")
i_m, i_cl, i_t = col("模型"), col("客户端"), col("时间")
rows = []
for row in it:
if row is None or row[i_t] is None:
continue
t = str(row[i_t])[:19]
try:
cr = float(row[i_cr] or 0)
except (TypeError, ValueError):
cr = 0.0
px = " ".join(str(row[i_px] or "").split())
if max_prompt and len(px) > max_prompt:
px = px[:max_prompt]
rid = str(row[i_rid] or "").strip() or ("xlsx-%s-%d" % (t, len(rows)))
rows.append({"request_id": rid, "ts": t, "credits": round(cr, 2), "prompt": px,
"model": str(row[i_m] or "-").strip() or "-",
"client": str(row[i_cl] or "-").strip() or "-"})
added, dup, _ = upsert(conn, rows)
log("[xlsx] 读取 %d 条,去重后新增 %d 条,存档共 %d 条" % (len(rows), added, record_count(conn)))
return added
def export_csv(conn, path=None):
"""导出与旧存档 / 官网 xlsx 完全同构的 CSV(备份与对端交换用)。"""
path = path or os.path.join(config.EXPORT_DIR, "usage_records.csv")
os.makedirs(os.path.dirname(path), exist_ok=True)
rows = conn.execute("SELECT request_id,credits,prompt,model,client,ts FROM usage_records "
"ORDER BY ts, request_id")
n = 0
with open(path, "w", encoding="utf-8-sig", newline="") as f:
w = csv.writer(f)
w.writerow(FIELDS)
for r in rows:
w.writerow([r["request_id"], "%.2f" % r["credits"], r["prompt"] or "",
r["model"], r["client"], r["ts"]])
n += 1
return path, n
def migrate_from_csv(conn, path, log=print):
"""把旧版 data/usage_records.csv 全量导入 SQLite(幂等,可重复执行)。"""
if not os.path.exists(path):
raise FileNotFoundError(path)
with open(path, "rb") as fb:
if fb.read(2) == b"PK":
raise ApiError("文件被 Excel 另存成了 xlsx(扩展名仍是 .csv):%s" % path)
with open(path, "r", encoding="utf-8-sig", newline="") as f:
recs = []
for r in csv.DictReader(f):
rid = (r.get("RequestID") or "").strip()
ts = (r.get("时间") or "").strip()[:19]
if not rid or len(ts) < 19:
continue
try:
cr = round(float(r.get("积分消耗") or 0), 2)
except (TypeError, ValueError):
cr = 0.0
recs.append({"request_id": rid, "ts": ts, "credits": cr,
"prompt": " ".join(str(r.get("User Prompt") or "").split()),
"model": (r.get("模型") or "-").strip() or "-",
"client": (r.get("客户端") or "-").strip() or "-"})
before = record_count(conn)
added, dup, _ = upsert(conn, recs)
log("[migrate] 源文件 %d 条 → 新增 %d / 已存在 %d,入库前 %d 条,现共 %d 条"
% (len(recs), added, dup, before, record_count(conn)))
return added
+169
查看文件
@@ -0,0 +1,169 @@
# -*- coding: utf-8 -*-
"""基础配置。
刻意保持「薄」:凡是运行期要改的东西(cookie、调度周期、采集参数)都放数据库
settings 表,由后台页面维护;这里只放路径、密钥、默认值这类启动期常量。
"""
import json
import os
import secrets
# ---------------- 项目标识(单一来源)----------------
# 仓库名 / 镜像名 / compose 服务名 / 界面品牌都从这里取,避免多处硬编码走样。
PROJECT_NAME = "workbuddy-portal" # 技术标识:目录、仓库、镜像名
PROJECT_TITLE = "WorkBuddy Portal" # 界面品牌
PROJECT_DESC = "WorkBuddy 积分用量采集 / 存储 / 呈现一体化门户"
# 项目根(workbuddy-portal/)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# 数据 / 日志目录可用环境变量覆盖(容器里把卷挂到别处时不必改代码)。
DATA_DIR = os.environ.get("WB_DATA_DIR") or os.path.join(BASE_DIR, "data")
LOG_DIR = os.environ.get("WB_LOG_DIR") or os.path.join(BASE_DIR, "logs")
SQLITE_PATH = os.environ.get("WB_DB") or os.path.join(DATA_DIR, "usage.sqlite")
EXPORT_DIR = os.path.join(DATA_DIR, "exports")
APP_LOG = os.path.join(LOG_DIR, "app.log")
INSTANCE_FILE = os.path.join(DATA_DIR, "instance.json")
# 旧版脚本项目的存档(迁移用;--migrate-csv 默认读这里)
LEGACY_CSV_CANDIDATES = [
os.path.join(os.path.dirname(BASE_DIR), "data", "usage_records.csv"),
os.path.join(BASE_DIR, "data", "usage_records.csv"),
]
# ---------------- 云端接口 ----------------
API_BASE = "https://www.workbuddy.cn"
API_PATH = "/billing/meter/get-user-request-usage"
# ---------------- 采集参数默认值(可被 settings 表覆盖)----------------
DEFAULTS = {
"api_base": API_BASE,
"api_path": API_PATH,
"page_size": "200",
"rewind_minutes": "2", # 断点回退分钟数
"drift_tolerance_minutes": "5", # 云端比本地早超过该值才告警
"max_prompt": "2048", # 0 表示不截断
"verify_days": "0", # 每次采集后做整日完整性校验的天数
"timeout": "30",
"ssl_verify": "1", # 校验云端 HTTPS 证书(cookie 是凭证,不该裸奔)
# 调度
"schedule_enabled": "1",
"schedule_times": "09:00,17:00", # 每天固定时刻(逗号分隔,本地时区)
"catch_up": "1", # 启动时补跑当天已错过且未执行的槽位
"catch_up_grace_hours": "12", # 超过该小时数就不再补跑
# 凭证
"cookie": "",
"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"),
}
# 页面展示用:哪些键属于「敏感」,在界面上做掩码
SECRET_KEYS = {"cookie"}
# 内部簿记键前缀:调度槽位标记等,**不属于用户可配置项**,
# 不在 /api/settings 里回传,也不允许通过接口写入。
INTERNAL_PREFIXES = ("slot:",)
def is_internal_key(key):
return any(str(key).startswith(p) for p in INTERNAL_PREFIXES)
# ---------------- 设置项校验表 ----------------
# 这些键必须能安全地转成数字:后台页面是自由文本框,用户敲错一个字符
# 就会让采集在 int() 处抛 ValueError(历史 bug),所以写入时校验、读取时兜底。
# 值 = (最小值, 最大值, 单位说明)
NUM_SETTINGS = {
"page_size": (20, 1000, "条/页"),
"rewind_minutes": (0, 120, "分钟"),
"drift_tolerance_minutes": (0, 720, "分钟"),
"max_prompt": (0, 20000, "字符"),
"verify_days": (0, 90, "天"),
"timeout": (5, 300, "秒"),
"catch_up_grace_hours": (1, 168, "小时"),
}
BOOL_SETTINGS = {"schedule_enabled", "catch_up"}
_TRUE = ("1", "true", "yes", "on", "是", "启用")
def normalize_setting(key, raw):
"""校验并规范化单个设置值。
返回 (value, error):
* value 为可直接写入 settings 表的字符串;error 非空时 value 为 None。
* 未知键(不在 DEFAULTS 里)直接拒绝,避免接口被用来写任意键。
"""
if key not in DEFAULTS:
return None, "未知配置项:%s" % key
if raw is None:
return "", None
if key in BOOL_SETTINGS:
v = str(raw).strip().lower()
if v in _TRUE:
return "1", None
if v in ("0", "false", "no", "off", "否", "停用", ""):
return "0", None
return None, "%s 只能是 0/1" % key
if key in NUM_SETTINGS:
lo, hi, unit = NUM_SETTINGS[key]
try:
n = int(float(str(raw).strip()))
except (TypeError, ValueError):
return None, "%s 必须是数字(%s)" % (key, unit)
if not (lo <= n <= hi):
return None, "%s 需在 %d ~ %d %s 之间" % (key, lo, hi, unit)
return str(n), None
if key == "schedule_times":
from . import scheduler # 局部导入避免循环依赖
parsed = scheduler.parse_times(raw)
if not parsed:
return None, "每日时刻格式不对,正确写法如 09:00,17:00"
return ",".join(parsed), None
if key in ("api_base", "api_path"):
v = str(raw).strip()
if not v:
return None, "%s 不能为空" % key
if key == "api_base" and not v.startswith(("http://", "https://")):
return None, "接口基址需以 http:// 或 https:// 开头"
return v, None
if key == "cookie":
return str(raw).strip(), None
return str(raw).strip(), None
# 服务
DEFAULT_HOST = "0.0.0.0" # 局域网可访问
DEFAULT_PORT = 8848
SESSION_HOURS = 12
MAX_LOGIN_FAILS = 5 # 同 IP 连续失败次数
LOGIN_LOCK_MINUTES = 10
def ensure_dirs():
for d in (DATA_DIR, LOG_DIR, EXPORT_DIR):
os.makedirs(d, exist_ok=True)
def secret_key():
"""SECRET_KEY 持久化在 data/instance.json,避免每次重启把登录态全踢掉。"""
ensure_dirs()
data = {}
if os.path.exists(INSTANCE_FILE):
try:
with open(INSTANCE_FILE, "r", encoding="utf-8") as f:
data = json.load(f) or {}
except (OSError, ValueError):
data = {}
key = data.get("secret_key")
if not key:
key = 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
+156
查看文件
@@ -0,0 +1,156 @@
# -*- coding: utf-8 -*-
"""SQLite 访问层。
并发约定(重要):
* WAL 模式 —— 采集写入期间页面查询不会被 `database is locked` 挡住
* 单写者 —— SQLite 同一时刻只允许一个写进程,所以采集必须串行
(由 scheduler / CLI 共享的 collect.lock 保证)
* busy_timeout=8s —— 偶发并发时等待而不是立刻报错
* 每个线程独立连接(sqlite3 默认禁止跨线程复用连接)
"""
import os
import sqlite3
import threading
from datetime import datetime
from . import config
_local = threading.local()
_init_lock = threading.Lock()
_initialized = False
def now_str():
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def connect(path=None):
"""新建一个连接(调用方负责关闭)。"""
config.ensure_dirs()
conn = sqlite3.connect(path or config.SQLITE_PATH, timeout=8.0,
isolation_level=None) # autocommit,事务用显式 BEGIN
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
conn.execute("PRAGMA busy_timeout=8000")
conn.execute("PRAGMA foreign_keys=ON")
return conn
def thread_conn():
"""按线程缓存的连接(采集线程、调度线程各自一份)。"""
c = getattr(_local, "conn", None)
if c is None:
c = _local.conn = connect()
return c
def close_thread_conn():
c = getattr(_local, "conn", None)
if c is not None:
try:
c.close()
except sqlite3.Error:
pass
_local.conn = None
# ---------------- 初始化 ----------------
def init_db(conn=None, create_admin=True, admin_user="admin", admin_password=None):
"""建表 + 灌默认配置。可重复执行(幂等)。"""
global _initialized
own = conn is None
conn = conn or connect()
try:
with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "schema.sql"),
"r", encoding="utf-8") as f:
conn.executescript(f.read())
# 默认配置(不覆盖已有值)
ts = now_str()
for k, v in config.DEFAULTS.items():
conn.execute("INSERT OR IGNORE INTO settings(key,value,updated_at) VALUES(?,?,?)",
(k, v, ts))
if create_admin:
n = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
if n == 0:
from .security import hash_password
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
finally:
if own:
conn.close()
# ---------------- 配置读写 ----------------
def get_setting(conn, key, default=None):
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)
return row["value"]
def get_settings(conn, keys=None):
rows = conn.execute("SELECT key,value FROM settings").fetchall()
got = {r["key"]: r["value"] for r in rows}
out = dict(config.DEFAULTS)
out.update(got)
if keys:
return {k: out.get(k) for k in keys}
return out
def set_setting(conn, key, value):
conn.execute("INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at",
(key, "" if value is None else str(value), now_str()))
def set_settings(conn, pairs):
for k, v in pairs.items():
set_setting(conn, k, v)
def get_int(conn, key, default=0):
try:
return int(float(get_setting(conn, key, default)))
except (TypeError, ValueError):
return default
def get_float(conn, key, default=0.0):
try:
return float(get_setting(conn, key, default))
except (TypeError, ValueError):
return default
def get_bool(conn, key, default=False):
v = str(get_setting(conn, key, "1" if default else "0")).strip().lower()
return v in ("1", "true", "yes", "on", "是")
# ---------------- 审计 ----------------
def audit(conn, action, actor=None, detail=None, ip=None):
conn.execute("INSERT INTO audit_log(at,actor,action,detail,ip) VALUES(?,?,?,?,?)",
(now_str(), actor, action, detail, ip))
# ---------------- Flask 集成 ----------------
def get_db():
from flask import g
if "db" not in g:
g.db = connect()
return g.db
def close_db(exc=None):
from flask import g
db = g.pop("db", None)
if db is not None:
try:
db.close()
except sqlite3.Error:
pass
+353
查看文件
@@ -0,0 +1,353 @@
# -*- coding: utf-8 -*-
"""SQL 聚合层:所有统计都在 SQLite 里算完再出去,页面不再搬运全量明细。
返回结构刻意与旧版 dashboard/data/*.json 的字段保持一致(d/c/k/fc/bc/m/h、
id/c/m/cl/t/px …),这样 ECharts 大屏的渲染代码一行都不用改,只换数据来源。
"""
from datetime import datetime, timedelta
from . import config, db
SCHEMA_VERSION = 4
TOP_EXCERPT_LEN = 400
DEFAULT_TOP_N = 200
# 大屏页一次最多下发多少条窗口明细(页面要拿它在浏览器里算窗口 TOP / 散点)。
# 存档长大后不能让首屏体量线性膨胀,所以设上限并在响应里标注是否被截断。
BUNDLE_RECORDS_CAP = 20000
# 分页接口单页上限(导出走独立的流式游标,不受此限)
MAX_PAGE_SIZE = 500
def norm_day(s):
"""把各种写法归一成 'YYYY-MM-DD';无法识别返回 None。
接受 '2026-09-08'、'2026-09-08 12:00:00'、'2026/09/08'。
"""
if s is None:
return None
t = str(s).strip().replace("/", "-")
if not t:
return None
t = t.split(" ")[0].split("T")[0]
try:
return datetime.strptime(t, "%Y-%m-%d").strftime("%Y-%m-%d")
except ValueError:
return None
def norm_window(frm=None, to=None):
"""归一化并保证 from <= to。返回 (from, to),任一无法识别则为 None。"""
f, t = norm_day(frm), norm_day(to)
if f and t and f > t:
f, t = t, f
return f, t
def _where(frm=None, to=None, model=None, client=None, q=None):
w, p = [], []
if frm:
w.append("day >= ?")
p.append(frm)
if to:
w.append("day <= ?")
p.append(to)
if model:
w.append("model = ?")
p.append(model)
if client:
w.append("client = ?")
p.append(client)
if q:
w.append("(prompt LIKE ? OR request_id LIKE ?)")
p += ["%" + q + "%", "%" + q + "%"]
return ("WHERE " + " AND ".join(w)) if w else "", p
def _excerpt(s, n):
s = " ".join(str(s or "").split())
if not n or len(s) <= n:
return s
return s[:n].rstrip() + "…"
# ---------------- 逐日聚合 ----------------
def daily(conn, frm=None, to=None, with_maps=True):
w, p = _where(frm, to)
days = {}
for r in conn.execute(
"SELECT day d, COUNT(*) k, ROUND(SUM(credits),2) c,"
" SUM(CASE WHEN credits<=0 THEN 1 ELSE 0 END) fc,"
" MIN(ts) first, MAX(ts) last"
" FROM usage_records %s GROUP BY day ORDER BY day" % w, p):
k = r["k"] or 0
fc = r["fc"] or 0
days[r["d"]] = {"d": r["d"], "c": r["c"] or 0.0, "k": k, "fc": fc, "bc": k - fc,
"m": {}, "h": [0.0] * 24, "first": r["first"] or "", "last": r["last"] or ""}
if with_maps and days:
for r in conn.execute(
"SELECT day d, model, ROUND(SUM(credits),2) c FROM usage_records %s"
" GROUP BY day, model" % w, p):
if r["d"] in days:
days[r["d"]]["m"][r["model"]] = r["c"] or 0.0
for r in conn.execute(
"SELECT day d, hour h, ROUND(SUM(credits),2) c FROM usage_records %s"
" GROUP BY day, hour" % w, p):
if r["d"] in days:
days[r["d"]]["h"][r["h"]] = r["c"] or 0.0
return [days[d] for d in sorted(days)]
# ---------------- 维度汇总 ----------------
def _dim(conn, col, frm=None, to=None):
w, p = _where(frm, to)
rows = conn.execute(
"SELECT %s name, COUNT(*) calls, ROUND(SUM(credits),2) credits,"
" SUM(CASE WHEN credits<=0 THEN 1 ELSE 0 END) freeCalls,"
" COUNT(DISTINCT day) activeDays, MIN(day) firstDay, MAX(day) lastDay"
" FROM usage_records %s GROUP BY %s"
" ORDER BY credits DESC, calls DESC" % (col, w, col), p)
out = []
for r in rows:
calls = r["calls"] or 0
fc = r["freeCalls"] or 0
cr = r["credits"] or 0.0
out.append({
"name": r["name"], "calls": calls, "credits": cr,
"freeCalls": fc, "billableCalls": calls - fc,
"activeDays": r["activeDays"] or 0,
"firstDay": r["firstDay"] or "", "lastDay": r["lastDay"] or "",
"avgPerCall": round(cr / calls, 4) if calls else 0.0,
"freeRate": round(fc / calls, 4) if calls else 0.0,
})
return out
def dims(conn, frm=None, to=None):
hours = {int(r["name"]): r for r in _dim(conn, "printf('%02d',hour)", frm, to)}
hlist = []
for i in range(24):
h = "%02d" % i
hlist.append(hours.get(i, {"name": h, "calls": 0, "credits": 0.0, "freeCalls": 0,
"billableCalls": 0, "activeDays": 0, "firstDay": "",
"lastDay": "", "avgPerCall": 0.0, "freeRate": 0.0}))
for i, o in enumerate(hlist):
o["name"] = "%02d" % i
return {"model": _dim(conn, "model", frm, to),
"client": _dim(conn, "client", frm, to),
"hour": hlist}
# ---------------- 单笔榜 ----------------
def top(conn, frm=None, to=None, n=DEFAULT_TOP_N):
w, p = _where(frm, to)
items = []
for i, r in enumerate(conn.execute(
"SELECT request_id, credits, model, client, ts, prompt FROM usage_records %s"
" ORDER BY credits DESC, ts LIMIT ?" % w, p + [n])):
items.append({"rank": i + 1, "id": r["request_id"], "c": r["credits"] or 0.0,
"m": r["model"], "cl": r["client"], "t": r["ts"],
"px": _excerpt(r["prompt"], TOP_EXCERPT_LEN)})
return {"n": len(items), "items": items}
# ---------------- 明细(窗口内精简记录,不带 prompt 全文)----------------
def records(conn, frm=None, to=None, excerpt=96, limit=0, offset=0, newest_first=False):
w, p = _where(frm, to)
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,"
" substr(replace(replace(COALESCE(prompt,''),char(10),' '),char(13),' '),1,?) px"
" FROM usage_records %s %s" % (w, order))
args = [excerpt] + p
if limit:
sql += " LIMIT ? OFFSET ?"
args += [limit, offset]
return [{"id": r["request_id"], "c": r["credits"] or 0.0, "m": r["model"],
"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,
page=1, size=50, order="ts_desc", with_prompt=True):
frm, to = norm_window(frm, to)
size = max(1, min(int(size or 50), MAX_PAGE_SIZE))
page = max(1, int(page or 1))
w, p = _where(frm, to, model, client, q)
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()
orders = {"ts_desc": "ts DESC, request_id", "ts": "ts, request_id",
"credits_desc": "credits DESC, ts DESC", "credits": "credits, ts"}
ob = orders.get(order, orders["ts_desc"])
cols = "request_id,credits,model,client,ts,first_seen,last_seen,day,hour"
cols += ",prompt" if with_prompt else ""
rows = conn.execute("SELECT %s FROM usage_records %s ORDER BY %s LIMIT ? OFFSET ?" % (cols, w, ob),
p + [size, (page - 1) * size])
items = []
for r in rows:
o = {"request_id": r["request_id"], "credits": r["credits"] or 0.0,
"model": r["model"], "client": r["client"], "ts": r["ts"], "day": r["day"],
"hour": r["hour"], "first_seen": r["first_seen"], "last_seen": r["last_seen"]}
if with_prompt:
o["prompt"] = r["prompt"] or ""
items.append(o)
return {"total": total, "credits": agg["c"] or 0.0, "page": page, "size": size,
"pages": max(1, (total + size - 1) // size), "items": items}
def iter_records(conn, frm=None, to=None, model=None, client=None, q=None,
order="ts_desc", with_prompt=True, batch=1000):
"""流式产出明细(给导出用):不把整个结果集读进内存。"""
frm, to = norm_window(frm, to)
w, p = _where(frm, to, model, client, q)
orders = {"ts_desc": "ts DESC, request_id", "ts": "ts, request_id",
"credits_desc": "credits DESC, ts DESC", "credits": "credits, ts"}
ob = orders.get(order, orders["ts_desc"])
cols = "request_id,credits,model,client,ts"
cols += ",prompt" if with_prompt else ""
cur = conn.execute("SELECT %s FROM usage_records %s ORDER BY %s" % (cols, w, ob), p)
while True:
chunk = cur.fetchmany(batch)
if not chunk:
return
for r in chunk:
o = {"request_id": r["request_id"], "credits": r["credits"] or 0.0,
"model": r["model"], "client": r["client"], "ts": r["ts"]}
if with_prompt:
o["prompt"] = r["prompt"] or ""
yield o
# ---------------- 全局元信息 ----------------
def months(conn):
return [r[0] for r in conn.execute(
"SELECT DISTINCT substr(day,1,7) m FROM usage_records ORDER BY m")]
def totals(conn, frm=None, to=None):
w, p = _where(frm, to)
r = conn.execute(
"SELECT COUNT(*) n, ROUND(COALESCE(SUM(credits),0),2) c,"
" SUM(CASE WHEN credits<=0 THEN 1 ELSE 0 END) fc,"
" MIN(day) d0, MAX(day) d1, COUNT(DISTINCT day) nd,"
" COUNT(DISTINCT model) nm, COUNT(DISTINCT client) nc,"
" MIN(ts) t0, MAX(ts) t1"
" FROM usage_records %s" % w, p).fetchone()
n = r["n"] or 0
fc = r["fc"] or 0
return {"records": n, "credits": r["c"] or 0.0, "calls": n,
"freeCalls": fc, "billableCalls": n - fc,
"firstDay": r["d0"] or "", "lastDay": r["d1"] or "", "days": r["nd"] or 0,
"models": r["nm"] or 0, "clients": r["nc"] or 0,
"first": r["t0"] or "", "last": r["t1"] or ""}
def day_list(conn):
return [r[0] for r in conn.execute("SELECT DISTINCT day FROM usage_records ORDER BY day")]
def manifest(conn):
t = totals(conn)
db_bytes = conn.execute("PRAGMA page_count").fetchone()[0] * \
conn.execute("PRAGMA page_size").fetchone()[0]
runs = conn.execute("SELECT COUNT(*) FROM collect_runs").fetchone()[0]
last_run = conn.execute("SELECT * FROM collect_runs ORDER BY id DESC LIMIT 1").fetchone()
health = db.get_setting(conn, "cookie", "")
mons = months(conn) # 只算一次(原来在返回体里调了两遍)
return {
"schema": SCHEMA_VERSION,
"generated": db.now_str(),
"archive": "data/usage.sqlite",
"producer": "workbuddy-portal(Flask + SQLite)",
"note": "数据正本为 SQLite 表 usage_records;daily/dims/top 均为 SQL 实时聚合结果。",
"totals": {"records": t["records"], "credits": t["credits"], "calls": t["calls"],
"freeCalls": t["freeCalls"], "billableCalls": t["billableCalls"],
"days": day_list(conn), "months": mons,
"models": t["models"], "clients": t["clients"],
"first": t["first"], "last": t["last"],
"topCredits": (conn.execute("SELECT COALESCE(MAX(credits),0) FROM usage_records")
.fetchone()[0] or 0.0)},
"months": mons,
"sources": [
{"path": "usage_records", "role": "明细正本(SQLite 表)", "count": t["records"],
"bytes": db_bytes},
{"path": "daily 聚合视图", "role": "逐日聚合(SQL GROUP BY day)", "count": t["days"], "bytes": 0},
{"path": "dims 聚合视图", "role": "模型/客户端/时段汇总(SQL GROUP BY)",
"count": t["models"] + t["clients"] + 24, "bytes": 0},
{"path": "top 查询", "role": "单笔消耗榜(ORDER BY credits DESC)", "count": DEFAULT_TOP_N, "bytes": 0},
{"path": "collect_runs", "role": "采集运行历史", "count": runs, "bytes": 0},
],
"focusDay": (last_run["win_to"] or "")[:10] if last_run else "",
"health": {"cookie": bool(health and health.strip()),
"lastRunAt": last_run["started_at"] 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,
records_cap=BUNDLE_RECORDS_CAP):
"""大屏页一次请求拿齐所需数据。
窗口裁剪:records(明细)、dims(维度)、totals(KPI)随 frm/to 变化。
刻意不裁剪:daily(全量逐日,供日历与日期轴,体量小)、top(全局 TOP 榜)。
records 有上限(records_cap)并在响应里标注 recordsTruncated,
避免存档长大后「全部」区间把整包明细都压到浏览器。
"""
frm, to = norm_window(frm, to)
tot = totals(conn, frm, to)
# 只有真的会超限时才改成「取最近 N 条」,避免改变现有正常路径的行为
truncated = tot["records"] > records_cap
recs = records(conn, frm, to, excerpt=excerpt,
limit=records_cap if truncated else 0, newest_first=truncated)
return {
"manifest": manifest(conn),
"daily": daily(conn), # 全量逐日(体量小,供日历与日期轴)
"dims": dims(conn, frm, to), # 窗口内维度
"top": top(conn, None, None, top_n)["items"], # 全局 TOP 榜(对应「全局 TOP200」视图)
"records": recs,
"recordsTotal": tot["records"],
"recordsCap": records_cap,
"recordsTruncated": truncated,
"totals": tot,
"window": {"from": frm or "", "to": to or ""},
}
# ---------------- 环比 ----------------
def summary(conn, frm, to):
"""KPI + 环比。前一段必须完整落在存档范围内,否则不给假数字。
frm/to 会先归一化(容错 '2026-09-08 12:00:00'、'2026/09/08' 等写法),
并在 from > to 时自动交换——否则环比区间会算到未来去。
"""
frm, to = norm_window(frm, to)
if not frm or not to:
# 无法识别的日期:退化成全量口径,不抛异常(API 层会先校验并返回 400)
t = totals(conn)
frm, to = t["firstDay"], t["lastDay"]
if not frm or not to:
frm = to = datetime.now().strftime("%Y-%m-%d")
cur = totals(conn, frm, to)
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_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]
prev = None
if first_day and p_frm >= first_day:
prev = totals(conn, p_frm, p_to)
out = dict(cur)
out["window"] = {"from": frm, "to": to, "days": days}
out["avgPerCall"] = round(cur["credits"] / cur["calls"], 4) if cur["calls"] else 0.0
out["prev"] = prev
if prev:
out["delta"] = {
"credits": (cur["credits"] - prev["credits"]) / prev["credits"] * 100 if prev["credits"] else None,
"calls": (cur["calls"] - prev["calls"]) / prev["calls"] * 100 if prev["calls"] else None,
"window": {"from": p_frm, "to": p_to},
}
else:
out["delta"] = None
# 残日:最后一天不是完整的一天
if to == datetime.now().strftime("%Y-%m-%d"):
row = conn.execute("SELECT MAX(ts) FROM usage_records WHERE day=?", (to,)).fetchone()
if row and row[0]:
out["partial"] = {"date": to, "hhmm": row[0][11:16]}
return out
+178
查看文件
@@ -0,0 +1,178 @@
# -*- coding: utf-8 -*-
"""进程内调度器(手写,不依赖 APScheduler)。
为什么不引 APScheduler:
* 需求只是「每天固定几个时刻跑一次」,一个线程 + 20 秒轮询足够
* 需要「启动补跑」(程序没开的时候错过了时刻,开机后要补上)
* 需要和 CLI 共享同一把文件锁,避免两处同时采集
单实例保证:
* Flask 的 reloader 会 fork 两个进程 → 只在 WERKZEUG_RUN_MAIN 里启动
* 多进程部署时用环境变量 WB_DISABLE_SCHEDULER=1 关掉除一个之外的所有实例
* 真正防重复采集靠 collect._Lock(文件锁),即使两个调度线程同时触发也只会跑一个
"""
import logging
import os
import threading
from datetime import datetime, timedelta
from . import collect, db
log = logging.getLogger("wb.scheduler")
SLOT_PREFIX = "slot:" # settings 键:slot:09:00 -> 最近执行的日期
def parse_times(raw):
"""把 '09:00,17:00' 解析成 ['09:00','17:00'];非法片段直接丢弃。
对外公开(config.normalize_setting 用它做写入校验),所以不要改名。
"""
out = []
for part in str(raw or "").replace(";", ",").replace(",", ",").split(","):
p = part.strip()
if not p:
continue
bits = p.split(":")
try:
hh = int(bits[0])
mm = int(bits[1]) if len(bits) > 1 else 0
except ValueError:
continue
if 0 <= hh <= 23 and 0 <= mm <= 59:
out.append("%02d:%02d" % (hh, mm))
return sorted(set(out))
# 兼容旧名(早期版本用 _parse_times)
_parse_times = parse_times
def slots(conn):
return parse_times(db.get_setting(conn, "schedule_times"))
def last_run_of_slot(conn, slot):
return db.get_setting(conn, SLOT_PREFIX + slot, "")
def mark_slot(conn, slot, day):
db.set_setting(conn, SLOT_PREFIX + slot, day)
def next_run_at(conn, now=None):
"""下一次计划执行时间(仅按配置推算,不含补跑)。"""
if not db.get_bool(conn, "schedule_enabled", True):
return None
now = now or datetime.now()
best = None
for s in slots(conn):
hh, mm = map(int, s.split(":"))
cand = now.replace(hour=hh, minute=mm, second=0, microsecond=0)
if cand <= now:
cand += timedelta(days=1)
if best is None or cand < best:
best = cand
return best
def due_slots(conn, now=None):
"""返回此刻应当执行的槽位列表(含启动补跑)。"""
if not db.get_bool(conn, "schedule_enabled", True):
return []
now = now or datetime.now()
today = now.strftime("%Y-%m-%d")
# 用 get_int 兜底:catch_up_grace_hours 在后台是自由文本框,
# 历史上填成 "12h" 会让这里 int() 抛 ValueError,把 /tasks 打成 500。
grace_hours = db.get_int(conn, "catch_up_grace_hours", 12)
grace = timedelta(hours=max(1, grace_hours))
catch_up = db.get_bool(conn, "catch_up", True)
out = []
for s in slots(conn):
hh, mm = map(int, s.split(":"))
when = now.replace(hour=hh, minute=mm, second=0, microsecond=0)
if when > now:
continue # 还没到点
if last_run_of_slot(conn, s) == today:
continue # 今天这个槽位已跑过
if when < now - grace and catch_up:
continue # 错过太久,不补(避免开机狂刷)
if when < now - timedelta(seconds=90) and not catch_up:
continue # 未开启补跑,只认刚到的点
out.append(s)
return out
class Scheduler:
def __init__(self, interval=20):
self.interval = interval
self._stop = threading.Event()
self._thread = None
# ---- 生命周期 ----
def start(self):
if self._thread and self._thread.is_alive():
return False
self._stop.clear()
self._thread = threading.Thread(target=self._loop, name="wb-scheduler", daemon=True)
self._thread.start()
log.info("调度器已启动,轮询间隔 %ss", self.interval)
return True
def stop(self):
self._stop.set()
if self._thread:
self._thread.join(timeout=5)
@property
def running(self):
return bool(self._thread and self._thread.is_alive())
# ---- 主循环 ----
def _loop(self):
while not self._stop.is_set():
try:
self.tick()
except Exception as e: # 任何异常都不能让线程死掉
log.exception("调度 tick 出错:%s", e)
self._stop.wait(self.interval)
def tick(self, now=None):
conn = db.thread_conn()
now = now or datetime.now()
today = now.strftime("%Y-%m-%d")
for slot in due_slots(conn, now):
scheduled = now.replace(hour=int(slot[:2]), minute=int(slot[3:]),
second=0, microsecond=0)
trigger = "startup" if now - scheduled > timedelta(minutes=5) else "schedule"
log.info("触发采集:槽位 %s(%s)", slot, trigger)
mark_slot(conn, slot, today) # 先占位,避免采集失败被无限重试打爆云端
try:
r = collect.run_sync(trigger=trigger)
log.info("采集完成:%s", r["message"])
except collect.Busy as e:
log.warning("跳过(%s)", e)
except Exception as e:
log.error("采集失败:%s", e)
return True
_scheduler = None
def get_scheduler(interval=20):
global _scheduler
if _scheduler is None:
_scheduler = Scheduler(interval=interval)
return _scheduler
def start_from_app(app):
"""由 create_app 调用。遵守 reloader 与显式禁用开关。"""
if os.environ.get("WB_DISABLE_SCHEDULER") == "1":
app.logger.info("WB_DISABLE_SCHEDULER=1,调度器未启动")
return None
if app.debug and os.environ.get("WERKZEUG_RUN_MAIN") != "true":
return None # reloader 的父进程不启动,避免跑两份
sch = get_scheduler()
sch.start()
return sch
+83
查看文件
@@ -0,0 +1,83 @@
-- WorkBuddy Portal —— SQLite 表结构
-- 设计要点:
-- * usage_records 是唯一正本,request_id 为主键,去重靠 ON CONFLICT,不再依赖内存比对
-- * ts 存「本地保留的最早开始时间」;cloud_ts 存云端最近一次返回的时间(观察长请求前移)
-- * day / hour 是冗余列,配合索引让区间扫描与 GROUP BY 都能走索引
-- * prompt 单独存一列且默认不参与任何列表接口(占传输量约 80%)
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
CREATE TABLE IF NOT EXISTS usage_records (
request_id TEXT PRIMARY KEY,
ts TEXT NOT NULL, -- 'YYYY-MM-DD HH:MM:SS'
day TEXT NOT NULL, -- 'YYYY-MM-DD'
hour INTEGER NOT NULL, -- 0..23
model TEXT NOT NULL DEFAULT '-',
client TEXT NOT NULL DEFAULT '-',
credits REAL NOT NULL DEFAULT 0,
prompt TEXT,
first_seen TEXT NOT NULL, -- 本地首次入库时间
last_seen TEXT NOT NULL, -- 本地最近一次见到的时间
cloud_ts TEXT -- 云端最近一次返回的 requestTime
);
CREATE INDEX IF NOT EXISTS idx_ur_day ON usage_records(day);
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_client_day ON usage_records(client, day);
CREATE INDEX IF NOT EXISTS idx_ur_credits ON usage_records(credits DESC);
CREATE INDEX IF NOT EXISTS idx_ur_ts ON usage_records(ts);
-- 采集运行历史(任务管理 + 日志管理的正本)
CREATE TABLE IF NOT EXISTS collect_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trigger TEXT NOT NULL, -- manual | schedule | cli | startup
status TEXT NOT NULL, -- running | ok | warn | error
started_at TEXT NOT NULL,
finished_at TEXT,
duration_ms INTEGER,
win_from TEXT,
win_to TEXT,
fetched INTEGER DEFAULT 0, -- 云端返回条数
added INTEGER DEFAULT 0,
dup INTEGER DEFAULT 0,
total INTEGER DEFAULT 0, -- 入库后总条数
conflicts INTEGER DEFAULT 0,
exit_code INTEGER,
message TEXT, -- 一句话结论
detail TEXT -- 逐行日志(含 [warn] / [error] 原文)
);
CREATE INDEX IF NOT EXISTS idx_runs_started ON collect_runs(started_at DESC);
-- 键值配置:cookie / user_agent / 调度时刻 / 采集参数 / 调度槽位去重标记
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT,
updated_at TEXT
);
-- 后台登录账号(局域网访问必须)
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
display_name TEXT,
is_admin INTEGER NOT NULL DEFAULT 1,
created_at TEXT,
last_login_at TEXT,
login_count INTEGER NOT NULL DEFAULT 0
);
-- 操作审计(登录、改配置、手动触发等)
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
actor TEXT,
action TEXT NOT NULL,
detail TEXT,
ip TEXT
);
CREATE INDEX IF NOT EXISTS idx_audit_at ON audit_log(at DESC);
+193
查看文件
@@ -0,0 +1,193 @@
# -*- coding: utf-8 -*-
"""密码哈希、登录装饰器、CSRF。
局域网可访问 ⇒ 必须有鉴权。这里用 Werkzeug 自带的 PBKDF2,不引第三方依赖。
"""
import functools
import hmac
import secrets
import time
from flask import (current_app, flash, jsonify, redirect, render_template, request,
session, url_for)
from werkzeug.security import check_password_hash, generate_password_hash
from . import config, db
# 简易失败计数(内存即可:单进程部署,重启清零可接受)
_fails = {} # ip -> [count, first_ts]
_FAILS_MAX_IPS = 4096 # 上限,防止大量来源 IP 把字典撑爆
_FAILS_TTL = 3600 # 超过 1 小时无更新的条目会被清理
def _prune_fails(now=None):
"""清掉过期条目;条目数超上限时按时间淘汰最旧的。"""
now = now or time.time()
dead = [ip for ip, c in _fails.items() if now - c[1] > _FAILS_TTL]
for ip in dead:
_fails.pop(ip, None)
if len(_fails) > _FAILS_MAX_IPS:
for ip, _ in sorted(_fails.items(), key=lambda kv: kv[1][1])[:len(_fails) - _FAILS_MAX_IPS]:
_fails.pop(ip, None)
def hash_password(p):
return generate_password_hash(p, method="pbkdf2:sha256:200000")
def verify_password(hashed, p):
try:
return check_password_hash(hashed, p)
except (ValueError, TypeError):
return False
def login_ok(conn, username, password):
row = conn.execute("SELECT * FROM users WHERE username=?", (username,)).fetchone()
if row is None or not verify_password(row["password_hash"], password):
return None
conn.execute("UPDATE users SET last_login_at=?, login_count=login_count+1 WHERE id=?",
(db.now_str(), row["id"]))
return row
# ---------------- 跳转目标白名单(防开放重定向) ----------------
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():
uid = session.get("uid")
if not uid:
return None
return {"id": uid, "username": session.get("uname"), "display_name": session.get("dname"),
"is_admin": bool(session.get("adm", 1))}
def is_admin():
u = current_user()
return bool(u and u.get("is_admin"))
def login_session(user):
session.clear()
session["uid"] = user["id"]
session["uname"] = user["username"]
session["dname"] = user["display_name"] or user["username"]
try:
session["adm"] = 1 if user["is_admin"] else 0
except (KeyError, IndexError, TypeError):
session["adm"] = 1
session.permanent = True
def logout_session():
session.clear()
def wants_json():
return (request.path.startswith("/api/")
or request.accept_mimetypes.best == "application/json")
def login_required(fn):
@functools.wraps(fn)
def wrapper(*a, **kw):
if current_user() is None:
if wants_json():
return jsonify({"ok": False, "error": "unauthorized",
"message": "登录已失效,请重新登录"}), 401
return redirect(url_for("views.login", next=request.full_path))
return fn(*a, **kw)
return wrapper
def admin_required(fn):
"""管理员专属操作(用户管理等)。非管理员返回 403。"""
@functools.wraps(fn)
@login_required
def wrapper(*a, **kw):
if not is_admin():
if wants_json():
return jsonify({"ok": False, "error": "forbidden",
"message": "只有管理员可以执行该操作"}), 403
return render_template("error.html", code=403, message="只有管理员可以执行该操作"), 403
return fn(*a, **kw)
return wrapper
# ---------------- CSRF ----------------
def csrf_token():
t = session.get("_csrf")
if not t:
t = session["_csrf"] = secrets.token_urlsafe(24)
return t
def check_csrf():
"""对所有 POST/PUT/DELETE 生效,失败直接 400。"""
if request.method in ("GET", "HEAD", "OPTIONS"):
return None
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 wants_json():
return jsonify({"ok": False, "error": "csrf", "message": "CSRF 校验失败,请刷新页面"}), 400
return "CSRF 校验失败,请刷新页面后重试", 400
return None
def init_app(app):
app.jinja_env.globals["csrf_token"] = csrf_token
app.jinja_env.globals["current_user"] = current_user
@app.before_request
def _guard():
return check_csrf()
+8
查看文件
@@ -0,0 +1,8 @@
# -*- coding: utf-8 -*-
"""Web 层包:蓝图注册。"""
from . import api, views
def register(app):
app.register_blueprint(views.bp)
app.register_blueprint(api.bp)
+443
查看文件
@@ -0,0 +1,443 @@
# -*- coding: utf-8 -*-
"""JSON API —— ECharts 大屏与后台页面的数据入口。
约定:
* 全部需要登录;POST 另需 CSRF(security.check_csrf 统一拦截)
* 参数 from/to 为 'YYYY-MM-DD';缺省则不限(即全量)
* 列表类接口默认不返回 prompt 全文(占传输量约 80%),只有 /api/top 与
/api/records/<request_id> 会带
"""
import os
from datetime import datetime
from flask import Blueprint, jsonify, request
from .. import collect, config, db, query, scheduler
from ..security import admin_required, current_user, login_required
bp = Blueprint("api", __name__, url_prefix="/api")
def _arg(name, default=None):
v = request.args.get(name)
return v if v not in (None, "") else default
class BadParam(ValueError):
"""查询参数不合法 -> 由 __init__ 的 ValueError 处理器统一转成 400。"""
def _win(required=False):
"""解析 from/to,返回归一化后的 (from, to)。
归一化放在这里,视图与聚合层就不必再各自防御:
无法识别的写法('abc'、'2026-09-08 12:00:00' 里的空格等)此前会一路冒到
`datetime.strptime` 变成 HTTP 500,现在统一 400 并带上人话说明。
"""
raw_f, raw_t = _arg("from"), _arg("to")
f, t = query.norm_day(raw_f), query.norm_day(raw_t)
if raw_f and not f:
raise BadParam("参数 from 不是合法日期:%s(正确写法 2026-09-08)" % raw_f)
if raw_t and not t:
raise BadParam("参数 to 不是合法日期:%s(正确写法 2026-09-08)" % raw_t)
if f and t and f > t:
f, t = t, f
if required and (not f or not t):
raise BadParam("需要同时提供 from 与 to(YYYY-MM-DD)")
return f, t
def _int(name, default, lo=1, hi=2000):
try:
return max(lo, min(hi, int(request.args.get(name, default))))
except (TypeError, ValueError):
return default
@bp.get("/manifest")
@login_required
def api_manifest():
return jsonify(query.manifest(db.get_db()))
@bp.get("/bundle")
@login_required
def api_bundle():
"""大屏页一次拿齐:全量 daily + 窗口 dims/top/records。"""
frm, to = _win()
return jsonify(query.bundle(db.get_db(), frm, to, top_n=_int("topN", query.DEFAULT_TOP_N, 1, 1000)))
@bp.get("/summary")
@login_required
def api_summary():
conn = db.get_db()
frm, to = _win()
if not frm or not to:
t = query.totals(conn)
frm, to = t["firstDay"], t["lastDay"]
return jsonify(query.summary(conn, frm, to))
@bp.get("/daily")
@login_required
def api_daily():
return jsonify({"days": query.daily(db.get_db(), *_win())})
@bp.get("/dims")
@login_required
def api_dims():
conn = db.get_db()
d = query.dims(conn, *_win())
dim = _arg("dim")
if dim in d:
return jsonify({dim: d[dim]})
return jsonify(d)
@bp.get("/top")
@login_required
def api_top():
conn = db.get_db()
return jsonify(query.top(conn, *_win(), n=_int("n", 50, 1, 1000)))
@bp.get("/records")
@login_required
def api_records():
conn = db.get_db()
frm, to = _win()
page = _int("page", 1, 1, 100000)
size = _int("size", 50, 1, 500)
r = query.records_page(conn, frm, to, model=_arg("model"), client=_arg("client"),
q=_arg("q"), page=page, size=size, order=_arg("order", "ts_desc"),
with_prompt=False if _arg("lean") == "1" else True)
return jsonify(r)
@bp.get("/records/<request_id>")
@login_required
def api_record(request_id):
row = db.get_db().execute(
"SELECT * FROM usage_records WHERE request_id=?", (request_id,)).fetchone()
if row is None:
return jsonify({"ok": False, "message": "记录不存在"}), 404
return jsonify(dict(row))
@bp.get("/runs")
@login_required
def api_runs():
conn = db.get_db()
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"
" FROM collect_runs ORDER BY id DESC LIMIT ?", (_int("limit", 50, 1, 500),))
return jsonify({"items": [dict(r) for r in rows]})
@bp.get("/runs/<int:run_id>")
@login_required
def api_run(run_id):
row = db.get_db().execute("SELECT * FROM collect_runs WHERE id=?", (run_id,)).fetchone()
if row is None:
return jsonify({"ok": False, "message": "运行记录不存在"}), 404
return jsonify(dict(row))
@bp.get("/status")
@login_required
def api_status():
conn = db.get_db()
sch = scheduler.get_scheduler()
nxt = scheduler.next_run_at(conn)
last = conn.execute("SELECT * FROM collect_runs ORDER BY id DESC LIMIT 1").fetchone()
running = conn.execute("SELECT COUNT(*) FROM collect_runs WHERE status='running'").fetchone()[0]
return jsonify({
"server_time": db.now_str(),
"scheduler": {
"running": sch.running,
"enabled": db.get_bool(conn, "schedule_enabled", True),
"times": scheduler.slots(conn),
"next_run": nxt.strftime("%Y-%m-%d %H:%M:%S") if nxt else None,
"catch_up": db.get_bool(conn, "catch_up", True),
"lockfile": os.path.exists(collect.LOCK_PATH),
},
"running_runs": running,
"last_run": dict(last) if last else None,
"cookie_set": bool((db.get_setting(conn, "cookie") or "").strip()),
})
@bp.post("/collect")
@login_required
def api_collect():
"""手动触发一次采集(后台线程之外同步执行,页面等待结果)。"""
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return jsonify({"ok": False, "message": "请求体必须是对象"}), 400
frm, to = body.get("from"), body.get("to")
try:
kw = {}
if frm:
d = query.norm_day(frm)
if not d:
raise BadParam("起始日期不合法:%s(正确写法 2026-09-08)" % frm)
kw["from_dt"] = datetime.strptime(d, "%Y-%m-%d")
if to:
d = query.norm_day(to)
if not d:
raise BadParam("结束日期不合法:%s(正确写法 2026-09-08)" % to)
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"]:
raise BadParam("起始日期不能晚于结束日期")
r = collect.run_sync(trigger="manual", **kw)
except BadParam as e:
return jsonify({"ok": False, "error": "bad_request", "message": str(e)}), 400
except collect.Busy as e:
return jsonify({"ok": False, "error": "busy", "message": str(e)}), 409
except collect.ApiError as e:
code = 401 if e.cookie_expired else 502
return jsonify({"ok": False, "error": "cookie_expired" if e.cookie_expired else "api",
"message": str(e)}), code
except Exception as e: # noqa: BLE001
return jsonify({"ok": False, "error": "internal", "message": str(e)}), 500
db.audit(db.get_db(), "collect", (current_user() or {}).get("username"), r["message"],
request.remote_addr)
return jsonify({"ok": True, "result": r})
@bp.get("/audit")
@login_required
def api_audit():
"""操作审计分页(日志管理页用;原来只能看最近 40 条)。"""
conn = db.get_db()
action = _arg("action")
page = _int("page", 1, 1, 100000)
size = _int("size", 50, 1, 500)
w, p = "", []
if action:
w, p = "WHERE action = ?", [action]
total = conn.execute("SELECT COUNT(*) FROM audit_log %s" % w, p).fetchone()[0]
rows = conn.execute("SELECT * FROM audit_log %s ORDER BY id DESC LIMIT ? OFFSET ?" % w,
p + [size, (page - 1) * size])
actions = [r[0] for r in conn.execute(
"SELECT DISTINCT action FROM audit_log ORDER BY action")]
return jsonify({"total": total, "page": page, "size": size,
"pages": max(1, (total + size - 1) // size),
"actions": actions,
"items": [dict(r) for r in rows]})
# ---------------- 维护动作(原来只有 CLI 能做) ----------------
@bp.post("/maintenance/<action>")
@login_required
def api_maintenance(action):
"""把 CLI 里的维护动作搬到页面上:补全 prompt / VACUUM / 导出 CSV。"""
conn = db.get_db()
user = (current_user() or {}).get("username")
try:
if action == "fill-prompt":
try:
n = collect.fill_prompt(conn, log=lambda m: None)
except collect.ApiError as e:
return jsonify({"ok": False, "error": "api", "message": str(e)}), 502
msg = "补全 %d 条 User Prompt" % n
elif action == "vacuum":
before = os.path.getsize(config.SQLITE_PATH) if os.path.exists(config.SQLITE_PATH) else 0
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
conn.execute("VACUUM")
after = os.path.getsize(config.SQLITE_PATH) if os.path.exists(config.SQLITE_PATH) else 0
msg = "数据库整理完成:%s → %s" % (_human(before), _human(after))
elif action == "export-csv":
path, n = collect.export_csv(conn)
msg = "已导出 %d 条到 %s" % (n, os.path.relpath(path, config.BASE_DIR))
elif action == "recount":
n = collect.record_count(conn)
msg = "存档当前 %d 条记录" % n
else:
return jsonify({"ok": False, "error": "unknown", "message": "未知维护动作"}), 404
except Exception as e: # noqa: BLE001
return jsonify({"ok": False, "error": "internal", "message": str(e)}), 500
db.audit(conn, "maintenance:" + action, user, msg, request.remote_addr)
return jsonify({"ok": True, "message": msg})
def _human(n):
for unit in ("B", "KB", "MB", "GB"):
if n < 1024 or unit == "GB":
return "%.1f %s" % (n, unit) if unit != "B" else "%d B" % n
n /= 1024.0
@bp.get("/settings")
@login_required
def api_settings_get():
conn = db.get_db()
s = db.get_settings(conn)
if (s.get("cookie") or "").strip():
s["cookie_hint"] = "%d 字符,…%s" % (len(s["cookie"]), s["cookie"][-12:])
else:
s["cookie_hint"] = ""
s.pop("cookie", None) # 不回传明文凭证
# 内部簿记键(slot:09:00 这类调度槽位标记)不属于配置项,绝不外泄
for k in [k for k in list(s) if config.is_internal_key(k)]:
s.pop(k, None)
return jsonify(s)
@bp.post("/settings")
@login_required
def api_settings_post():
conn = db.get_db()
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return jsonify({"ok": False, "message": "请求体必须是对象"}), 400
changed, errors, ignored = [], [], []
for k, v in body.items():
if config.is_internal_key(k):
ignored.append(k)
continue # slot:* 是调度簿记,不允许前台写
if k == "cookie":
if not str(v).strip():
continue # 空值不动,避免误清
if str(v).strip().lower() in ("__clear__", "-"):
db.set_setting(conn, "cookie", "")
changed.append(k)
continue
val, err = config.normalize_setting(k, v)
if err:
errors.append(err)
continue
db.set_setting(conn, k, val)
changed.append(k)
if errors:
db.audit(conn, "settings_rejected", (current_user() or {}).get("username"),
";".join(errors)[:500], request.remote_addr)
return jsonify({"ok": False, "error": "invalid", "message": ";".join(errors),
"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"),
"修改:" + (",".join(sorted(changed)) or "(无变化)"), request.remote_addr)
return jsonify({"ok": True, "changed": sorted(changed), "ignored": sorted(ignored)})
@bp.post("/password")
@login_required
def api_password():
from ..security import hash_password, verify_password
conn = db.get_db()
body = request.get_json(silent=True) or {}
u = current_user()
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 ""):
return jsonify({"ok": False, "message": "原密码不正确"}), 400
new = (body.get("new") or "").strip()
err = _check_password(new, body.get("new2"))
if err:
return jsonify({"ok": False, "message": err}), 400
conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(new), u["id"]))
db.audit(conn, "password", u["username"], "修改登录密码", request.remote_addr)
return jsonify({"ok": True, "message": "密码已更新"})
# ---------------- 用户管理(原来只有 CLI passwd) ----------------
def _check_password(new, new2=None):
if len(new or "") < 6:
return "密码至少 6 位"
if len(new) > 128:
return "密码过长(上限 128 位)"
if new2 is not None and new2 != new:
return "两次输入的新密码不一致"
return None
@bp.get("/users")
@admin_required
def api_users():
rows = db.get_db().execute(
"SELECT id,username,display_name,is_admin,created_at,last_login_at,login_count"
" FROM users ORDER BY id").fetchall()
return jsonify({"items": [dict(r) for r in rows]})
@bp.post("/users")
@admin_required
def api_user_create():
from ..security import hash_password
conn = db.get_db()
body = request.get_json(silent=True) or {}
name = (body.get("username") or "").strip()
pwd = (body.get("password") or "").strip()
if not name or len(name) > 32:
return jsonify({"ok": False, "message": "用户名必填且不超过 32 字符"}), 400
err = _check_password(pwd, body.get("password2"))
if err:
return jsonify({"ok": False, "message": err}), 400
exist = conn.execute("SELECT id FROM users WHERE username=?", (name,)).fetchone()
if exist:
return jsonify({"ok": False, "message": "用户名已存在"}), 400
conn.execute("INSERT INTO users(username,password_hash,display_name,is_admin,created_at)"
" VALUES(?,?,?,?,?)",
(name, hash_password(pwd), (body.get("display_name") or name).strip()[:64],
1 if str(body.get("is_admin", "1")) in ("1", "true", "on") else 0,
db.now_str()))
db.audit(conn, "user_create", (current_user() or {}).get("username"), "新建用户 " + name,
request.remote_addr)
return jsonify({"ok": True, "message": "已创建用户 " + name})
@bp.post("/users/<int:uid>")
@admin_required
def api_user_update(uid):
from ..security import hash_password
conn = db.get_db()
row = conn.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone()
if row is None:
return jsonify({"ok": False, "message": "用户不存在"}), 404
body = request.get_json(silent=True) or {}
me = current_user()
changed = []
if "display_name" in body:
conn.execute("UPDATE users SET display_name=? WHERE id=?",
((body.get("display_name") or "").strip()[:64], uid))
changed.append("显示名")
if "is_admin" in body:
v = 1 if str(body.get("is_admin")) in ("1", "true", "on") else 0
if uid == me["id"] and not v:
return jsonify({"ok": False, "message": "不能取消自己的管理员身份"}), 400
conn.execute("UPDATE users SET is_admin=? WHERE id=?", (v, uid))
changed.append("管理员")
pwd = (body.get("password") or "").strip()
if pwd:
err = _check_password(pwd, body.get("password2"))
if err:
return jsonify({"ok": False, "message": err}), 400
conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(pwd), uid))
changed.append("密码")
if not changed:
return jsonify({"ok": False, "message": "没有要修改的内容"}), 400
db.audit(conn, "user_update", me["username"],
"修改用户 %s:%s" % (row["username"], "、".join(changed)), request.remote_addr)
return jsonify({"ok": True, "message": "已更新:" + "、".join(changed)})
@bp.post("/users/<int:uid>/delete")
@admin_required
def api_user_delete(uid):
conn = db.get_db()
me = current_user()
row = conn.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone()
if row is None:
return jsonify({"ok": False, "message": "用户不存在"}), 404
if uid == me["id"]:
return jsonify({"ok": False, "message": "不能删除当前登录的自己"}), 400
n = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
if n <= 1:
return jsonify({"ok": False, "message": "至少要保留一个账号"}), 400
conn.execute("DELETE FROM users WHERE id=?", (uid,))
db.audit(conn, "user_delete", me["username"], "删除用户 " + row["username"],
request.remote_addr)
return jsonify({"ok": True, "message": "已删除 " + row["username"]})
+417
查看文件
@@ -0,0 +1,417 @@
/* ============================================================
WorkBuddy Portal —— 统一视觉系统
设计令牌与大屏页(static/dashboard/index.html)保持一致,
两处用同一套色板/圆角/间距,避免后台与大屏像两个产品。
============================================================ */
:root {
/* 底色与层次 */
--bg: #080d1a;
--panel: rgba(255, 255, 255, .045);
--panel-hi: rgba(255, 255, 255, .07);
--panel-dim: rgba(0, 0, 0, .30);
--line: rgba(255, 255, 255, .09);
--line-soft: rgba(255, 255, 255, .06);
/* 文本 */
--text: #e8edf7;
--sub: #8b9bb4;
--dim: #64748b;
/* 主色板(与大屏 ECharts PALETTE 同源) */
--cyan: #22d3ee;
--violet: #a78bfa;
--amber: #fbbf24;
--green: #34d399;
--red: #f87171;
--blue: #60a5fa;
--pink: #f472b6;
/* 语义色(用量口径:升用琥珀、降用青,刻意不用红绿) */
--up: var(--amber);
--down: var(--cyan);
/* 形状与节奏 */
--r-card: 14px;
--r-ctl: 9px;
--r-pill: 6px;
--gap: 16px;
--shadow: 0 12px 34px rgba(0, 0, 0, .42);
color-scheme: dark;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background:
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);
font: 13.5px/1.65 "Microsoft YaHei", "PingFang SC", system-ui, -apple-system,
"Segoe UI", Roboto, Helvetica, Arial, sans-serif;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
a { color: var(--cyan); text-decoration: none; transition: color .15s; }
a:hover { text-decoration: underline; }
/* 表单控件必须显式继承字体:曾经写过 `font: 13px/1.5 inherit`,
那是非法声明(简写里不能出现 inherit 作为字族),整条被浏览器丢弃,
结果输入框用回浏览器默认字体,与页面格格不入。 */
button, input, select, textarea { font-family: inherit; font-size: 13px; }
code {
background: rgba(255, 255, 255, .07); padding: 1px 5px; border-radius: 4px;
font: 12px/1.5 ui-monospace, Consolas, "Cascadia Mono", monospace;
color: var(--cyan);
}
.mono, .logbox, .fullprompt {
font-family: ui-monospace, Consolas, "Cascadia Mono", monospace;
}
.mono { font-size: 12px; }
.sm { font-size: 11.5px; }
.nowrap { white-space: nowrap; }
.num, .tabular { font-variant-numeric: tabular-nums; }
.muted { color: var(--sub); }
.right { text-align: right; }
/* 页面标题里嵌的关键数字(如总积分),给一点强调但不喧宾夺主 */
.hl { color: var(--cyan); font-variant-numeric: tabular-nums; }
/* 0 积分的记录整行数字变暗,扫一眼就能跳过长尾 */
.tbl td.zero { color: var(--dim); }
/* 可访问性:键盘焦点一定要看得见 */
:focus-visible { outline: 2px solid rgba(34, 211, 238, .65); outline-offset: 2px; }
/* ---------------- 顶栏 ---------------- */
.topbar {
display: flex; align-items: center; gap: 18px;
padding: 0 22px; height: 58px;
background: rgba(10, 16, 28, .86);
border-bottom: 1px solid var(--line);
backdrop-filter: blur(10px);
position: sticky; top: 0; z-index: 50;
}
.brand { display: flex; align-items: center; gap: 9px; font-size: 14px; letter-spacing: .2px; }
.brand a { color: var(--text); }
.brand a:hover { text-decoration: none; color: var(--cyan); }
.brand .dot {
width: 9px; height: 9px; border-radius: 50%; background: var(--cyan);
box-shadow: 0 0 12px var(--cyan); flex: 0 0 auto;
}
.brand .ver { color: var(--dim); font-size: 11px; }
.topbar nav { display: flex; gap: 3px; margin-left: 6px; overflow-x: auto; }
.topbar nav a {
padding: 6px 13px; border-radius: var(--r-ctl); color: var(--sub);
font-size: 13px; white-space: nowrap; position: relative;
}
.topbar nav a:hover { background: var(--panel-hi); color: var(--text); text-decoration: none; }
.topbar nav a.on {
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 .who { color: var(--sub); font-size: 12.5px; }
.topbar .who b { color: var(--text); font-weight: 600; }
/* ---------------- 布局 ---------------- */
.wrap { max-width: 1480px; margin: 0 auto; padding: 20px 22px 60px; }
.pagehead {
display: flex; align-items: flex-end; justify-content: space-between;
gap: 14px; flex-wrap: wrap; margin-bottom: var(--gap);
}
.pagehead h1 {
font-size: 20px; margin: 0; font-weight: 700; letter-spacing: .3px;
display: flex; align-items: center; gap: 10px;
}
.pagehead h1::before {
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; }
.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); }
/* ---------------- 卡片 ---------------- */
.card {
background: var(--panel);
border: 1px solid var(--line); border-radius: var(--r-card);
padding: 16px 18px; margin-bottom: var(--gap);
}
.card > h2, .card > .cardhead > h2 {
font-size: 14px; margin: 0 0 12px; font-weight: 600; letter-spacing: .2px;
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
}
.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 {
display: flex; align-items: center; justify-content: space-between;
gap: 12px; flex-wrap: wrap; margin-bottom: 12px;
}
.cardhead > h2 { margin-bottom: 0; }
.card h3 { font-size: 13px; margin: 18px 0 10px; font-weight: 600; color: var(--text); }
.card h3:first-child { margin-top: 0; }
.hint { color: var(--sub); font-size: 12px; line-height: 1.75; }
.hint a { text-decoration: underline; text-decoration-color: rgba(34, 211, 238, .4); }
.card > .hint:last-child { margin-bottom: 0; }
.sect-divider { border: 0; border-top: 1px solid var(--line); margin: 18px 0; }
/* ---------------- KPI ---------------- */
.kpis {
display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 14px; margin-bottom: var(--gap);
}
.kpi {
background: var(--panel); border: 1px solid var(--line); border-radius: var(--r-card);
padding: 14px 16px; position: relative; overflow: hidden;
}
.kpi::after {
content: ""; position: absolute; left: 0; top: 0; width: 3px; height: 100%;
background: var(--c, var(--cyan));
}
.kpi > span { color: var(--sub); font-size: 12px; display: block; letter-spacing: .2px; }
.kpi > b {
display: block; font-size: 23px; margin: 7px 0 4px; font-weight: 700;
font-variant-numeric: tabular-nums; letter-spacing: .3px;
}
.kpi > i { color: var(--sub); font-size: 11.5px; font-style: normal; display: block; }
.kpi > i .delta { color: var(--up); font-variant-numeric: tabular-nums; }
.kpi > i .delta.dn { color: var(--down); }
/* ---------------- 表格 ---------------- */
/* 表格必须包在 .tablewrap 里:窄屏时横向滚动,而不是把卡片撑破 */
.tablewrap { overflow-x: auto; margin: 0 -2px; }
.tablewrap::-webkit-scrollbar { height: 8px; }
.tablewrap::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, .14); border-radius: 4px; }
.tbl { width: 100%; border-collapse: collapse; font-size: 12.5px; }
.tbl th, .tbl td {
padding: 9px 10px; text-align: left; border-bottom: 1px solid var(--line-soft);
vertical-align: top;
}
.tbl th {
color: var(--sub); font-weight: 500; font-size: 11.5px;
white-space: nowrap; letter-spacing: .3px;
position: sticky; top: 0; background: rgba(12, 18, 32, .96);
backdrop-filter: blur(4px); z-index: 1;
}
.tbl tbody tr:hover { background: rgba(255, 255, 255, .028); }
.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.empty, .tbl td.empty:hover { text-align: center; color: var(--sub); padding: 28px; }
.tbl td.px { max-width: 460px; min-width: 220px; }
.tbl details summary { cursor: pointer; color: var(--sub); }
.tbl details summary:hover { color: var(--text); }
.tbl details[open] summary { color: var(--cyan); }
.scroll-y { max-height: 340px; overflow: auto; }
.kv { width: 100%; border-collapse: collapse; font-size: 12.5px; }
.kv th {
width: 118px; color: var(--sub); text-align: left; font-weight: 500;
padding: 7px 10px 7px 0; vertical-align: top; white-space: nowrap;
}
.kv td { padding: 7px 0; }
.fullprompt {
margin-top: 9px; padding: 11px 13px; background: var(--panel-dim);
border: 1px solid var(--line); border-radius: 8px;
white-space: pre-wrap; word-break: break-word; color: var(--text);
font-size: 12px; line-height: 1.75; max-height: 320px; overflow: auto;
}
/* ---------------- 标签 / 徽章 ---------------- */
.tag {
display: inline-block; padding: 1px 7px; border-radius: var(--r-pill);
font-size: 11px; line-height: 17px; white-space: nowrap;
background: rgba(255, 255, 255, .08); color: var(--sub); vertical-align: 1px;
}
.tag.ok { background: rgba(52, 211, 153, .16); color: var(--green); }
.tag.warn { background: rgba(251, 191, 36, .16); color: var(--amber); }
.tag.bad { background: rgba(248, 113, 113, .16); color: var(--red); }
.tag.info { background: rgba(96, 165, 250, .16); color: var(--blue); }
.tag.accent { background: rgba(167, 139, 250, .18); color: var(--violet); }
.tag.mute { background: rgba(255, 255, 255, .06); color: var(--dim); }
.kbd {
display: inline-block; padding: 0 6px; border-radius: 4px;
border: 1px solid var(--line); background: var(--panel-dim);
font: 11.5px/18px ui-monospace, Consolas, monospace; color: var(--sub);
}
/* ---------------- 按钮 ---------------- */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
padding: 7px 14px; border-radius: var(--r-ctl); border: 1px solid var(--line);
background: var(--panel-hi); color: var(--text); font-size: 12.5px;
cursor: pointer; transition: background .15s, border-color .15s, box-shadow .15s, filter .15s;
white-space: nowrap; text-decoration: none;
}
.btn:hover { background: rgba(255, 255, 255, .11); text-decoration: none; }
.btn.primary {
background: linear-gradient(135deg, rgba(34, 211, 238, .26), rgba(167, 139, 250, .26));
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.ghost { background: transparent; color: var(--sub); }
.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:hover { background: rgba(248, 113, 113, .18); }
.btn.sm { padding: 4px 10px; font-size: 12px; }
.btn[disabled], .btn[aria-disabled=true] { opacity: .5; cursor: not-allowed; }
.btnrow { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
form .btn { margin-top: 8px; }
/* ---------------- 表单 ---------------- */
label { display: block; color: var(--sub); font-size: 12px; }
label input, label select, label textarea, .inp {
width: 100%; margin-top: 5px; padding: 8px 10px; border-radius: 8px;
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;
}
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 textarea {
font-family: ui-monospace, Consolas, "Cascadia Mono", monospace;
font-size: 12px; line-height: 1.6; resize: vertical;
}
label.col { margin-bottom: 13px; }
label.row { display: flex; align-items: center; gap: 12px; margin-bottom: 11px; }
label.row > span { flex: 0 0 132px; }
label.row input, label.row select { margin-top: 0; }
label.row.inline { display: inline-flex; margin-bottom: 0; }
label.row.inline > span { 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; }
select option { background: #111a2e; color: var(--text); }
.filters { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; }
.filters label { flex: 1 1 152px; }
.filters label input, .filters label select { min-width: 110px; }
.filters .btn { margin-top: 0; }
/* 快捷区间按钮组:卡片标题右侧、筛选条里都可能出现,所以不限定父级 */
.quick { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
/* 行内小输入框(用户列表里直接改显示名/权限) */
.inp-sm { padding: 4px 8px; font-size: 12px; margin-top: 0; width: auto; min-width: 96px; }
.tbl select.inp-sm { min-width: 84px; }
/* 分段控件(与大屏页 .seg 一致) */
.seg {
display: inline-flex; background: var(--panel-hi); border: 1px solid var(--line);
border-radius: var(--r-ctl); overflow: hidden;
}
.seg a, .seg button {
background: transparent; border: 0; color: var(--sub); padding: 5px 12px;
font-size: 12.5px; cursor: pointer; transition: .15s; font-family: inherit;
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.on, .seg button.on {
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 a, .pager .cur, .pager .gap {
padding: 4px 11px; border-radius: 7px; border: 1px solid var(--line);
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 .cur {
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 .meta { margin-left: auto; color: var(--sub); font-size: 11.5px; }
/* ---------------- 提示 / 日志 ---------------- */
.flashes { margin-bottom: var(--gap); }
.flash {
padding: 11px 14px; border-radius: var(--r-ctl); font-size: 12.5px;
margin-bottom: 8px; border: 1px solid var(--line);
background: rgba(34, 211, 238, .10); color: var(--text);
line-height: 1.7;
}
.flash.error { background: rgba(248, 113, 113, .12); border-color: rgba(248, 113, 113, .3); }
.flash.warn { background: rgba(251, 191, 36, .12); border-color: rgba(251, 191, 36, .3); }
.flash.ok { background: rgba(52, 211, 153, .12); border-color: rgba(52, 211, 153, .3); }
.flash:last-child { margin-bottom: 0; }
.logbox {
background: rgba(0, 0, 0, .40); border: 1px solid var(--line); border-radius: var(--r-ctl);
padding: 12px 14px; max-height: 430px; overflow: auto; margin: 0;
font-size: 11.5px; line-height: 1.75; white-space: pre-wrap;
word-break: break-word; color: #c6d3e6;
}
.logbox:empty::before { content: "(暂无内容)"; color: var(--dim); }
.empty-state {
padding: 34px 16px; text-align: center; color: var(--sub); font-size: 12.5px;
border: 1px dashed var(--line); border-radius: var(--r-ctl);
}
.empty-state b { display: block; color: var(--text); font-size: 13.5px; margin-bottom: 6px; font-weight: 600; }
/* ---------------- 登录 ---------------- */
.loginwrap {
display: flex; align-items: center; justify-content: center;
min-height: calc(100vh - 140px); padding: 24px 16px;
}
.login { width: 100%; max-width: 400px; padding: 28px 28px 22px; margin: 0; }
.login .logo {
width: 44px; height: 44px; border-radius: 13px; margin-bottom: 14px;
background: linear-gradient(135deg, var(--cyan), var(--violet));
display: flex; align-items: center; justify-content: center;
font-size: 20px; font-weight: 800; color: #061019;
}
.login h1 { font-size: 19px; margin: 0 0 5px; }
.login .hint { margin: 0 0 20px; }
.login label { margin-bottom: 14px; }
.login .btn { width: 100%; padding: 10px; margin-top: 4px; }
.login .foot-note {
margin: 16px 0 0; padding-top: 14px; border-top: 1px solid var(--line);
color: var(--dim); font-size: 11.5px; line-height: 1.7;
}
/* ---------------- 错误页 ---------------- */
.errpage { text-align: center; padding: 66px 24px; }
.errpage .code {
font-size: 52px; font-weight: 800; line-height: 1; margin: 0 0 12px;
background: linear-gradient(135deg, var(--cyan), var(--violet));
-webkit-background-clip: text; background-clip: text; color: transparent;
}
.errpage p { color: var(--sub); margin: 0 0 20px; }
.foot {
text-align: center; color: var(--dim); font-size: 11.5px;
padding: 18px 22px 30px; line-height: 1.9;
}
.foot code { font-size: 11px; }
/* ---------------- 响应式 ---------------- */
@media (max-width: 1100px) {
.grid2 { grid-template-columns: 1fr; }
}
@media (max-width: 820px) {
.topbar { gap: 10px; padding: 0 12px; height: auto; flex-wrap: wrap; padding-bottom: 8px; }
.topbar nav { order: 3; width: 100%; margin-left: 0; padding-bottom: 2px; }
.topbar .me { margin-left: auto; }
.wrap { padding: 14px 12px 48px; }
.pagehead { align-items: flex-start; }
.pagehead h1 { font-size: 18px; }
.kpi > b { font-size: 20px; }
label.row { flex-direction: column; align-items: stretch; gap: 4px; }
label.row > span { flex: 0 0 auto; }
.card { padding: 14px; }
.login { padding: 22px 18px 18px; }
}
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; animation: none !important; }
}
文件差异内容过多而无法显示 加载差异
文件差异因一行或多行过长而隐藏
+16
查看文件
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#22d3ee"/>
<stop offset="1" stop-color="#a78bfa"/>
</linearGradient>
</defs>
<rect x="2" y="2" width="60" height="60" rx="16" fill="#0b1220"/>
<rect x="2.75" y="2.75" width="58.5" height="58.5" rx="15.25" fill="none"
stroke="url(#g)" stroke-width="1.5" opacity=".55"/>
<g fill="url(#g)">
<rect x="14" y="34" width="8" height="16" rx="2.5"/>
<rect x="28" y="22" width="8" height="28" rx="2.5"/>
<rect x="42" y="14" width="8" height="36" rx="2.5"/>
</g>
</svg>

之后

宽度:  |  高度:  |  大小: 660 B

+193
查看文件
@@ -0,0 +1,193 @@
/* 门户页面的轻量交互:统一带 CSRF 的请求、表单提交、日志尾部加载、维护动作 */
(function () {
"use strict";
function token() {
return window.WB_CSRF || "";
}
// 所有提示都先转义再插入:服务端 message 里可能带用户输入(用户名、文件名),
// 直接 innerHTML 等于给它一个 XSS 入口。
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
});
}
function flashBox() {
var box = document.getElementById("collectMsg");
if (!box) {
box = document.createElement("div");
box.id = "collectMsg";
box.className = "flash";
var head = document.querySelector(".pagehead");
(head ? head.parentNode : document.body).insertBefore(box, head ? head.nextSibling : null);
}
return box;
}
// say(文本, 类别):文本按纯文本渲染,\n 转成换行
function say(msg, cat) {
var box = flashBox();
box.style.display = "block";
box.className = "flash " + (cat || "");
box.innerHTML = esc(msg).replace(/\n/g, "<br>");
if (cat !== "warn") {
window.clearTimeout(say._t);
say._t = window.setTimeout(function () { box.style.display = "none"; }, 12000);
}
}
function post(url, data, opts) {
opts = opts || {};
return fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", "X-CSRF-Token": token() },
body: JSON.stringify(data || {})
}).then(function (r) {
return r.json().catch(function () { return { ok: false, message: "响应不是 JSON(HTTP " + r.status + ")" }; })
.then(function (j) { return { status: r.status, body: j }; });
}).then(function (res) {
if (res.status === 401) {
say("登录已失效,即将跳转登录页…", "warn");
window.setTimeout(function () { location.href = "/login?next=" + encodeURIComponent(location.pathname); }, 1200);
throw new Error("unauthorized");
}
return res.body;
});
}
function formData(form) {
var out = {};
Array.prototype.forEach.call(form.elements, function (el) {
if (!el.name || el.type === "submit" || el.name === "_csrf") 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; }
out[el.name] = el.value;
});
return out;
}
// 失败提示的公共尾巴:把「下一步怎么办」直接告诉用户
function hintOf(j) {
if (!j) return "";
if (j.error === "cookie_expired") return "\n请到「配置管理」更新 Cookie 与 User-Agent(两者须取自同一次浏览器请求)。";
if (j.error === "busy") return "\n已有采集在进行,可稍后重试,或到「日志管理」查看进度。";
if (j.errors && j.errors.length) return "\n" + j.errors.join("\n");
return "";
}
// 通用表单绑定:提交到指定 API,成功后按 reload 与否决定刷新
function bindForm(sel, url, opts) {
opts = opts || {};
var form = document.querySelector(sel);
if (!form) return;
form.addEventListener("submit", function (e) {
e.preventDefault();
var d = formData(form);
if (opts.validate) {
var err = opts.validate(d);
if (err) { say(err, "warn"); return; }
}
if (opts.confirmText && !window.confirm(opts.confirmText)) return;
var btn = form.querySelector("button[type=submit]");
if (btn) { btn.disabled = true; btn.dataset.t = btn.textContent; btn.textContent = "处理中…"; }
var isCollect = url.indexOf("/collect") >= 0;
var req = isCollect ? post(url, { from: d.from || null, to: d.to || null }) : post(url, d);
req.then(function (j) {
if (j.ok === false) { say((j.message || "操作失败") + hintOf(j), "error"); return; }
if (isCollect && j.result) {
var r = j.result;
say("采集完成:云端返回 " + r.fetched + " 条,新增 " + r.added + " 条,重复 " + r.dup +
" 条,存档共 " + r.total + " 条。" + (r.conflicts ? "(有 " + r.conflicts + " 条时间漂移警告)" : ""),
r.conflicts ? "warn" : "ok");
} else {
say("已保存" + (j.changed && j.changed.length ? ":" + j.changed.join("、") : ""), "ok");
}
if (opts.reload !== false) window.setTimeout(function () { location.reload(); }, 900);
}).catch(function (e) {
if (String(e.message) !== "unauthorized") say("请求失败:" + e.message, "error");
}).finally(function () {
if (btn) { btn.disabled = false; btn.textContent = btn.dataset.t; }
});
});
}
function bindTail(btnSel, url, boxSel, metaSel) {
var btn = document.querySelector(btnSel);
if (!btn) return;
function load() {
btn.disabled = true;
fetch(url + "?lines=400", { headers: { "X-Requested-With": "fetch" } })
.then(function (r) { return r.json(); })
.then(function (j) {
var box = document.querySelector(boxSel);
box.textContent = (j.lines && j.lines.length) ? j.lines.join("\n") : "(日志为空)";
box.scrollTop = box.scrollHeight;
var meta = document.querySelector(metaSel);
if (meta) meta.textContent = j.path + " · " + (j.size / 1024).toFixed(1) + " KB";
})
.catch(function (e) { say("日志加载失败:" + e.message, "error"); })
.finally(function () { btn.disabled = false; });
}
btn.addEventListener("click", load);
load();
}
// 顶栏/页头的「立即采集一次」按钮
function bindCollect(sel) {
var btn = document.querySelector(sel || "#btnCollect");
if (!btn) return;
btn.addEventListener("click", function () {
btn.disabled = true;
var old = btn.textContent;
btn.textContent = "采集中…";
post("/api/collect", {})
.then(function (j) {
if (j.ok === false) { say((j.message || "采集失败") + hintOf(j), "error"); return; }
var r = j.result;
say("采集完成:云端返回 " + r.fetched + " 条,新增 " + r.added + " 条,重复 " + r.dup +
" 条,存档共 " + r.total + " 条。" + (r.conflicts ? "(" + r.conflicts + " 条时间漂移警告)" : ""),
r.conflicts ? "warn" : "ok");
window.setTimeout(function () { location.reload(); }, 1100);
})
.catch(function (e) { if (String(e.message) !== "unauthorized") say("请求失败:" + e.message, "error"); })
.finally(function () { btn.disabled = false; btn.textContent = old; });
});
}
// 维护动作:补全 Prompt / 导出全量 CSV / 整理数据库
var MAINT = {
"fill-prompt": { label: "补全缺失 Prompt", confirm: "补全 Prompt 需要联网并逐天重拉云端,可能耗时较久。继续?" },
"export-csv": { label: "导出全量 CSV" },
"vacuum": { label: "整理数据库", confirm: "将执行 checkpoint + VACUUM,期间数据库会短暂锁定。继续?" },
"recount": { label: "重新计数" }
};
function bindMaint(sel) {
Array.prototype.forEach.call(document.querySelectorAll(sel || "[data-maint]"), function (btn) {
btn.addEventListener("click", function () {
var act = btn.dataset.maint;
var meta = MAINT[act] || { label: act };
if (meta.confirm && !window.confirm(meta.confirm)) return;
btn.disabled = true;
var old = btn.textContent;
btn.textContent = "处理中…";
post("/api/maintenance/" + encodeURIComponent(act), {})
.then(function (j) {
if (j.ok === false) { say((j.message || (meta.label + " 失败")) + hintOf(j), "error"); return; }
say(j.message || (meta.label + " 完成"), "ok");
window.setTimeout(function () { location.reload(); }, 1500);
})
.catch(function (e) { if (String(e.message) !== "unauthorized") say("请求失败:" + e.message, "error"); })
.finally(function () { btn.disabled = false; btn.textContent = old; });
});
});
}
window.WBU = {
post: post, bindForm: bindForm, bindTail: bindTail,
bindCollect: bindCollect, bindMaint: bindMaint, say: say, esc: esc
};
document.addEventListener("DOMContentLoaded", function () { bindCollect("#btnCollect"); });
})();
+64
查看文件
@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>{% block title %}{{ project_title }}{% endblock %}</title>
<link rel="icon" href="{{ url_for('static', filename='favicon.svg') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}">
</head>
<body>
{% set nav = active|default('') %}
{% set on_dash = request.path.startswith('/dashboard') %}
{% if current_user() %}
<header class="topbar">
<div class="brand">
<span class="dot"></span>
<a href="{{ url_for('views.overview') }}"><b>{{ project_title }}</b></a>
<span class="ver">v{{ app_version }}</span>
</div>
<nav>
<a href="{{ url_for('views.overview') }}" class="{{ 'on' if nav=='overview' }}">概览</a>
<a href="{{ url_for('views.dashboard') }}" class="{{ 'on' if on_dash }}">用量大屏</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.config_page') }}" class="{{ 'on' if nav=='config' }}">配置管理</a>
<a href="{{ url_for('views.logs') }}" class="{{ 'on' if nav=='logs' }}">日志管理</a>
{% if current_user().is_admin %}
<a href="{{ url_for('views.users_page') }}" class="{{ 'on' if nav=='users' }}">用户管理</a>
{% endif %}
</nav>
<div class="me">
<span class="who"><b>{{ current_user().display_name }}</b>{% if current_user().is_admin %} <span class="tag accent">管理员</span>{% endif %}</span>
{# 退出用 POST + CSRF:GET 型退出会被 <img src="/logout"> 这类请求静默触发 #}
<form method="post" action="{{ url_for('views.logout_post') }}" style="margin:0">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<button class="btn ghost sm" type="submit">退出</button>
</form>
</div>
</header>
{% endif %}
<main class="wrap">
{% with msgs = get_flashed_messages(with_categories=true) %}
{% if msgs %}
<div class="flashes">
{% for cat, m in msgs %}<div class="flash {{ cat }}">{{ m }}</div>{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block body %}{% endblock %}
</main>
<footer class="foot">
<b>{{ project_title }}</b> · {{ project_name }} · 采集 / 存储 / 呈现三合一 · 数据正本 <code>data/usage.sqlite</code><br>
采集在 Web 进程内按配置时刻执行,无需外部计划任务
</footer>
<script>
window.WB_CSRF = "{{ csrf_token() }}";
</script>
{% block scripts %}{% endblock %}
</body>
</html>
+109
查看文件
@@ -0,0 +1,109 @@
{% extends "base.html" %}
{% block title %}配置管理 · {{ project_title }}{% endblock %}
{% block body %}
<div class="pagehead">
<div>
<h1>配置管理</h1>
<p class="lead">凭证、采集参数、维护动作都在这里;所有配置存在数据库,改完立即生效</p>
</div>
</div>
<section class="card">
<div class="cardhead">
<h2>云端凭证</h2>
<span class="tag {{ 'ok' if s.cookie_hint else 'bad' }}">{{ '已配置' if s.cookie_hint else '未配置' }}</span>
</div>
<p class="hint">
{% if s.cookie_hint %}当前 Cookie:{{ s.cookie_hint }}(页面与接口都不回传明文){% endif %}
<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>
(<b>两者必须取自同一次请求</b>),粘贴到下面。
</p>
<form id="formCred">
<label class="col">Cookie
<textarea name="cookie" rows="4" placeholder="留空表示不修改;填 - 表示清空已保存的 Cookie" spellcheck="false"></textarea>
</label>
<label class="col">User-Agent
<textarea name="user_agent" rows="2" spellcheck="false">{{ s.user_agent }}</textarea>
</label>
<button class="btn primary" type="submit">保存凭证</button>
</form>
</section>
<div class="grid2">
<section class="card">
<h2>采集参数</h2>
<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 %}
<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 }}">
<em class="unit">{{ b.page_size[0] }}~{{ b.page_size[1] }} 条/页</em></label>
<label class="row"><span>断点回退</span>
<input name="rewind_minutes" type="number" min="{{ b.rewind_minutes[0] }}" max="{{ b.rewind_minutes[1] }}" value="{{ s.rewind_minutes }}">
<em class="unit">分钟</em></label>
<label class="row"><span>时间漂移容差</span>
<input name="drift_tolerance_minutes" type="number" min="{{ b.drift_tolerance_minutes[0] }}" max="{{ b.drift_tolerance_minutes[1] }}" value="{{ s.drift_tolerance_minutes }}">
<em class="unit">分钟</em></label>
<label class="row"><span>Prompt 截断</span>
<input name="max_prompt" type="number" min="{{ b.max_prompt[0] }}" max="{{ b.max_prompt[1] }}" value="{{ s.max_prompt }}">
<em class="unit">字符(0 = 不截断)</em></label>
<label class="row"><span>整日校验天数</span>
<input name="verify_days" type="number" min="{{ b.verify_days[0] }}" max="{{ b.verify_days[1] }}" value="{{ s.verify_days }}">
<em class="unit">天</em></label>
<label class="row"><span>请求超时</span>
<input name="timeout" type="number" min="{{ b.timeout[0] }}" max="{{ b.timeout[1] }}" value="{{ s.timeout }}">
<em class="unit">秒</em></label>
<label class="row"><span>校验 TLS 证书</span>
<select name="ssl_verify">
<option value="1" {{ 'selected' if s.ssl_verify != '0' }}>校验(推荐)</option>
<option value="0" {{ 'selected' if s.ssl_verify == '0' }}>不校验(仅自签/企业代理时用)</option>
</select>
</label>
<p class="hint">Cookie 就是账号凭证,关掉证书校验等于把它暴露给中间人,非必要不要关。</p>
<button class="btn primary" type="submit">保存采集参数</button>
<p class="hint">整日校验会按天重新拉云端 total 与本地比对,发现缺记录自动补入;设为 0 表示关闭(日常够用)。</p>
</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">至少 6 位。修改成功后当前会话仍有效,不必重新登录。</p>
</form>
<hr class="sect-divider">
<h3>维护动作</h3>
<div class="btnrow">
<button class="btn" type="button" data-maint="fill-prompt"
title="把云端仍保留、但本地为空的 User Prompt 补回来">补全缺失 Prompt</button>
<button class="btn" type="button" data-maint="export-csv"
title="导出与官网 xlsx 同构的全量 CSV 到 data/exports/">导出全量 CSV</button>
<button class="btn" type="button" data-maint="vacuum"
title="checkpoint + VACUUM,回收删除后的空闲页">整理数据库</button>
</div>
<p class="hint">补全 Prompt 需要联网并逐天重拉云端;导出与整理只动本地数据。</p>
<div class="btnrow" style="margin-top:14px">
<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 %}
</div>
</section>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
<script>
WBU.bindForm('#formCred', '/api/settings');
WBU.bindForm('#formCollect', '/api/settings');
WBU.bindForm('#formPwd', '/api/password', {validate: d => d.new === d.new2 ? null : '两次输入的新密码不一致'});
WBU.bindMaint('[data-maint]');
</script>
{% endblock %}
+16
查看文件
@@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block title %}{{ code }} · {{ project_title }}{% endblock %}
{% block body %}
<div class="card errpage">
<div class="code">{{ code }}</div>
<p>{{ message }}</p>
<div class="btnrow" style="justify-content:center">
{% if current_user() %}
<a class="btn primary" href="{{ url_for('views.overview') }}">回到概览</a>
<a class="btn ghost" href="javascript:history.back()">返回上一页</a>
{% else %}
<a class="btn primary" href="{{ url_for('views.login') }}">去登录</a>
{% endif %}
</div>
</div>
{% endblock %}
+25
查看文件
@@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block title %}登录 · {{ project_title }}{% endblock %}
{% block body %}
<div class="loginwrap">
<form class="card login" method="post" action="{{ url_for('views.login') }}">
<div class="logo">W</div>
<h1>{{ project_title }}</h1>
<p class="hint">{{ project_desc }}</p>
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ next_url or '' }}">
<label>用户名
<input name="username" value="{{ username or '' }}" autocomplete="username"
autofocus required maxlength="32">
</label>
<label>密码
<input name="password" type="password" autocomplete="current-password" required>
</label>
<button class="btn primary" type="submit">登 录</button>
<p class="foot-note">
首次部署默认账号 <code>admin</code> / <code>admin123</code>,登录后请立即到「配置管理」修改密码。<br>
连续输错 {{ max_fails }} 次将锁定 {{ lock_minutes }} 分钟;登录状态保持 {{ session_hours }} 小时。
</p>
</form>
</div>
{% endblock %}
+155
查看文件
@@ -0,0 +1,155 @@
{% extends "base.html" %}
{% block title %}日志管理 · {{ project_title }}{% endblock %}
{% block body %}
<div class="pagehead">
<div>
<h1>日志管理</h1>
<p class="lead">采集逐次日志、应用运行日志与操作审计</p>
</div>
<div class="actions">
<button class="btn" id="btnTail" type="button">刷新应用日志</button>
</div>
</div>
{% if detail %}
<section class="card">
<div class="cardhead">
<h2>运行 #{{ detail.id }} 详情</h2>
<a class="btn ghost sm" href="{{ url_for('views.logs') }}">收起</a>
</div>
<table class="kv">
<tr><th>触发方式</th><td>{{ detail.trigger }}</td><th>状态</th>
<td>
{% if detail.status=='ok' %}<span class="tag ok">成功</span>
{% elif detail.status=='warn' %}<span class="tag warn">有警告</span>
{% elif detail.status=='running' %}<span class="tag info">进行中</span>
{% else %}<span class="tag bad">失败</span>{% endif %}
{% if detail.exit_code is not none %}<span class="tag mute">exit {{ detail.exit_code }}</span>{% endif %}
</td></tr>
<tr><th>开始</th><td class="mono">{{ detail.started_at }}</td><th>结束</th><td class="mono">{{ detail.finished_at or '—' }}</td></tr>
<tr><th>同步区间</th><td colspan="3" class="mono">{{ detail.win_from or '—' }} ~ {{ detail.win_to or '' }}</td></tr>
<tr><th>云端返回</th><td>{{ detail.fetched }}</td><th>新增 / 重复</th><td>{{ detail.added }} / {{ detail.dup }}</td></tr>
<tr><th>入库总数</th><td>{{ detail.total }}</td><th>时间冲突</th><td>{{ detail.conflicts }}</td></tr>
<tr><th>结论</th><td colspan="3">{{ detail.message or '—' }}</td></tr>
</table>
<pre class="logbox" style="margin-top:12px">{{ detail.detail or '(无日志)' }}</pre>
</section>
{% endif %}
<section class="card">
<div class="cardhead">
<h2>采集运行日志</h2>
<div class="seg">
<a href="{{ url_for('views.logs') }}" class="{{ 'on' if not status }}">全部</a>
<a href="{{ url_for('views.logs', status='ok') }}" class="{{ 'on' if status=='ok' }}">成功</a>
<a href="{{ url_for('views.logs', status='warn') }}" class="{{ 'on' if status=='warn' }}">警告</a>
<a href="{{ url_for('views.logs', status='error') }}" class="{{ 'on' if status=='error' }}">失败</a>
</div>
</div>
<div class="tablewrap">
<table class="tbl">
<thead><tr><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>
<tbody>
{% for r in runs %}
<tr>
<td>{{ r.id }}</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 class="nowrap">
{% if r.status=='ok' %}<span class="tag ok">成功</span>
{% elif r.status=='warn' %}<span class="tag warn">有警告</span>
{% elif r.status=='running' %}<span class="tag info">进行中</span>
{% else %}<span class="tag bad">失败</span>{% endif %}
</td>
<td class="num">{{ ((r.duration_ms or 0) / 1000) | round(1) }}s</td>
<td class="num">{{ r.added }}</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>{{ r.message or '—' }}</td>
<td><a href="{{ url_for('views.logs', run=r.id, status=status or none) }}">详情</a></td>
</tr>
{% else %}
<tr><td colspan="10" class="empty">暂无采集日志</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% if pages > 1 %}
{# 翻页时保留 status/act,否则一翻页筛选条件就丢了 #}
<div class="pager">
{% if page > 1 %}<a href="{{ url_for('views.logs', page=page-1, status=status or none, act=act or none) }}">‹</a>{% endif %}
{% for p in page_window %}
{% if p == page %}<span class="cur">{{ p }}</span>
{% else %}<a href="{{ url_for('views.logs', page=p, status=status or none, act=act or none) }}">{{ p }}</a>{% endif %}
{% endfor %}
{% if page < pages %}<a href="{{ url_for('views.logs', page=page+1, status=status or none, act=act or none) }}">›</a>{% endif %}
<span class="meta">第 {{ page }} / {{ pages }} 页 · 共 {{ '{:,}'.format(total) }} 次</span>
</div>
{% endif %}
</section>
<div class="grid2">
<section class="card">
<div class="cardhead">
<h2>应用日志</h2>
<span class="hint" id="tailMeta"></span>
</div>
<pre class="logbox" id="appLog">正在加载 logs/app.log …</pre>
</section>
<section class="card">
<div class="cardhead">
<h2>操作审计</h2>
<span class="hint">共 {{ '{:,}'.format(atotal) }} 条 · 第 {{ apage }} / {{ apages }} 页</span>
</div>
{% if actions %}
<div class="seg" style="margin-bottom:10px">
<a href="{{ url_for('views.logs', status=status or none) }}" class="{{ 'on' if not act }}">全部</a>
{% for a in actions[:6] %}
<a href="{{ url_for('views.logs', act=a[0], status=status or none) }}"
class="{{ 'on' if act==a[0] }}">{{ a[0] }} <span class="muted">{{ a[1] }}</span></a>
{% endfor %}
</div>
{% endif %}
<div class="tablewrap scroll-y">
<table class="tbl" id="auditTable">
<thead><tr><th>时间</th><th>用户</th><th>动作</th><th>说明</th><th>IP</th></tr></thead>
<tbody>
{% for a in audits %}
<tr><td class="mono nowrap">{{ a.at[5:] if a.at else '' }}</td>
<td>{{ a.actor or '—' }}</td>
<td><span class="tag mute">{{ a.action }}</span></td>
<td>{{ a.detail or '—' }}</td><td class="mono">{{ a.ip or '' }}</td></tr>
{% else %}
<tr><td colspan="5" class="empty">暂无审计记录</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% if apages > 1 %}
<div class="pager">
{% if apage > 1 %}
<a href="{{ url_for('views.logs', apage=apage-1, act=act or none, status=status or none) }}">‹</a>
{% endif %}
{% for p in apage_window %}
{% if p == apage %}<span class="cur">{{ p }}</span>
{% else %}<a href="{{ url_for('views.logs', apage=p, act=act or none, status=status or none) }}">{{ p }}</a>{% endif %}
{% endfor %}
{% if apage < apages %}
<a href="{{ url_for('views.logs', apage=apage+1, act=act or none, status=status or none) }}">›</a>
{% endif %}
<span class="meta">共 {{ '{:,}'.format(atotal) }} 条</span>
</div>
{% endif %}
</section>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
<script>
WBU.bindTail('#btnTail', '/logs/tail', '#appLog', '#tailMeta');
</script>
{% endblock %}
@@ -0,0 +1,124 @@
{% extends "base.html" %}
{% block title %}概览 · {{ project_title }}{% endblock %}
{% block body %}
<div class="pagehead">
<div>
<h1>概览</h1>
<p class="lead">存档 {{ '{:,}'.format(totals.records) }} 条记录,覆盖 {{ totals.firstDay }} ~ {{ totals.lastDay }}</p>
</div>
<div class="actions">
<a class="btn" href="{{ url_for('views.dashboard') }}">打开用量大屏</a>
<button class="btn primary" id="btnCollect" 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>{{ '%.2f'|format(totals.credits) }}</b>
<i>{{ totals.days }} 个活跃日 · 单次均价 {{ '%.2f'|format(totals.credits / totals.calls if totals.calls else 0) }}</i>
</div>
<div class="kpi" style="--c:var(--violet)">
<span>调用次数</span><b>{{ '{:,}'.format(totals.calls) }}</b>
<i>计费 {{ '{:,}'.format(totals.billableCalls) }} · 免费 {{ '{:,}'.format(totals.freeCalls) }}
({{ '%.1f'|format(totals.freeCalls / totals.calls * 100 if totals.calls else 0) }}%)</i>
</div>
<div class="kpi" style="--c:var(--green)">
<span>今日积分</span><b>{{ '%.2f'|format(today_stat.credits) }}</b>
<i>{{ today_stat.calls }} 次调用{% if today_stat.partial %} · 残日截至 {{ today_stat.partial.hhmm }}{% endif %}
{% if yesterday.credits %}
· 昨日整日 {{ '%.2f'|format(yesterday.credits) }}
{% endif %}</i>
</div>
<div class="kpi" style="--c:var(--amber)">
<span>近 30 天积分</span><b>{{ '%.2f'|format(stat30.credits) }}</b>
<i>{% if stat30.delta %}<span class="delta {{ 'dn' if stat30.delta.credits < 0 }}">环比 {{ '%+.1f'|format(stat30.delta.credits) }}%</span>
· 前一区间 {{ '%.0f'|format(stat30.prev.credits) }}{% else %}无可比区间{% endif %}</i>
</div>
<div class="kpi" style="--c:var(--blue)">
<span>模型 / 客户端</span><b>{{ totals.models }} / {{ totals.clients }}</b>
<i>按积分排序见下表</i>
</div>
<div class="kpi" style="--c:{{ 'var(--green)' if mf.health.cookie else 'var(--red)' }}">
<span>采集健康</span><b>{{ '正常' if mf.health.cookie else '缺 Cookie' }}</b>
<i>{% if mf.health.lastRunAt %}最近采集 {{ mf.health.lastRunAt[5:16] }}{% else %}尚无采集记录{% endif %}</i>
</div>
</div>
<div class="grid2">
<section class="card">
<h2>调度状态</h2>
<table class="kv">
<tr><th>调度线程</th><td>{% if sch.running %}<span class="tag ok">运行中</span> 每 {{ sch.interval }}s 轮询{% else %}<span class="tag bad">未运行</span>{% endif %}</td></tr>
<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 '—' }}{% if sch.catch_up %} <span class="tag">含启动补跑</span>{% endif %}</td></tr>
<tr><th>下次执行</th><td class="mono">{{ sch.next_run or '—' }}</td></tr>
<tr><th>采集互斥锁</th><td>{% if sch.lock %}<span class="tag warn">采集中</span>{% else %}<span class="tag ok">空闲</span>{% 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>
</table>
<p class="hint">调度在 Web 进程内运行,不再需要计划任务或外部自动化。所有时刻与开关都在
<a href="{{ url_for('views.tasks') }}">任务管理</a>里改。</p>
</section>
<section class="card">
<div class="cardhead">
<h2>模型消耗 TOP</h2>
<span class="hint">按积分降序,取前 {{ models|length }} 名</span>
</div>
<div class="tablewrap">
<table class="tbl">
<thead><tr><th>模型</th><th class="num">调用</th><th class="num">积分</th><th class="num">单次均价</th><th class="num">免费占比</th></tr></thead>
<tbody>
{% for m in models %}
<tr><td>{{ m.name }}</td><td class="num">{{ m.calls }}</td>
<td class="num">{{ '%.2f'|format(m.credits) }}</td>
<td class="num">{{ '%.2f'|format(m.avgPerCall) }}</td>
<td class="num">{{ '%.0f%%'|format(m.freeRate * 100) }}</td></tr>
{% else %}
<tr><td colspan="5" class="empty">还没有数据,先点右上角「立即采集一次」</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<p class="hint">客户端构成:{% for c in clients %}{{ c.name }} {{ '%.1f%%'|format(c.credits / totals.credits * 100 if totals.credits else 0) }}{% if not loop.last %} · {% endif %}{% else %}—{% endfor %}</p>
</section>
</div>
<section class="card">
<div class="cardhead">
<h2>最近采集</h2>
<a class="btn ghost sm" href="{{ url_for('views.tasks') }}">查看全部</a>
</div>
<div class="tablewrap">
<table class="tbl">
<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 class="num">冲突</th><th class="num">总数</th><th>结论</th></tr></thead>
<tbody>
{% for r in runs %}
<tr>
<td><a href="{{ url_for('views.logs', run=r.id) }}">{{ r.id }}</a></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="num">{{ ((r.duration_ms or 0) / 1000) | round(1) }}s</td>
<td class="num">{{ r.fetched if r.fetched is not none else '—' }}</td>
<td class="num">{{ r.added }}</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">{{ r.total }}</td>
<td>{{ r.message or '—' }}</td>
</tr>
{% else %}
<tr><td colspan="10" class="empty">还没有采集记录,点右上角「立即采集一次」</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
{% endblock %}
+169
查看文件
@@ -0,0 +1,169 @@
{% extends "base.html" %}
{% block title %}数据明细 · {{ project_title }}{% endblock %}
{% block body %}
{# 注意:模板里统一用 frm 而不是 from,避免与 Python 关键字混淆;
拼回 URL 时必须换成 HTTP 规范参数名 from/to,否则导出/翻页会丢筛选条件。 #}
{% set qs = {'from': f.frm, 'to': f.to, 'model': f.model, 'client': f.client,
'q': f.q, 'order': f.order, 'size': f.size} %}
{% set has_filter = f.frm or f.to or f.model or f.client or f.q %}
<div class="pagehead">
<div>
<h1>数据明细</h1>
<p class="lead">
共 {{ '{:,}'.format(data.total) }} 条匹配记录 · 合计
<b class="hl">{{ '%.2f'|format(data.credits) }}</b> 积分
</p>
</div>
<div class="actions">
<a class="btn ghost" href="{{ url_for('views.dashboard') }}">用量大屏</a>
<a class="btn primary" href="{{ url_for('views.records_export', **qs) }}">导出当前筛选 CSV</a>
</div>
</div>
<section class="card">
<div class="cardhead">
<h2>筛选</h2>
<div class="quick">
<button class="btn sm ghost" type="button" data-range="today">今日</button>
<button class="btn sm ghost" type="button" data-range="7d">近 7 天</button>
<button class="btn sm ghost" type="button" data-range="30d">近 30 天</button>
<button class="btn sm ghost" type="button" data-range="all">全部</button>
</div>
</div>
<form class="filters" id="formFilter" method="get" action="{{ url_for('views.records') }}">
<label>开始日期<input type="date" name="from" id="fFrom" value="{{ f.frm }}"></label>
<label>结束日期<input type="date" name="to" id="fTo" value="{{ f.to }}"></label>
<label>模型
<select name="model">
<option value="">全部</option>
{% for m in models %}<option value="{{ m }}" {{ 'selected' if f.model==m }}>{{ m }}</option>{% endfor %}
</select>
</label>
<label>客户端
<select name="client">
<option value="">全部</option>
{% for c in clients %}<option value="{{ c }}" {{ 'selected' if f.client==c }}>{{ c }}</option>{% endfor %}
</select>
</label>
<label>关键词
<input name="q" value="{{ f.q }}" placeholder="搜 Prompt / RequestID">
</label>
<label>排序
<select name="order">
<option value="ts_desc" {{ 'selected' if f.order=='ts_desc' }}>时间倒序</option>
<option value="ts" {{ 'selected' if f.order=='ts' }}>时间正序</option>
<option value="credits_desc" {{ 'selected' if f.order=='credits_desc' }}>积分从高到低</option>
<option value="credits" {{ 'selected' if f.order=='credits' }}>积分从低到高</option>
</select>
</label>
<label>每页
<select name="size">
{% for n in [20, 50, 100, 200, 500] %}
<option value="{{ n }}" {{ 'selected' if f.size==n }}>{{ n }}</option>
{% endfor %}
</select>
</label>
<button class="btn primary" type="submit">查询</button>
{% if has_filter %}<a class="btn ghost" href="{{ url_for('views.records') }}">重置</a>{% endif %}
</form>
<p class="hint">
日期留空表示不限。有关键词时会同时匹配 User Prompt 与 RequestID(按天全表扫描,区间越大越慢)。
</p>
</section>
<section class="card">
<div class="cardhead">
<h2>记录列表</h2>
<span class="hint">
第 {{ data.page }} / {{ data.pages }} 页 · 每页 {{ data.size }} 条
{%- if data.total %} · 本页 {{ rows|length }} 条{% endif %}
</span>
</div>
{% if rows %}
<div class="tablewrap">
<table class="tbl">
<thead>
<tr><th class="num">#</th><th>时间</th><th class="num">积分</th><th>模型</th>
<th>客户端</th><th>RequestID</th><th>User Prompt</th></tr>
</thead>
<tbody>
{% for r in rows %}
<tr>
<td class="num muted">{{ (data.page - 1) * data.size + loop.index }}</td>
<td class="mono nowrap">{{ r.ts }}</td>
<td class="num {{ 'zero' if not r.credits }}">{{ '%.2f'|format(r.credits) }}</td>
<td class="nowrap">{{ r.model }}</td>
<td class="nowrap"><span class="tag mute">{{ r.client }}</span></td>
<td class="mono sm nowrap" title="{{ r.request_id }}">{{ r.request_id[:16] }}…</td>
<td class="px">
{% if r.prompt %}
<details>
<summary>{{ r.prompt[:70] }}{% if r.prompt|length > 70 %}…{% endif %}</summary>
<div class="fullprompt">{{ r.prompt }}</div>
</details>
{% else %}
<span class="hint">(空)</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">
<b>没有匹配的记录</b>
{% if has_filter %}当前筛选条件过窄,试试放宽日期区间或点「重置」。{% else %}存档里还没有数据,到「任务管理」手动采集一次。{% endif %}
</div>
{% endif %}
{% if data.pages > 1 %}
<div class="pager">
{% if data.page > 1 %}
<a href="{{ url_for('views.records', page=1, **qs) }}">«</a>
<a href="{{ url_for('views.records', page=data.page-1, **qs) }}">‹ 上一页</a>
{% endif %}
{% for p in page_window %}
{% if p == data.page %}<span class="cur">{{ p }}</span>
{% else %}<a href="{{ url_for('views.records', page=p, **qs) }}">{{ p }}</a>{% endif %}
{% endfor %}
{% if data.page < data.pages %}
<a href="{{ url_for('views.records', page=data.page+1, **qs) }}">下一页 ›</a>
<a href="{{ url_for('views.records', page=data.pages, **qs) }}">»</a>
{% endif %}
<span class="meta">第 {{ data.page }} / {{ data.pages }} 页 · 共 {{ '{:,}'.format(data.total) }} 条</span>
</div>
{% endif %}
</section>
{% endblock %}
{% block scripts %}
<script>
// 快捷区间:只填表单里的两个日期输入框,真正的提交交给「查询」按钮,
// 避免按一下就把当前排序/每页设置冲掉。
(function () {
function ymd(d) { // 必须用本地字段拼串:toISOString 走 UTC,GMT+8 下会少一天
var m = d.getMonth() + 1, day = d.getDate();
return d.getFullYear() + "-" + (m < 10 ? "0" : "") + m + "-" + (day < 10 ? "0" : "") + day;
}
var from = document.getElementById("fFrom"), to = document.getElementById("fTo");
Array.prototype.forEach.call(document.querySelectorAll("[data-range]"), function (btn) {
btn.addEventListener("click", function () {
var v = btn.dataset.range, now = new Date();
if (v === "all") { from.value = ""; to.value = ""; }
else if (v === "today") { from.value = to.value = ymd(now); }
else {
var n = v === "7d" ? 6 : 29; // 含今天,所以是 N-1
from.value = ymd(new Date(now.getTime() - n * 86400000));
to.value = ymd(now);
}
from.focus();
});
});
})();
</script>
{% endblock %}
+128
查看文件
@@ -0,0 +1,128 @@
{% extends "base.html" %}
{% block title %}任务管理 · {{ project_title }}{% endblock %}
{% block body %}
<div class="pagehead">
<div>
<h1>任务管理</h1>
<p class="lead">调度在 Web 进程内执行,采集互斥由文件锁保证;这里也能手动触发与按区间回填</p>
</div>
<div class="actions">
<button class="btn primary" id="btnCollect" type="button">立即采集一次</button>
</div>
</div>
<div id="collectMsg" class="flash" style="display:none"></div>
<div class="grid2">
<section class="card">
<h2>采集调度</h2>
<form id="formTask">
<label class="row"><span>启用调度</span>
<select name="schedule_enabled">
<option value="1" {{ 'selected' if sch.enabled }}>启用</option>
<option value="0" {{ 'selected' if not sch.enabled }}>停用</option>
</select>
</label>
<label class="row"><span>每日时刻</span>
<input name="schedule_times" value="{{ s_times }}" placeholder="09:00,17:00" spellcheck="false">
</label>
<p class="hint">本地时区,逗号分隔,支持 <code>HH:MM</code>(也可只写 <code>9</code>)。保存后立即生效,
并会清空当天已执行的槽位标记以便新时刻接管。</p>
<label class="row"><span>启动补跑</span>
<select name="catch_up">
<option value="1" {{ 'selected' if sch.catch_up }}>开启(错过的时刻在宽限期内补跑)</option>
<option value="0" {{ 'selected' if not sch.catch_up }}>关闭(只在到点时执行)</option>
</select>
</label>
<label class="row"><span>补跑宽限</span>
<input name="catch_up_grace_hours" value="{{ s_grace }}" type="number" min="1" max="168">
<em class="unit">小时(超过就不补,避免开机狂刷)</em>
</label>
<button class="btn primary" type="submit">保存调度配置</button>
</form>
</section>
<section class="card">
<h2>运行状态</h2>
<table class="kv">
<tr><th>服务器时间</th><td class="mono">{{ sch.now }}</td></tr>
<tr><th>调度线程</th><td>{% if sch.running %}<span class="tag ok">运行中</span>{% else %}<span class="tag bad">未运行</span>{% endif %}
<span class="hint">({{ sch.interval }}s 轮询)</span></td></tr>
<tr><th>下次执行</th><td class="mono">{{ sch.next_run or '—' }}</td></tr>
<tr><th>互斥锁</th><td>{% if sch.lock %}<span class="tag warn">有采集在跑</span>{% else %}<span class="tag ok">空闲</span>{% endif %}</td></tr>
<tr><th>最近采集</th><td>{% if sch.last %}<span class="tag {{ 'ok' if sch.last.status=='ok' else ('warn' if sch.last.status=='warn' else 'bad') }}">#{{ sch.last.id }} {{ sch.last.status }}</span>
<span class="hint">{{ sch.last.message or '' }}</span>{% else %}—{% endif %}</td></tr>
</table>
<hr class="sect-divider">
<h3>历史回填</h3>
<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="to" value="{{ sch.now[:10] }}" max="{{ sch.now[:10] }}"></label>
<button class="btn" type="submit">按区间补采</button>
<p class="hint">指定区间重新拉取云端明细,已存在的记录按 <code>RequestID</code> 去重,不会重复计入。
区间越大耗时越长(云端按天分页拉取)。</p>
</form>
</section>
</div>
<section class="card">
<div class="cardhead">
<h2>采集运行历史</h2>
<span class="hint">共 {{ '{:,}'.format(total) }} 次</span>
</div>
<div class="tablewrap">
<table class="tbl">
<thead><tr><th>#</th><th>触发</th><th>状态</th><th>开始</th><th class="num">耗时</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 runs %}
<tr>
<td>{{ r.id }}</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">
{% if r.status=='ok' %}<span class="tag ok">成功</span>
{% elif r.status=='warn' %}<span class="tag warn">有警告</span>
{% elif r.status=='running' %}<span class="tag info">进行中</span>
{% else %}<span class="tag bad">失败</span>{% endif %}
{% if r.exit_code %}<span class="tag mute">exit {{ r.exit_code }}</span>{% endif %}
</td>
<td class="mono nowrap">{{ 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="mono">{{ r.win_from or '—' }}<br>{{ r.win_to or '' }}</td>
<td class="num">{{ r.fetched }}</td>
<td class="num">{{ r.added }}</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>{{ r.message or '—' }}</td>
<td><a href="{{ url_for('views.logs', run=r.id) }}">日志</a></td>
</tr>
{% else %}
<tr><td colspan="12" class="empty">暂无运行记录</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% if pages > 1 %}
<div class="pager">
{% if page > 1 %}<a href="{{ url_for('views.tasks', page=page-1) }}">‹</a>{% endif %}
{% for p in page_window %}
{% if p == page %}<span class="cur">{{ p }}</span>
{% else %}<a href="{{ url_for('views.tasks', page=p) }}">{{ p }}</a>{% endif %}
{% endfor %}
{% if page < pages %}<a href="{{ url_for('views.tasks', page=page+1) }}">›</a>{% endif %}
<span class="meta">第 {{ page }} / {{ pages }} 页</span>
</div>
{% endif %}
</section>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
<script>
WBU.bindForm('#formTask', '/api/settings');
WBU.bindForm('#formBackfill', '/api/collect', {confirmText: '确定按该区间重新采集吗?区间较大时会耗时较久。'});
</script>
{% endblock %}
+162
查看文件
@@ -0,0 +1,162 @@
{% extends "base.html" %}
{% block title %}用户管理 · {{ project_title }}{% endblock %}
{% block body %}
<div class="pagehead">
<div>
<h1>用户管理</h1>
<p class="lead">门户在局域网可访问,因此必须靠账号隔离;这里维护账号、管理员身份与密码</p>
</div>
<div class="actions">
<span class="tag accent">仅管理员可见</span>
</div>
</div>
<div class="grid2">
<section class="card">
<h2>新建账号</h2>
<form id="formNewUser">
<label class="row"><span>用户名</span>
<input name="username" maxlength="32" placeholder="登录名(≤32 字符)" autocomplete="off" spellcheck="false"></label>
<label class="row"><span>显示名</span>
<input name="display_name" maxlength="64" placeholder="留空则与用户名相同"></label>
<label class="row"><span>密码</span>
<input name="password" type="password" autocomplete="new-password"></label>
<label class="row"><span>确认密码</span>
<input name="password2" type="password" autocomplete="new-password"></label>
<label class="row"><span>权限</span>
<select name="is_admin">
<option value="1">管理员(可管理用户)</option>
<option value="0">普通账号(只读数据与日志)</option>
</select>
</label>
<button class="btn primary" type="submit">创建账号</button>
<p class="hint">密码至少 6 位、最多 128 位。普通账号不能用本页,也调不动用户管理接口。</p>
</form>
</section>
<section class="card">
<div class="cardhead">
<h2>账号列表</h2>
<span class="hint">共 {{ users|length }} 个</span>
</div>
<div class="tablewrap">
<table class="tbl" id="userTable">
<thead><tr><th class="num">ID</th><th>用户名</th><th>显示名</th><th>权限</th>
<th>最后登录</th><th class="num">次数</th><th>操作</th></tr></thead>
<tbody>
{% for u in users %}
<tr data-uid="{{ u.id }}" data-name="{{ u.username }}">
<td class="num muted">{{ u.id }}</td>
<td class="nowrap"><b>{{ u.username }}</b>
{% if u.id == me.id %}<span class="tag accent">当前</span>{% endif %}</td>
<td><input class="inp inp-sm" name="display_name" maxlength="64"
value="{{ u.display_name or '' }}" spellcheck="false"></td>
<td>
{# 不能取消自己的管理员身份,所以本人的下拉直接禁用(服务端也会再拦一次) #}
<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="0" {{ 'selected' if not u.is_admin }}>普通</option>
</select>
</td>
<td class="mono sm nowrap">{{ u.last_login_at or '—' }}</td>
<td class="num">{{ u.login_count }}</td>
<td class="nowrap">
<button class="btn sm" type="button" data-act="save">保存</button>
<button class="btn sm ghost" type="button" data-act="pwd">改密</button>
{% if u.id != me.id %}
<button class="btn sm danger" type="button" data-act="del">删除</button>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="7" class="empty">还没有账号</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<p class="hint">「改密」会依次询问新密码与确认;管理员不能取消自己的管理员身份,任何人也不能删除自己。</p>
</section>
</div>
<section class="card">
<div class="cardhead">
<h2>用户操作审计</h2>
<span class="hint">最近 20 条</span>
</div>
<div class="tablewrap scroll-y">
<table class="tbl">
<thead><tr><th>时间</th><th>操作者</th><th>动作</th><th>说明</th><th>IP</th></tr></thead>
<tbody>
{% for a in audits %}
<tr>
<td class="mono sm nowrap">{{ a.at[5:] if a.at else '' }}</td>
<td>{{ a.actor or '—' }}</td>
<td><span class="tag mute">{{ a.action }}</span></td>
<td>{{ a.detail or '—' }}</td>
<td class="mono sm">{{ a.ip or '' }}</td>
</tr>
{% else %}
<tr><td colspan="5" class="empty">暂无用户操作记录</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
<script>
WBU.bindForm('#formNewUser', '/api/users', {
validate: function (d) {
if (!d.username) return '用户名必填';
return d.password === d.password2 ? null : '两次输入的密码不一致';
}
});
// 行内操作走事件委托:表格动态渲染也不会丢绑定
document.getElementById('userTable').addEventListener('click', function (e) {
var btn = e.target.closest('button[data-act]');
if (!btn) return;
var row = btn.closest('tr'), uid = row.dataset.uid, name = row.dataset.name;
var act = btn.dataset.act;
var old = btn.textContent;
btn.disabled = true;
btn.textContent = '…';
function done(p) {
p.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; });
}
if (act === 'save') {
var fields = {};
fields.display_name = row.querySelector('[name=display_name]').value;
var sel = row.querySelector('[name=is_admin]');
if (sel && !sel.disabled) fields.is_admin = sel.value;
done(WBU.post('/api/users/' + uid, fields));
} else if (act === 'pwd') {
var p1 = window.prompt('为用户「' + name + '」设置新密码(至少 6 位)');
if (p1 === null) { btn.disabled = false; btn.textContent = old; return; }
var p2 = window.prompt('再输入一次新密码以确认');
if (p2 === null) { 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 }));
} else if (act === 'del') {
if (!window.confirm('确定删除用户「' + name + '」?该操作不可撤销(其历史审计记录会保留)。')) {
btn.disabled = false; btn.textContent = old; return;
}
done(WBU.post('/api/users/' + uid + '/delete', {}));
} else {
btn.disabled = false; btn.textContent = old;
}
});
</script>
{% endblock %}
+361
查看文件
@@ -0,0 +1,361 @@
# -*- coding: utf-8 -*-
"""页面路由(Jinja 模板)。
分工:
/ 概览(KPI + 入口)
/dashboard ECharts 交互大屏(独立静态页,登录后可达,数据走 /api/bundle)
/tasks 任务管理:调度开关/时刻、手动触发、运行历史
/config 配置管理:Cookie / UA / 采集参数 / 改密码
/logs 日志管理:采集逐次明细 + 应用日志尾部
/records 数据明细:分页、筛选、搜索、导出
"""
import csv
import io
import os
import sqlite3
from flask import (Blueprint, current_app, flash, jsonify, redirect, render_template,
request, send_from_directory, url_for)
from .. import collect, config, db, query, scheduler
from ..security import (admin_required, clear_fail, current_user, is_locked, lock_left,
login_ok, login_required, login_session, logout_session,
note_fail, safe_next)
bp = Blueprint("views", __name__)
def _ip():
return request.headers.get("X-Forwarded-For", request.remote_addr or "").split(",")[0].strip()
# ---------------- 登录 ----------------
def _login_ctx(**kw):
"""登录页共用的上下文:锁定阈值/会话时长都从配置读,避免模板里写死数字。"""
kw.setdefault("max_fails", config.MAX_LOGIN_FAILS)
kw.setdefault("lock_minutes", config.LOGIN_LOCK_MINUTES)
kw.setdefault("session_hours", config.SESSION_HOURS)
return kw
@bp.route("/login", methods=["GET", "POST"])
def login():
nxt = request.values.get("next") or ""
if request.method == "POST":
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()
pwd = request.form.get("password") or ""
conn = db.get_db()
user = login_ok(conn, username, pwd)
if user is None:
n = note_fail(ip)
db.audit(conn, "login_failed", username, "第 %d 次失败" % n, ip)
flash("用户名或密码不正确(剩余尝试 %d 次)" % max(0, config.MAX_LOGIN_FAILS - n), "error")
# 必须把 next 显式回填:失败后 request.args 为空,
# 若模板从 request.args 取值会导致跳转目标丢失(历史 bug)。
return render_template("login.html", **_login_ctx(username=username, next_url=nxt)), 401
clear_fail(ip)
login_session(user)
db.audit(conn, "login", username, "登录成功", ip)
return redirect(safe_next(nxt, url_for("views.overview")))
if current_user():
return redirect(url_for("views.overview"))
return render_template("login.html", **_login_ctx(next_url=nxt))
@bp.post("/logout")
@login_required
def logout_post():
"""退出登录改为 POST + CSRF:GET 型退出会被 <img src> 这类请求静默触发。"""
u = current_user()
if u:
db.audit(db.get_db(), "logout", u["username"], "", _ip())
logout_session()
flash("已退出登录", "ok")
return redirect(url_for("views.login"))
@bp.get("/logout")
def logout():
"""保留 GET 入口:老书签/旧页面不会 405,但只做跳转不执行退出。"""
if current_user():
flash("为安全起见,退出登录请点页面右上角的「退出」按钮", "warn")
return redirect(url_for("views.overview"))
return redirect(url_for("views.login"))
# ---------------- 概览 ----------------
@bp.get("/")
@login_required
def overview():
conn = db.get_db()
mf = query.manifest(conn)
t = query.totals(conn)
today = db.now_str()[:10]
st = query.summary(conn, today, today)
d30 = query.summary(conn, _shift(-29), today)
# 昨日对比:昨日整日 vs 今日(残日),让「今天偏少」有参照
y = _shift(-1)
yest = query.summary(conn, y, y)
dims = query.dims(conn)
# 注意:这里的 SQL 必须把模板用到的列都选出来(模板渲染 r.fetched,
# 少选一列并不会报错,只会静默渲染成空白 —— 历史 bug)。
runs = conn.execute(
"SELECT id,trigger,status,started_at,duration_ms,fetched,added,dup,total,conflicts,message"
" FROM collect_runs ORDER BY id DESC LIMIT 8").fetchall()
return render_template("overview.html", mf=mf, totals=t, today_stat=st, stat30=d30,
yesterday=yest, yday=y,
models=dims["model"][:8], clients=dims["client"],
runs=runs, sch=_sch_info(conn), active="overview")
def _shift(days):
from datetime import datetime, timedelta
return (datetime.now() + timedelta(days=days)).strftime("%Y-%m-%d")
# ---------------- 大屏(独立 ECharts 页)----------------
@bp.get("/dashboard")
@bp.get("/dashboard/")
@login_required
def dashboard():
# 大屏是独立静态页,但它也是导航的一项 —— 用 redirect 到一个带 active 的
# 路由做不到(静态页由 send_from_directory 直接吐文件),所以这里只负责发文件;
# 导航高亮改为让大屏页自己提供「返回后台」入口(见 index.html 的 topbar)。
return send_from_directory(os.path.join(current_app.static_folder, "dashboard"), "index.html")
# ---------------- 任务管理 ----------------
def _sch_info(conn):
sch = scheduler.get_scheduler()
nxt = scheduler.next_run_at(conn)
last = conn.execute("SELECT * FROM collect_runs ORDER BY id DESC LIMIT 1").fetchone()
return {
"running": sch.running,
"enabled": db.get_bool(conn, "schedule_enabled", True),
"times": scheduler.slots(conn),
"next_run": nxt.strftime("%Y-%m-%d %H:%M:%S") if nxt else None,
"catch_up": db.get_bool(conn, "catch_up", True),
"interval": sch.interval,
"last": dict(last) if last else None,
"lock": os.path.exists(collect.LOCK_PATH),
"now": db.now_str(),
}
@bp.get("/tasks")
@login_required
def tasks():
conn = db.get_db()
page = _int_arg("page", 1, 1, 10 ** 6)
size = 20
total = conn.execute("SELECT COUNT(*) FROM collect_runs").fetchone()[0]
runs = conn.execute("SELECT * FROM collect_runs ORDER BY id DESC LIMIT ? OFFSET ?",
(size, (page - 1) * size)).fetchall()
s = db.get_settings(conn)
pages = max(1, (total + size - 1) // size)
return render_template("tasks.html", runs=runs, sch=_sch_info(conn),
s_times=s.get("schedule_times") or "",
s_grace=s.get("catch_up_grace_hours") or "12",
page=page, pages=pages, total=total,
page_window=_page_window(page, pages),
active="tasks")
def _int_arg(name, default, lo, hi):
"""健壮地取整数查询参数:非法值回落到 default,不抛异常(历史 500 来源)。"""
try:
return max(lo, min(hi, int(request.args.get(name) or default)))
except (TypeError, ValueError):
return default
def _page_window(page, pages, span=9):
"""分页器只渲染当前页附近的一段,避免几百个页码链接把页面撑长。"""
if pages <= span:
return list(range(1, pages + 1))
half = span // 2
lo = max(1, min(page - half, pages - span + 1))
return list(range(lo, lo + span))
# ---------------- 配置管理 ----------------
@bp.get("/config")
@login_required
def config_page():
conn = db.get_db()
s = db.get_settings(conn)
for k in [k for k in list(s) if config.is_internal_key(k)]:
s.pop(k, None)
cookie = (s.pop("cookie", "") or "")
s["cookie_hint"] = ("%d 字符,结尾 …%s" % (len(cookie), cookie[-16:])) if cookie else ""
return render_template("config.html", s=s, sch=_sch_info(conn),
secret_keys=config.SECRET_KEYS,
num_settings=config.NUM_SETTINGS,
active="config")
# ---------------- 用户管理 ----------------
@bp.get("/users")
@admin_required
def users_page():
conn = db.get_db()
users = conn.execute(
"SELECT id,username,display_name,is_admin,created_at,last_login_at,login_count"
" FROM users ORDER BY id").fetchall()
audits = conn.execute("SELECT * FROM audit_log WHERE action LIKE 'user%'"
" ORDER BY id DESC LIMIT 20").fetchall()
return render_template("users.html", users=users, audits=audits,
me=current_user(), active="users")
# ---------------- 日志管理 ----------------
@bp.get("/logs")
@login_required
def logs():
conn = db.get_db()
run_id = request.args.get("run")
detail = None
if run_id and str(run_id).isdigit():
detail = conn.execute("SELECT * FROM collect_runs WHERE id=?", (int(run_id),)).fetchone()
status = request.args.get("status") or ""
w, p = ("WHERE status = ?", [status]) if status in ("ok", "warn", "error", "running") else ("", [])
# 操作审计:按动作筛选 + 分页(原来只能看最近 40 条,等于不可查)
act = request.args.get("act") or ""
aw, ap = ("WHERE action = ?", [act]) if act else ("", [])
apage = _int_arg("apage", 1, 1, 10 ** 6)
asize = 20
atotal = conn.execute("SELECT COUNT(*) FROM audit_log %s" % aw, ap).fetchone()[0]
audits = conn.execute("SELECT * FROM audit_log %s ORDER BY id DESC LIMIT ? OFFSET ?" % aw,
ap + [asize, (apage - 1) * asize]).fetchall()
# 注意传的是 sqlite3.Row 列表而不是纯字符串列表:模板要用 a[0]=动作、a[1]=次数,
# 若在这里就用推导式取 r[0],模板里的 a[0] 会变成「字符串的第一个字符」。
actions = conn.execute(
"SELECT action, COUNT(*) n FROM audit_log GROUP BY action ORDER BY n DESC, action").fetchall()
page = _int_arg("page", 1, 1, 10 ** 6)
size = 30
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"
" ORDER BY id DESC LIMIT ? OFFSET ?" % w, p + [size, (page - 1) * size]).fetchall()
apages = max(1, (atotal + asize - 1) // asize)
return render_template("logs.html", runs=runs, detail=detail, audits=audits,
actions=actions, act=act, apage=apage, apages=apages, atotal=atotal,
apage_window=_page_window(apage, apages, span=7),
page=page, pages=max(1, (total + size - 1) // size), total=total,
page_window=_page_window(page, max(1, (total + size - 1) // size)),
status=status,
active="logs")
@bp.get("/logs/tail")
@login_required
def logs_tail():
n = _int_arg("lines", 200, 10, 2000)
path = config.APP_LOG
if not os.path.exists(path):
return jsonify({"lines": [], "path": path, "size": 0})
# 只保留尾部 n 行,不把整个日志文件读进内存
from collections import deque
with open(path, "r", encoding="utf-8", errors="replace") as f:
lines = list(deque(f, maxlen=n))
return jsonify({"lines": [l.rstrip("\n") for l in lines], "path": path,
"size": os.path.getsize(path)})
# ---------------- 数据明细 ----------------
def _day_args():
"""取明细页的日期区间。
规范参数名是 from / to,但模板内部为了避免与 Python 关键字混淆用的是
frm,历史上也出现过 `?frm=` 的链接,这里一并接受,避免导出/翻页丢筛选条件。
"""
a = request.args
frm = query.norm_day(a.get("from") or a.get("frm"))
to = query.norm_day(a.get("to"))
if frm and to and frm > to:
frm, to = to, frm
return frm, to
@bp.get("/records")
@login_required
def records():
conn = db.get_db()
frm, to = _day_args()
model = request.args.get("model") or None
client = request.args.get("client") or None
q = request.args.get("q") or None
order = request.args.get("order") or "ts_desc"
page = _int_arg("page", 1, 1, 10 ** 6)
size = _int_arg("size", 50, 10, query.MAX_PAGE_SIZE)
data = query.records_page(conn, frm, to, model=model, client=client, q=q,
page=page, size=size, order=order)
# 只算一次 dims:query.dims() 内部有 3 条 GROUP BY,重复调用纯属浪费
d = query.dims(conn)
models = [r["name"] for r in d["model"]]
clients = [r["name"] for r in d["client"]]
# 注意:不要把含 "items" 键的 dict 直接交给模板——Jinja 的属性查找会先命中
# dict.items 这个方法而不是数据,所以这里拆出独立变量。
return render_template("records.html", data=data, rows=data["items"], models=models,
clients=clients,
page_window=_page_window(page, data["pages"]),
f=dict(frm=frm or "", to=to or "", model=model or "",
client=client or "", q=q or "", order=order, size=size),
active="records")
@bp.get("/records/export")
@login_required
def records_export():
"""按当前筛选条件导出 CSV(与官网 xlsx 同构的列)。
用 csv 模块逐行写(原来手工拼字符串,model/client 含逗号或引号时会串列),
数据用 query.iter_records 流式取,不把整个结果集读进内存。
"""
from flask import Response
frm, to = _day_args()
model = request.args.get("model") or None
client = request.args.get("client") or None
q = request.args.get("q") or None
order = request.args.get("order") or "ts_desc"
def gen():
yield "\ufeff" # UTF-8 BOM:Excel 直接双击不乱码
buf = io.StringIO()
w = csv.writer(buf, lineterminator="\r\n")
w.writerow(collect.FIELDS)
yield buf.getvalue()
# 重要:流式响应的 body 是在视图返回、请求上下文被 pop(teardown 里
# close_db 已把 g.db 关掉)之后才被 WSGI 服务器逐块拉取的,
# 所以这里绝不能复用 db.get_db() 的连接——那会直接
# `sqlite3.ProgrammingError: Cannot operate on a closed database`。
# 自己开一条连接,并在流结束时关掉。
own = db.connect()
try:
for r in query.iter_records(own, frm, to, model=model, client=client,
q=q, order=order):
buf.seek(0)
buf.truncate(0)
w.writerow([r["request_id"], "%.2f" % r["credits"], r["prompt"] or "",
r["model"], r["client"], r["ts"]])
yield buf.getvalue()
finally:
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",
headers={"Content-Disposition": 'attachment; filename="%s"' % name})
# 导出可能很慢,避免 nginx 之类的前置代理先缓冲整个响应体
resp.headers["X-Accel-Buffering"] = "no"
return resp
# ---------------- 兼容旧地址 ----------------
@bp.get("/index.html")
def legacy_index():
return redirect(url_for("views.dashboard"))