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 条)
这个提交包含在:
@@ -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,
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户