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 同步
这个提交包含在:
2026-09-15 17:32:35 +08:00
父节点 23799b4ea5
当前提交 df7db3582e
共修改 46 个文件,包含 4348 行新增和 953 行删除
+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 表;一次性使用,校验后立即删除。",
})
+20
查看文件
@@ -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; }
+13 -5
查看文件
@@ -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>
+75 -10
查看文件
@@ -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 %}
+16 -1
查看文件
@@ -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>
+103
查看文件
@@ -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 %}
+53 -21
查看文件
@@ -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 之类的前置代理先缓冲整个响应体