数据隔离
- settings / usage_records 主键改为 (user_id, key) / (user_id, request_id),
索引一律以 user_id 打头;collect_runs / audit_log 增加 user_id
- query / collect / scheduler 全链路把 uid 作为 conn 之后的第一个位置参数且无默认值
(漏传直接 TypeError,不会退化成「返回全量」)
- 配置三级回落 个人→实例→DEFAULTS;NO_FALLBACK_KEYS={cookie,user_agent} 不回落
凭证保密
- 新增 workbuddy_portal/crypto.py:手写 ChaCha20(RFC8439 §2.3) + HMAC-SHA256
encrypt-then-MAC,零第三方依赖;主密钥 cookie_key 与 SECRET_KEY 分键位存放
- get_secret() 是取明文的唯一通道;get_settings() 把加密键置空;
secret_state() 只回 {set,chars,tail,broken};升级时自动加密历史明文
注册与验证码
- 新增 /register 与 workbuddy_portal/captcha.py(手写 PNG + 点阵字模 + 干扰线)
- 验证码答案只存服务端表、不进 session,一次性、5 分钟过期、按 purpose 隔离
- allow_register / register_max_per_ip / captcha_policy / captcha_length 四个实例级开关
- 失败限速改为 IP + 用户名双维度;停用账号每请求回查、立即失效
页面
- 新增 /profile(个人中心)与注册页;登录页加验证码与自助注册入口
- /config 增加凭证状态、cookie_broken 告警、实例级设置区;/users 增加邮箱/状态与启停
修复
- base.html 顶层 {% set me %} 覆盖子模板同名变量,导致个人中心「注册于」渲染为空
- WB_COOKIE_SECURE 未写进 compose 的 environment,在 .env 里设了不生效
- 「修改登录密码」提示写「至少 6 位」,与实际策略(≥8 位 + 两类字符)不符
- 「用户管理」删除说明写「可勾选保留」,与页面实际行为不符
- 注册页与 flash 文案里的 **强调** Markdown 字面量
验证与文档
- smoke.py 99 → 165 项断言(多用户隔离 / 凭证保密 / 注册与验证码 / 3 条防回归)
- check_live.py 56 → 83 项断言(新增注册 / 验证码 / 安全响应头一节)
- demo_data.py 造两个账号;shots.py 自动过验证码、重出 11 张截图
- README / SECURITY / ARCHITECTURE / API / DEPLOYMENT / USER-GUIDE / FAQ / CHANGELOG / CONTRIBUTING 同步
122 行
5.6 KiB
SQL
122 行
5.6 KiB
SQL
-- WorkBuddy Portal —— SQLite 表结构
|
||
-- 设计要点:
|
||
-- * 多用户:usage_records / collect_runs / audit_log 都带 user_id;
|
||
-- 每个账号只看得到自己的数据,管理员也不越过这条线(见 docs/ARCHITECTURE.md)
|
||
-- * settings 是 (user_id, key) 复合主键:user_id=0 为实例级,其余为个人级
|
||
-- * usage_records 主键是 (user_id, request_id):去重按「人 + 请求」,
|
||
-- 不同账号拿到相同 requestId 时互不覆盖
|
||
-- * ts 存「本地保留的最早开始时间」;cloud_ts 存云端最近一次返回的时间(观察长请求前移)
|
||
-- * day / hour 是冗余列,配合索引让区间扫描与 GROUP BY 都能走索引
|
||
-- * prompt 单独存一列且默认不参与任何列表接口(占传输量约 80%)
|
||
--
|
||
-- 升级:本文件是 DDL 的唯一来源。db._migrate() 用「改名旧表 -> 重跑本文件 ->
|
||
-- 回填数据 -> 删旧表 -> 再跑一次本文件补索引」的方式做在线迁移。
|
||
|
||
PRAGMA journal_mode = WAL;
|
||
PRAGMA synchronous = NORMAL;
|
||
|
||
-- ---------------- 采集正本 ----------------
|
||
CREATE TABLE IF NOT EXISTS usage_records (
|
||
user_id INTEGER NOT NULL DEFAULT 0, -- 归属账号(usage_records.user_id -> users.id)
|
||
request_id TEXT NOT NULL,
|
||
ts TEXT NOT NULL, -- 'YYYY-MM-DD HH:MM:SS'
|
||
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
|
||
PRIMARY KEY (user_id, request_id)
|
||
);
|
||
|
||
-- 索引一律以 user_id 打头:所有查询都带「归属人」这个条件,
|
||
-- 少了它会退化成全表扫描(多用户下这是最容易踩的性能坑)。
|
||
CREATE INDEX IF NOT EXISTS idx_ur_day ON usage_records(user_id, day);
|
||
CREATE INDEX IF NOT EXISTS idx_ur_day_hour ON usage_records(user_id, day, hour);
|
||
CREATE INDEX IF NOT EXISTS idx_ur_model_day ON usage_records(user_id, model, day);
|
||
CREATE INDEX IF NOT EXISTS idx_ur_client_day ON usage_records(user_id, client, day);
|
||
CREATE INDEX IF NOT EXISTS idx_ur_credits ON usage_records(user_id, credits DESC);
|
||
CREATE INDEX IF NOT EXISTS idx_ur_ts ON usage_records(user_id, ts);
|
||
|
||
-- ---------------- 采集运行历史 ----------------
|
||
CREATE TABLE IF NOT EXISTS collect_runs (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
user_id INTEGER NOT NULL DEFAULT 0, -- 哪次「谁」的采集
|
||
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);
|
||
CREATE INDEX IF NOT EXISTS idx_runs_user ON collect_runs(user_id, id DESC);
|
||
|
||
-- ---------------- 键值配置 ----------------
|
||
-- user_id = 0 : 实例级(接口基址、注册开关、验证码策略)
|
||
-- user_id > 0 : 个人级(自己填的 Cookie / UA、采集参数、调度时刻、slot:* 簿记)
|
||
CREATE TABLE IF NOT EXISTS settings (
|
||
user_id INTEGER NOT NULL DEFAULT 0,
|
||
key TEXT NOT NULL,
|
||
value TEXT,
|
||
updated_at TEXT,
|
||
PRIMARY KEY (user_id, key)
|
||
);
|
||
|
||
-- ---------------- 账号 ----------------
|
||
CREATE TABLE IF NOT EXISTS users (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
username TEXT UNIQUE NOT NULL,
|
||
password_hash TEXT NOT NULL,
|
||
display_name TEXT,
|
||
email TEXT,
|
||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||
status TEXT NOT NULL DEFAULT 'active', -- active | disabled
|
||
created_at TEXT,
|
||
register_ip TEXT, -- 自助注册来源,用于每日限额
|
||
last_login_at TEXT,
|
||
last_login_ip TEXT,
|
||
login_count INTEGER NOT NULL DEFAULT 0
|
||
);
|
||
|
||
-- ---------------- 操作审计 ----------------
|
||
-- 刻意**不记录任何凭证**:detail 里只写「改了哪些键」,不写键的值。
|
||
CREATE TABLE IF NOT EXISTS audit_log (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
user_id INTEGER NOT NULL DEFAULT 0, -- 归属账号(0 = 系统 / CLI / 未登录事件)
|
||
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);
|
||
CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id, id DESC);
|
||
|
||
-- ---------------- 图形验证码 ----------------
|
||
-- 答案只在服务端存在。下发到浏览器的只是 id,且用一次即删。
|
||
CREATE TABLE IF NOT EXISTS captchas (
|
||
id TEXT PRIMARY KEY, -- 随机 token(下发给客户端)
|
||
answer TEXT NOT NULL, -- 正确答案(绝不下发)
|
||
purpose TEXT NOT NULL, -- login | register
|
||
created_at TEXT NOT NULL,
|
||
expires_at TEXT NOT NULL,
|
||
used_at TEXT
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_captcha_expires ON captchas(expires_at);
|