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 行删除
+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,
}