feat(multi-user): 多用户化 + 凭证加密 + 自助注册与图形验证码
数据隔离
- settings / usage_records 主键改为 (user_id, key) / (user_id, request_id),
索引一律以 user_id 打头;collect_runs / audit_log 增加 user_id
- query / collect / scheduler 全链路把 uid 作为 conn 之后的第一个位置参数且无默认值
(漏传直接 TypeError,不会退化成「返回全量」)
- 配置三级回落 个人→实例→DEFAULTS;NO_FALLBACK_KEYS={cookie,user_agent} 不回落
凭证保密
- 新增 workbuddy_portal/crypto.py:手写 ChaCha20(RFC8439 §2.3) + HMAC-SHA256
encrypt-then-MAC,零第三方依赖;主密钥 cookie_key 与 SECRET_KEY 分键位存放
- get_secret() 是取明文的唯一通道;get_settings() 把加密键置空;
secret_state() 只回 {set,chars,tail,broken};升级时自动加密历史明文
注册与验证码
- 新增 /register 与 workbuddy_portal/captcha.py(手写 PNG + 点阵字模 + 干扰线)
- 验证码答案只存服务端表、不进 session,一次性、5 分钟过期、按 purpose 隔离
- allow_register / register_max_per_ip / captcha_policy / captcha_length 四个实例级开关
- 失败限速改为 IP + 用户名双维度;停用账号每请求回查、立即失效
页面
- 新增 /profile(个人中心)与注册页;登录页加验证码与自助注册入口
- /config 增加凭证状态、cookie_broken 告警、实例级设置区;/users 增加邮箱/状态与启停
修复
- base.html 顶层 {% set me %} 覆盖子模板同名变量,导致个人中心「注册于」渲染为空
- WB_COOKIE_SECURE 未写进 compose 的 environment,在 .env 里设了不生效
- 「修改登录密码」提示写「至少 6 位」,与实际策略(≥8 位 + 两类字符)不符
- 「用户管理」删除说明写「可勾选保留」,与页面实际行为不符
- 注册页与 flash 文案里的 **强调** Markdown 字面量
验证与文档
- smoke.py 99 → 165 项断言(多用户隔离 / 凭证保密 / 注册与验证码 / 3 条防回归)
- check_live.py 56 → 83 项断言(新增注册 / 验证码 / 安全响应头一节)
- demo_data.py 造两个账号;shots.py 自动过验证码、重出 11 张截图
- README / SECURITY / ARCHITECTURE / API / DEPLOYMENT / USER-GUIDE / FAQ / CHANGELOG / CONTRIBUTING 同步
这个提交包含在:
@@ -25,7 +25,7 @@ from flask import Flask, jsonify, render_template, request
|
||||
|
||||
from . import config, db, security
|
||||
|
||||
__version__ = "1.1.0"
|
||||
__version__ = "1.2.0"
|
||||
PROJECT_NAME = config.PROJECT_NAME
|
||||
|
||||
|
||||
@@ -51,8 +51,14 @@ def create_app(start_scheduler=True, do_init_db=True, **overrides):
|
||||
app.config.update(
|
||||
SECRET_KEY=config.secret_key(),
|
||||
PERMANENT_SESSION_LIFETIME=timedelta(hours=config.SESSION_HOURS),
|
||||
# ---- 会话 Cookie 加固 ----
|
||||
# HttpOnly:JS 读不到(XSS 也别想直接偷走会话)
|
||||
SESSION_COOKIE_HTTPONLY=True,
|
||||
SESSION_COOKIE_SAMESITE="Lax",
|
||||
# Secure:仅 HTTPS 下发。纯局域网 HTTP 部署必须留 0,否则浏览器
|
||||
# 根本不会回传 Cookie,表现为「刚登录完又被弹回登录页」。
|
||||
SESSION_COOKIE_SECURE=config.COOKIE_SECURE,
|
||||
SESSION_COOKIE_PATH="/",
|
||||
SESSION_COOKIE_NAME="workbuddy_portal_sid",
|
||||
MAX_CONTENT_LENGTH=4 * 1024 * 1024,
|
||||
TEMPLATES_AUTO_RELOAD=True,
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# SPDX-License-Identifier: MIT
|
||||
# Copyright (c) 2026 Wang Chuanli
|
||||
|
||||
"""图形验证码(自托管,零第三方依赖)。
|
||||
|
||||
设计要点
|
||||
--------
|
||||
1. **答案只存在服务端**。下发到浏览器的是一个随机 `captcha_id`,它本身
|
||||
不含任何信息。之所以不把答案放进 Flask session:Flask 的 session 是
|
||||
**签名而非加密**的(base64 + HMAC),客户端 base64 解开就能读到明文——
|
||||
把答案放进去等于把答案直接送给机器人。
|
||||
2. **一次性**。校验时无论成功失败都立刻删除该 `captcha_id`,
|
||||
防止「一个码刷一万个用户名」的撞库变体。
|
||||
3. **光栅图,不是 SVG**。SVG 是文本,答案会以明文出现在页面源码或 DOM 里,
|
||||
必须用位图。这里手写 PNG 编码器(zlib 是标准库),点阵字模自带。
|
||||
4. **字符集避开易混字符**(0/O、1/I/L),降低正常用户输错概率。
|
||||
|
||||
字模
|
||||
----
|
||||
5×7 点阵,`#` 为前景。渲染时按整数倍放大并逐字符抖动,
|
||||
再叠噪点与干扰线,普通 OCR 与「按色块切分」都会被破坏。
|
||||
"""
|
||||
import hmac
|
||||
import os
|
||||
import random
|
||||
import secrets
|
||||
import struct
|
||||
import zlib
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
ALPHABET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ" # 去掉 0 O 1 I L
|
||||
|
||||
_FONT = {
|
||||
"2": (".###.", "#...#", "....#", "...#.", "..#..", ".#...", "#####"),
|
||||
"3": ("####.", "....#", "....#", ".###.", "....#", "....#", "####."),
|
||||
"4": ("...#.", "..##.", ".#.#.", "#..#.", "#####", "...#.", "...#."),
|
||||
"5": ("#####", "#....", "####.", "....#", "....#", "#...#", ".###."),
|
||||
"6": ("..##.", ".#...", "#....", "####.", "#...#", "#...#", ".###."),
|
||||
"7": ("#####", "....#", "...#.", "..#..", ".#...", ".#...", ".#..."),
|
||||
"8": (".###.", "#...#", "#...#", ".###.", "#...#", "#...#", ".###."),
|
||||
"9": (".###.", "#...#", "#...#", ".####", "....#", "...#.", ".##.."),
|
||||
"A": ("..#..", ".#.#.", "#...#", "#...#", "#####", "#...#", "#...#"),
|
||||
"B": ("####.", "#...#", "#...#", "####.", "#...#", "#...#", "####."),
|
||||
"C": (".###.", "#...#", "#....", "#....", "#....", "#...#", ".###."),
|
||||
"D": ("###..", "#..#.", "#...#", "#...#", "#...#", "#..#.", "###.."),
|
||||
"E": ("#####", "#....", "#....", "####.", "#....", "#....", "#####"),
|
||||
"F": ("#####", "#....", "#....", "####.", "#....", "#....", "#...."),
|
||||
"G": (".###.", "#...#", "#....", "#.###", "#...#", "#...#", ".###."),
|
||||
"H": ("#...#", "#...#", "#...#", "#####", "#...#", "#...#", "#...#"),
|
||||
"J": ("..###", "...#.", "...#.", "...#.", "...#.", "#..#.", ".##.."),
|
||||
"K": ("#...#", "#..#.", "#.#..", "##...", "#.#..", "#..#.", "#...#"),
|
||||
"M": ("#...#", "##.##", "#.#.#", "#...#", "#...#", "#...#", "#...#"),
|
||||
"N": ("#...#", "##..#", "#.#.#", "#..##", "#...#", "#...#", "#...#"),
|
||||
"P": ("####.", "#...#", "#...#", "####.", "#....", "#....", "#...."),
|
||||
"Q": (".###.", "#...#", "#...#", "#...#", "#.#.#", "#..#.", ".##.#"),
|
||||
"R": ("####.", "#...#", "#...#", "####.", "#.#..", "#..#.", "#...#"),
|
||||
"S": (".####", "#....", "#....", ".###.", "....#", "....#", "####."),
|
||||
"T": ("#####", "..#..", "..#..", "..#..", "..#..", "..#..", "..#.."),
|
||||
"U": ("#...#", "#...#", "#...#", "#...#", "#...#", "#...#", ".###."),
|
||||
"V": ("#...#", "#...#", "#...#", "#...#", "#...#", ".#.#.", "..#.."),
|
||||
"W": ("#...#", "#...#", "#...#", "#...#", "#.#.#", "##.##", "#...#"),
|
||||
"X": ("#...#", "#...#", ".#.#.", "..#..", ".#.#.", "#...#", "#...#"),
|
||||
"Y": ("#...#", "#...#", ".#.#.", "..#..", "..#..", "..#..", "..#.."),
|
||||
"Z": ("#####", "....#", "...#.", "..#..", ".#...", "#....", "#####"),
|
||||
}
|
||||
|
||||
GLYPH_W, GLYPH_H = 5, 7
|
||||
|
||||
# 一次性验证码有效期(秒)。太短用户来不及看,太长给暴力破解留窗口。
|
||||
TTL_SECONDS = 300
|
||||
# 保留已过期记录多久后清理(仅用于体积控制,不影响安全性)
|
||||
PURGE_AFTER_SECONDS = 3600
|
||||
|
||||
|
||||
def random_code(length=4):
|
||||
return "".join(secrets.choice(ALPHABET) for _ in range(length))
|
||||
|
||||
|
||||
# ---------------- PNG 编码(手写,无依赖) ----------------
|
||||
def _chunk(tag, data):
|
||||
return (struct.pack(">I", len(data)) + tag + data
|
||||
+ struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF))
|
||||
|
||||
|
||||
def encode_png(width, height, rgb):
|
||||
"""把 RGB 字节串编码成 PNG(8 位真彩,无 alpha)。
|
||||
|
||||
rgb 长度必须是 width*height*3。每行前面加一个 filter 字节 0(None),
|
||||
这是 PNG 对「一行一张扫描线」的强制要求。
|
||||
"""
|
||||
stride = width * 3
|
||||
raw = bytearray()
|
||||
for y in range(height):
|
||||
raw.append(0)
|
||||
raw += rgb[y * stride:(y + 1) * stride]
|
||||
ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
|
||||
return (b"\x89PNG\r\n\x1a\n"
|
||||
+ _chunk(b"IHDR", ihdr)
|
||||
+ _chunk(b"IDAT", zlib.compress(bytes(raw), 9))
|
||||
+ _chunk(b"IEND", b""))
|
||||
|
||||
|
||||
class _Canvas:
|
||||
"""极小的 RGB 画布。坐标越界自动丢弃,省得每处调用都判边界。"""
|
||||
|
||||
def __init__(self, w, h, bg):
|
||||
self.w, self.h = w, h
|
||||
self.buf = bytearray(bg * (w * h))
|
||||
|
||||
def dot(self, x, y, color):
|
||||
if 0 <= x < self.w and 0 <= y < self.h:
|
||||
i = (y * self.w + x) * 3
|
||||
self.buf[i:i + 3] = bytes(color)
|
||||
|
||||
def rect(self, x, y, w, h, color):
|
||||
for dy in range(h):
|
||||
for dx in range(w):
|
||||
self.dot(x + dx, y + dy, color)
|
||||
|
||||
def line(self, x0, y0, x1, y1, color):
|
||||
"""Bresenham 直线。"""
|
||||
dx, dy = abs(x1 - x0), -abs(y1 - y0)
|
||||
sx = 1 if x0 < x1 else -1
|
||||
sy = 1 if y0 < y1 else -1
|
||||
err = dx + dy
|
||||
while True:
|
||||
self.dot(x0, y0, color)
|
||||
if x0 == x1 and y0 == y1:
|
||||
return
|
||||
e2 = 2 * err
|
||||
if e2 >= dy:
|
||||
err += dy
|
||||
x0 += sx
|
||||
if e2 <= dx:
|
||||
err += dx
|
||||
y0 += sy
|
||||
|
||||
def bytes(self):
|
||||
return bytes(self.buf)
|
||||
|
||||
|
||||
def render(code, width=150, height=56, scale=5, rng=None):
|
||||
"""把验证码渲染成 PNG 字节串。
|
||||
|
||||
刻意不做「清晰排版」而是加抖动/噪点/干扰线:这是防机器识别的核心,
|
||||
可读性靠放大字符(scale=5 即 25×35 像素)来补偿。
|
||||
"""
|
||||
rng = rng or random.SystemRandom()
|
||||
n = len(code)
|
||||
gap = 7
|
||||
text_w = n * GLYPH_W * scale + (n - 1) * gap
|
||||
if text_w + 16 > width:
|
||||
width = text_w + 16
|
||||
x0 = max(4, (width - text_w) // 2)
|
||||
y0 = max(3, (height - GLYPH_H * scale) // 2)
|
||||
|
||||
# 背景取浅色,前景取深色 —— 深色底+浅字在缩略图上更容易糊,
|
||||
# 而且打印/截图后对比度更差。
|
||||
bg = tuple(rng.randint(238, 252) for _ in range(3))
|
||||
cv = _Canvas(width, height, bg)
|
||||
|
||||
# 1) 干扰线(先画,压在字下面,不遮挡主体)
|
||||
for _ in range(4):
|
||||
cv.line(rng.randint(0, width - 1), rng.randint(0, height - 1),
|
||||
rng.randint(0, width - 1), rng.randint(0, height - 1),
|
||||
tuple(rng.randint(150, 205) for _ in range(3)))
|
||||
|
||||
# 2) 字符本体:逐字符随机取色 + 整数抖动,破坏固定网格切分
|
||||
for i, ch in enumerate(code):
|
||||
glyph = _FONT.get(ch)
|
||||
if glyph is None:
|
||||
continue
|
||||
color = tuple(rng.randint(20, 105) for _ in range(3))
|
||||
gx = x0 + i * (GLYPH_W * scale + gap) + rng.randint(-1, 1)
|
||||
gy = y0 + rng.randint(-2, 2)
|
||||
for row, bits in enumerate(glyph):
|
||||
for col, bit in enumerate(bits):
|
||||
if bit == "#":
|
||||
cv.rect(gx + col * scale, gy + row * scale, scale, scale, color)
|
||||
|
||||
# 3) 前景噪点:少量深色点会让「按连通域找字符」变得不可靠
|
||||
for _ in range(46):
|
||||
cv.dot(rng.randint(0, width - 1), rng.randint(0, height - 1),
|
||||
tuple(rng.randint(90, 190) for _ in range(3)))
|
||||
|
||||
# 4) 压在字上的细斜线:这是最有效的反 OCR 手段,但别太密,否则人也认不出
|
||||
for _ in range(3):
|
||||
y = rng.randint(2, height - 3)
|
||||
cv.line(0, y, width - 1, y + rng.randint(-9, 9),
|
||||
tuple(rng.randint(120, 175) for _ in range(3)))
|
||||
|
||||
return encode_png(width, height, cv.bytes())
|
||||
|
||||
|
||||
# ---------------- 挑战的存储与校验(SQLite) ----------------
|
||||
def _fmt(dt):
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def purge(conn, now=None):
|
||||
"""清掉过期的挑战。每次新建时顺手调用(有 expires_at 索引,代价很小)。"""
|
||||
now = now or datetime.now()
|
||||
cut = _fmt(now - timedelta(seconds=PURGE_AFTER_SECONDS))
|
||||
conn.execute("DELETE FROM captchas WHERE expires_at < ?", (cut,))
|
||||
|
||||
|
||||
def create(conn, purpose, length=4, ttl=TTL_SECONDS, now=None):
|
||||
"""新建一个挑战,返回 (captcha_id, code)。code 只应交给渲染函数,不要下发。"""
|
||||
now = now or datetime.now()
|
||||
code = random_code(length)
|
||||
cid = secrets.token_urlsafe(24)
|
||||
purge(conn, now)
|
||||
conn.execute(
|
||||
"INSERT INTO captchas(id,answer,purpose,created_at,expires_at) VALUES(?,?,?,?,?)",
|
||||
(cid, code, purpose, _fmt(now), _fmt(now + timedelta(seconds=ttl))))
|
||||
return cid, code
|
||||
|
||||
|
||||
def verify(conn, captcha_id, answer, purpose, now=None):
|
||||
"""校验并**立即作废**该挑战。返回 True/False。
|
||||
|
||||
永远不区分「过期」「不存在」「答案错」——对外只回一句人话,
|
||||
避免把「这个 id 存在但答错了」这类信息透露给攻击者。
|
||||
"""
|
||||
if not captcha_id or answer is None:
|
||||
return False
|
||||
row = conn.execute("SELECT * FROM captchas WHERE id=?", (captcha_id,)).fetchone()
|
||||
# 先删后判:无论结果如何都不允许第二次使用同一个 id
|
||||
conn.execute("DELETE FROM captchas WHERE id=?", (captcha_id,))
|
||||
if row is None or row["purpose"] != purpose:
|
||||
return False
|
||||
now = now or datetime.now()
|
||||
if row["expires_at"] < _fmt(now):
|
||||
return False
|
||||
return hmac.compare_digest(str(row["answer"]), str(answer).strip().upper())
|
||||
+127
-72
@@ -5,10 +5,19 @@
|
||||
"""采集主流程:云端增量 -> SQLite(去重、断点、漂移校验、运行记录)。
|
||||
|
||||
与原 fetch_usage.py 的差别:
|
||||
* 存档正本从 CSV 换成 SQLite,去重由 `ON CONFLICT(request_id)` 承担
|
||||
* 断点由 `SELECT MAX(ts)` 承担,不再需要全量读入内存
|
||||
* 存档正本从 CSV 换成 SQLite,去重由 `ON CONFLICT(user_id, request_id)` 承担
|
||||
* 断点由 `SELECT MAX(ts) WHERE user_id=?` 承担,不再需要全量读入内存
|
||||
* 每次运行落一条 collect_runs 记录,页面据此展示任务历史与日志
|
||||
* 采集互斥用文件锁,保证「单写者」——SQLite 只允许一个写进程
|
||||
|
||||
**多用户约定(最重要)**
|
||||
所有写入函数都要求显式传入 `uid`,且 `uid` 是 `conn` 之后的第一个位置参数、
|
||||
没有默认值。采集**只使用该账号自己保存的 Cookie**:
|
||||
|
||||
* 明文的 Cookie 从来只存在于内存里(库里是 crypto.encrypt 后的密文)
|
||||
* 不再支持 `WB_COOKIE` 环境变量作为采集凭证 —— 那会让所有人共用一份凭证,
|
||||
一旦生效就是「A 的采集把数据写进 B 的账号」这种串号事故。
|
||||
环境变量只保留给 `manage.py import-creds` 做一次性导入。
|
||||
"""
|
||||
import csv
|
||||
import os
|
||||
@@ -27,8 +36,19 @@ class Busy(Exception):
|
||||
"""已有采集在跑。"""
|
||||
|
||||
|
||||
class NotReady(Exception):
|
||||
"""该账号还没配好凭证 —— 不是错误,只是「没什么可做的」。"""
|
||||
|
||||
|
||||
# ---------------- 互斥锁 ----------------
|
||||
class _Lock:
|
||||
"""全局单写者锁。
|
||||
|
||||
刻意**不做成按用户加锁**:SQLite 同一时刻只允许一个写事务,
|
||||
按用户并行反而会在 busy_timeout 上互相拖死。串行跑完所有人的采集,
|
||||
总耗时与并发差别很小(每个人一天也就拉一次)。
|
||||
"""
|
||||
|
||||
def __init__(self, path=LOCK_PATH):
|
||||
self.path = path
|
||||
self.fd = None
|
||||
@@ -65,18 +85,17 @@ class _Lock:
|
||||
|
||||
|
||||
# ---------------- 运行记录 ----------------
|
||||
def start_run(conn, trigger):
|
||||
cur = conn.execute("INSERT INTO collect_runs(trigger,status,started_at) VALUES(?,?,?)",
|
||||
(trigger, "running", db.now_str()))
|
||||
def start_run(conn, uid, trigger):
|
||||
cur = conn.execute("INSERT INTO collect_runs(user_id,trigger,status,started_at)"
|
||||
" VALUES(?,?,?,?)", (uid, 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:]:
|
||||
for f in ("duration_ms", "win_from", "win_to", "fetched", "added", "dup",
|
||||
"total", "conflicts", "exit_code", "message", "detail"):
|
||||
if f in kw:
|
||||
sets.append("%s=?" % f)
|
||||
vals.append(kw[f])
|
||||
@@ -86,10 +105,11 @@ def finish_run(conn, run_id, status, **kw):
|
||||
|
||||
# ---------------- 入库 ----------------
|
||||
_UPSERT = """
|
||||
INSERT INTO usage_records(request_id,ts,day,hour,model,client,credits,prompt,
|
||||
INSERT INTO usage_records(user_id,request_id,ts,day,hour,model,client,credits,prompt,
|
||||
first_seen,last_seen,cloud_ts)
|
||||
VALUES(:request_id,:ts,:day,:hour,:model,:client,:credits,:prompt,:first_seen,:last_seen,:cloud_ts)
|
||||
ON CONFLICT(request_id) DO UPDATE SET
|
||||
VALUES(:user_id,:request_id,:ts,:day,:hour,:model,:client,:credits,:prompt,
|
||||
:first_seen,:last_seen,:cloud_ts)
|
||||
ON CONFLICT(user_id,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
|
||||
@@ -107,10 +127,11 @@ ON CONFLICT(request_id) DO UPDATE SET
|
||||
"""
|
||||
|
||||
|
||||
def _row_dict(n):
|
||||
def _row_dict(n, uid):
|
||||
ts = (n.get("ts") or "").strip()[:19]
|
||||
hh = ts[11:13]
|
||||
return {
|
||||
"user_id": uid,
|
||||
"request_id": n["request_id"],
|
||||
"ts": ts,
|
||||
"day": ts[:10],
|
||||
@@ -125,8 +146,8 @@ def _row_dict(n):
|
||||
}
|
||||
|
||||
|
||||
def upsert(conn, normalized, drift_tolerance=5, log=None):
|
||||
"""按 request_id 去重写入。返回 (added, dup, conflicts) 与逐行警告。
|
||||
def upsert(conn, uid, normalized, drift_tolerance=5, log=None):
|
||||
"""按 (user_id, request_id) 去重写入。返回 (added, dup, conflicts) 与逐行警告。
|
||||
|
||||
每 200 条一个事务(连接是 autocommit,不显式 BEGIN 的话每条 INSERT 都要
|
||||
单独 fsync)。upsert 本身幂等,所以按块提交是安全的。
|
||||
@@ -135,7 +156,7 @@ def upsert(conn, normalized, drift_tolerance=5, log=None):
|
||||
for n in normalized:
|
||||
if not n.get("request_id") or not n.get("ts"):
|
||||
continue
|
||||
recs.append(_row_dict(n))
|
||||
recs.append(_row_dict(n, uid))
|
||||
added = dup = 0
|
||||
conflicts = []
|
||||
for i in range(0, len(recs), 200):
|
||||
@@ -146,8 +167,9 @@ def upsert(conn, normalized, drift_tolerance=5, log=None):
|
||||
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)}
|
||||
old = {x["request_id"]: x["ts"] for x in conn.execute(
|
||||
"SELECT request_id,ts FROM usage_records WHERE user_id=? AND request_id IN (%s)"
|
||||
% ph, [uid] + ids)}
|
||||
for r in chunk:
|
||||
prev = old.get(r["request_id"])
|
||||
if prev is None:
|
||||
@@ -178,18 +200,33 @@ def upsert(conn, normalized, drift_tolerance=5, log=None):
|
||||
return added, dup, conflicts
|
||||
|
||||
|
||||
def record_count(conn):
|
||||
return conn.execute("SELECT COUNT(*) FROM usage_records").fetchone()[0]
|
||||
def record_count(conn, uid):
|
||||
return conn.execute("SELECT COUNT(*) FROM usage_records WHERE user_id=?",
|
||||
(uid or 0,)).fetchone()[0]
|
||||
|
||||
|
||||
def last_ts(conn):
|
||||
return conn.execute("SELECT MAX(ts) FROM usage_records").fetchone()[0]
|
||||
def last_ts(conn, uid):
|
||||
return conn.execute("SELECT MAX(ts) FROM usage_records WHERE user_id=?",
|
||||
(uid or 0,)).fetchone()[0]
|
||||
|
||||
|
||||
# ---------------- 凭证读取(解密) ----------------
|
||||
def load_credentials(conn, uid):
|
||||
"""取该账号的 (cookie, user_agent)。
|
||||
|
||||
Cookie 从库里读出来是密文,由 db.get_secret 解密;解不开会抛
|
||||
db.SecretUnreadable(多半是 instance.json 里的 cookie_key 被换过),
|
||||
这时应当明确告诉用户「重新粘贴 Cookie」,而不是当成「未配置」静默跳过。
|
||||
"""
|
||||
cookie = db.get_secret(conn, "cookie", uid).strip()
|
||||
ua = (db.get_setting(conn, "user_agent", "", uid) or "").strip()
|
||||
return cookie, ua
|
||||
|
||||
|
||||
# ---------------- 主同步 ----------------
|
||||
def sync(conn, trigger="manual", from_dt=None, to_dt=None, verify_days=None,
|
||||
def sync(conn, uid, trigger="manual", from_dt=None, to_dt=None, verify_days=None,
|
||||
do_write=True, log=None):
|
||||
"""增量同步。
|
||||
"""增量同步(仅限 uid 这个账号)。
|
||||
|
||||
trigger: manual | schedule | cli | startup(写进 collect_runs 便于区分来源)
|
||||
返回 result dict;异常时抛出 ApiError(调用方决定如何展示)。
|
||||
@@ -201,40 +238,39 @@ def sync(conn, trigger="manual", from_dt=None, to_dt=None, verify_days=None,
|
||||
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()
|
||||
s = db.get_settings(conn, uid=uid)
|
||||
cookie, ua = load_credentials(conn, uid)
|
||||
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)
|
||||
page_size = db.get_int(conn, "page_size", 200, uid)
|
||||
rewind = db.get_int(conn, "rewind_minutes", 2, uid)
|
||||
drift = db.get_int(conn, "drift_tolerance_minutes", 5, uid)
|
||||
max_prompt = db.get_int(conn, "max_prompt", 0, uid)
|
||||
timeout = db.get_int(conn, "timeout", 30, uid)
|
||||
ssl_verify = db.get_bool(conn, "ssl_verify", True, uid)
|
||||
if verify_days is None:
|
||||
verify_days = db.get_int(conn, "verify_days", 0)
|
||||
verify_days = db.get_int(conn, "verify_days", 0, uid)
|
||||
# 夹到合法区间,避免历史脏数据(如超大的 page_size)把云端打爆
|
||||
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
|
||||
run_id = start_run(conn, uid, 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"
|
||||
msg = "未配置 Cookie,请到「配置管理」页粘贴自己账号的 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)
|
||||
raise NotReady(msg)
|
||||
|
||||
total_before = record_count(conn)
|
||||
tail = last_ts(conn)
|
||||
total_before = record_count(conn, uid)
|
||||
tail = last_ts(conn, uid)
|
||||
now = datetime.now()
|
||||
_log("存档:%d 条%s" % (total_before, (",最后记录 " + tail) if tail else "(空)"))
|
||||
|
||||
@@ -270,15 +306,17 @@ def sync(conn, trigger="manual", from_dt=None, to_dt=None, verify_days=None,
|
||||
new_rows = [client.normalize(r, max_prompt=max_prompt) for r in raw]
|
||||
_log("云端返回:%d 条" % len(raw))
|
||||
|
||||
added, dup, conflicts = upsert(conn, new_rows, drift_tolerance=drift, log=_log)
|
||||
added, dup, conflicts = upsert(conn, uid, new_rows, drift_tolerance=drift, log=_log)
|
||||
|
||||
# 整日完整性校验(默认关闭;用于排查缺记录)
|
||||
if verify_days > 0:
|
||||
days = [r["day"] for r in conn.execute(
|
||||
"SELECT DISTINCT day FROM usage_records ORDER BY day DESC LIMIT ?", (verify_days,))]
|
||||
"SELECT DISTINCT day FROM usage_records WHERE user_id=? ORDER BY day DESC LIMIT ?",
|
||||
(uid, verify_days))]
|
||||
_log("完整性校验:最近 %d 天" % len(days))
|
||||
for d in sorted(days):
|
||||
local = conn.execute("SELECT COUNT(*) FROM usage_records WHERE day=?", (d,)).fetchone()[0]
|
||||
local = conn.execute("SELECT COUNT(*) FROM usage_records WHERE user_id=? AND day=?",
|
||||
(uid, d)).fetchone()[0]
|
||||
d0 = datetime.strptime(d, "%Y-%m-%d")
|
||||
try:
|
||||
raw2, t2 = client.fetch_range(d0, d0.replace(hour=23, minute=59, second=59),
|
||||
@@ -290,14 +328,15 @@ def sync(conn, trigger="manual", from_dt=None, to_dt=None, verify_days=None,
|
||||
continue
|
||||
cloud = t2.get(d, 0)
|
||||
if local < cloud:
|
||||
a2, _, _ = upsert(conn, [client.normalize(r, max_prompt=max_prompt) for r in raw2],
|
||||
a2, _, _ = upsert(conn, uid,
|
||||
[client.normalize(r, max_prompt=max_prompt) for r in raw2],
|
||||
drift_tolerance=drift)
|
||||
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)
|
||||
total_after = record_count(conn, uid)
|
||||
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))
|
||||
@@ -313,27 +352,31 @@ def sync(conn, trigger="manual", from_dt=None, to_dt=None, verify_days=None,
|
||||
"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}
|
||||
"message": msg, "lines": lines, "run_id": run_id, "uid": uid}
|
||||
|
||||
|
||||
def run_sync(trigger="manual", **kw):
|
||||
"""带锁的同步入口(供 CLI / 调度器 / 页面手动触发共用)。"""
|
||||
"""带锁的同步入口(供 CLI / 调度器 / 页面手动触发共用)。
|
||||
|
||||
必须显式给出 `uid=...`;漏传会由 sync() 直接报 TypeError,
|
||||
不会退化成「用某个默认账号去采集」。
|
||||
"""
|
||||
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)
|
||||
def fill_prompt(conn, uid, log=print):
|
||||
"""补全该账号缺失的 User Prompt(官网导出的 xlsx 会丢约 22%,云端仍保留)。"""
|
||||
s = db.get_settings(conn, uid=uid)
|
||||
cookie, ua = load_credentials(conn, uid)
|
||||
max_prompt = db.get_int(conn, "max_prompt", 0, uid)
|
||||
if not cookie:
|
||||
raise ApiError("未配置 Cookie")
|
||||
raise NotReady("未配置 Cookie")
|
||||
todo = conn.execute("SELECT request_id, day FROM usage_records "
|
||||
"WHERE COALESCE(prompt,'')='' ORDER BY day").fetchall()
|
||||
"WHERE user_id=? AND COALESCE(prompt,'')='' ORDER BY day",
|
||||
(uid,)).fetchall()
|
||||
if not todo:
|
||||
log("没有缺失的 User Prompt")
|
||||
return 0
|
||||
@@ -345,9 +388,9 @@ def fill_prompt(conn, log=print):
|
||||
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))
|
||||
page_size=db.get_int(conn, "page_size", 200, uid),
|
||||
timeout=db.get_int(conn, "timeout", 30, uid),
|
||||
ssl_verify=db.get_bool(conn, "ssl_verify", True, uid))
|
||||
for r in raw:
|
||||
rid = (r.get("requestId") or "").strip()
|
||||
if rid:
|
||||
@@ -357,21 +400,22 @@ def fill_prompt(conn, log=print):
|
||||
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"]))
|
||||
conn.execute("UPDATE usage_records SET prompt=? WHERE user_id=? AND request_id=?",
|
||||
(p, uid, row["request_id"]))
|
||||
n += 1
|
||||
left = conn.execute("SELECT COUNT(*) FROM usage_records WHERE COALESCE(prompt,'')=''").fetchone()[0]
|
||||
left = conn.execute("SELECT COUNT(*) FROM usage_records WHERE user_id=?"
|
||||
" AND COALESCE(prompt,'')=''", (uid,)).fetchone()[0]
|
||||
log("补全 %d 条,仍为空 %d 条" % (n, left))
|
||||
return n
|
||||
|
||||
|
||||
def import_xlsx(conn, path, log=print):
|
||||
def import_xlsx(conn, uid, 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)
|
||||
max_prompt = db.get_int(conn, "max_prompt", 0, uid)
|
||||
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)]
|
||||
@@ -401,17 +445,24 @@ def import_xlsx(conn, path, log=print):
|
||||
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)))
|
||||
added, dup, _ = upsert(conn, uid, rows)
|
||||
log("[xlsx] 读取 %d 条,去重后新增 %d 条,该账号存档共 %d 条"
|
||||
% (len(rows), added, record_count(conn, uid)))
|
||||
return added
|
||||
|
||||
|
||||
def export_csv(conn, path=None):
|
||||
"""导出与旧存档 / 官网 xlsx 完全同构的 CSV(备份与对端交换用)。"""
|
||||
path = path or os.path.join(config.EXPORT_DIR, "usage_records.csv")
|
||||
def export_csv(conn, uid, path=None, username=None):
|
||||
"""导出与旧存档 / 官网 xlsx 完全同构的 CSV(备份与对端交换用)。
|
||||
|
||||
文件名带账号名:多用户下所有人导出到同一个目录,
|
||||
不带归属就会互相覆盖。
|
||||
"""
|
||||
if path is None:
|
||||
tag = username or ("u%s" % uid)
|
||||
path = os.path.join(config.EXPORT_DIR, "usage_records_%s.csv" % tag)
|
||||
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")
|
||||
"WHERE user_id=? ORDER BY ts, request_id", (uid,))
|
||||
n = 0
|
||||
with open(path, "w", encoding="utf-8-sig", newline="") as f:
|
||||
w = csv.writer(f)
|
||||
@@ -423,8 +474,12 @@ def export_csv(conn, path=None):
|
||||
return path, n
|
||||
|
||||
|
||||
def migrate_from_csv(conn, path, log=print):
|
||||
"""把旧版 data/usage_records.csv 全量导入 SQLite(幂等,可重复执行)。"""
|
||||
def migrate_from_csv(conn, uid, path, log=print):
|
||||
"""把旧版 data/usage_records.csv 全量导入 SQLite(幂等,可重复执行)。
|
||||
|
||||
导入的数据归属 `uid` 指定的账号 —— 老存档是单用户的,
|
||||
必须由调用方明确「这份数据算谁的」。
|
||||
"""
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError(path)
|
||||
with open(path, "rb") as fb:
|
||||
@@ -445,8 +500,8 @@ def migrate_from_csv(conn, path, log=print):
|
||||
"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)
|
||||
before = record_count(conn, uid)
|
||||
added, dup, _ = upsert(conn, uid, recs)
|
||||
log("[migrate] 源文件 %d 条 → 新增 %d / 已存在 %d,入库前 %d 条,现共 %d 条"
|
||||
% (len(recs), added, dup, before, record_count(conn)))
|
||||
% (len(recs), added, dup, before, record_count(conn, uid)))
|
||||
return added
|
||||
|
||||
+94
-19
@@ -39,9 +39,18 @@ API_BASE = "https://www.workbuddy.cn"
|
||||
API_PATH = "/billing/meter/get-user-request-usage"
|
||||
|
||||
# ---------------- 采集参数默认值(可被 settings 表覆盖)----------------
|
||||
# settings 表是 (user_id, key) 复合主键:user_id=0 表示**实例级**,
|
||||
# 其余表示**个人级**(每个账号一份,互不可见)。见下方的 GLOBAL_KEYS。
|
||||
DEFAULTS = {
|
||||
# ---- 实例级:连接的是哪个云端 ----
|
||||
"api_base": API_BASE,
|
||||
"api_path": API_PATH,
|
||||
# ---- 实例级:开放注册与防攻击策略 ----
|
||||
"allow_register": "1", # 是否开放自助注册
|
||||
"register_max_per_ip": "3", # 同一 IP 每天最多注册几个账号
|
||||
"captcha_policy": "always", # always | adaptive | off(见 CAPTCHA_POLICIES)
|
||||
"captcha_length": "4", # 验证码字符数 4~6
|
||||
# ---- 个人级:采集参数 ----
|
||||
"page_size": "200",
|
||||
"rewind_minutes": "2", # 断点回退分钟数
|
||||
"drift_tolerance_minutes": "5", # 云端比本地早超过该值才告警
|
||||
@@ -49,19 +58,36 @@ DEFAULTS = {
|
||||
"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 静态加密后入库)----
|
||||
"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"),
|
||||
}
|
||||
|
||||
# 实例级配置:所有账号共用一份,只有管理员能改。
|
||||
# 其余键(采集参数 / 调度 / 凭证)都是个人级 —— 这正是「多用户」的核心:
|
||||
# 每个人填自己的 Cookie、收自己的数据、定自己的采集时刻。
|
||||
GLOBAL_KEYS = {
|
||||
"api_base", "api_path",
|
||||
"allow_register", "register_max_per_ip", "captcha_policy", "captcha_length",
|
||||
}
|
||||
|
||||
# 验证码策略
|
||||
CAPTCHA_POLICIES = {
|
||||
"always": "始终要求(默认,最安全)",
|
||||
"adaptive": "仅在同一来源连续失败 2 次后要求",
|
||||
"off": "关闭(仅当前面有可信网关做鉴权时才考虑)",
|
||||
}
|
||||
|
||||
# 页面展示用:哪些键属于「敏感」,在界面上做掩码
|
||||
SECRET_KEYS = {"cookie"}
|
||||
# 需要静态加密后再入库的键(明文只存在于内存与请求体里)
|
||||
ENCRYPTED_KEYS = {"cookie"}
|
||||
|
||||
# 内部簿记键前缀:调度槽位标记等,**不属于用户可配置项**,
|
||||
# 不在 /api/settings 里回传,也不允许通过接口写入。
|
||||
@@ -72,6 +98,11 @@ def is_internal_key(key):
|
||||
return any(str(key).startswith(p) for p in INTERNAL_PREFIXES)
|
||||
|
||||
|
||||
def is_global_key(key):
|
||||
"""实例级键:所有账号共用一份,只有管理员可写。"""
|
||||
return key in GLOBAL_KEYS
|
||||
|
||||
|
||||
# ---------------- 设置项校验表 ----------------
|
||||
# 这些键必须能安全地转成数字:后台页面是自由文本框,用户敲错一个字符
|
||||
# 就会让采集在 int() 处抛 ValueError(历史 bug),所以写入时校验、读取时兜底。
|
||||
@@ -84,8 +115,10 @@ NUM_SETTINGS = {
|
||||
"verify_days": (0, 90, "天"),
|
||||
"timeout": (5, 300, "秒"),
|
||||
"catch_up_grace_hours": (1, 168, "小时"),
|
||||
"captcha_length": (4, 6, "个字符"),
|
||||
"register_max_per_ip": (1, 50, "个/天"),
|
||||
}
|
||||
BOOL_SETTINGS = {"schedule_enabled", "catch_up"}
|
||||
BOOL_SETTINGS = {"schedule_enabled", "catch_up", "allow_register"}
|
||||
|
||||
_TRUE = ("1", "true", "yes", "on", "是", "启用")
|
||||
|
||||
@@ -127,6 +160,12 @@ def normalize_setting(key, raw):
|
||||
return None, "每日时刻格式不对,正确写法如 09:00,17:00"
|
||||
return ",".join(parsed), None
|
||||
|
||||
if key == "captcha_policy":
|
||||
v = str(raw).strip().lower()
|
||||
if v not in CAPTCHA_POLICIES:
|
||||
return None, "验证码策略只能是 %s" % " / ".join(sorted(CAPTCHA_POLICIES))
|
||||
return v, None
|
||||
|
||||
if key in ("api_base", "api_path"):
|
||||
v = str(raw).strip()
|
||||
if not v:
|
||||
@@ -136,6 +175,7 @@ def normalize_setting(key, raw):
|
||||
return v, None
|
||||
|
||||
if key == "cookie":
|
||||
# 明文原样返回,由 db.set_setting 负责加密后再落库
|
||||
return str(raw).strip(), None
|
||||
|
||||
return str(raw).strip(), None
|
||||
@@ -144,29 +184,64 @@ def normalize_setting(key, raw):
|
||||
DEFAULT_HOST = "0.0.0.0" # 局域网可访问
|
||||
DEFAULT_PORT = 8848
|
||||
SESSION_HOURS = 12
|
||||
MAX_LOGIN_FAILS = 5 # 同 IP 连续失败次数
|
||||
MAX_LOGIN_FAILS = 5 # 同 IP / 同用户名连续失败次数
|
||||
LOGIN_LOCK_MINUTES = 10
|
||||
|
||||
# ---------------- 账号与口令策略 ----------------
|
||||
USERNAME_RE = r"^[A-Za-z0-9][A-Za-z0-9_.\-]{2,31}$" # 3~32 位,字母开头
|
||||
PASSWORD_MIN = 8
|
||||
PASSWORD_MAX = 128
|
||||
# 开启注册后,未配置 Cookie 的新账号在概览页会被提示「去配置」——
|
||||
# 采集只使用**本人**的 Cookie,绝不复用别人的(否则会串号)。
|
||||
PROFILE_EMAIL_MAX = 128
|
||||
|
||||
# 会话 Cookie 是否只走 HTTPS。纯局域网 HTTP 部署必须留 0,否则浏览器不发送,
|
||||
# 表现为「登录成功但立刻又跳回登录页」,极难排查。
|
||||
COOKIE_SECURE = os.environ.get("WB_COOKIE_SECURE", "0").strip() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def ensure_dirs():
|
||||
for d in (DATA_DIR, LOG_DIR, EXPORT_DIR):
|
||||
os.makedirs(d, exist_ok=True)
|
||||
|
||||
|
||||
def _instance_read():
|
||||
"""读 data/instance.json(不存在或损坏都当空字典,不让启动因此失败)。"""
|
||||
ensure_dirs()
|
||||
if not os.path.exists(INSTANCE_FILE):
|
||||
return {}
|
||||
try:
|
||||
with open(INSTANCE_FILE, "r", encoding="utf-8") as f:
|
||||
return json.load(f) or {}
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def _instance_init(key, maker):
|
||||
"""取 instance.json 里的 key,没有就生成并持久化。"""
|
||||
data = _instance_read()
|
||||
val = data.get(key)
|
||||
if not val:
|
||||
val = maker()
|
||||
data[key] = val
|
||||
try:
|
||||
with open(INSTANCE_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
except OSError:
|
||||
pass # 只读文件系统时退化为「本次进程内有效」
|
||||
return val
|
||||
|
||||
|
||||
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
|
||||
return _instance_init("secret_key", lambda: secrets.token_hex(32))
|
||||
|
||||
|
||||
def encryption_key():
|
||||
"""Cookie 静态加密的主密钥(32 字节)。
|
||||
|
||||
与 SECRET_KEY **分开**存放:两者轮换的代价完全不同 —— 换 SECRET_KEY
|
||||
只是让所有人重新登录,换这把会让已存的 Cookie 全部解不开。
|
||||
所以混用同一个值会让「想轮换其中一个」变成一件危险的事。
|
||||
"""
|
||||
return bytes.fromhex(_instance_init("cookie_key", lambda: secrets.token_hex(32)))
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# SPDX-License-Identifier: MIT
|
||||
# Copyright (c) 2026 Wang Chuanli
|
||||
|
||||
"""对称加密 —— 给「数据库里的 Cookie」做静态加密。
|
||||
|
||||
为什么要自己写而不用 `cryptography` / `pycryptodome`
|
||||
----------------------------------------------------
|
||||
本项目刻意保持零第三方依赖(`requirements.txt` 只有 Flask / waitress / openpyxl),
|
||||
而这里需要的原语只有两个,都能在 RFC 里逐行对照实现:
|
||||
|
||||
* **ChaCha20** 流密码(RFC 8439 §2.3)—— 加密
|
||||
* **HMAC-SHA256**(RFC 2104)—— 认证,采用 **encrypt-then-MAC**
|
||||
|
||||
明文密钥不是口令而是 32 字节随机数,所以派生不需要慢速 KDF
|
||||
(PBKDF2/scrypt 是为「低熵口令」设计的),用 HMAC 做一次密钥分离即可。
|
||||
|
||||
密文格式
|
||||
--------
|
||||
v1.<b64(salt)>.<b64(nonce)>.<b64(ciphertext)>.<b64(tag)>
|
||||
|
||||
* salt —— 16 字节随机,用于把主密钥分离成 enc/mac 两把子密钥
|
||||
* nonce —— 12 字节随机,每次加密都重新生成(绝不复用)
|
||||
* tag —— HMAC(mac_key, nonce || ciphertext) 的 SHA-256
|
||||
|
||||
**不做压缩**:Cookie 是几百到几千字节的高熵串,压缩比接近 1,
|
||||
反而会引入 CRIME 类侧信道,不值得。
|
||||
|
||||
向后兼容
|
||||
--------
|
||||
`decrypt()` 遇到不是 `v1.` 开头的值会**原样返回**,这样从旧版本
|
||||
(Cookie 明文存在 settings 表)升级过来不会立刻炸;下次写入时自然
|
||||
会被改写为密文(见 `db.set_secret`)。
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
import struct
|
||||
|
||||
PREFIX = "v1."
|
||||
|
||||
# RFC 8439 §2.3 的常数:"expand 32-byte k"
|
||||
_CONST = b"expand 32-byte k"
|
||||
_MASK = 0xFFFFFFFF
|
||||
|
||||
|
||||
# ---------------- ChaCha20 ----------------
|
||||
def _rotl32(v, c):
|
||||
return ((v << c) & _MASK) | (v >> (32 - c))
|
||||
|
||||
|
||||
def _quarter_round(s, a, b, c, d):
|
||||
"""RFC 8439 §2.1。就地修改 s。"""
|
||||
s[a] = (s[a] + s[b]) & _MASK
|
||||
s[d] = _rotl32(s[d] ^ s[a], 16)
|
||||
s[c] = (s[c] + s[d]) & _MASK
|
||||
s[b] = _rotl32(s[b] ^ s[c], 12)
|
||||
s[a] = (s[a] + s[b]) & _MASK
|
||||
s[d] = _rotl32(s[d] ^ s[a], 8)
|
||||
s[c] = (s[c] + s[d]) & _MASK
|
||||
s[b] = _rotl32(s[b] ^ s[c], 7)
|
||||
|
||||
|
||||
def chacha20_block(key32, counter, nonce12):
|
||||
"""产出一个 64 字节的块(RFC 8439 §2.3.2)。"""
|
||||
st = (list(struct.unpack("<4I", _CONST))
|
||||
+ list(struct.unpack("<8I", key32))
|
||||
+ [counter & _MASK]
|
||||
+ list(struct.unpack("<3I", nonce12)))
|
||||
w = list(st)
|
||||
for _ in range(10): # 10 组 = 20 轮
|
||||
_quarter_round(w, 0, 4, 8, 12)
|
||||
_quarter_round(w, 1, 5, 9, 13)
|
||||
_quarter_round(w, 2, 6, 10, 14)
|
||||
_quarter_round(w, 3, 7, 11, 15)
|
||||
_quarter_round(w, 0, 5, 10, 15)
|
||||
_quarter_round(w, 1, 6, 11, 12)
|
||||
_quarter_round(w, 2, 7, 8, 13)
|
||||
_quarter_round(w, 3, 4, 9, 14)
|
||||
return struct.pack("<16I", *[(w[i] + st[i]) & _MASK for i in range(16)])
|
||||
|
||||
|
||||
def _keystream(key32, nonce12, n):
|
||||
"""按需生成 n 字节密钥流。counter 从 1 开始(0 号块留给 Poly1305 用,这里不用)。"""
|
||||
out = bytearray()
|
||||
counter = 1
|
||||
while len(out) < n:
|
||||
out += chacha20_block(key32, counter, nonce12)
|
||||
counter += 1
|
||||
return bytes(out[:n])
|
||||
|
||||
|
||||
def _xor(a, b):
|
||||
return bytes(x ^ y for x, y in zip(a, b))
|
||||
|
||||
|
||||
# ---------------- 密钥分离 ----------------
|
||||
def _derive(master, salt, label):
|
||||
"""HMAC 做一次密钥分离:主密钥是高熵随机数,一次 HMAC 足够。"""
|
||||
return hmac.new(master, salt + label, hashlib.sha256).digest()
|
||||
|
||||
|
||||
def _b64(raw):
|
||||
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _unb64(text):
|
||||
pad = "=" * (-len(text) % 4)
|
||||
return base64.urlsafe_b64decode(text + pad)
|
||||
|
||||
|
||||
# ---------------- 对外接口 ----------------
|
||||
def is_encrypted(value):
|
||||
return isinstance(value, str) and value.startswith(PREFIX)
|
||||
|
||||
|
||||
def encrypt(plaintext, master):
|
||||
"""加密任意字符串;空值原样返回(不产生「有密文的空值」这种歧义状态)。
|
||||
|
||||
master: 32 字节主密钥(bytes)。返回可直接存库的 ASCII 字符串。
|
||||
"""
|
||||
if plaintext is None or plaintext == "":
|
||||
return ""
|
||||
if not isinstance(master, (bytes, bytearray)) or len(master) != 32:
|
||||
raise ValueError("主密钥必须是 32 字节")
|
||||
data = plaintext.encode("utf-8") if isinstance(plaintext, str) else bytes(plaintext)
|
||||
salt = secrets.token_bytes(16)
|
||||
nonce = secrets.token_bytes(12)
|
||||
enc_key = _derive(bytes(master), salt, b"enc")
|
||||
mac_key = _derive(bytes(master), salt, b"mac")
|
||||
ct = _xor(data, _keystream(enc_key, nonce, len(data)))
|
||||
tag = hmac.new(mac_key, nonce + ct, hashlib.sha256).digest()
|
||||
return PREFIX + ".".join((_b64(salt), _b64(nonce), _b64(ct), _b64(tag)))
|
||||
|
||||
|
||||
class DecryptError(ValueError):
|
||||
"""密文被篡改、格式损坏或密钥不对。"""
|
||||
|
||||
|
||||
def decrypt(token, master):
|
||||
"""解密。
|
||||
|
||||
* 非密文(历史明文、空串)原样返回,便于平滑升级
|
||||
* 密文校验失败抛 DecryptError —— **绝不**「失败就返回原值」,
|
||||
否则一次篡改会被静默当成合法明文用下去
|
||||
"""
|
||||
if not token or not isinstance(token, str):
|
||||
return ""
|
||||
if not token.startswith(PREFIX):
|
||||
return token # 兼容旧的明文存储
|
||||
if not isinstance(master, (bytes, bytearray)) or len(master) != 32:
|
||||
raise DecryptError("主密钥必须是 32 字节")
|
||||
parts = token[len(PREFIX):].split(".")
|
||||
if len(parts) != 4:
|
||||
raise DecryptError("密文格式不正确")
|
||||
try:
|
||||
salt, nonce, ct, tag = (_unb64(p) for p in parts)
|
||||
except (ValueError, TypeError) as e:
|
||||
raise DecryptError("密文 base64 解码失败:%s" % e)
|
||||
if len(salt) != 16 or len(nonce) != 12 or len(tag) != 32:
|
||||
raise DecryptError("密文长度不合法")
|
||||
mac_key = _derive(bytes(master), salt, b"mac")
|
||||
want = hmac.new(mac_key, nonce + ct, hashlib.sha256).digest()
|
||||
# 先比 MAC 再解密:认证失败时不接触密文,避免 padding/解析类侧信道
|
||||
if not hmac.compare_digest(want, tag):
|
||||
raise DecryptError("完整性校验失败(密文被篡改或主密钥已更换)")
|
||||
enc_key = _derive(bytes(master), salt, b"enc")
|
||||
return _xor(ct, _keystream(enc_key, nonce, len(ct))).decode("utf-8")
|
||||
|
||||
|
||||
def fingerprint(plaintext):
|
||||
"""值指纹:用于「是否换过」的判断,不能反推原文。"""
|
||||
if not plaintext:
|
||||
return ""
|
||||
return hashlib.sha256(plaintext.encode("utf-8")).hexdigest()[:16]
|
||||
+302
-37
@@ -2,7 +2,7 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
# Copyright (c) 2026 Wang Chuanli
|
||||
|
||||
"""SQLite 访问层。
|
||||
"""SQLite 访问层(多用户版)。
|
||||
|
||||
并发约定(重要):
|
||||
* WAL 模式 —— 采集写入期间页面查询不会被 `database is locked` 挡住
|
||||
@@ -10,18 +10,39 @@
|
||||
(由 scheduler / CLI 共享的 collect.lock 保证)
|
||||
* busy_timeout=8s —— 偶发并发时等待而不是立刻报错
|
||||
* 每个线程独立连接(sqlite3 默认禁止跨线程复用连接)
|
||||
|
||||
多用户约定(改代码前务必先读):
|
||||
* `user_id = 0` 在 settings / collect_runs / audit_log 里表示**实例级**;
|
||||
usage_records 里 0 是「历史遗留数据尚未归属」的兜底值,正常不会出现。
|
||||
* `get_settings()` 会把 `ENCRYPTED_KEYS`(Cookie)**一律置空**;
|
||||
要拿明文只有 `get_secret()` 一条路。这样任何「顺手打印一下全部配置」
|
||||
的代码都不可能把凭证带出去。
|
||||
* `NO_FALLBACK_KEYS`(Cookie / User-Agent)**不参与实例级回退**:
|
||||
Cookie 是账号凭证,回落等于串号,是最严重的一类越权。
|
||||
"""
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
from datetime import datetime
|
||||
|
||||
from . import config
|
||||
from . import config, crypto
|
||||
|
||||
_local = threading.local()
|
||||
_init_lock = threading.Lock()
|
||||
_initialized = False
|
||||
|
||||
# 库结构版本。写在 PRAGMA user_version 里,用来判断是否需要迁移。
|
||||
# 1 -> 单用户布局(settings 以 key 为主键,usage_records 以 request_id 为主键)
|
||||
# 2 -> 多用户布局(见 schema.sql 顶部说明)
|
||||
DB_SCHEMA_VERSION = 2
|
||||
|
||||
# 这些键即使个人作用域没有值,也**不**回落到实例级
|
||||
NO_FALLBACK_KEYS = {"cookie", "user_agent"}
|
||||
|
||||
|
||||
class SecretUnreadable(Exception):
|
||||
"""密文解不开 —— 通常是 data/instance.json 里的 cookie_key 被换过。"""
|
||||
|
||||
|
||||
def now_str():
|
||||
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
@@ -58,87 +79,331 @@ def close_thread_conn():
|
||||
_local.conn = None
|
||||
|
||||
|
||||
# ---------------- 初始化 ----------------
|
||||
def _schema_sql():
|
||||
with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "schema.sql"),
|
||||
"r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
# ---------------- 初始化 / 迁移 ----------------
|
||||
def _table_cols(conn, table):
|
||||
return {r["name"] for r in conn.execute("PRAGMA table_info(%s)" % table)}
|
||||
|
||||
|
||||
def _has_table(conn, name):
|
||||
return bool(conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (name,)).fetchone())
|
||||
|
||||
|
||||
def _first_owner_uid(conn):
|
||||
"""历史数据归谁:优先第一个管理员,其次第一个账号。"""
|
||||
row = conn.execute("SELECT id FROM users WHERE is_admin=1 ORDER BY id LIMIT 1").fetchone()
|
||||
if row:
|
||||
return row["id"]
|
||||
row = conn.execute("SELECT id FROM users ORDER BY id LIMIT 1").fetchone()
|
||||
return row["id"] if row else 0
|
||||
|
||||
|
||||
def _drop_all_user_indexes(conn):
|
||||
"""删掉本项目自建的全部索引(idx_*)。
|
||||
|
||||
必须先删:`ALTER TABLE ... RENAME TO` 会**把索引一起带走**(名字仍指向
|
||||
改名后的表),于是后面 `CREATE INDEX IF NOT EXISTS` 会被当成「已存在」
|
||||
静默跳过,最终新表上一个索引都没有 —— 表面完全正常,只是慢几百倍。
|
||||
`sqlite_autoindex_*` 是主键/唯一约束的隐式索引,不能动,靠前缀过滤掉。
|
||||
"""
|
||||
names = [r["name"] for r in conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'idx_%'")]
|
||||
for n in names:
|
||||
conn.execute('DROP INDEX IF EXISTS "%s"' % n)
|
||||
return names
|
||||
|
||||
|
||||
def _migrate(conn):
|
||||
"""把老库升到 DB_SCHEMA_VERSION。幂等,返回迁移说明列表。
|
||||
|
||||
顺序不能变:
|
||||
1. 删索引(否则改名会把索引名占住,新表建不出索引)
|
||||
2. 改名主键变了的表(settings / usage_records)
|
||||
3. ALTER 加列(collect_runs / audit_log / users)
|
||||
4. 跑 schema.sql —— 此时所有列都齐了,表与索引一次建全
|
||||
5. 回填数据、删掉 _v1_ 旧表
|
||||
"""
|
||||
ver = conn.execute("PRAGMA user_version").fetchone()[0]
|
||||
if ver >= DB_SCHEMA_VERSION:
|
||||
return []
|
||||
done = []
|
||||
ts = now_str()
|
||||
owner = _first_owner_uid(conn)
|
||||
|
||||
tset = _table_cols(conn, "settings")
|
||||
tur = _table_cols(conn, "usage_records")
|
||||
rebuild_settings = "user_id" not in tset
|
||||
rebuild_records = "user_id" not in tur
|
||||
|
||||
_drop_all_user_indexes(conn)
|
||||
if rebuild_settings:
|
||||
conn.execute("ALTER TABLE settings RENAME TO _v1_settings")
|
||||
if rebuild_records:
|
||||
conn.execute("ALTER TABLE usage_records RENAME TO _v1_usage_records")
|
||||
|
||||
# ---- 只加列的表用 ALTER,代价小得多。放在建表之前,好让 schema.sql
|
||||
# 里的 CREATE INDEX 一次就成功(索引引用了这些新列)----
|
||||
if "user_id" not in _table_cols(conn, "collect_runs"):
|
||||
conn.execute("ALTER TABLE collect_runs ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0")
|
||||
conn.execute("UPDATE collect_runs SET user_id=?", (owner,))
|
||||
done.append("collect_runs 增加 user_id")
|
||||
if "user_id" not in _table_cols(conn, "audit_log"):
|
||||
conn.execute("ALTER TABLE audit_log ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0")
|
||||
done.append("audit_log 增加 user_id")
|
||||
tusers = _table_cols(conn, "users")
|
||||
for col, ddl in (("email", "TEXT"),
|
||||
("status", "TEXT NOT NULL DEFAULT 'active'"),
|
||||
("register_ip", "TEXT"),
|
||||
("last_login_ip", "TEXT")):
|
||||
if col not in tusers:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN %s %s" % (col, ddl))
|
||||
done.append("users 增加 %s" % col)
|
||||
conn.execute("UPDATE users SET status='active' WHERE status IS NULL OR status=''")
|
||||
|
||||
conn.executescript(_schema_sql()) # 建出新表 + 全部索引
|
||||
|
||||
if rebuild_settings:
|
||||
# 老布局里 cookie / user_agent 是实例级的 —— 留在实例级等于
|
||||
# 「所有人共用管理员的凭证」,必须归到 owner 名下,且之后永不回落。
|
||||
conn.execute("INSERT INTO settings(user_id,key,value,updated_at)"
|
||||
" SELECT 0,key,value,updated_at FROM _v1_settings"
|
||||
" WHERE key NOT IN ('cookie','user_agent')")
|
||||
conn.execute("INSERT INTO settings(user_id,key,value,updated_at)"
|
||||
" SELECT ?,key,value,updated_at FROM _v1_settings"
|
||||
" WHERE key IN ('cookie','user_agent') AND value IS NOT NULL AND value <> ''",
|
||||
(owner,))
|
||||
conn.execute("DROP TABLE _v1_settings")
|
||||
done.append("settings 改为 (user_id,key) 复合主键:旧值归实例级,"
|
||||
"Cookie/UA 已归属账号 #%d" % owner)
|
||||
|
||||
if rebuild_records:
|
||||
conn.execute("INSERT INTO usage_records(user_id,request_id,ts,day,hour,model,client,"
|
||||
"credits,prompt,first_seen,last_seen,cloud_ts)"
|
||||
" SELECT ?,request_id,ts,day,hour,model,client,credits,prompt,"
|
||||
"first_seen,last_seen,cloud_ts FROM _v1_usage_records", (owner,))
|
||||
conn.execute("DROP TABLE _v1_usage_records")
|
||||
done.append("usage_records 增加 user_id,主键改为 (user_id, request_id),"
|
||||
"历史数据归属账号 #%d" % owner)
|
||||
|
||||
conn.execute("UPDATE users SET is_admin=1 WHERE id=?", (owner,))
|
||||
conn.execute("PRAGMA user_version=%d" % DB_SCHEMA_VERSION)
|
||||
if done:
|
||||
conn.execute("INSERT INTO audit_log(user_id,at,actor,action,detail,ip)"
|
||||
" VALUES(0,?,?,?,?,?)",
|
||||
(ts, "system", "schema_migrate", ";".join(done)[:500], "127.0.0.1"))
|
||||
return done
|
||||
|
||||
|
||||
def _encrypt_legacy_secrets(conn):
|
||||
"""把历史**明文**凭证就地加密。幂等,可反复执行。
|
||||
|
||||
老版本把 Cookie 直接明文写进 settings 表。升级后即便功能正常,
|
||||
「库里躺着一段明文凭证」本身就是风险:备份文件、磁盘镜像、
|
||||
误提交、排障时的一次 dump 都会把它带出去。
|
||||
这里对 `config.ENCRYPTED_KEYS` 里所有**非 v1. 前缀**的值加密一次;
|
||||
已加密的值会因前缀判定被跳过,所以每次启动跑一遍是安全的。
|
||||
"""
|
||||
if not config.ENCRYPTED_KEYS:
|
||||
return []
|
||||
key = config.encryption_key()
|
||||
done = []
|
||||
for r in conn.execute("SELECT user_id,key,value FROM settings").fetchall():
|
||||
if r["key"] not in config.ENCRYPTED_KEYS:
|
||||
continue
|
||||
raw = r["value"]
|
||||
if not raw or crypto.is_encrypted(raw):
|
||||
continue
|
||||
conn.execute("UPDATE settings SET value=?,updated_at=? WHERE user_id=? AND key=?",
|
||||
(crypto.encrypt(raw, key), now_str(), r["user_id"], r["key"]))
|
||||
done.append("settings[uid=%s].%s" % (r["user_id"], r["key"]))
|
||||
return done
|
||||
|
||||
|
||||
def 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())
|
||||
# 默认配置(不覆盖已有值)
|
||||
if not _has_table(conn, "users"):
|
||||
# 全新库:schema.sql 一次到位(避免走迁移路径去 ALTER 不存在的表)
|
||||
conn.executescript(_schema_sql())
|
||||
conn.execute("PRAGMA user_version=%d" % DB_SCHEMA_VERSION)
|
||||
migrated = []
|
||||
else:
|
||||
migrated = _migrate(conn)
|
||||
# 无论如何再跑一次:幂等补齐(例如后续版本新增了表/索引,
|
||||
# 而老库的 user_version 已经是最新,就不会走 _migrate 了)
|
||||
conn.executescript(_schema_sql())
|
||||
ts = now_str()
|
||||
# 默认配置灌在**实例级**(user_id=0)。个人作用域不预置行,
|
||||
# 读取时按「个人 -> 实例 -> DEFAULTS」三级回落,语义更清楚。
|
||||
for k, v in config.DEFAULTS.items():
|
||||
conn.execute("INSERT OR IGNORE INTO settings(key,value,updated_at) VALUES(?,?,?)",
|
||||
(k, v, ts))
|
||||
conn.execute("INSERT OR IGNORE INTO settings(user_id,key,value,updated_at)"
|
||||
" VALUES(0,?,?,?)", (k, v, ts))
|
||||
# 顺手把历史明文凭证加密(幂等;新库无事可做)
|
||||
enc = _encrypt_legacy_secrets(conn)
|
||||
if enc:
|
||||
migrated.append("明文凭证已加密:%s" % ", ".join(enc))
|
||||
conn.execute("INSERT INTO audit_log(user_id,at,actor,action,detail,ip)"
|
||||
" VALUES(0,?,?,?,?,?)",
|
||||
(ts, "system", "encrypt_secrets",
|
||||
"明文凭证已加密:%s" % ", ".join(enc)[:400], "127.0.0.1"))
|
||||
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))
|
||||
"INSERT INTO users(username,password_hash,display_name,is_admin,status,"
|
||||
" created_at) VALUES(?,?,?,1,'active',?)",
|
||||
(admin_user, hash_password(pwd), "管理员", ts))
|
||||
_initialized = True
|
||||
return migrated
|
||||
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_setting(conn, key, default=None, uid=0):
|
||||
"""取单个配置。
|
||||
|
||||
**加密键一律返回空串**:想拿 Cookie 明文只能用 get_secret(),
|
||||
避免任何「顺手读一下配置」的代码把凭证带进日志或响应体。
|
||||
"""
|
||||
if key in config.ENCRYPTED_KEYS:
|
||||
return ""
|
||||
uid = 0 if config.is_global_key(key) else (uid or 0)
|
||||
row = conn.execute("SELECT value FROM settings WHERE user_id=? AND key=?",
|
||||
(uid, key)).fetchone()
|
||||
if row is not None and row["value"] is not None:
|
||||
return row["value"]
|
||||
if uid and key not in NO_FALLBACK_KEYS:
|
||||
row = conn.execute("SELECT value FROM settings WHERE user_id=0 AND key=?",
|
||||
(key,)).fetchone()
|
||||
if row is not None and row["value"] is not None:
|
||||
return row["value"]
|
||||
return config.DEFAULTS.get(key, default)
|
||||
|
||||
|
||||
def get_settings(conn, keys=None):
|
||||
rows = conn.execute("SELECT key,value FROM settings").fetchall()
|
||||
got = {r["key"]: r["value"] for r in rows}
|
||||
def get_settings(conn, keys=None, uid=0):
|
||||
"""取该账号的**有效配置**(DEFAULTS -> 实例级 -> 个人级 三级合并)。
|
||||
|
||||
Cookie 等加密键固定为空串,页面/接口可以直接整体回传。
|
||||
"""
|
||||
out = dict(config.DEFAULTS)
|
||||
out.update(got)
|
||||
for r in conn.execute("SELECT key,value FROM settings WHERE user_id=0"):
|
||||
out[r["key"]] = r["value"]
|
||||
if uid:
|
||||
for r in conn.execute("SELECT key,value FROM settings WHERE user_id=?", (uid,)):
|
||||
if config.is_global_key(r["key"]):
|
||||
continue # 个人作用域里不该有全局键,有也不认
|
||||
out[r["key"]] = r["value"]
|
||||
for k in config.ENCRYPTED_KEYS:
|
||||
out[k] = ""
|
||||
if keys:
|
||||
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_setting(conn, key, value, uid=0):
|
||||
"""写单个配置。全局键强制落到 user_id=0;加密键自动加密后落库。"""
|
||||
uid = 0 if config.is_global_key(key) else (uid or 0)
|
||||
text = "" if value is None else str(value)
|
||||
if key in config.ENCRYPTED_KEYS and text:
|
||||
text = crypto.encrypt(text, config.encryption_key())
|
||||
conn.execute("INSERT INTO settings(user_id,key,value,updated_at) VALUES(?,?,?,?) "
|
||||
"ON CONFLICT(user_id,key) DO UPDATE SET value=excluded.value,"
|
||||
" updated_at=excluded.updated_at", (uid, key, text, now_str()))
|
||||
|
||||
|
||||
def set_settings(conn, pairs):
|
||||
def set_settings(conn, pairs, uid=0):
|
||||
for k, v in pairs.items():
|
||||
set_setting(conn, k, v)
|
||||
set_setting(conn, k, v, uid)
|
||||
|
||||
|
||||
def get_int(conn, key, default=0):
|
||||
def get_int(conn, key, default=0, uid=0):
|
||||
try:
|
||||
return int(float(get_setting(conn, key, default)))
|
||||
return int(float(get_setting(conn, key, default, uid)))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def get_float(conn, key, default=0.0):
|
||||
def get_float(conn, key, default=0.0, uid=0):
|
||||
try:
|
||||
return float(get_setting(conn, key, default))
|
||||
return float(get_setting(conn, key, default, uid))
|
||||
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()
|
||||
def get_bool(conn, key, default=False, uid=0):
|
||||
v = str(get_setting(conn, key, "1" if default else "0", uid)).strip().lower()
|
||||
return v in ("1", "true", "yes", "on", "是")
|
||||
|
||||
|
||||
# ---------------- 凭证(加密存储) ----------------
|
||||
def get_secret(conn, key, uid=0):
|
||||
"""取凭证明文。仅在真正要用它对外发请求时调用。"""
|
||||
row = conn.execute("SELECT value FROM settings WHERE user_id=? AND key=?",
|
||||
(uid or 0, key)).fetchone()
|
||||
if row is None or not row["value"]:
|
||||
return ""
|
||||
try:
|
||||
return crypto.decrypt(row["value"], config.encryption_key())
|
||||
except crypto.DecryptError as e:
|
||||
raise SecretUnreadable("%s 无法解密:%s" % (key, e))
|
||||
|
||||
|
||||
def set_secret(conn, key, value, uid=0):
|
||||
set_setting(conn, key, value, uid)
|
||||
|
||||
|
||||
def secret_state(conn, key, uid=0):
|
||||
"""给界面用的凭证状态:只回「有没有 / 多少字符 / 尾部 4 位」,绝不含明文。"""
|
||||
row = conn.execute("SELECT value FROM settings WHERE user_id=? AND key=?",
|
||||
(uid or 0, key)).fetchone()
|
||||
if row is None or not row["value"]:
|
||||
return {"set": False, "chars": 0, "tail": "", "broken": False, "at": ""}
|
||||
try:
|
||||
plain = crypto.decrypt(row["value"], config.encryption_key())
|
||||
except crypto.DecryptError:
|
||||
return {"set": True, "chars": 0, "tail": "", "broken": True, "at": ""}
|
||||
at = conn.execute("SELECT updated_at FROM settings WHERE user_id=? AND key=?",
|
||||
(uid or 0, key)).fetchone()
|
||||
return {"set": bool(plain), "chars": len(plain),
|
||||
"tail": plain[-4:] if len(plain) >= 4 else "",
|
||||
"broken": False, "at": (at["updated_at"] if at else "") or ""}
|
||||
|
||||
|
||||
# ---------------- 账号 ----------------
|
||||
def user_by_id(conn, uid):
|
||||
return conn.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone()
|
||||
|
||||
|
||||
def user_by_name(conn, username):
|
||||
return conn.execute("SELECT * FROM users WHERE username=?", (username,)).fetchone()
|
||||
|
||||
|
||||
def active_users(conn):
|
||||
"""启用状态的账号(调度器按人遍历)。"""
|
||||
return conn.execute("SELECT * FROM users WHERE status='active' ORDER BY id").fetchall()
|
||||
|
||||
|
||||
def user_count(conn):
|
||||
return conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||
|
||||
|
||||
# ---------------- 审计 ----------------
|
||||
def 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))
|
||||
def audit(conn, action, actor=None, detail=None, ip=None, uid=0):
|
||||
conn.execute("INSERT INTO audit_log(user_id,at,actor,action,detail,ip)"
|
||||
" VALUES(?,?,?,?,?,?)", (uid or 0, now_str(), actor, action, detail, ip))
|
||||
|
||||
|
||||
# ---------------- Flask 集成 ----------------
|
||||
|
||||
+80
-57
@@ -6,12 +6,21 @@
|
||||
|
||||
返回结构刻意与旧版 dashboard/data/*.json 的字段保持一致(d/c/k/fc/bc/m/h、
|
||||
id/c/m/cl/t/px …),这样 ECharts 大屏的渲染代码一行都不用改,只换数据来源。
|
||||
|
||||
**多用户约定(最重要)**
|
||||
所有公开函数都要求显式传入 `uid`(归属账号),且 `uid` 是 `conn` 之后的
|
||||
第一个位置参数、**没有默认值**。这是有意设计的:
|
||||
|
||||
忘记传 uid 会直接 TypeError,而不是静默把「全部人的数据」算进去。
|
||||
|
||||
聚合层内部一律通过 `_where(..., uid)` 把 `user_id = ?` 拼进 WHERE,
|
||||
所以任何一条 SQL 都不可能跨账号取数。
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from . import config, db
|
||||
|
||||
SCHEMA_VERSION = 4
|
||||
SCHEMA_VERSION = 5 # /api/manifest 里对外的结构版本(多用户改版)
|
||||
TOP_EXCERPT_LEN = 400
|
||||
DEFAULT_TOP_N = 200
|
||||
# 大屏页一次最多下发多少条窗口明细(页面要拿它在浏览器里算窗口 TOP / 散点)。
|
||||
@@ -46,8 +55,9 @@ def norm_window(frm=None, to=None):
|
||||
return f, t
|
||||
|
||||
|
||||
def _where(frm=None, to=None, model=None, client=None, q=None):
|
||||
w, p = [], []
|
||||
def _where(frm=None, to=None, model=None, client=None, q=None, uid=0):
|
||||
"""拼 WHERE。**user_id 永远在第一个条件上**,任何调用方都绕不过去。"""
|
||||
w, p = ["user_id = ?"], [uid or 0]
|
||||
if frm:
|
||||
w.append("day >= ?")
|
||||
p.append(frm)
|
||||
@@ -63,7 +73,7 @@ def _where(frm=None, to=None, model=None, client=None, q=None):
|
||||
if q:
|
||||
w.append("(prompt LIKE ? OR request_id LIKE ?)")
|
||||
p += ["%" + q + "%", "%" + q + "%"]
|
||||
return ("WHERE " + " AND ".join(w)) if w else "", p
|
||||
return "WHERE " + " AND ".join(w), p
|
||||
|
||||
|
||||
def _excerpt(s, n):
|
||||
@@ -74,8 +84,8 @@ def _excerpt(s, n):
|
||||
|
||||
|
||||
# ---------------- 逐日聚合 ----------------
|
||||
def daily(conn, frm=None, to=None, with_maps=True):
|
||||
w, p = _where(frm, to)
|
||||
def daily(conn, uid, frm=None, to=None, with_maps=True):
|
||||
w, p = _where(frm, to, uid=uid)
|
||||
days = {}
|
||||
for r in conn.execute(
|
||||
"SELECT day d, COUNT(*) k, ROUND(SUM(credits),2) c,"
|
||||
@@ -101,8 +111,8 @@ def daily(conn, frm=None, to=None, with_maps=True):
|
||||
|
||||
|
||||
# ---------------- 维度汇总 ----------------
|
||||
def _dim(conn, col, frm=None, to=None):
|
||||
w, p = _where(frm, to)
|
||||
def _dim(conn, uid, col, frm=None, to=None):
|
||||
w, p = _where(frm, to, uid=uid)
|
||||
rows = conn.execute(
|
||||
"SELECT %s name, COUNT(*) calls, ROUND(SUM(credits),2) credits,"
|
||||
" SUM(CASE WHEN credits<=0 THEN 1 ELSE 0 END) freeCalls,"
|
||||
@@ -125,8 +135,8 @@ def _dim(conn, col, frm=None, to=None):
|
||||
return out
|
||||
|
||||
|
||||
def dims(conn, frm=None, to=None):
|
||||
hours = {int(r["name"]): r for r in _dim(conn, "printf('%02d',hour)", frm, to)}
|
||||
def dims(conn, uid, frm=None, to=None):
|
||||
hours = {int(r["name"]): r for r in _dim(conn, uid, "printf('%02d',hour)", frm, to)}
|
||||
hlist = []
|
||||
for i in range(24):
|
||||
h = "%02d" % i
|
||||
@@ -135,14 +145,14 @@ def dims(conn, frm=None, to=None):
|
||||
"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),
|
||||
return {"model": _dim(conn, uid, "model", frm, to),
|
||||
"client": _dim(conn, uid, "client", frm, to),
|
||||
"hour": hlist}
|
||||
|
||||
|
||||
# ---------------- 单笔榜 ----------------
|
||||
def top(conn, frm=None, to=None, n=DEFAULT_TOP_N):
|
||||
w, p = _where(frm, to)
|
||||
def top(conn, uid, frm=None, to=None, n=DEFAULT_TOP_N):
|
||||
w, p = _where(frm, to, uid=uid)
|
||||
items = []
|
||||
for i, r in enumerate(conn.execute(
|
||||
"SELECT request_id, credits, model, client, ts, prompt FROM usage_records %s"
|
||||
@@ -154,8 +164,8 @@ def top(conn, frm=None, to=None, n=DEFAULT_TOP_N):
|
||||
|
||||
|
||||
# ---------------- 明细(窗口内精简记录,不带 prompt 全文)----------------
|
||||
def records(conn, frm=None, to=None, excerpt=96, limit=0, offset=0, newest_first=False):
|
||||
w, p = _where(frm, to)
|
||||
def records(conn, uid, frm=None, to=None, excerpt=96, limit=0, offset=0, newest_first=False):
|
||||
w, p = _where(frm, to, uid=uid)
|
||||
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"
|
||||
@@ -168,14 +178,15 @@ def records(conn, frm=None, to=None, excerpt=96, limit=0, offset=0, newest_first
|
||||
"cl": r["client"], "t": r["ts"], "px": (r["px"] or "")} for r in conn.execute(sql, args)]
|
||||
|
||||
|
||||
def records_page(conn, frm=None, to=None, model=None, client=None, q=None,
|
||||
def records_page(conn, uid, frm=None, to=None, model=None, client=None, q=None,
|
||||
page=1, size=50, order="ts_desc", with_prompt=True):
|
||||
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)
|
||||
w, p = _where(frm, to, model, client, q, uid=uid)
|
||||
total = conn.execute("SELECT COUNT(*) FROM usage_records %s" % w, p).fetchone()[0]
|
||||
agg = conn.execute("SELECT ROUND(COALESCE(SUM(credits),0),2) c FROM usage_records %s" % w, p).fetchone()
|
||||
agg = conn.execute("SELECT ROUND(COALESCE(SUM(credits),0),2) c FROM usage_records %s"
|
||||
% w, p).fetchone()
|
||||
orders = {"ts_desc": "ts DESC, request_id", "ts": "ts, request_id",
|
||||
"credits_desc": "credits DESC, ts DESC", "credits": "credits, ts"}
|
||||
ob = orders.get(order, orders["ts_desc"])
|
||||
@@ -195,11 +206,11 @@ def records_page(conn, frm=None, to=None, model=None, client=None, q=None,
|
||||
"pages": max(1, (total + size - 1) // size), "items": items}
|
||||
|
||||
|
||||
def iter_records(conn, frm=None, to=None, model=None, client=None, q=None,
|
||||
def iter_records(conn, uid, frm=None, to=None, model=None, client=None, q=None,
|
||||
order="ts_desc", with_prompt=True, batch=1000):
|
||||
"""流式产出明细(给导出用):不把整个结果集读进内存。"""
|
||||
frm, to = norm_window(frm, to)
|
||||
w, p = _where(frm, to, model, client, q)
|
||||
w, p = _where(frm, to, model, client, q, uid=uid)
|
||||
orders = {"ts_desc": "ts DESC, request_id", "ts": "ts, request_id",
|
||||
"credits_desc": "credits DESC, ts DESC", "credits": "credits, ts"}
|
||||
ob = orders.get(order, orders["ts_desc"])
|
||||
@@ -219,13 +230,14 @@ def iter_records(conn, frm=None, to=None, model=None, client=None, q=None,
|
||||
|
||||
|
||||
# ---------------- 全局元信息 ----------------
|
||||
def months(conn):
|
||||
def months(conn, uid=0):
|
||||
return [r[0] for r in conn.execute(
|
||||
"SELECT DISTINCT substr(day,1,7) m FROM usage_records ORDER BY m")]
|
||||
"SELECT DISTINCT substr(day,1,7) m FROM usage_records WHERE user_id=? ORDER BY m",
|
||||
(uid or 0,))]
|
||||
|
||||
|
||||
def totals(conn, frm=None, to=None):
|
||||
w, p = _where(frm, to)
|
||||
def totals(conn, uid, frm=None, to=None):
|
||||
w, p = _where(frm, to, uid=uid)
|
||||
r = conn.execute(
|
||||
"SELECT COUNT(*) n, ROUND(COALESCE(SUM(credits),0),2) c,"
|
||||
" SUM(CASE WHEN credits<=0 THEN 1 ELSE 0 END) fc,"
|
||||
@@ -242,51 +254,60 @@ def totals(conn, frm=None, to=None):
|
||||
"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 day_list(conn, uid=0):
|
||||
return [r[0] for r in conn.execute(
|
||||
"SELECT DISTINCT day FROM usage_records WHERE user_id=? ORDER BY day", (uid or 0,))]
|
||||
|
||||
|
||||
def manifest(conn):
|
||||
t = totals(conn)
|
||||
def manifest(conn, uid):
|
||||
"""该账号的数据清单 + 采集健康状态(凭证是否已配置 = 本人是否配了 Cookie)。"""
|
||||
t = totals(conn, uid)
|
||||
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) # 只算一次(原来在返回体里调了两遍)
|
||||
runs = conn.execute("SELECT COUNT(*) FROM collect_runs WHERE user_id=?", (uid,)).fetchone()[0]
|
||||
last_run = conn.execute("SELECT * FROM collect_runs WHERE user_id=?"
|
||||
" ORDER BY id DESC LIMIT 1", (uid,)).fetchone()
|
||||
cred = db.secret_state(conn, "cookie", uid)
|
||||
mons = months(conn, uid) # 只算一次(原来在返回体里调了两遍)
|
||||
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 实时聚合结果。",
|
||||
"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,
|
||||
"days": day_list(conn, uid), "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)},
|
||||
"topCredits": (conn.execute(
|
||||
"SELECT COALESCE(MAX(credits),0) FROM usage_records WHERE user_id=?",
|
||||
(uid,)).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": "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},
|
||||
{"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()),
|
||||
"health": {"cookie": cred["set"] and not cred["broken"],
|
||||
"cookieChars": cred["chars"],
|
||||
"cookieBroken": cred["broken"],
|
||||
"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,
|
||||
def bundle(conn, uid, 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 榜)。
|
||||
@@ -295,16 +316,16 @@ def bundle(conn, frm=None, to=None, top_n=DEFAULT_TOP_N, excerpt=140,
|
||||
避免存档长大后「全部」区间把整包明细都压到浏览器。
|
||||
"""
|
||||
frm, to = norm_window(frm, to)
|
||||
tot = totals(conn, frm, to)
|
||||
tot = totals(conn, uid, frm, to)
|
||||
# 只有真的会超限时才改成「取最近 N 条」,避免改变现有正常路径的行为
|
||||
truncated = tot["records"] > records_cap
|
||||
recs = records(conn, frm, to, excerpt=excerpt,
|
||||
recs = records(conn, uid, frm, to, excerpt=excerpt,
|
||||
limit=records_cap if truncated else 0, newest_first=truncated)
|
||||
return {
|
||||
"manifest": manifest(conn),
|
||||
"daily": daily(conn), # 全量逐日(体量小,供日历与日期轴)
|
||||
"dims": dims(conn, frm, to), # 窗口内维度
|
||||
"top": top(conn, None, None, top_n)["items"], # 全局 TOP 榜(对应「全局 TOP200」视图)
|
||||
"manifest": manifest(conn, uid),
|
||||
"daily": daily(conn, uid), # 全量逐日(体量小,供日历与日期轴)
|
||||
"dims": dims(conn, uid, frm, to), # 窗口内维度
|
||||
"top": top(conn, uid, None, None, top_n)["items"], # 该账号全局 TOP 榜
|
||||
"records": recs,
|
||||
"recordsTotal": tot["records"],
|
||||
"recordsCap": records_cap,
|
||||
@@ -315,7 +336,7 @@ def bundle(conn, frm=None, to=None, top_n=DEFAULT_TOP_N, excerpt=140,
|
||||
|
||||
|
||||
# ---------------- 环比 ----------------
|
||||
def summary(conn, frm, to):
|
||||
def summary(conn, uid, frm, to):
|
||||
"""KPI + 环比。前一段必须完整落在存档范围内,否则不给假数字。
|
||||
|
||||
frm/to 会先归一化(容错 '2026-09-08 12:00:00'、'2026/09/08' 等写法),
|
||||
@@ -324,18 +345,19 @@ def summary(conn, frm, to):
|
||||
frm, to = norm_window(frm, to)
|
||||
if not frm or not to:
|
||||
# 无法识别的日期:退化成全量口径,不抛异常(API 层会先校验并返回 400)
|
||||
t = totals(conn)
|
||||
t = totals(conn, uid)
|
||||
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)
|
||||
cur = totals(conn, uid, 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]
|
||||
first_day = conn.execute("SELECT MIN(day) FROM usage_records WHERE user_id=?",
|
||||
(uid or 0,)).fetchone()[0]
|
||||
prev = None
|
||||
if first_day and p_frm >= first_day:
|
||||
prev = totals(conn, p_frm, p_to)
|
||||
prev = totals(conn, uid, p_frm, p_to)
|
||||
out = dict(cur)
|
||||
out["window"] = {"from": frm, "to": to, "days": days}
|
||||
out["avgPerCall"] = round(cur["credits"] / cur["calls"], 4) if cur["calls"] else 0.0
|
||||
@@ -350,7 +372,8 @@ def summary(conn, frm, to):
|
||||
out["delta"] = None
|
||||
# 残日:最后一天不是完整的一天
|
||||
if to == datetime.now().strftime("%Y-%m-%d"):
|
||||
row = conn.execute("SELECT MAX(ts) FROM usage_records WHERE day=?", (to,)).fetchone()
|
||||
row = conn.execute("SELECT MAX(ts) FROM usage_records WHERE user_id=? AND day=?",
|
||||
(uid or 0, to)).fetchone()
|
||||
if row and row[0]:
|
||||
out["partial"] = {"date": to, "hhmm": row[0][11:16]}
|
||||
return out
|
||||
|
||||
+48
-28
@@ -9,6 +9,13 @@
|
||||
* 需要「启动补跑」(程序没开的时候错过了时刻,开机后要补上)
|
||||
* 需要和 CLI 共享同一把文件锁,避免两处同时采集
|
||||
|
||||
多用户
|
||||
------
|
||||
调度配置(开关 / 时刻 / 补跑 / slot:* 簿记)都是**个人级**设置,
|
||||
所以 tick 会遍历所有启用状态的账号,各自判断有没有到期槽位。
|
||||
好处是「A 想 9 点采、B 想 21 点采」互不影响;代价是串行执行 ——
|
||||
这是刻意的,SQLite 单写者不允许并发采集。
|
||||
|
||||
单实例保证:
|
||||
* Flask 的 reloader 会 fork 两个进程 → 只在 WERKZEUG_RUN_MAIN 里启动
|
||||
* 多进程部署时用环境变量 WB_DISABLE_SCHEDULER=1 关掉除一个之外的所有实例
|
||||
@@ -22,7 +29,7 @@ from datetime import datetime, timedelta
|
||||
from . import collect, db
|
||||
|
||||
log = logging.getLogger("wb.scheduler")
|
||||
SLOT_PREFIX = "slot:" # settings 键:slot:09:00 -> 最近执行的日期
|
||||
SLOT_PREFIX = "slot:" # settings 键:slot:09:00 -> 最近执行的日期(按 user_id 存)
|
||||
|
||||
|
||||
def parse_times(raw):
|
||||
@@ -50,25 +57,25 @@ def parse_times(raw):
|
||||
_parse_times = parse_times
|
||||
|
||||
|
||||
def slots(conn):
|
||||
return parse_times(db.get_setting(conn, "schedule_times"))
|
||||
def slots(conn, uid=0):
|
||||
return parse_times(db.get_setting(conn, "schedule_times", uid=uid))
|
||||
|
||||
|
||||
def last_run_of_slot(conn, slot):
|
||||
return db.get_setting(conn, SLOT_PREFIX + slot, "")
|
||||
def last_run_of_slot(conn, uid, slot):
|
||||
return db.get_setting(conn, SLOT_PREFIX + slot, "", uid)
|
||||
|
||||
|
||||
def mark_slot(conn, slot, day):
|
||||
db.set_setting(conn, SLOT_PREFIX + slot, day)
|
||||
def mark_slot(conn, uid, slot, day):
|
||||
db.set_setting(conn, SLOT_PREFIX + slot, day, uid)
|
||||
|
||||
|
||||
def next_run_at(conn, now=None):
|
||||
def next_run_at(conn, uid=0, now=None):
|
||||
"""下一次计划执行时间(仅按配置推算,不含补跑)。"""
|
||||
if not db.get_bool(conn, "schedule_enabled", True):
|
||||
if not db.get_bool(conn, "schedule_enabled", True, uid):
|
||||
return None
|
||||
now = now or datetime.now()
|
||||
best = None
|
||||
for s in slots(conn):
|
||||
for s in slots(conn, uid):
|
||||
hh, mm = map(int, s.split(":"))
|
||||
cand = now.replace(hour=hh, minute=mm, second=0, microsecond=0)
|
||||
if cand <= now:
|
||||
@@ -78,24 +85,24 @@ def next_run_at(conn, now=None):
|
||||
return best
|
||||
|
||||
|
||||
def due_slots(conn, now=None):
|
||||
def due_slots(conn, uid=0, now=None):
|
||||
"""返回此刻应当执行的槽位列表(含启动补跑)。"""
|
||||
if not db.get_bool(conn, "schedule_enabled", True):
|
||||
if not db.get_bool(conn, "schedule_enabled", True, uid):
|
||||
return []
|
||||
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_hours = db.get_int(conn, "catch_up_grace_hours", 12, uid)
|
||||
grace = timedelta(hours=max(1, grace_hours))
|
||||
catch_up = db.get_bool(conn, "catch_up", True)
|
||||
catch_up = db.get_bool(conn, "catch_up", True, uid)
|
||||
out = []
|
||||
for s in slots(conn):
|
||||
for s in slots(conn, uid):
|
||||
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:
|
||||
if last_run_of_slot(conn, uid, s) == today:
|
||||
continue # 今天这个槽位已跑过
|
||||
if when < now - grace and catch_up:
|
||||
continue # 错过太久,不补(避免开机狂刷)
|
||||
@@ -143,19 +150,32 @@ class Scheduler:
|
||||
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) # 先占位,避免采集失败被无限重试打爆云端
|
||||
for u in db.active_users(conn):
|
||||
uid = u["id"]
|
||||
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)
|
||||
pending = due_slots(conn, uid, now)
|
||||
except Exception as e: # 单个账号配置坏了不能拖垮其他人
|
||||
log.error("账号 #%s(%s) 读取调度配置失败:%s", uid, u["username"], e)
|
||||
continue
|
||||
for slot in pending:
|
||||
scheduled = now.replace(hour=int(slot[:2]), minute=int(slot[3:]),
|
||||
second=0, microsecond=0)
|
||||
trigger = "startup" if now - scheduled > timedelta(minutes=5) else "schedule"
|
||||
log.info("触发采集:账号 %s 槽位 %s(%s)", u["username"], slot, trigger)
|
||||
# 先占位,避免采集失败被无限重试打爆云端
|
||||
mark_slot(conn, uid, slot, today)
|
||||
if not db.secret_state(conn, "cookie", uid)["set"]:
|
||||
log.info("跳过:账号 %s 还没配置自己的 Cookie", u["username"])
|
||||
continue
|
||||
try:
|
||||
r = collect.run_sync(trigger=trigger, uid=uid)
|
||||
log.info("采集完成:%s → %s", u["username"], r["message"])
|
||||
except collect.Busy as e:
|
||||
log.warning("跳过(%s)", e)
|
||||
except db.SecretUnreadable as e:
|
||||
log.error("账号 %s 的 Cookie 解不开:%s", u["username"], e)
|
||||
except Exception as e:
|
||||
log.error("账号 %s 采集失败:%s", u["username"], e)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
+62
-24
@@ -1,15 +1,24 @@
|
||||
-- WorkBuddy Portal —— SQLite 表结构
|
||||
-- 设计要点:
|
||||
-- * usage_records 是唯一正本,request_id 为主键,去重靠 ON CONFLICT,不再依赖内存比对
|
||||
-- * 多用户:usage_records / collect_runs / audit_log 都带 user_id;
|
||||
-- 每个账号只看得到自己的数据,管理员也不越过这条线(见 docs/ARCHITECTURE.md)
|
||||
-- * settings 是 (user_id, key) 复合主键:user_id=0 为实例级,其余为个人级
|
||||
-- * usage_records 主键是 (user_id, request_id):去重按「人 + 请求」,
|
||||
-- 不同账号拿到相同 requestId 时互不覆盖
|
||||
-- * ts 存「本地保留的最早开始时间」;cloud_ts 存云端最近一次返回的时间(观察长请求前移)
|
||||
-- * day / hour 是冗余列,配合索引让区间扫描与 GROUP BY 都能走索引
|
||||
-- * prompt 单独存一列且默认不参与任何列表接口(占传输量约 80%)
|
||||
--
|
||||
-- 升级:本文件是 DDL 的唯一来源。db._migrate() 用「改名旧表 -> 重跑本文件 ->
|
||||
-- 回填数据 -> 删旧表 -> 再跑一次本文件补索引」的方式做在线迁移。
|
||||
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
|
||||
-- ---------------- 采集正本 ----------------
|
||||
CREATE TABLE IF NOT EXISTS usage_records (
|
||||
request_id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL DEFAULT 0, -- 归属账号(usage_records.user_id -> users.id)
|
||||
request_id TEXT NOT NULL,
|
||||
ts TEXT NOT NULL, -- 'YYYY-MM-DD HH:MM:SS'
|
||||
day TEXT NOT NULL, -- 'YYYY-MM-DD'
|
||||
hour INTEGER NOT NULL, -- 0..23
|
||||
@@ -19,19 +28,23 @@ CREATE TABLE IF NOT EXISTS usage_records (
|
||||
prompt TEXT,
|
||||
first_seen TEXT NOT NULL, -- 本地首次入库时间
|
||||
last_seen TEXT NOT NULL, -- 本地最近一次见到的时间
|
||||
cloud_ts TEXT -- 云端最近一次返回的 requestTime
|
||||
cloud_ts TEXT, -- 云端最近一次返回的 requestTime
|
||||
PRIMARY KEY (user_id, request_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ur_day ON usage_records(day);
|
||||
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);
|
||||
-- 索引一律以 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,
|
||||
@@ -42,7 +55,7 @@ CREATE TABLE IF NOT EXISTS collect_runs (
|
||||
fetched INTEGER DEFAULT 0, -- 云端返回条数
|
||||
added INTEGER DEFAULT 0,
|
||||
dup INTEGER DEFAULT 0,
|
||||
total INTEGER DEFAULT 0, -- 入库后总条数
|
||||
total INTEGER DEFAULT 0, -- 入库后该账号总条数
|
||||
conflicts INTEGER DEFAULT 0,
|
||||
exit_code INTEGER,
|
||||
message TEXT, -- 一句话结论
|
||||
@@ -50,34 +63,59 @@ CREATE TABLE IF NOT EXISTS collect_runs (
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_runs_started ON collect_runs(started_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_runs_user ON collect_runs(user_id, id DESC);
|
||||
|
||||
-- 键值配置:cookie / user_agent / 调度时刻 / 采集参数 / 调度槽位去重标记
|
||||
-- ---------------- 键值配置 ----------------
|
||||
-- user_id = 0 : 实例级(接口基址、注册开关、验证码策略)
|
||||
-- user_id > 0 : 个人级(自己填的 Cookie / UA、采集参数、调度时刻、slot:* 簿记)
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT,
|
||||
updated_at 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,
|
||||
is_admin INTEGER NOT NULL DEFAULT 1,
|
||||
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,
|
||||
at TEXT NOT NULL,
|
||||
actor TEXT,
|
||||
action TEXT NOT NULL,
|
||||
detail TEXT,
|
||||
ip TEXT
|
||||
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_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);
|
||||
|
||||
+303
-80
@@ -2,117 +2,211 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
# Copyright (c) 2026 Wang Chuanli
|
||||
|
||||
"""密码哈希、登录装饰器、CSRF。
|
||||
"""鉴权、口令策略、图形验证码、限速、CSRF、安全响应头。
|
||||
|
||||
局域网可访问 ⇒ 必须有鉴权。这里用 Werkzeug 自带的 PBKDF2,不引第三方依赖。
|
||||
多用户下的三条红线
|
||||
------------------
|
||||
1. **Cookie(账号凭证)是按 user_id 分作用域存的**,读取一律走
|
||||
`db.get_secret(conn, "cookie", uid)`;`db.get_settings()` 会把凭证置空,
|
||||
所以「顺手把配置回传给前端」这类代码不可能把它带出去。
|
||||
2. **禁用/删除账号立刻失效**:`current_user()` 每个请求回查一次
|
||||
users.status,不靠会话过期来兜底(默认会话 12 小时,太久了)。
|
||||
3. **失败限速按「来源 IP」和「用户名」双维度计数**:只按 IP 挡不住
|
||||
「一批肉鸡轮流撞同一个账号」,只按用户名又会让一个 IP 无限注册。
|
||||
"""
|
||||
import functools
|
||||
import hmac
|
||||
import re
|
||||
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 flask import (current_app, flash, g, jsonify, redirect, render_template,
|
||||
request, session, url_for)
|
||||
|
||||
from . import config, db
|
||||
from . import captcha, config, db
|
||||
|
||||
# 简易失败计数(内存即可:单进程部署,重启清零可接受)
|
||||
_fails = {} # ip -> [count, first_ts]
|
||||
_FAILS_MAX_IPS = 4096 # 上限,防止大量来源 IP 把字典撑爆
|
||||
_FAILS_TTL = 3600 # 超过 1 小时无更新的条目会被清理
|
||||
# ---------------- 失败计数(内存即可) ----------------
|
||||
# 单进程部署(见 README 的部署约束),重启清零可接受;
|
||||
# 真正的防爆破靠「验证码 + 双维度限速」两道,而不是靠计数持久化。
|
||||
_fails = {} # key -> [count, last_ts]
|
||||
_FAILS_MAX_KEYS = 8192 # 上限,防止海量来源把字典撑爆
|
||||
_FAILS_TTL = 3600 # 超过 1 小时无更新即清理
|
||||
|
||||
CAPTCHA_SESSION_PREFIX = "cap_"
|
||||
|
||||
|
||||
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)
|
||||
dead = [k for k, c in _fails.items() if now - c[1] > _FAILS_TTL]
|
||||
for k in dead:
|
||||
_fails.pop(k, None)
|
||||
if len(_fails) > _FAILS_MAX_KEYS:
|
||||
for k, _ in sorted(_fails.items(), key=lambda kv: kv[1][1])[:len(_fails) - _FAILS_MAX_KEYS]:
|
||||
_fails.pop(k, None)
|
||||
|
||||
|
||||
def _ip_key(ip):
|
||||
return "ip:" + (ip or "")
|
||||
|
||||
|
||||
def _user_key(username):
|
||||
return "user:" + (username or "").strip().lower()
|
||||
|
||||
|
||||
def note_fail(key):
|
||||
now = time.time()
|
||||
_prune_fails(now)
|
||||
c = _fails.get(key)
|
||||
if c is None or now - c[1] > config.LOGIN_LOCK_MINUTES * 60:
|
||||
_fails[key] = [1, now]
|
||||
return 1
|
||||
c[0] += 1
|
||||
c[1] = now
|
||||
return c[0]
|
||||
|
||||
|
||||
def is_locked(key):
|
||||
c = _fails.get(key)
|
||||
if not c or c[0] < config.MAX_LOGIN_FAILS:
|
||||
return False
|
||||
return time.time() - c[1] <= config.LOGIN_LOCK_MINUTES * 60
|
||||
|
||||
|
||||
def clear_fail(key):
|
||||
_fails.pop(key, None)
|
||||
|
||||
|
||||
def lock_left(key):
|
||||
c = _fails.get(key)
|
||||
if not c:
|
||||
return 0
|
||||
return max(0, int(config.LOGIN_LOCK_MINUTES * 60 - (time.time() - c[1])))
|
||||
|
||||
|
||||
def fail_count(key):
|
||||
c = _fails.get(key)
|
||||
return c[0] if c else 0
|
||||
|
||||
|
||||
def auth_locked(ip, username=""):
|
||||
"""返回还需锁定的秒数(0 = 未锁)。IP 与用户名任一超限即锁。"""
|
||||
return max(lock_left(_ip_key(ip)), lock_left(_user_key(username)))
|
||||
|
||||
|
||||
def note_auth_fail(ip, username=""):
|
||||
n1 = note_fail(_ip_key(ip))
|
||||
n2 = note_fail(_user_key(username)) if username else 0
|
||||
return max(n1, n2)
|
||||
|
||||
|
||||
def clear_auth_fail(ip, username=""):
|
||||
clear_fail(_ip_key(ip))
|
||||
if username:
|
||||
clear_fail(_user_key(username))
|
||||
|
||||
|
||||
# ---------------- 口令 / 用户名策略 ----------------
|
||||
_USERNAME_RE = re.compile(config.USERNAME_RE)
|
||||
|
||||
|
||||
def hash_password(p):
|
||||
from werkzeug.security import generate_password_hash
|
||||
return generate_password_hash(p, method="pbkdf2:sha256:200000")
|
||||
|
||||
|
||||
def verify_password(hashed, p):
|
||||
from werkzeug.security import check_password_hash
|
||||
try:
|
||||
return check_password_hash(hashed, p)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def username_problem(name):
|
||||
"""校验用户名。开放注册后这是第一个入口,必须收紧。"""
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
return "用户名必填"
|
||||
if not _USERNAME_RE.match(name):
|
||||
return "用户名需 3~32 位,以字母或数字开头,只能用字母、数字、下划线、点、连字符"
|
||||
if name.lower() in ("admin", "administrator", "root", "system", "guest", "null"):
|
||||
return "该用户名为系统保留字,请换一个"
|
||||
return None
|
||||
|
||||
|
||||
def password_problem(new, new2=None, username=None):
|
||||
"""口令强度:8 位以上,且至少包含两类字符。
|
||||
|
||||
比原来的「只要 6 位」严格——因为现在任何人都能自助注册,
|
||||
弱口令直接决定了整个实例的抗爆破能力。
|
||||
"""
|
||||
new = new or ""
|
||||
if len(new) < config.PASSWORD_MIN:
|
||||
return "密码至少 %d 位" % config.PASSWORD_MIN
|
||||
if len(new) > config.PASSWORD_MAX:
|
||||
return "密码过长(上限 %d 位)" % config.PASSWORD_MAX
|
||||
classes = sum(bool(re.search(p, new)) for p in
|
||||
(r"[a-z]", r"[A-Z]", r"[0-9]", r"[^A-Za-z0-9]"))
|
||||
if classes < 2:
|
||||
return "密码需包含大写字母、小写字母、数字、符号中的至少两类"
|
||||
if new2 is not None and new2 != new:
|
||||
return "两次输入的新密码不一致"
|
||||
if username and new.lower() == str(username).lower():
|
||||
return "密码不能与用户名相同"
|
||||
return None
|
||||
|
||||
|
||||
# 兼容旧名(原来的 api.py 内部函数)
|
||||
_check_password = password_problem
|
||||
|
||||
|
||||
# ---------------- 登录 ----------------
|
||||
def login_ok(conn, username, password):
|
||||
"""校验口令。返回 (user_row, error_message)。
|
||||
|
||||
停用账号与口令错误返回**同一句话**,避免探测哪些用户名存在
|
||||
(不过自助注册本身就暴露了用户名唯一性,这里只是不打额外的广告)。
|
||||
"""
|
||||
username = (username or "").strip()
|
||||
row = conn.execute("SELECT * FROM users WHERE username=?", (username,)).fetchone()
|
||||
if row is None or not verify_password(row["password_hash"], password):
|
||||
return None
|
||||
return None, "用户名或密码不正确"
|
||||
if (row["status"] or "active") != "active":
|
||||
return None, "该账号已被停用,请联系管理员"
|
||||
conn.execute("UPDATE users SET last_login_at=?, login_count=login_count+1 WHERE id=?",
|
||||
(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])))
|
||||
return row, None
|
||||
|
||||
|
||||
# ---------------- 会话 ----------------
|
||||
def current_user():
|
||||
"""当前登录用户(dict)或 None。
|
||||
|
||||
每个请求回查一次 users 表:账号被停用/删除后**立刻**失效,
|
||||
而不是等 12 小时会话自然过期。结果缓存在 flask.g 里,一次请求只查一次。
|
||||
"""
|
||||
if "wb_user" in g:
|
||||
return g.wb_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))}
|
||||
user = None
|
||||
if uid:
|
||||
try:
|
||||
row = db.get_db().execute(
|
||||
"SELECT id,username,display_name,is_admin,status FROM users WHERE id=?",
|
||||
(uid,)).fetchone()
|
||||
except Exception: # noqa: BLE001 (无请求上下文等)
|
||||
row = None
|
||||
if row is None or (row["status"] or "active") != "active":
|
||||
session.clear()
|
||||
else:
|
||||
user = {"id": row["id"], "username": row["username"],
|
||||
"display_name": row["display_name"] or row["username"],
|
||||
"is_admin": bool(row["is_admin"])}
|
||||
# 个人信息(显示名)改过之后立即生效,不必重新登录
|
||||
session["dname"] = user["display_name"]
|
||||
session["adm"] = 1 if user["is_admin"] else 0
|
||||
g.wb_user = user
|
||||
return user
|
||||
|
||||
|
||||
def is_admin():
|
||||
@@ -121,14 +215,17 @@ def is_admin():
|
||||
|
||||
|
||||
def login_session(user):
|
||||
"""建立登录会话。
|
||||
|
||||
`session.clear()` 是必须的:既清掉前一次的残留,
|
||||
也顺带换掉 CSRF token 与验证码 id —— 这正是防「会话固定」的做法。
|
||||
"""
|
||||
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["adm"] = 1 if user["is_admin"] else 0
|
||||
session["login_at"] = db.now_str()
|
||||
session.permanent = True
|
||||
|
||||
|
||||
@@ -167,6 +264,100 @@ def admin_required(fn):
|
||||
return wrapper
|
||||
|
||||
|
||||
# ---------------- 跳转目标白名单(防开放重定向) ----------------
|
||||
def safe_next(target, fallback="/"):
|
||||
"""只允许站内相对路径。
|
||||
|
||||
`//evil.com`、`/\\evil.com`、`https://evil.com` 都必须拒绝:
|
||||
`//` 开头是协议相对 URL,浏览器会把 `//evil.com` 当成外站跳转。
|
||||
"""
|
||||
if not target:
|
||||
return fallback
|
||||
t = str(target).strip()
|
||||
if not t.startswith("/"):
|
||||
return fallback
|
||||
if t.startswith("//") or t.startswith("/\\") or "\\" in t:
|
||||
return fallback
|
||||
if t.lstrip("/").startswith("//"):
|
||||
return fallback
|
||||
if "\r" in t or "\n" in t:
|
||||
return fallback
|
||||
return t
|
||||
|
||||
|
||||
# ---------------- 图形验证码 ----------------
|
||||
def captcha_required(conn, ip, username=""):
|
||||
"""按 captcha_policy 决定本次是否需要验证码。"""
|
||||
policy = (db.get_setting(conn, "captcha_policy", "always") or "always").strip().lower()
|
||||
if policy == "off":
|
||||
return False
|
||||
if policy == "adaptive":
|
||||
# 「自适应」= 这个来源出过问题才要求,日常登录不打扰
|
||||
return (fail_count(_ip_key(ip)) >= 2
|
||||
or (username and fail_count(_user_key(username)) >= 2))
|
||||
return True # always(默认)
|
||||
|
||||
|
||||
def issue_captcha(conn, purpose):
|
||||
"""新建验证码并把 id 记进会话,返回 PNG 字节。答案绝不离开服务端。"""
|
||||
try:
|
||||
length = int(db.get_setting(conn, "captcha_length", 4) or 4)
|
||||
except (TypeError, ValueError):
|
||||
length = 4
|
||||
length = max(4, min(6, length))
|
||||
cid, code = captcha.create(conn, purpose, length=length)
|
||||
session[CAPTCHA_SESSION_PREFIX + purpose] = cid
|
||||
return captcha.render(code, width=150 if length <= 4 else 150 + (length - 4) * 32)
|
||||
|
||||
|
||||
def consume_captcha(conn, purpose, answer):
|
||||
"""校验并作废本次验证码。会话里的 id 一并丢掉,逼迫下次换一张新图。"""
|
||||
cid = session.pop(CAPTCHA_SESSION_PREFIX + purpose, None)
|
||||
return captcha.verify(conn, cid, (answer or "").strip().upper(), purpose)
|
||||
|
||||
|
||||
# 验证码出图限速:出图本身要做点阵渲染 + zlib,不设限就是一条廉价的
|
||||
# CPU/带宽放大路径(有人拿它当免费的图片生成器刷)。
|
||||
_cap_fetch = {} # ip -> [count, window_started_at]
|
||||
_CAP_FETCH_MAX = 40 # 每窗口最多出图张数
|
||||
_CAP_FETCH_WINDOW = 60 # 窗口长度(秒)
|
||||
|
||||
|
||||
def captcha_fetch_allowed(ip):
|
||||
now = time.time()
|
||||
cur = _cap_fetch.get(ip)
|
||||
if cur is None or now - cur[1] > _CAP_FETCH_WINDOW:
|
||||
if len(_cap_fetch) > _FAILS_MAX_KEYS:
|
||||
_cap_fetch.clear()
|
||||
_cap_fetch[ip] = [1, now]
|
||||
return True
|
||||
cur[0] += 1
|
||||
return cur[0] <= _CAP_FETCH_MAX
|
||||
|
||||
|
||||
def audit_login_fail(conn, username, detail, ip):
|
||||
"""登录失败审计。
|
||||
|
||||
`user_id` 留 0:此时还不能确定是谁(可能是有人在撞别人的账号),
|
||||
但 `actor` 记下被尝试的用户名,便于事后按人名检索。
|
||||
"""
|
||||
db.audit(conn, "login_failed", username or "-", detail, ip, 0)
|
||||
|
||||
|
||||
# ---------------- 注册开关与配额 ----------------
|
||||
def register_allowed(conn):
|
||||
return db.get_bool(conn, "allow_register", True)
|
||||
|
||||
|
||||
def register_quota(conn, ip):
|
||||
"""同一 IP 当天的注册配额。返回 (是否允许, 已注册数, 上限)。"""
|
||||
limit = db.get_int(conn, "register_max_per_ip", 3)
|
||||
today = db.now_str()[:10]
|
||||
n = conn.execute("SELECT COUNT(*) FROM users WHERE register_ip=?"
|
||||
" AND substr(COALESCE(created_at,''),1,10)=?", (ip, today)).fetchone()[0]
|
||||
return n < limit, n, limit
|
||||
|
||||
|
||||
# ---------------- CSRF ----------------
|
||||
def csrf_token():
|
||||
t = session.get("_csrf")
|
||||
@@ -182,11 +373,39 @@ def check_csrf():
|
||||
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 jsonify({"ok": False, "error": "csrf",
|
||||
"message": "CSRF 校验失败,请刷新页面"}), 400
|
||||
return "CSRF 校验失败,请刷新页面后重试", 400
|
||||
return None
|
||||
|
||||
|
||||
# ---------------- 安全响应头 ----------------
|
||||
# 这些头是「纵深防御」:本项目的输出都过了 Jinja 自动转义 + app.js 手动转义,
|
||||
# 但多一层 nosniff / frame-ancestors 能让「某处漏转义」不至于直接变成可利用的 XSS。
|
||||
CSP = ("default-src 'self'; "
|
||||
"img-src 'self' data:; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"script-src 'self' 'unsafe-inline'; "
|
||||
"connect-src 'self'; "
|
||||
"font-src 'self' data:; "
|
||||
"object-src 'none'; "
|
||||
"base-uri 'self'; "
|
||||
"form-action 'self'; "
|
||||
"frame-ancestors 'none'")
|
||||
|
||||
|
||||
def apply_security_headers(resp):
|
||||
resp.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
resp.headers.setdefault("X-Frame-Options", "DENY")
|
||||
# 不让站内 URL(可能含 next=、run= 等参数)随外链 referer 泄漏出去
|
||||
resp.headers.setdefault("Referrer-Policy", "same-origin")
|
||||
resp.headers.setdefault("Content-Security-Policy", CSP)
|
||||
resp.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin")
|
||||
if request.path.startswith("/api/") or request.path.startswith("/captcha"):
|
||||
resp.headers.setdefault("Cache-Control", "no-store")
|
||||
return resp
|
||||
|
||||
|
||||
def init_app(app):
|
||||
app.jinja_env.globals["csrf_token"] = csrf_token
|
||||
app.jinja_env.globals["current_user"] = current_user
|
||||
@@ -194,3 +413,7 @@ def init_app(app):
|
||||
@app.before_request
|
||||
def _guard():
|
||||
return check_csrf()
|
||||
|
||||
@app.after_request
|
||||
def _headers(resp):
|
||||
return apply_security_headers(resp)
|
||||
|
||||
+253
-105
@@ -9,23 +9,39 @@
|
||||
* 参数 from/to 为 'YYYY-MM-DD';缺省则不限(即全量)
|
||||
* 列表类接口默认不返回 prompt 全文(占传输量约 80%),只有 /api/top 与
|
||||
/api/records/<request_id> 会带
|
||||
|
||||
**多用户约定**
|
||||
每个接口都只操作 `current_user()["id"]` 那份数据。查询函数要求显式传 uid,
|
||||
所以这里漏传会直接 TypeError(而不是静默返回全量)。
|
||||
`/api/settings` 是唯一的例外:它会回传实例级配置供非管理员只读展示,
|
||||
但**拒绝**非管理员写入实例级键。
|
||||
"""
|
||||
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
|
||||
from .. import collect, config, db, query, scheduler, security
|
||||
from ..security import admin_required, current_user, is_admin, login_required
|
||||
|
||||
bp = Blueprint("api", __name__, url_prefix="/api")
|
||||
|
||||
|
||||
def _uid():
|
||||
u = current_user()
|
||||
return u["id"] if u else 0
|
||||
|
||||
|
||||
def _arg(name, default=None):
|
||||
v = request.args.get(name)
|
||||
return v if v not in (None, "") else default
|
||||
|
||||
|
||||
def _json_body():
|
||||
body = request.get_json(silent=True)
|
||||
return body if isinstance(body, dict) else {}
|
||||
|
||||
|
||||
class BadParam(ValueError):
|
||||
"""查询参数不合法 -> 由 __init__ 的 ValueError 处理器统一转成 400。"""
|
||||
|
||||
@@ -60,7 +76,7 @@ def _int(name, default, lo=1, hi=2000):
|
||||
@bp.get("/manifest")
|
||||
@login_required
|
||||
def api_manifest():
|
||||
return jsonify(query.manifest(db.get_db()))
|
||||
return jsonify(query.manifest(db.get_db(), _uid()))
|
||||
|
||||
|
||||
@bp.get("/bundle")
|
||||
@@ -68,31 +84,33 @@ def api_manifest():
|
||||
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)))
|
||||
return jsonify(query.bundle(db.get_db(), _uid(), frm, to,
|
||||
top_n=_int("topN", query.DEFAULT_TOP_N, 1, 1000)))
|
||||
|
||||
|
||||
@bp.get("/summary")
|
||||
@login_required
|
||||
def api_summary():
|
||||
conn = db.get_db()
|
||||
uid = _uid()
|
||||
frm, to = _win()
|
||||
if not frm or not to:
|
||||
t = query.totals(conn)
|
||||
t = query.totals(conn, uid)
|
||||
frm, to = t["firstDay"], t["lastDay"]
|
||||
return jsonify(query.summary(conn, frm, to))
|
||||
return jsonify(query.summary(conn, uid, frm, to))
|
||||
|
||||
|
||||
@bp.get("/daily")
|
||||
@login_required
|
||||
def api_daily():
|
||||
return jsonify({"days": query.daily(db.get_db(), *_win())})
|
||||
return jsonify({"days": query.daily(db.get_db(), _uid(), *_win())})
|
||||
|
||||
|
||||
@bp.get("/dims")
|
||||
@login_required
|
||||
def api_dims():
|
||||
conn = db.get_db()
|
||||
d = query.dims(conn, *_win())
|
||||
d = query.dims(conn, _uid(), *_win())
|
||||
dim = _arg("dim")
|
||||
if dim in d:
|
||||
return jsonify({dim: d[dim]})
|
||||
@@ -103,17 +121,18 @@ def api_dims():
|
||||
@login_required
|
||||
def api_top():
|
||||
conn = db.get_db()
|
||||
return jsonify(query.top(conn, *_win(), n=_int("n", 50, 1, 1000)))
|
||||
return jsonify(query.top(conn, _uid(), *_win(), n=_int("n", 50, 1, 1000)))
|
||||
|
||||
|
||||
@bp.get("/records")
|
||||
@login_required
|
||||
def api_records():
|
||||
conn = db.get_db()
|
||||
uid = _uid()
|
||||
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"),
|
||||
r = query.records_page(conn, uid, frm, to, model=_arg("model"), client=_arg("client"),
|
||||
q=_arg("q"), page=page, size=size, order=_arg("order", "ts_desc"),
|
||||
with_prompt=False if _arg("lean") == "1" else True)
|
||||
return jsonify(r)
|
||||
@@ -122,8 +141,9 @@ def api_records():
|
||||
@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()
|
||||
# user_id 必须进 WHERE:否则改一个 URL 就能读到别人的 Prompt 全文
|
||||
row = db.get_db().execute("SELECT * FROM usage_records WHERE user_id=? AND request_id=?",
|
||||
(_uid(), request_id)).fetchone()
|
||||
if row is None:
|
||||
return jsonify({"ok": False, "message": "记录不存在"}), 404
|
||||
return jsonify(dict(row))
|
||||
@@ -135,14 +155,16 @@ 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),))
|
||||
" FROM collect_runs WHERE user_id=? ORDER BY id DESC LIMIT ?",
|
||||
(_uid(), _int("limit", 50, 1, 500)))
|
||||
return jsonify({"items": [dict(r) for r in rows]})
|
||||
|
||||
|
||||
@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()
|
||||
row = db.get_db().execute("SELECT * FROM collect_runs WHERE id=? AND user_id=?",
|
||||
(run_id, _uid())).fetchone()
|
||||
if row is None:
|
||||
return jsonify({"ok": False, "message": "运行记录不存在"}), 404
|
||||
return jsonify(dict(row))
|
||||
@@ -152,23 +174,30 @@ def api_run(run_id):
|
||||
@login_required
|
||||
def api_status():
|
||||
conn = db.get_db()
|
||||
uid = _uid()
|
||||
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]
|
||||
nxt = scheduler.next_run_at(conn, uid)
|
||||
last = conn.execute("SELECT * FROM collect_runs WHERE user_id=? ORDER BY id DESC LIMIT 1",
|
||||
(uid,)).fetchone()
|
||||
running = conn.execute("SELECT COUNT(*) FROM collect_runs WHERE user_id=? AND status='running'",
|
||||
(uid,)).fetchone()[0]
|
||||
cred = db.secret_state(conn, "cookie", uid)
|
||||
return jsonify({
|
||||
"server_time": db.now_str(),
|
||||
"scheduler": {
|
||||
"running": sch.running,
|
||||
"enabled": db.get_bool(conn, "schedule_enabled", True),
|
||||
"times": scheduler.slots(conn),
|
||||
"enabled": db.get_bool(conn, "schedule_enabled", True, uid),
|
||||
"times": scheduler.slots(conn, uid),
|
||||
"next_run": nxt.strftime("%Y-%m-%d %H:%M:%S") if nxt else None,
|
||||
"catch_up": db.get_bool(conn, "catch_up", True),
|
||||
"catch_up": db.get_bool(conn, "catch_up", True, uid),
|
||||
"lockfile": os.path.exists(collect.LOCK_PATH),
|
||||
},
|
||||
"running_runs": running,
|
||||
"last_run": dict(last) if last else None,
|
||||
"cookie_set": bool((db.get_setting(conn, "cookie") or "").strip()),
|
||||
# 只回「有没有配」与字符数,绝不回凭证内容
|
||||
"cookie_set": bool(cred["set"] and not cred["broken"]),
|
||||
"cookie_chars": cred["chars"],
|
||||
"cookie_broken": cred["broken"],
|
||||
})
|
||||
|
||||
|
||||
@@ -176,9 +205,8 @@ def api_status():
|
||||
@login_required
|
||||
def api_collect():
|
||||
"""手动触发一次采集(后台线程之外同步执行,页面等待结果)。"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
if not isinstance(body, dict):
|
||||
return jsonify({"ok": False, "message": "请求体必须是对象"}), 400
|
||||
u = current_user()
|
||||
body = _json_body()
|
||||
frm, to = body.get("from"), body.get("to")
|
||||
try:
|
||||
kw = {}
|
||||
@@ -194,40 +222,55 @@ def api_collect():
|
||||
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)
|
||||
r = collect.run_sync(trigger="manual", uid=u["id"], **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.NotReady as e:
|
||||
return jsonify({"ok": False, "error": "no_cookie", "message": str(e)}), 409
|
||||
except db.SecretUnreadable as e:
|
||||
return jsonify({"ok": False, "error": "cookie_broken",
|
||||
"message": "已保存的 Cookie 无法解密(实例密钥被更换过):%s。"
|
||||
"请到「配置管理」重新粘贴。" % e}), 409
|
||||
except collect.ApiError as e:
|
||||
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)
|
||||
db.audit(db.get_db(), "collect", u["username"], r["message"], request.remote_addr, u["id"])
|
||||
return jsonify({"ok": True, "result": r})
|
||||
|
||||
|
||||
@bp.get("/audit")
|
||||
@login_required
|
||||
def api_audit():
|
||||
"""操作审计分页(日志管理页用;原来只能看最近 40 条)。"""
|
||||
"""操作审计分页。管理员看全部(便于追责),普通用户只看自己触发的。"""
|
||||
conn = db.get_db()
|
||||
u = current_user()
|
||||
action = _arg("action")
|
||||
page = _int("page", 1, 1, 100000)
|
||||
size = _int("size", 50, 1, 500)
|
||||
w, p = "", []
|
||||
w, p = [], []
|
||||
if not u["is_admin"]:
|
||||
w.append("user_id = ?")
|
||||
p.append(u["id"])
|
||||
if action:
|
||||
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,
|
||||
w.append("action = ?")
|
||||
p.append(action)
|
||||
ws = ("WHERE " + " AND ".join(w)) if w else ""
|
||||
total = conn.execute("SELECT COUNT(*) FROM audit_log %s" % ws, p).fetchone()[0]
|
||||
rows = conn.execute("SELECT * FROM audit_log %s ORDER BY id DESC LIMIT ? OFFSET ?" % ws,
|
||||
p + [size, (page - 1) * size])
|
||||
# 动作清单不带 action 条件,否则只剩下当前那一个动作可选
|
||||
base_p = [u["id"]] if not u["is_admin"] else []
|
||||
base = "WHERE user_id = ?" if not u["is_admin"] else ""
|
||||
actions = [r[0] for r in conn.execute(
|
||||
"SELECT DISTINCT action FROM audit_log ORDER BY action")]
|
||||
"SELECT DISTINCT action FROM audit_log %s ORDER BY action" % base, base_p)]
|
||||
return jsonify({"total": total, "page": page, "size": size,
|
||||
"pages": max(1, (total + size - 1) // size),
|
||||
"scope": "all" if u["is_admin"] else "self",
|
||||
"actions": actions,
|
||||
"items": [dict(r) for r in rows]})
|
||||
|
||||
@@ -236,13 +279,26 @@ def api_audit():
|
||||
@bp.post("/maintenance/<action>")
|
||||
@login_required
|
||||
def api_maintenance(action):
|
||||
"""把 CLI 里的维护动作搬到页面上:补全 prompt / VACUUM / 导出 CSV。"""
|
||||
"""把 CLI 里的维护动作搬到页面上。
|
||||
|
||||
只有 `vacuum` 是**实例级**动作(整个库一起整理),所以它仅管理员可用;
|
||||
其余三个都只作用于当前账号自己的数据。
|
||||
"""
|
||||
conn = db.get_db()
|
||||
user = (current_user() or {}).get("username")
|
||||
u = current_user()
|
||||
uid = u["id"]
|
||||
if action == "vacuum" and not u["is_admin"]:
|
||||
return jsonify({"ok": False, "error": "forbidden",
|
||||
"message": "数据库整理是整库操作,仅管理员可执行"}), 403
|
||||
try:
|
||||
if action == "fill-prompt":
|
||||
try:
|
||||
n = collect.fill_prompt(conn, log=lambda m: None)
|
||||
n = collect.fill_prompt(conn, uid, log=lambda m: None)
|
||||
except collect.NotReady as e:
|
||||
return jsonify({"ok": False, "error": "no_cookie", "message": str(e)}), 409
|
||||
except db.SecretUnreadable as e:
|
||||
return jsonify({"ok": False, "error": "cookie_broken",
|
||||
"message": "已保存的 Cookie 无法解密,请重新粘贴:%s" % e}), 409
|
||||
except collect.ApiError as e:
|
||||
return jsonify({"ok": False, "error": "api", "message": str(e)}), 502
|
||||
msg = "补全 %d 条 User Prompt" % n
|
||||
@@ -250,19 +306,20 @@ def api_maintenance(action):
|
||||
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
|
||||
msg = "数据库整理完成:%s → %s" % (_human(before), _human(after))
|
||||
elif action == "export-csv":
|
||||
path, n = collect.export_csv(conn)
|
||||
path, n = collect.export_csv(conn, uid, username=u["username"])
|
||||
msg = "已导出 %d 条到 %s" % (n, os.path.relpath(path, config.BASE_DIR))
|
||||
elif action == "recount":
|
||||
n = collect.record_count(conn)
|
||||
n = collect.record_count(conn, uid)
|
||||
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)
|
||||
db.audit(conn, "maintenance:" + action, u["username"], msg, request.remote_addr, uid)
|
||||
return jsonify({"ok": True, "message": msg})
|
||||
|
||||
|
||||
@@ -276,16 +333,18 @@ def _human(n):
|
||||
@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) # 不回传明文凭证
|
||||
u = current_user()
|
||||
s = db.get_settings(conn, uid=u["id"])
|
||||
st = db.secret_state(conn, "cookie", u["id"])
|
||||
s["cookie_hint"] = ("%d 字符,…%s" % (st["chars"], st["tail"])) if st["set"] else ""
|
||||
s["cookie_broken"] = st["broken"]
|
||||
# 内部簿记键(slot:09:00 这类调度槽位标记)不属于配置项,绝不外泄
|
||||
for k in [k for k in list(s) if config.is_internal_key(k)]:
|
||||
s.pop(k, None)
|
||||
s["_globalKeys"] = sorted(config.GLOBAL_KEYS)
|
||||
s["_canEditGlobal"] = bool(u["is_admin"])
|
||||
return jsonify(s)
|
||||
|
||||
|
||||
@@ -293,137 +352,200 @@ def api_settings_get():
|
||||
@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 = [], [], []
|
||||
u = current_user()
|
||||
uid = u["id"]
|
||||
body = _json_body()
|
||||
changed, errors, ignored, denied = [], [], [], []
|
||||
for k, v in body.items():
|
||||
if config.is_internal_key(k):
|
||||
ignored.append(k)
|
||||
continue # slot:* 是调度簿记,不允许前台写
|
||||
if k not in config.DEFAULTS:
|
||||
errors.append("未知配置项:%s" % k)
|
||||
continue
|
||||
if config.is_global_key(k) and not u["is_admin"]:
|
||||
# 实例级配置(接口基址、注册开关)只有管理员能改 ——
|
||||
# 否则任意注册用户都能把大家的数据采集指向别的服务器
|
||||
denied.append(k)
|
||||
continue
|
||||
if k == "cookie":
|
||||
if not str(v).strip():
|
||||
raw = str(v).strip()
|
||||
if not raw:
|
||||
continue # 空值不动,避免误清
|
||||
if str(v).strip().lower() in ("__clear__", "-"):
|
||||
db.set_setting(conn, "cookie", "")
|
||||
if raw.lower() in ("__clear__", "-"):
|
||||
db.set_secret(conn, "cookie", "", uid)
|
||||
changed.append(k)
|
||||
continue
|
||||
val, err = config.normalize_setting(k, v)
|
||||
if err:
|
||||
errors.append(err)
|
||||
continue
|
||||
db.set_setting(conn, k, val)
|
||||
if k in config.ENCRYPTED_KEYS:
|
||||
db.set_secret(conn, k, val, uid)
|
||||
else:
|
||||
db.set_setting(conn, k, val, uid)
|
||||
changed.append(k)
|
||||
if denied:
|
||||
errors.append("以下为实例级配置,仅管理员可修改:%s" % "、".join(sorted(denied)))
|
||||
if errors:
|
||||
db.audit(conn, "settings_rejected", (current_user() or {}).get("username"),
|
||||
";".join(errors)[:500], request.remote_addr)
|
||||
db.audit(conn, "settings_rejected", u["username"], ";".join(errors)[:500],
|
||||
request.remote_addr, uid)
|
||||
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)
|
||||
conn.execute("DELETE FROM settings WHERE user_id=? AND key LIKE ?",
|
||||
(uid, scheduler.SLOT_PREFIX + "%"))
|
||||
db.audit(conn, "settings", u["username"],
|
||||
"修改:" + (",".join(sorted(changed)) or "(无变化)"), request.remote_addr, uid)
|
||||
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()
|
||||
body = _json_body()
|
||||
row = conn.execute("SELECT * FROM users WHERE id=?", (u["id"],)).fetchone()
|
||||
if row is None or not verify_password(row["password_hash"], body.get("old") or ""):
|
||||
if row is None or not security.verify_password(row["password_hash"], body.get("old") or ""):
|
||||
return jsonify({"ok": False, "message": "原密码不正确"}), 400
|
||||
new = (body.get("new") or "").strip()
|
||||
err = _check_password(new, body.get("new2"))
|
||||
err = security.password_problem(new, body.get("new2"), u["username"])
|
||||
if err:
|
||||
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)
|
||||
conn.execute("UPDATE users SET password_hash=? WHERE id=?",
|
||||
(security.hash_password(new), u["id"]))
|
||||
db.audit(conn, "password", u["username"], "修改登录密码", request.remote_addr, u["id"])
|
||||
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.post("/profile")
|
||||
@login_required
|
||||
def api_profile():
|
||||
"""自助修改个人资料(显示名 / 邮箱)。用户名不可改 —— 它是审计里的主键。"""
|
||||
conn = db.get_db()
|
||||
u = current_user()
|
||||
body = _json_body()
|
||||
changed = []
|
||||
if "display_name" in body:
|
||||
name = (body.get("display_name") or "").strip()[:64] or u["username"]
|
||||
conn.execute("UPDATE users SET display_name=? WHERE id=?", (name, u["id"]))
|
||||
changed.append("显示名")
|
||||
if "email" in body:
|
||||
email = (body.get("email") or "").strip()[:config.PROFILE_EMAIL_MAX]
|
||||
if email and ("@" not in email or " " in email):
|
||||
return jsonify({"ok": False, "message": "邮箱格式不正确"}), 400
|
||||
conn.execute("UPDATE users SET email=? WHERE id=?", (email or None, u["id"]))
|
||||
changed.append("邮箱")
|
||||
if not changed:
|
||||
return jsonify({"ok": False, "message": "没有要修改的内容"}), 400
|
||||
db.audit(conn, "profile", u["username"], "修改:" + "、".join(changed),
|
||||
request.remote_addr, u["id"])
|
||||
return jsonify({"ok": True, "message": "已更新:" + "、".join(changed)})
|
||||
|
||||
|
||||
# ---------------- 用户管理(管理员) ----------------
|
||||
def _user_public(r):
|
||||
"""用户行 -> 可下发结构。**绝不包含口令散列,也不包含任何凭证。**"""
|
||||
return {"id": r["id"], "username": r["username"], "display_name": r["display_name"],
|
||||
"email": r["email"], "is_admin": bool(r["is_admin"]),
|
||||
"status": r["status"] or "active", "created_at": r["created_at"],
|
||||
"register_ip": r["register_ip"], "last_login_at": r["last_login_at"],
|
||||
"last_login_ip": r["last_login_ip"], "login_count": r["login_count"]}
|
||||
|
||||
|
||||
@bp.get("/users")
|
||||
@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]})
|
||||
rows = db.get_db().execute("SELECT * FROM users ORDER BY id").fetchall()
|
||||
return jsonify({"items": [_user_public(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 {}
|
||||
me = current_user()
|
||||
body = _json_body()
|
||||
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"))
|
||||
err = security.username_problem(name) or security.password_problem(
|
||||
pwd, body.get("password2"), name)
|
||||
if err:
|
||||
return jsonify({"ok": False, "message": err}), 400
|
||||
exist = conn.execute("SELECT id FROM users WHERE username=?", (name,)).fetchone()
|
||||
if exist:
|
||||
if db.user_by_name(conn, name):
|
||||
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})
|
||||
# 默认建**普通账号**:多用户系统里「默认给管理员」是最常见的越权起点
|
||||
adm = 1 if str(body.get("is_admin", "0")) in ("1", "true", "on") else 0
|
||||
cur = conn.execute(
|
||||
"INSERT INTO users(username,password_hash,display_name,email,is_admin,status,created_at)"
|
||||
" VALUES(?,?,?,?,?, 'active', ?)",
|
||||
(name, security.hash_password(pwd),
|
||||
(body.get("display_name") or name).strip()[:64],
|
||||
(body.get("email") or "").strip()[:128] or None, adm, db.now_str()))
|
||||
db.audit(conn, "user_create", me["username"],
|
||||
"新建用户 %s(%s)" % (name, "管理员" if adm else "普通"), request.remote_addr, me["id"])
|
||||
return jsonify({"ok": True, "message": "已创建用户 %s" % name, "id": cur.lastrowid})
|
||||
|
||||
|
||||
@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()
|
||||
me = current_user()
|
||||
row = db.user_by_id(conn, uid)
|
||||
if row is None:
|
||||
return jsonify({"ok": False, "message": "用户不存在"}), 404
|
||||
body = request.get_json(silent=True) or {}
|
||||
me = current_user()
|
||||
body = _json_body()
|
||||
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 "email" in body:
|
||||
email = (body.get("email") or "").strip()[:config.PROFILE_EMAIL_MAX]
|
||||
if email and ("@" not in email or " " in email):
|
||||
return jsonify({"ok": False, "message": "邮箱格式不正确"}), 400
|
||||
conn.execute("UPDATE users SET email=? WHERE id=?", (email or None, uid))
|
||||
changed.append("邮箱")
|
||||
if "is_admin" in body:
|
||||
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
|
||||
if not v and row["is_admin"]:
|
||||
left = conn.execute("SELECT COUNT(*) FROM users WHERE is_admin=1 AND status='active'"
|
||||
" AND id<>?", (uid,)).fetchone()[0]
|
||||
if left == 0:
|
||||
return jsonify({"ok": False,
|
||||
"message": "至少要保留一个启用状态的管理员"}), 400
|
||||
conn.execute("UPDATE users SET is_admin=? WHERE id=?", (v, uid))
|
||||
changed.append("管理员")
|
||||
if "status" in body:
|
||||
v = "active" if str(body.get("status")) in ("active", "1", "true", "on") else "disabled"
|
||||
if uid == me["id"] and v != "active":
|
||||
return jsonify({"ok": False, "message": "不能停用自己的账号"}), 400
|
||||
if v != "active":
|
||||
left = conn.execute("SELECT COUNT(*) FROM users WHERE is_admin=1 AND status='active'"
|
||||
" AND id<>?", (uid,)).fetchone()[0]
|
||||
if row["is_admin"] and left == 0:
|
||||
return jsonify({"ok": False,
|
||||
"message": "至少要保留一个启用状态的管理员"}), 400
|
||||
conn.execute("UPDATE users SET status=? WHERE id=?", (v, uid))
|
||||
changed.append("状态→" + ("启用" if v == "active" else "停用"))
|
||||
pwd = (body.get("password") or "").strip()
|
||||
if pwd:
|
||||
err = _check_password(pwd, body.get("password2"))
|
||||
err = security.password_problem(pwd, body.get("password2"), row["username"])
|
||||
if err:
|
||||
return jsonify({"ok": False, "message": err}), 400
|
||||
conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(pwd), uid))
|
||||
conn.execute("UPDATE users SET password_hash=? WHERE id=?",
|
||||
(security.hash_password(pwd), uid))
|
||||
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)
|
||||
"修改用户 %s:%s" % (row["username"], "、".join(changed)),
|
||||
request.remote_addr, me["id"])
|
||||
return jsonify({"ok": True, "message": "已更新:" + "、".join(changed)})
|
||||
|
||||
|
||||
@@ -432,15 +554,41 @@ def api_user_update(uid):
|
||||
def api_user_delete(uid):
|
||||
conn = db.get_db()
|
||||
me = current_user()
|
||||
row = conn.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone()
|
||||
row = db.user_by_id(conn, uid)
|
||||
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:
|
||||
if conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] <= 1:
|
||||
return jsonify({"ok": False, "message": "至少要保留一个账号"}), 400
|
||||
if row["is_admin"]:
|
||||
left = conn.execute("SELECT COUNT(*) FROM users WHERE is_admin=1 AND status='active'"
|
||||
" AND id<>?", (uid,)).fetchone()[0]
|
||||
if left == 0:
|
||||
return jsonify({"ok": False, "message": "至少要保留一个启用状态的管理员"}), 400
|
||||
keep = str(_json_body().get("keep_data", "")).strip() in ("1", "true", "on", "yes")
|
||||
if not keep:
|
||||
# 默认连同数据一起删 —— 留下孤儿数据既占空间,也会在重新注册
|
||||
# 同名用户时被新用户看到(历史遗留 user_id 复用风险)
|
||||
conn.execute("DELETE FROM usage_records WHERE user_id=?", (uid,))
|
||||
conn.execute("DELETE FROM settings WHERE user_id=?", (uid,))
|
||||
conn.execute("DELETE FROM collect_runs WHERE user_id=?", (uid,))
|
||||
conn.execute("DELETE FROM users WHERE id=?", (uid,))
|
||||
db.audit(conn, "user_delete", me["username"], "删除用户 " + row["username"],
|
||||
request.remote_addr)
|
||||
db.audit(conn, "user_delete", me["username"],
|
||||
"删除用户 %s(%s)" % (row["username"], "保留其数据" if keep else "连同数据一并删除"),
|
||||
request.remote_addr, me["id"])
|
||||
return jsonify({"ok": True, "message": "已删除 " + row["username"]})
|
||||
|
||||
|
||||
@bp.post("/captcha")
|
||||
@login_required
|
||||
def api_captcha_note():
|
||||
"""给前端一个「验证码怎么工作」的自述,便于排障时自检。"""
|
||||
conn = db.get_db()
|
||||
return jsonify({
|
||||
"policy": db.get_setting(conn, "captcha_policy", "always"),
|
||||
"length": db.get_int(conn, "captcha_length", 4),
|
||||
"ttl_seconds": 300,
|
||||
"image_url": "/captcha.png",
|
||||
"note": "答案只存在服务端 captchas 表;一次性使用,校验后立即删除。",
|
||||
})
|
||||
|
||||
@@ -115,6 +115,9 @@ code {
|
||||
.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; }
|
||||
/* 用户名同时是「个人中心」入口,所以是 <a>:去掉下划线并给悬浮反馈 */
|
||||
.topbar .who { display: flex; align-items: center; gap: 6px; text-decoration: none; }
|
||||
.topbar .who:hover, .topbar .who:hover b { color: var(--cyan); }
|
||||
|
||||
/* ---------------- 布局 ---------------- */
|
||||
.wrap { max-width: 1480px; margin: 0 auto; padding: 20px 22px 60px; }
|
||||
@@ -379,6 +382,23 @@ select option { background: #111a2e; color: var(--text); }
|
||||
margin: 16px 0 0; padding-top: 14px; border-top: 1px solid var(--line);
|
||||
color: var(--dim); font-size: 11.5px; line-height: 1.7;
|
||||
}
|
||||
/* 注册页字段比登录页多,卡片给宽一点(沿用 .login 的纵向节奏) */
|
||||
.login.wide { max-width: 470px; }
|
||||
/* 字段说明(如「3~32 位,字母或数字开头」)跟在 label 文字后面 */
|
||||
.login label em.unit { font-style: normal; margin-left: 6px; font-size: 11px; color: var(--dim); }
|
||||
|
||||
/* 验证码:输入框与图片并排一行(.caprow 是**新组件独有**前缀,
|
||||
刻意不叫 .bar —— 页面里 .bar 是筛选条,带着 backdrop-filter 与 margin,
|
||||
撞名会导致整块文字被虚化且被顶高) */
|
||||
.caprow { display: flex; align-items: center; gap: 10px; }
|
||||
.caprow input { flex: 1 1 auto; min-width: 0; letter-spacing: 2px; }
|
||||
/* 图片按 PNG 原始高度显示(150x56,scale=5),不缩放才最清晰 */
|
||||
.capimg {
|
||||
flex: 0 0 auto; height: 56px; width: auto; display: block;
|
||||
border: 1px solid var(--line); border-radius: var(--r-ctl);
|
||||
background: var(--panel-dim); cursor: pointer; transition: .15s;
|
||||
}
|
||||
.capimg:hover { border-color: var(--cyan); }
|
||||
|
||||
/* ---------------- 错误页 ---------------- */
|
||||
.errpage { text-align: center; padding: 66px 24px; }
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<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') }}">
|
||||
@@ -11,7 +12,11 @@
|
||||
<body>
|
||||
{% set nav = active|default('') %}
|
||||
{% set on_dash = request.path.startswith('/dashboard') %}
|
||||
{% if current_user() %}
|
||||
{# 变量名刻意叫 cur 而不是 me:模板里的 {% set %} 会覆盖子模板传入的同名变量,
|
||||
而 current_user() 只含 id/username/display_name/is_admin —— 曾因此让
|
||||
个人中心把 me.created_at 渲染成空(子模板的 me 是完整的用户行)。 #}
|
||||
{% set cur = current_user() %}
|
||||
{% if cur %}
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<span class="dot"></span>
|
||||
@@ -25,12 +30,15 @@
|
||||
<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 %}
|
||||
{% if cur.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>
|
||||
<a class="who" href="{{ url_for('views.profile_page') }}" title="个人中心">
|
||||
<b>{{ cur.display_name }}</b>
|
||||
{% if cur.is_admin %}<span class="tag accent">管理员</span>{% endif %}
|
||||
</a>
|
||||
{# 退出用 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() }}">
|
||||
@@ -52,8 +60,8 @@
|
||||
</main>
|
||||
|
||||
<footer class="foot">
|
||||
<b>{{ project_title }}</b> · {{ project_name }} · 采集 / 存储 / 呈现三合一 · 数据正本 <code>data/usage.sqlite</code><br>
|
||||
采集在 Web 进程内按配置时刻执行,无需外部计划任务
|
||||
<b>{{ project_title }}</b> · {{ project_name }} · 多用户 · 采集在 Web 进程内按各账号配置的时刻执行<br>
|
||||
每个账号只使用并只见得到自己的 Cookie 与用量数据;数据正本 <code>data/usage.sqlite</code>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -5,24 +5,35 @@
|
||||
<div class="pagehead">
|
||||
<div>
|
||||
<h1>配置管理</h1>
|
||||
<p class="lead">凭证、采集参数、维护动作都在这里;所有配置存在数据库,改完立即生效</p>
|
||||
<p class="lead">这里改的都是<b>你自己账号</b>的配置:凭证、采集参数、维护动作;改完立即生效</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if s.cookie_broken %}
|
||||
<div class="flash error" style="margin-bottom:16px">
|
||||
已保存的 Cookie <b>无法解密</b>(通常是 <code>data/instance.json</code> 里的
|
||||
<code>cookie_key</code> 被更换或文件丢失)。请重新粘贴一次;在此之前该账号的采集会失败。
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<section class="card">
|
||||
<div class="cardhead">
|
||||
<h2>云端凭证</h2>
|
||||
<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 %}
|
||||
{% if s.cookie_hint %}当前 Cookie:{{ s.cookie_hint }}
|
||||
{% if s.cookie_at %}({{ s.cookie_at }} 更新){% endif %}
|
||||
—— 页面与接口都不回传明文,数据库里也是密文。{% endif %}
|
||||
<br>获取方式:Chrome 打开 <code>https://www.workbuddy.cn/profile/plans-usage</code> → F12 → Network →
|
||||
任选一个 <code>billing</code> 请求 → 复制 Request Headers 里的 <code>cookie</code> 与 <code>user-agent</code>
|
||||
(<b>两者必须取自同一次请求</b>),粘贴到下面。
|
||||
<br><b>请粘贴你自己账号的 Cookie</b>:采集只使用本人凭证,各账号的数据互不可见。
|
||||
</p>
|
||||
<form id="formCred">
|
||||
<label class="col">Cookie
|
||||
<textarea name="cookie" rows="4" placeholder="留空表示不修改;填 - 表示清空已保存的 Cookie" spellcheck="false"></textarea>
|
||||
<textarea name="cookie" rows="4" autocomplete="off" spellcheck="false"
|
||||
placeholder="留空表示不修改;填 - 表示清空已保存的 Cookie"></textarea>
|
||||
</label>
|
||||
<label class="col">User-Agent
|
||||
<textarea name="user_agent" rows="2" spellcheck="false">{{ s.user_agent }}</textarea>
|
||||
@@ -35,8 +46,15 @@
|
||||
<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>
|
||||
{# 实例级键:所有账号共用,只有管理员能改;普通账号只读展示 #}
|
||||
<label class="row"><span>接口基址</span>
|
||||
<input name="api_base" value="{{ s.api_base }}" spellcheck="false"
|
||||
{{ '' if is_admin else 'disabled' }}>
|
||||
{% if not is_admin %}<em class="unit">实例级,仅管理员可改</em>{% endif %}</label>
|
||||
<label class="row"><span>接口路径</span>
|
||||
<input name="api_path" value="{{ s.api_path }}" spellcheck="false"
|
||||
{{ '' if is_admin else 'disabled' }}>
|
||||
{% if not is_admin %}<em class="unit">实例级</em>{% endif %}</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 }}">
|
||||
@@ -75,7 +93,8 @@
|
||||
<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>
|
||||
<p class="hint">至少 {{ pwd_min }} 位,且需包含大写字母、小写字母、数字、符号中的至少两类。
|
||||
修改成功后当前会话仍有效,不必重新登录。</p>
|
||||
</form>
|
||||
|
||||
<hr class="sect-divider">
|
||||
@@ -84,18 +103,62 @@
|
||||
<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>
|
||||
title="导出与官网 xlsx 同构的 CSV 到 data/exports/">导出我的 CSV</button>
|
||||
{% if is_admin %}
|
||||
<button class="btn" type="button" data-maint="vacuum"
|
||||
title="checkpoint + VACUUM,回收删除后的空闲页">整理数据库</button>
|
||||
title="checkpoint + VACUUM,回收删除后的空闲页(整库操作,仅管理员)">整理数据库</button>
|
||||
{% endif %}
|
||||
</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 %}
|
||||
<a class="btn ghost" href="{{ url_for('views.profile_page') }}">个人中心</a>
|
||||
{% if is_admin %}<a class="btn ghost" href="{{ url_for('views.users_page') }}">用户管理</a>{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{% if is_admin %}
|
||||
<section class="card">
|
||||
<div class="cardhead">
|
||||
<h2>实例级设置</h2>
|
||||
<span class="tag accent">仅管理员可改,对所有账号生效</span>
|
||||
</div>
|
||||
<form id="formGlobal">
|
||||
{% set b = num_settings %}
|
||||
<label class="row"><span>开放自助注册</span>
|
||||
<select name="allow_register">
|
||||
<option value="1" {{ 'selected' if s.allow_register != '0' }}>允许任何人注册</option>
|
||||
<option value="0" {{ 'selected' if s.allow_register == '0' }}>关闭注册(只能由管理员建号)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="row"><span>同 IP 每日注册上限</span>
|
||||
<input name="register_max_per_ip" type="number"
|
||||
min="{{ b.register_max_per_ip[0] }}" max="{{ b.register_max_per_ip[1] }}"
|
||||
value="{{ s.register_max_per_ip }}">
|
||||
<em class="unit">{{ b.register_max_per_ip[0] }}~{{ b.register_max_per_ip[1] }} 个/天</em></label>
|
||||
<label class="row"><span>验证码策略</span>
|
||||
<select name="captcha_policy">
|
||||
<option value="always" {{ 'selected' if s.captcha_policy == 'always' }}>始终要求(推荐)</option>
|
||||
<option value="adaptive" {{ 'selected' if s.captcha_policy == 'adaptive' }}>仅连续失败后要求(对日常更友好)</option>
|
||||
<option value="off" {{ 'selected' if s.captcha_policy == 'off' }}>关闭(不推荐)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="row"><span>验证码位数</span>
|
||||
<input name="captcha_length" type="number"
|
||||
min="{{ b.captcha_length[0] }}" max="{{ b.captcha_length[1] }}"
|
||||
value="{{ s.captcha_length }}">
|
||||
<em class="unit">4~6 位</em></label>
|
||||
<button class="btn primary" type="submit">保存实例设置</button>
|
||||
<p class="hint">
|
||||
验证码的答案只存在服务端 <code>captchas</code> 表里(下发到浏览器的只是一个随机 id),
|
||||
一次性使用、5 分钟过期 —— 所以答案不会随会话 Cookie 泄漏出去。
|
||||
关闭验证码会显著放大被撞库与批量注册的风险,只有在前面已经有可信网关时才考虑。
|
||||
</p>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
@@ -104,6 +167,8 @@
|
||||
WBU.bindForm('#formCred', '/api/settings');
|
||||
WBU.bindForm('#formCollect', '/api/settings');
|
||||
WBU.bindForm('#formPwd', '/api/password', {validate: d => d.new === d.new2 ? null : '两次输入的新密码不一致'});
|
||||
var fg = document.querySelector('#formGlobal');
|
||||
if (fg) WBU.bindForm('#formGlobal', '/api/settings');
|
||||
WBU.bindMaint('[data-maint]');
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -15,9 +15,24 @@
|
||||
<label>密码
|
||||
<input name="password" type="password" autocomplete="current-password" required>
|
||||
</label>
|
||||
{% if need_captcha %}
|
||||
<label>验证码
|
||||
<span class="caprow">
|
||||
<input name="captcha" maxlength="6" autocomplete="off" spellcheck="false"
|
||||
required placeholder="不区分大小写">
|
||||
{# 点击换一张:URL 带时间戳,避免浏览器复用已被消费的旧图 #}
|
||||
<img class="capimg" alt="图形验证码" title="看不清?点一下换一张"
|
||||
src="{{ url_for('views.captcha_png', purpose='login') }}&t={{ range(1000000)|random }}"
|
||||
onclick="this.src='{{ url_for('views.captcha_png', purpose='login') }}&t=' + Date.now();">
|
||||
</span>
|
||||
</label>
|
||||
{% endif %}
|
||||
<button class="btn primary" type="submit">登 录</button>
|
||||
{% if allow_register %}
|
||||
<p class="foot-note">还没有账号?<a href="{{ url_for('views.register') }}">自助注册</a></p>
|
||||
{% endif %}
|
||||
<p class="foot-note">
|
||||
首次部署默认账号 <code>admin</code> / <code>admin123</code>,登录后请立即到「配置管理」修改密码。<br>
|
||||
首次部署默认账号 <code>admin</code> / <code>admin123</code>,登录后请立即修改密码。<br>
|
||||
连续输错 {{ max_fails }} 次将锁定 {{ lock_minutes }} 分钟;登录状态保持 {{ session_hours }} 小时。
|
||||
</p>
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}个人中心 · {{ project_title }}{% endblock %}
|
||||
{% block body %}
|
||||
|
||||
<div class="pagehead">
|
||||
<div>
|
||||
<h1>个人中心</h1>
|
||||
<p class="lead">账号 <b>{{ me.username }}</b> · 注册于 {{ (me.created_at or '')[:16] }} ·
|
||||
最近登录 {{ (me.last_login_at or '未登录')[:19] }}</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a class="btn" href="{{ url_for('views.config_page') }}">管理我的凭证</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kpis">
|
||||
<div class="kpi" style="--c:var(--cyan)">
|
||||
<span>我的记录</span><b>{{ '{:,}'.format(my.n or 0) }}</b>
|
||||
<i>{{ my.d0 or '—' }} ~ {{ my.d1 or '—' }}</i>
|
||||
</div>
|
||||
<div class="kpi" style="--c:var(--violet)">
|
||||
<span>我的积分</span><b>{{ '%.2f'|format(my.c or 0) }}</b>
|
||||
<i>仅统计归属本账号的数据</i>
|
||||
</div>
|
||||
<div class="kpi" style="--c:var(--blue)">
|
||||
<span>采集次数</span><b>{{ '{:,}'.format(runs) }}</b>
|
||||
<i>{% if sch.last %}最近 {{ sch.last.started_at[5:16] if sch.last.started_at else '—' }}{% else %}尚无采集{% endif %}</i>
|
||||
</div>
|
||||
<div class="kpi" style="--c:{{ 'var(--green)' if cred.set and not cred.broken else 'var(--red)' }}">
|
||||
<span>我的 Cookie</span>
|
||||
<b>{% if cred.broken %}无法解密{% elif cred.set %}已配置{% else %}未配置{% endif %}</b>
|
||||
<i>{% if cred.set and not cred.broken %}{{ cred.chars }} 字符,结尾 …{{ cred.tail }}
|
||||
{%- elif cred.broken %}实例密钥被更换,请重新粘贴
|
||||
{%- else %}采集需要本人凭证{% endif %}</i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid2">
|
||||
<section class="card">
|
||||
<h2>修改资料</h2>
|
||||
<form id="formProfile">
|
||||
<label class="row"><span>用户名</span>
|
||||
<input value="{{ me.username }}" disabled spellcheck="false">
|
||||
<em class="unit">登录名不可改</em></label>
|
||||
<label class="row"><span>显示名</span>
|
||||
<input name="display_name" maxlength="64" value="{{ me.display_name or '' }}" spellcheck="false"></label>
|
||||
<label class="row"><span>邮箱</span>
|
||||
<input name="email" type="email" maxlength="128" value="{{ me.email or '' }}" spellcheck="false"></label>
|
||||
<button class="btn primary" type="submit">保存资料</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>修改登录密码</h2>
|
||||
<form id="formPwd">
|
||||
<label class="col">原密码<input name="old" type="password" autocomplete="current-password"></label>
|
||||
<label class="col">新密码<input name="new" type="password" autocomplete="new-password"></label>
|
||||
<label class="col">确认新密码<input name="new2" type="password" autocomplete="new-password"></label>
|
||||
<button class="btn primary" type="submit">修改密码</button>
|
||||
<p class="hint">至少 {{ pwd_min }} 位,且需包含大写字母、小写字母、数字、符号中的至少两类。
|
||||
修改成功后当前会话仍有效,不必重新登录。</p>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<div class="cardhead">
|
||||
<h2>我的采集凭证</h2>
|
||||
<span class="tag {{ 'ok' if cred.set and not cred.broken else ('bad' if not cred.set else 'warn') }}">
|
||||
{{ '正常' if cred.set and not cred.broken else ('未配置' if not cred.set else '需要重配') }}</span>
|
||||
</div>
|
||||
<table class="kv">
|
||||
<tr><th>状态</th><td>
|
||||
{% if cred.broken %}<span class="tag bad">已保存但无法解密</span>,请到「配置管理」重新粘贴
|
||||
{% elif cred.set %}<span class="tag ok">已保存(密文入库)</span>
|
||||
{% else %}<span class="tag bad">未配置</span>{% endif %}
|
||||
</td></tr>
|
||||
<tr><th>字符数 / 尾部</th><td class="mono">{{ cred.chars or 0 }} / {{ ('…' + cred.tail) if cred.tail else '—' }}</td></tr>
|
||||
<tr><th>最后更新</th><td class="mono">{{ cred.at or '—' }}</td></tr>
|
||||
<tr><th>调度</th><td>
|
||||
{% if sch.enabled %}{{ sch.times | join(' · ') or '未设置时刻' }}{% else %}<span class="tag bad">已停用</span>{% endif %}
|
||||
{% if sch.next_run %}· 下次 <span class="mono">{{ sch.next_run }}</span>{% endif %}
|
||||
</td></tr>
|
||||
</table>
|
||||
<p class="hint">
|
||||
凭证以密文形式存在数据库里(主密钥在 <code>data/instance.json</code>),
|
||||
页面与接口**任何时候都不回传明文**,只显示长度与尾部 4 位。要更换请到
|
||||
<a href="{{ url_for('views.config_page') }}">配置管理</a>粘贴新的 Cookie 与 User-Agent
|
||||
(两者必须取自同一次浏览器请求)。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||
<script>
|
||||
WBU.bindForm('#formProfile', '/api/profile');
|
||||
WBU.bindForm('#formPwd', '/api/password', {
|
||||
validate: function (d) { return d.new === d.new2 ? null : '两次输入的新密码不一致'; }
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,46 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}注册 · {{ project_title }}{% endblock %}
|
||||
{% block body %}
|
||||
<div class="loginwrap">
|
||||
<form class="card login wide" method="post" action="{{ url_for('views.register') }}">
|
||||
<div class="logo">W</div>
|
||||
<h1>注册 {{ project_title }}</h1>
|
||||
<p class="hint">注册后请粘贴<strong>你自己账号</strong>的 Cookie —— 采集只使用本人的凭证,各账号数据互相隔离。</p>
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||
|
||||
<label>用户名 <em class="unit">3~32 位,字母或数字开头</em>
|
||||
<input name="username" value="{{ username or '' }}" autocomplete="username"
|
||||
autofocus required maxlength="32" spellcheck="false">
|
||||
</label>
|
||||
<label>显示名 <em class="unit">留空则与用户名相同</em>
|
||||
<input name="display_name" value="{{ display_name or '' }}" maxlength="64" autocomplete="nickname">
|
||||
</label>
|
||||
<label>邮箱 <em class="unit">选填,便于日后找回</em>
|
||||
<input name="email" type="email" value="{{ email or '' }}" maxlength="128" autocomplete="email">
|
||||
</label>
|
||||
<label>密码 <em class="unit">至少 {{ pwd_min }} 位,需含两类以上字符</em>
|
||||
<input name="password" type="password" autocomplete="new-password" required>
|
||||
</label>
|
||||
<label>确认密码
|
||||
<input name="password2" type="password" autocomplete="new-password" required>
|
||||
</label>
|
||||
{% if need_captcha %}
|
||||
<label>验证码
|
||||
<span class="caprow">
|
||||
<input name="captcha" maxlength="6" autocomplete="off" spellcheck="false"
|
||||
required placeholder="不区分大小写">
|
||||
<img class="capimg" alt="图形验证码" title="看不清?点一下换一张"
|
||||
src="{{ url_for('views.captcha_png', purpose='register') }}&t={{ range(1000000)|random }}"
|
||||
onclick="this.src='{{ url_for('views.captcha_png', purpose='register') }}&t=' + Date.now();">
|
||||
</span>
|
||||
</label>
|
||||
{% endif %}
|
||||
<button class="btn primary" type="submit">注 册</button>
|
||||
<p class="foot-note">已有账号?<a href="{{ url_for('views.login') }}">返回登录</a></p>
|
||||
<p class="foot-note">
|
||||
注册受来源限额与图形验证码双重保护;同一来源每天可注册的账号数由管理员设定。<br>
|
||||
连续输错 {{ max_fails }} 次将锁定 {{ lock_minutes }} 分钟。
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -5,10 +5,12 @@
|
||||
<div class="pagehead">
|
||||
<div>
|
||||
<h1>用户管理</h1>
|
||||
<p class="lead">门户在局域网可访问,因此必须靠账号隔离;这里维护账号、管理员身份与密码</p>
|
||||
<p class="lead">维护账号、状态与管理员身份。单价数据<b>按账号隔离</b>——管理员也看不到别人的用量与凭证</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<span class="tag accent">仅管理员可见</span>
|
||||
<span class="tag {{ 'ok' if allow_register else 'mute' }}">
|
||||
自助注册:{{ '已开放' if allow_register else '已关闭' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,21 +19,25 @@
|
||||
<h2>新建账号</h2>
|
||||
<form id="formNewUser">
|
||||
<label class="row"><span>用户名</span>
|
||||
<input name="username" maxlength="32" placeholder="登录名(≤32 字符)" autocomplete="off" spellcheck="false"></label>
|
||||
<input name="username" maxlength="32" placeholder="3~32 位,字母或数字开头"
|
||||
autocomplete="off" spellcheck="false"></label>
|
||||
<label class="row"><span>显示名</span>
|
||||
<input name="display_name" maxlength="64" placeholder="留空则与用户名相同"></label>
|
||||
<label class="row"><span>邮箱</span>
|
||||
<input name="email" type="email" maxlength="128" placeholder="选填"></label>
|
||||
<label class="row"><span>密码</span>
|
||||
<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>
|
||||
<option value="0">普通账号(只管自己的凭证与数据)</option>
|
||||
<option value="1">管理员(可管理用户与实例设置)</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="btn primary" type="submit">创建账号</button>
|
||||
<p class="hint">密码至少 6 位、最多 128 位。普通账号不能用本页,也调不动用户管理接口。</p>
|
||||
<p class="hint">密码至少 8 位且需含两类以上字符。新账号默认是<b>普通账号</b>;
|
||||
管理员身份请显式选择。无论哪种身份,都需要各自配置自己的 Cookie 才能采集。</p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -42,29 +48,39 @@
|
||||
</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>
|
||||
<thead><tr><th class="num">ID</th><th>用户名</th><th>显示名</th><th>权限 / 状态</th>
|
||||
<th class="num">我的数据</th><th>最后登录</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr data-uid="{{ u.id }}" data-name="{{ u.username }}">
|
||||
<tr data-uid="{{ u.id }}" data-name="{{ u.username }}"
|
||||
data-status="{{ u.status or 'active' }}">
|
||||
<td class="num muted">{{ u.id }}</td>
|
||||
<td class="nowrap"><b>{{ u.username }}</b>
|
||||
{% if u.id == me.id %}<span class="tag accent">当前</span>{% endif %}</td>
|
||||
{% if u.id == me.id %}<span class="tag accent">当前</span>{% endif %}
|
||||
{% if u.email %}<br><span class="muted sm">{{ u.email }}</span>{% endif %}</td>
|
||||
<td><input class="inp inp-sm" name="display_name" maxlength="64"
|
||||
value="{{ u.display_name or '' }}" spellcheck="false"></td>
|
||||
<td>
|
||||
{# 不能取消自己的管理员身份,所以本人的下拉直接禁用(服务端也会再拦一次) #}
|
||||
<td class="nowrap">
|
||||
{# 不能取消自己的管理员身份,也不能停用自己(服务端也会再拦一次) #}
|
||||
<select class="inp inp-sm" name="is_admin" {{ 'disabled' if u.id == me.id }}>
|
||||
<option value="1" {{ 'selected' if u.is_admin }}>管理员</option>
|
||||
<option value="0" {{ 'selected' if not u.is_admin }}>普通</option>
|
||||
</select>
|
||||
<select class="inp inp-sm" name="status" {{ 'disabled' if u.id == me.id }}>
|
||||
<option value="active" {{ 'selected' if (u.status or 'active') == 'active' }}>启用</option>
|
||||
<option value="disabled" {{ 'selected' if u.status == 'disabled' }}>停用</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="mono sm nowrap">{{ u.last_login_at or '—' }}</td>
|
||||
<td class="num">{{ u.login_count }}</td>
|
||||
<td class="num nowrap">{{ '{:,}'.format(u.recs or 0) }} 条
|
||||
<br><span class="muted sm">{{ '%.2f'|format(u.credits or 0) }} 分</span></td>
|
||||
<td class="mono sm nowrap">{{ (u.last_login_at or '—')[:16] }}
|
||||
{% if u.last_login_ip %}<br><span class="muted">{{ u.last_login_ip }}</span>{% endif %}</td>
|
||||
<td class="nowrap">
|
||||
<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 ghost" type="button" data-act="toggle">
|
||||
{{ '停用' if (u.status or 'active') == 'active' else '启用' }}</button>
|
||||
<button class="btn sm danger" type="button" data-act="del">删除</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
@@ -75,14 +91,19 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="hint">「改密」会依次询问新密码与确认;管理员不能取消自己的管理员身份,任何人也不能删除自己。</p>
|
||||
<p class="hint">
|
||||
「停用」会让该账号<b>立刻</b>失效(每个请求都会校验状态,不必等会话过期),
|
||||
其数据与 Cookie 都保留;「删除」是<b>不可逆</b>的,会连同该账号的用量数据与 Cookie
|
||||
一起删除(接口层另有保留数据的开关,供脚本调用时指定)。
|
||||
管理员不能取消自己的管理员身份、不能停用或删除自己,也不能删掉最后一个启用的管理员。
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<div class="cardhead">
|
||||
<h2>用户操作审计</h2>
|
||||
<span class="hint">最近 20 条</span>
|
||||
<h2>账号操作审计</h2>
|
||||
<span class="hint">最近 20 条(含注册与登录失败)</span>
|
||||
</div>
|
||||
<div class="tablewrap scroll-y">
|
||||
<table class="tbl">
|
||||
@@ -139,20 +160,31 @@
|
||||
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;
|
||||
var adm = row.querySelector('[name=is_admin]');
|
||||
if (adm && !adm.disabled) fields.is_admin = adm.value;
|
||||
var st = row.querySelector('[name=status]');
|
||||
if (st && !st.disabled) fields.status = st.value;
|
||||
done(WBU.post('/api/users/' + uid, fields));
|
||||
} else if (act === 'pwd') {
|
||||
var p1 = window.prompt('为用户「' + name + '」设置新密码(至少 6 位)');
|
||||
var p1 = window.prompt('为用户「' + name + '」设置新密码(至少 8 位,需含两类字符)');
|
||||
if (p1 === null) { btn.disabled = false; btn.textContent = old; return; }
|
||||
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 + '」?该操作不可撤销(其历史审计记录会保留)。')) {
|
||||
} else if (act === 'toggle') {
|
||||
var cur = row.dataset.status === 'active';
|
||||
var next = cur ? 'disabled' : 'active';
|
||||
if (!window.confirm(cur ? ('停用「' + name + '」?其数据与 Cookie 会保留,但无法登录。')
|
||||
: ('启用「' + name + '」?'))) {
|
||||
btn.disabled = false; btn.textContent = old; return;
|
||||
}
|
||||
done(WBU.post('/api/users/' + uid, { status: next }));
|
||||
} else if (act === 'del') {
|
||||
var keep = window.confirm('确定删除用户「' + name + '」?\n\n'
|
||||
+ '点「确定」= 连同其用量数据与 Cookie 一起删除(推荐)\n'
|
||||
+ '点「取消」= 取消本次删除');
|
||||
if (!keep) { btn.disabled = false; btn.textContent = old; return; }
|
||||
done(WBU.post('/api/users/' + uid + '/delete', {}));
|
||||
} else {
|
||||
btn.disabled = false; btn.textContent = old;
|
||||
|
||||
+288
-73
@@ -5,12 +5,20 @@
|
||||
"""页面路由(Jinja 模板)。
|
||||
|
||||
分工:
|
||||
/ 概览(KPI + 入口)
|
||||
/dashboard ECharts 交互大屏(独立静态页,登录后可达,数据走 /api/bundle)
|
||||
/tasks 任务管理:调度开关/时刻、手动触发、运行历史
|
||||
/config 配置管理:Cookie / UA / 采集参数 / 改密码
|
||||
/logs 日志管理:采集逐次明细 + 应用日志尾部
|
||||
/records 数据明细:分页、筛选、搜索、导出
|
||||
/ 概览(KPI + 入口)
|
||||
/dashboard ECharts 交互大屏(独立静态页,登录后可达,数据走 /api/bundle)
|
||||
/records 数据明细:分页、筛选、搜索、导出
|
||||
/tasks 任务管理:调度开关/时刻、手动触发、运行历史
|
||||
/config 配置管理:本人的 Cookie / UA / 采集参数
|
||||
/logs 日志管理:采集逐次明细 + 应用日志尾部
|
||||
/profile 个人中心:资料、密码、凭证状态
|
||||
/users 用户管理(仅管理员)
|
||||
/register 自助注册(受 allow_register 开关约束)
|
||||
/captcha.png 图形验证码
|
||||
|
||||
**多用户约定**
|
||||
所有数据类页面都只取 `current_user()["id"]` 那份数据;管理员在
|
||||
「用户管理」里能看到账号列表,但**看不到别人的用量与凭证**。
|
||||
"""
|
||||
import csv
|
||||
import io
|
||||
@@ -18,12 +26,11 @@ import os
|
||||
import sqlite3
|
||||
|
||||
from flask import (Blueprint, current_app, flash, jsonify, redirect, render_template,
|
||||
request, send_from_directory, url_for)
|
||||
request, send_from_directory, session, url_for)
|
||||
|
||||
from .. import collect, config, db, query, scheduler
|
||||
from ..security import (admin_required, clear_fail, current_user, is_locked, lock_left,
|
||||
login_ok, login_required, login_session, logout_session,
|
||||
note_fail, safe_next)
|
||||
from .. import collect, config, db, query, scheduler, security
|
||||
from ..security import (admin_required, current_user, is_admin, login_required,
|
||||
safe_next)
|
||||
|
||||
bp = Blueprint("views", __name__)
|
||||
|
||||
@@ -32,42 +39,182 @@ def _ip():
|
||||
return request.headers.get("X-Forwarded-For", request.remote_addr or "").split(",")[0].strip()
|
||||
|
||||
|
||||
def _uid():
|
||||
"""当前账号 id。调用方必须已过 @login_required。"""
|
||||
u = current_user()
|
||||
return u["id"] if u else 0
|
||||
|
||||
|
||||
def _shift(days):
|
||||
from datetime import datetime, timedelta
|
||||
return (datetime.now() + timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
# ---------------- 验证码 ----------------
|
||||
@bp.get("/captcha.png")
|
||||
def captcha_png():
|
||||
"""生成一张验证码。答案只写进 captchas 表,会话里只记 id。"""
|
||||
purpose = (request.args.get("purpose") or "login").strip().lower()
|
||||
if purpose not in ("login", "register"):
|
||||
purpose = "login"
|
||||
if not security.captcha_fetch_allowed(_ip()):
|
||||
return "验证码请求过于频繁,请稍后再试", 429
|
||||
try:
|
||||
png = security.issue_captcha(db.get_db(), purpose)
|
||||
except sqlite3.Error:
|
||||
return "验证码服务暂不可用", 503
|
||||
resp = current_app.response_class(png, mimetype="image/png")
|
||||
# 必须禁缓存:否则浏览器复用旧图,而服务端那张已经被消费掉了,
|
||||
# 表现为「图没变但怎么输都错」。
|
||||
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
||||
resp.headers["Pragma"] = "no-cache"
|
||||
return resp
|
||||
|
||||
|
||||
# ---------------- 登录 ----------------
|
||||
def _login_ctx(**kw):
|
||||
"""登录页共用的上下文:锁定阈值/会话时长都从配置读,避免模板里写死数字。"""
|
||||
def _login_ctx(conn=None, **kw):
|
||||
"""登录页共用的上下文:锁定阈值/会话时长都从配置读,避免模板里写死数字。
|
||||
|
||||
`need_captcha` 与 `allow_register` 也在这里补齐 —— 登录页与注册页
|
||||
必须对「要不要验证码」保持一致,否则会出现「页面没给输入框、
|
||||
服务端却在校验」的死循环。
|
||||
"""
|
||||
kw.setdefault("max_fails", config.MAX_LOGIN_FAILS)
|
||||
kw.setdefault("lock_minutes", config.LOGIN_LOCK_MINUTES)
|
||||
kw.setdefault("session_hours", config.SESSION_HOURS)
|
||||
kw.setdefault("pwd_min", config.PASSWORD_MIN)
|
||||
if conn is not None:
|
||||
kw.setdefault("need_captcha", security.captcha_required(conn, _ip(), kw.get("username") or ""))
|
||||
kw.setdefault("allow_register", security.register_allowed(conn))
|
||||
return kw
|
||||
|
||||
|
||||
@bp.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
nxt = request.values.get("next") or ""
|
||||
conn = db.get_db()
|
||||
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)
|
||||
|
||||
left = security.auth_locked(ip, username)
|
||||
if left:
|
||||
security.audit_login_fail(conn, username, "已锁定,剩余 %d 秒" % left, ip)
|
||||
flash("登录失败次数过多,请 %d 秒后再试" % left, "error")
|
||||
return render_template("login.html",
|
||||
**_login_ctx(conn, next_url=nxt, username=username,
|
||||
need_captcha=True)), 429
|
||||
|
||||
# 验证码**先于**口令校验:否则攻击者可以拿「密码对不对」当信号,
|
||||
# 在解验证码之前就把字典跑完。
|
||||
need_cap = security.captcha_required(conn, ip, username)
|
||||
if need_cap and not security.consume_captcha(conn, "login", request.form.get("captcha")):
|
||||
n = security.note_auth_fail(ip, username)
|
||||
security.audit_login_fail(conn, username, "验证码错误(第 %d 次)" % n, ip)
|
||||
flash("验证码不正确或已过期,请重新输入", "error")
|
||||
return render_template("login.html",
|
||||
**_login_ctx(conn, username=username, next_url=nxt,
|
||||
need_captcha=True)), 400
|
||||
|
||||
user, err = security.login_ok(conn, username, pwd)
|
||||
if user is None:
|
||||
n = note_fail(ip)
|
||||
db.audit(conn, "login_failed", username, "第 %d 次失败" % n, ip)
|
||||
flash("用户名或密码不正确(剩余尝试 %d 次)" % max(0, config.MAX_LOGIN_FAILS - n), "error")
|
||||
n = security.note_auth_fail(ip, username)
|
||||
security.audit_login_fail(conn, username, err + "(第 %d 次)" % n, ip)
|
||||
flash("%s(剩余尝试 %d 次)" % (err, 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")))
|
||||
return render_template("login.html",
|
||||
**_login_ctx(conn, username=username, next_url=nxt,
|
||||
need_captcha=True)), 401
|
||||
security.clear_auth_fail(ip, username)
|
||||
security.login_session(user)
|
||||
conn.execute("UPDATE users SET last_login_ip=? WHERE id=?", (ip, user["id"]))
|
||||
db.audit(conn, "login", username, "登录成功", ip, user["id"])
|
||||
target = safe_next(nxt, "")
|
||||
if not target:
|
||||
# 新注册 / 还没配凭证 → 直接带到配置页,少一步摸索
|
||||
cred = db.secret_state(conn, "cookie", user["id"])
|
||||
target = url_for("views.config_page") if not cred["set"] else url_for("views.overview")
|
||||
return redirect(target)
|
||||
|
||||
if current_user():
|
||||
return redirect(url_for("views.overview"))
|
||||
return render_template("login.html", **_login_ctx(next_url=nxt))
|
||||
return render_template("login.html", **_login_ctx(conn, next_url=nxt))
|
||||
|
||||
|
||||
# ---------------- 注册 ----------------
|
||||
def _register_ctx(**kw):
|
||||
kw.setdefault("max_fails", config.MAX_LOGIN_FAILS)
|
||||
kw.setdefault("lock_minutes", config.LOGIN_LOCK_MINUTES)
|
||||
kw.setdefault("pwd_min", config.PASSWORD_MIN)
|
||||
return kw
|
||||
|
||||
|
||||
@bp.route("/register", methods=["GET", "POST"])
|
||||
def register():
|
||||
conn = db.get_db()
|
||||
if not security.register_allowed(conn):
|
||||
return render_template("error.html", code=403,
|
||||
message="管理员已关闭自助注册,请联系管理员开通账号"), 403
|
||||
if current_user():
|
||||
return redirect(url_for("views.overview"))
|
||||
|
||||
if request.method == "POST":
|
||||
ip = _ip()
|
||||
username = (request.form.get("username") or "").strip()
|
||||
display = (request.form.get("display_name") or "").strip()[:64]
|
||||
email = (request.form.get("email") or "").strip()[:128]
|
||||
pwd = request.form.get("password") or ""
|
||||
pwd2 = request.form.get("password2") or ""
|
||||
ctx = _register_ctx(username=username, display_name=display, email=email,
|
||||
need_captcha=True)
|
||||
|
||||
left = security.auth_locked(ip, username)
|
||||
if left:
|
||||
flash("操作过于频繁,请 %d 秒后再试" % left, "error")
|
||||
return render_template("register.html", **ctx), 429
|
||||
|
||||
# 注册一律要验证码:这是唯一能让陌生人写库的入口
|
||||
if not security.consume_captcha(conn, "register", request.form.get("captcha")):
|
||||
security.note_auth_fail(ip, username)
|
||||
db.audit(conn, "register_rejected", username or "-", "验证码错误", ip)
|
||||
flash("验证码不正确或已过期,请重新输入", "error")
|
||||
return render_template("register.html", **ctx), 400
|
||||
|
||||
ok, n, limit = security.register_quota(conn, ip)
|
||||
if not ok:
|
||||
db.audit(conn, "register_rejected", username or "-",
|
||||
"同 IP 当日注册数已达上限 %d" % limit, ip)
|
||||
flash("同一来源每天最多注册 %d 个账号,请明天再试或联系管理员" % limit, "error")
|
||||
return render_template("register.html", **ctx), 429
|
||||
|
||||
err = security.username_problem(username) or security.password_problem(pwd, pwd2, username)
|
||||
if err:
|
||||
security.note_auth_fail(ip, username)
|
||||
db.audit(conn, "register_rejected", username or "-", err, ip)
|
||||
flash(err, "error")
|
||||
return render_template("register.html", **ctx), 400
|
||||
if db.user_by_name(conn, username):
|
||||
# 用户名唯一性本来就暴露(注册时要查重),这里如实告知
|
||||
flash("用户名已被占用,请换一个", "error")
|
||||
return render_template("register.html", **ctx), 400
|
||||
|
||||
cur = conn.execute(
|
||||
"INSERT INTO users(username,password_hash,display_name,email,is_admin,status,"
|
||||
" created_at,register_ip) VALUES(?,?,?,?,0,'active',?,?)",
|
||||
(username, security.hash_password(pwd), display or username, email or None,
|
||||
db.now_str(), ip))
|
||||
uid = cur.lastrowid
|
||||
db.audit(conn, "register", username, "自助注册成功(账号 #%d)" % uid, ip, uid)
|
||||
# 注册即登录:少一次输密码,也顺手把会话建立起来
|
||||
row = db.user_by_id(conn, uid)
|
||||
security.login_session(row)
|
||||
security.clear_auth_fail(ip, username)
|
||||
flash("注册成功。请粘贴你自己账号的 Cookie —— 采集只使用本人的凭证。", "ok")
|
||||
return redirect(url_for("views.config_page"))
|
||||
|
||||
return render_template("register.html", **_register_ctx(need_captcha=True))
|
||||
|
||||
|
||||
@bp.post("/logout")
|
||||
@@ -76,8 +223,8 @@ def logout_post():
|
||||
"""退出登录改为 POST + CSRF:GET 型退出会被 <img src> 这类请求静默触发。"""
|
||||
u = current_user()
|
||||
if u:
|
||||
db.audit(db.get_db(), "logout", u["username"], "", _ip())
|
||||
logout_session()
|
||||
db.audit(db.get_db(), "logout", u["username"], "", _ip(), u["id"])
|
||||
security.logout_session()
|
||||
flash("已退出登录", "ok")
|
||||
return redirect(url_for("views.login"))
|
||||
|
||||
@@ -96,29 +243,25 @@ def logout():
|
||||
@login_required
|
||||
def overview():
|
||||
conn = db.get_db()
|
||||
mf = query.manifest(conn)
|
||||
t = query.totals(conn)
|
||||
uid = _uid()
|
||||
mf = query.manifest(conn, uid)
|
||||
t = query.totals(conn, uid)
|
||||
today = db.now_str()[:10]
|
||||
st = query.summary(conn, today, today)
|
||||
d30 = query.summary(conn, _shift(-29), today)
|
||||
st = query.summary(conn, uid, today, today)
|
||||
d30 = query.summary(conn, uid, _shift(-29), today)
|
||||
# 昨日对比:昨日整日 vs 今日(残日),让「今天偏少」有参照
|
||||
y = _shift(-1)
|
||||
yest = query.summary(conn, y, y)
|
||||
dims = query.dims(conn)
|
||||
yest = query.summary(conn, uid, y, y)
|
||||
dims = query.dims(conn, uid)
|
||||
# 注意:这里的 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()
|
||||
" FROM collect_runs WHERE user_id=? ORDER BY id DESC LIMIT 8", (uid,)).fetchall()
|
||||
return render_template("overview.html", mf=mf, totals=t, today_stat=st, stat30=d30,
|
||||
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")
|
||||
runs=runs, sch=_sch_info(conn, uid), active="overview")
|
||||
|
||||
|
||||
# ---------------- 大屏(独立 ECharts 页)----------------
|
||||
@@ -133,16 +276,17 @@ def dashboard():
|
||||
|
||||
|
||||
# ---------------- 任务管理 ----------------
|
||||
def _sch_info(conn):
|
||||
def _sch_info(conn, uid):
|
||||
sch = scheduler.get_scheduler()
|
||||
nxt = scheduler.next_run_at(conn)
|
||||
last = conn.execute("SELECT * FROM collect_runs ORDER BY id DESC LIMIT 1").fetchone()
|
||||
nxt = scheduler.next_run_at(conn, uid)
|
||||
last = conn.execute("SELECT * FROM collect_runs WHERE user_id=? ORDER BY id DESC LIMIT 1",
|
||||
(uid,)).fetchone()
|
||||
return {
|
||||
"running": sch.running,
|
||||
"enabled": db.get_bool(conn, "schedule_enabled", True),
|
||||
"times": scheduler.slots(conn),
|
||||
"enabled": db.get_bool(conn, "schedule_enabled", True, uid),
|
||||
"times": scheduler.slots(conn, uid),
|
||||
"next_run": nxt.strftime("%Y-%m-%d %H:%M:%S") if nxt else None,
|
||||
"catch_up": db.get_bool(conn, "catch_up", True),
|
||||
"catch_up": db.get_bool(conn, "catch_up", True, uid),
|
||||
"interval": sch.interval,
|
||||
"last": dict(last) if last else None,
|
||||
"lock": os.path.exists(collect.LOCK_PATH),
|
||||
@@ -154,14 +298,16 @@ def _sch_info(conn):
|
||||
@login_required
|
||||
def tasks():
|
||||
conn = db.get_db()
|
||||
uid = _uid()
|
||||
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)
|
||||
total = conn.execute("SELECT COUNT(*) FROM collect_runs WHERE user_id=?",
|
||||
(uid,)).fetchone()[0]
|
||||
runs = conn.execute("SELECT * FROM collect_runs WHERE user_id=? ORDER BY id DESC"
|
||||
" LIMIT ? OFFSET ?", (uid, size, (page - 1) * size)).fetchall()
|
||||
s = db.get_settings(conn, uid=uid)
|
||||
pages = max(1, (total + size - 1) // size)
|
||||
return render_template("tasks.html", runs=runs, sch=_sch_info(conn),
|
||||
return render_template("tasks.html", runs=runs, sch=_sch_info(conn, uid),
|
||||
s_times=s.get("schedule_times") or "",
|
||||
s_grace=s.get("catch_up_grace_hours") or "12",
|
||||
page=page, pages=pages, total=total,
|
||||
@@ -191,29 +337,60 @@ def _page_window(page, pages, span=9):
|
||||
@login_required
|
||||
def config_page():
|
||||
conn = db.get_db()
|
||||
s = db.get_settings(conn)
|
||||
uid = _uid()
|
||||
s = db.get_settings(conn, uid=uid)
|
||||
for k in [k for k in list(s) if config.is_internal_key(k)]:
|
||||
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),
|
||||
# 加密键在 get_settings 里已经被置空,这里补上「状态摘要」给页面显示
|
||||
st = db.secret_state(conn, "cookie", uid)
|
||||
s["cookie_hint"] = ("%d 字符,结尾 …%s" % (st["chars"], st["tail"])) if st["set"] else ""
|
||||
s["cookie_broken"] = st["broken"]
|
||||
s["cookie_at"] = st["at"]
|
||||
return render_template("config.html", s=s, sch=_sch_info(conn, uid),
|
||||
secret_keys=config.SECRET_KEYS,
|
||||
num_settings=config.NUM_SETTINGS,
|
||||
pwd_min=config.PASSWORD_MIN,
|
||||
is_admin=is_admin(),
|
||||
global_keys=config.GLOBAL_KEYS,
|
||||
active="config")
|
||||
|
||||
|
||||
# ---------------- 个人中心 ----------------
|
||||
@bp.get("/profile")
|
||||
@login_required
|
||||
def profile_page():
|
||||
conn = db.get_db()
|
||||
u = current_user()
|
||||
row = db.user_by_id(conn, u["id"])
|
||||
cred = db.secret_state(conn, "cookie", u["id"])
|
||||
my = conn.execute(
|
||||
"SELECT COUNT(*) n, COALESCE(SUM(credits),0) c, MIN(day) d0, MAX(day) d1"
|
||||
" FROM usage_records WHERE user_id=?", (u["id"],)).fetchone()
|
||||
runs = conn.execute("SELECT COUNT(*) FROM collect_runs WHERE user_id=?",
|
||||
(u["id"],)).fetchone()[0]
|
||||
return render_template("profile.html", me=dict(row), cred=cred, my=dict(my),
|
||||
runs=runs, sch=_sch_info(conn, u["id"]),
|
||||
pwd_min=config.PASSWORD_MIN, active="profile")
|
||||
|
||||
|
||||
# ---------------- 用户管理 ----------------
|
||||
@bp.get("/users")
|
||||
@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()
|
||||
"SELECT u.id,u.username,u.display_name,u.email,u.is_admin,u.status,u.created_at,"
|
||||
" u.register_ip,u.last_login_at,u.last_login_ip,u.login_count,"
|
||||
" (SELECT COUNT(*) FROM usage_records r WHERE r.user_id=u.id) recs,"
|
||||
" (SELECT COALESCE(SUM(credits),0) FROM usage_records r WHERE r.user_id=u.id) credits"
|
||||
" FROM users u ORDER BY u.id").fetchall()
|
||||
audits = conn.execute("SELECT * FROM audit_log WHERE action LIKE 'user%'"
|
||||
" OR action IN ('register','register_rejected','password','login_failed')"
|
||||
" ORDER BY id DESC LIMIT 20").fetchall()
|
||||
return render_template("users.html", users=users, audits=audits,
|
||||
me=current_user(), active="users")
|
||||
me=current_user(), allow_register=db.get_bool(
|
||||
conn, "allow_register", True),
|
||||
active="users")
|
||||
|
||||
|
||||
# ---------------- 日志管理 ----------------
|
||||
@@ -221,24 +398,50 @@ def users_page():
|
||||
@login_required
|
||||
def logs():
|
||||
conn = db.get_db()
|
||||
u = current_user()
|
||||
uid = u["id"]
|
||||
adm = bool(u["is_admin"])
|
||||
|
||||
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()
|
||||
# 明细也必须限本人:否则改一个 ?run= 就能看到别人的采集日志
|
||||
if adm:
|
||||
detail = conn.execute("SELECT * FROM collect_runs WHERE id=?",
|
||||
(int(run_id),)).fetchone()
|
||||
else:
|
||||
detail = conn.execute("SELECT * FROM collect_runs WHERE id=? AND user_id=?",
|
||||
(int(run_id), uid)).fetchone()
|
||||
|
||||
status = request.args.get("status") or ""
|
||||
w, p = ("WHERE status = ?", [status]) if status in ("ok", "warn", "error", "running") else ("", [])
|
||||
# 操作审计:按动作筛选 + 分页(原来只能看最近 40 条,等于不可查)
|
||||
w, p = ("WHERE user_id = ? AND status = ?", [uid, status]) \
|
||||
if status in ("ok", "warn", "error", "running") else ("WHERE user_id = ?", [uid])
|
||||
|
||||
# 操作审计:管理员看全部(便于追责),普通用户只看自己触发的
|
||||
aw, ap = [], []
|
||||
if not adm:
|
||||
aw.append("user_id = ?")
|
||||
ap.append(uid)
|
||||
act = request.args.get("act") or ""
|
||||
aw, ap = ("WHERE action = ?", [act]) if act else ("", [])
|
||||
if act:
|
||||
aw.append("action = ?")
|
||||
ap.append(act)
|
||||
aw_sql = ("WHERE " + " AND ".join(aw)) if aw else ""
|
||||
# 动作清单的统计基数不能带 action 条件(否则永远只剩一个动作可选)
|
||||
base = ("WHERE user_id = ?" if not adm else "")
|
||||
base_p = [uid] if not adm 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,
|
||||
atotal = conn.execute("SELECT COUNT(*) FROM audit_log %s" % aw_sql, ap).fetchone()[0]
|
||||
audits = conn.execute("SELECT * FROM audit_log %s ORDER BY id DESC LIMIT ? OFFSET ?" % aw_sql,
|
||||
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()
|
||||
"SELECT action, COUNT(*) n FROM audit_log %s GROUP BY action ORDER BY n DESC, action"
|
||||
% base, base_p).fetchall()
|
||||
|
||||
page = _int_arg("page", 1, 1, 10 ** 6)
|
||||
size = 30
|
||||
total = conn.execute("SELECT COUNT(*) FROM collect_runs %s" % w, p).fetchone()[0]
|
||||
@@ -251,13 +454,21 @@ def logs():
|
||||
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,
|
||||
status=status, audit_all=adm,
|
||||
active="logs")
|
||||
|
||||
|
||||
@bp.get("/logs/tail")
|
||||
@login_required
|
||||
def logs_tail():
|
||||
"""应用日志尾部(进程级,所有账号看到的是同一份)。
|
||||
|
||||
只对管理员开放:日志里会打印数据库路径、账号名等运行信息,
|
||||
没有理由让任意注册用户读到整个实例的运行轨迹。
|
||||
"""
|
||||
if not is_admin():
|
||||
return jsonify({"ok": False, "error": "forbidden",
|
||||
"message": "应用日志仅管理员可查看"}), 403
|
||||
n = _int_arg("lines", 200, 10, 2000)
|
||||
path = config.APP_LOG
|
||||
if not os.path.exists(path):
|
||||
@@ -289,6 +500,7 @@ def _day_args():
|
||||
@login_required
|
||||
def records():
|
||||
conn = db.get_db()
|
||||
uid = _uid()
|
||||
frm, to = _day_args()
|
||||
model = request.args.get("model") or None
|
||||
client = request.args.get("client") or None
|
||||
@@ -296,10 +508,10 @@ def records():
|
||||
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,
|
||||
data = query.records_page(conn, uid, frm, to, model=model, client=client, q=q,
|
||||
page=page, size=size, order=order)
|
||||
# 只算一次 dims:query.dims() 内部有 3 条 GROUP BY,重复调用纯属浪费
|
||||
d = query.dims(conn)
|
||||
d = query.dims(conn, uid)
|
||||
models = [r["name"] for r in d["model"]]
|
||||
clients = [r["name"] for r in d["client"]]
|
||||
# 注意:不要把含 "items" 键的 dict 直接交给模板——Jinja 的属性查找会先命中
|
||||
@@ -321,6 +533,8 @@ def records_export():
|
||||
数据用 query.iter_records 流式取,不把整个结果集读进内存。
|
||||
"""
|
||||
from flask import Response
|
||||
u = current_user()
|
||||
uid = u["id"]
|
||||
frm, to = _day_args()
|
||||
model = request.args.get("model") or None
|
||||
client = request.args.get("client") or None
|
||||
@@ -340,7 +554,7 @@ def records_export():
|
||||
# 自己开一条连接,并在流结束时关掉。
|
||||
own = db.connect()
|
||||
try:
|
||||
for r in query.iter_records(own, frm, to, model=model, client=client,
|
||||
for r in query.iter_records(own, uid, frm, to, model=model, client=client,
|
||||
q=q, order=order):
|
||||
buf.seek(0)
|
||||
buf.truncate(0)
|
||||
@@ -350,7 +564,8 @@ def records_export():
|
||||
finally:
|
||||
own.close()
|
||||
|
||||
name = "usage_%s_%s.csv" % (frm or "all", to or db.now_str()[:10])
|
||||
# 文件名带账号名:多人导出到同一目录时不会互相覆盖
|
||||
name = "usage_%s_%s_%s.csv" % (u["username"], 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 之类的前置代理先缓冲整个响应体
|
||||
|
||||
在新工单中引用
屏蔽一个用户