feat(权限): 收敛普通账号写权限至本人凭证
将调度时刻、采集参数等实例级配置收归管理员,普通账号仅可维护本人 Cookie 与 User-Agent。 新增 config.writable_by 作为唯一写权限入口,set_setting 强制全局键落到 user_id=0, 消除「管理员改了只有自己生效」的静默缺陷。新增 tools/check_docs.py 文档自检, smoke 断言扩至 215 项、check_live 扩至 122 项并支持普通账号越权验收, 忽略 backups/、data/*.bak* 与 legacy-v1/,版本升至 v1.3.0。
这个提交包含在:
@@ -25,7 +25,7 @@ from flask import Flask, jsonify, render_template, request
|
||||
|
||||
from . import config, db, security
|
||||
|
||||
__version__ = "1.2.0"
|
||||
__version__ = "1.3.0"
|
||||
PROJECT_NAME = config.PROJECT_NAME
|
||||
|
||||
|
||||
|
||||
+48
-10
@@ -28,8 +28,11 @@ EXPORT_DIR = os.path.join(DATA_DIR, "exports")
|
||||
APP_LOG = os.path.join(LOG_DIR, "app.log")
|
||||
INSTANCE_FILE = os.path.join(DATA_DIR, "instance.json")
|
||||
|
||||
# 旧版脚本项目的存档(迁移用;--migrate-csv 默认读这里)
|
||||
# 旧版脚本项目的存档(迁移用;--migrate-csv 默认读这里)。
|
||||
# v1.3.0 起旧版被收进工作区级的 legacy-v1/ 目录,所以第一个候选是新位置,
|
||||
# 后面两个保留以兼容「还没挪走」的部署。
|
||||
LEGACY_CSV_CANDIDATES = [
|
||||
os.path.join(os.path.dirname(BASE_DIR), "legacy-v1", "data", "usage_records.csv"),
|
||||
os.path.join(os.path.dirname(BASE_DIR), "data", "usage_records.csv"),
|
||||
os.path.join(BASE_DIR, "data", "usage_records.csv"),
|
||||
]
|
||||
@@ -50,7 +53,12 @@ DEFAULTS = {
|
||||
"register_max_per_ip": "3", # 同一 IP 每天最多注册几个账号
|
||||
"captcha_policy": "always", # always | adaptive | off(见 CAPTCHA_POLICIES)
|
||||
"captcha_length": "4", # 验证码字符数 4~6
|
||||
# ---- 个人级:采集参数 ----
|
||||
# ---- 实例级:采集调度(全实例统一,见 GLOBAL_KEYS)----
|
||||
"schedule_enabled": "1",
|
||||
"schedule_times": "09:00,17:00", # 每天固定时刻(逗号分隔,本地时区)
|
||||
"catch_up": "1", # 启动时补跑当天已错过且未执行的槽位
|
||||
"catch_up_grace_hours": "12", # 超过该小时数就不再补跑
|
||||
# ---- 实例级:采集参数 ----
|
||||
"page_size": "200",
|
||||
"rewind_minutes": "2", # 断点回退分钟数
|
||||
"drift_tolerance_minutes": "5", # 云端比本地早超过该值才告警
|
||||
@@ -58,25 +66,55 @@ 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、收自己的数据、定自己的采集时刻。
|
||||
# ---------------- 配置的作用域与写权限(改这里之前先读完整段)----------------
|
||||
#
|
||||
# 三级回落:**个人级(user_id=n) -> 实例级(user_id=0) -> config.DEFAULTS**。
|
||||
# 但「谁能写哪个键」和「键存在哪一级」是两件事,本项目把两者对齐成一条规则:
|
||||
#
|
||||
# 普通用户只能写 USER_EDITABLE_KEYS(本人凭证);
|
||||
# 其余所有键都归管理员,且一律存在**实例级**(user_id=0)。
|
||||
#
|
||||
# 为什么采集参数 / 调度也要放到实例级,而不是「个人级但只有管理员能写」:
|
||||
# 如果它们只在管理员自己的 user_id 下,其它账号读取时会回落到
|
||||
# DEFAULTS,管理员改的值对别人**完全不生效** —— 那才是真正的坑。
|
||||
# 统一放实例级,语义是「一台部署一套采集与调度策略」,读起来也简单。
|
||||
GLOBAL_KEYS = {
|
||||
# 云端接口
|
||||
"api_base", "api_path",
|
||||
# 开放注册与防攻击策略
|
||||
"allow_register", "register_max_per_ip", "captcha_policy", "captcha_length",
|
||||
# 采集调度(v1.3.0 起为实例级:普通用户只读,不能设置频率)
|
||||
"schedule_enabled", "schedule_times", "catch_up", "catch_up_grace_hours",
|
||||
# 采集参数(同理:允许普通用户调 page_size/关 ssl_verify 都是越权)
|
||||
"page_size", "rewind_minutes", "drift_tolerance_minutes", "max_prompt",
|
||||
"verify_days", "timeout", "ssl_verify",
|
||||
}
|
||||
|
||||
# 普通用户**唯一**可写的两个键:本人账号的云端凭证。
|
||||
# cookie —— 静态加密后入库,页面/接口只回掩码
|
||||
# user_agent —— 必须与拿 Cookie 的那次请求同源,所以和 Cookie 归在一起
|
||||
# 这两个键是个人级、且不参与实例级回落(见 db.NO_FALLBACK_KEYS):
|
||||
# 回落等于「用别人的身份采集」,是最严重的一类越权。
|
||||
USER_EDITABLE_KEYS = {"cookie", "user_agent"}
|
||||
|
||||
|
||||
def is_user_key(key):
|
||||
"""是否属于「个人凭证」类配置。"""
|
||||
return key in USER_EDITABLE_KEYS
|
||||
|
||||
|
||||
def writable_by(key, is_admin):
|
||||
"""当前角色能否写这个键 —— 前后端与测试都走这一个判断,避免两处规则漂移。"""
|
||||
if key in USER_EDITABLE_KEYS:
|
||||
return True
|
||||
return bool(is_admin)
|
||||
|
||||
# 验证码策略
|
||||
CAPTCHA_POLICIES = {
|
||||
"always": "始终要求(默认,最安全)",
|
||||
|
||||
+59
-1
@@ -14,11 +14,17 @@
|
||||
多用户约定(改代码前务必先读):
|
||||
* `user_id = 0` 在 settings / collect_runs / audit_log 里表示**实例级**;
|
||||
usage_records 里 0 是「历史遗留数据尚未归属」的兜底值,正常不会出现。
|
||||
* **settings 里只有两个键是个人级:`cookie` 与 `user_agent`**(即
|
||||
`config.USER_EDITABLE_KEYS`)。调度、采集参数、接口地址、注册策略全部
|
||||
是实例级(`config.GLOBAL_KEYS`),`set_setting()` 会把它们强制写到
|
||||
user_id=0 —— 因此「管理员改了但别人不生效」这类 bug 在结构上不存在。
|
||||
* `get_settings()` 会把 `ENCRYPTED_KEYS`(Cookie)**一律置空**;
|
||||
要拿明文只有 `get_secret()` 一条路。这样任何「顺手打印一下全部配置」
|
||||
的代码都不可能把凭证带出去。
|
||||
* `NO_FALLBACK_KEYS`(Cookie / User-Agent)**不参与实例级回退**:
|
||||
Cookie 是账号凭证,回落等于串号,是最严重的一类越权。
|
||||
* `slot:*` 调度簿记键是**个人级**(每个账号各自记「今天这个槽位跑过没」),
|
||||
虽然时刻本身是实例级的 —— 这两个千万别一起改。
|
||||
"""
|
||||
import os
|
||||
import sqlite3
|
||||
@@ -34,7 +40,8 @@ _initialized = False
|
||||
# 库结构版本。写在 PRAGMA user_version 里,用来判断是否需要迁移。
|
||||
# 1 -> 单用户布局(settings 以 key 为主键,usage_records 以 request_id 为主键)
|
||||
# 2 -> 多用户布局(见 schema.sql 顶部说明)
|
||||
DB_SCHEMA_VERSION = 2
|
||||
# 3 -> 调度与采集参数从个人级提升为实例级(普通用户只读;见 config.GLOBAL_KEYS)
|
||||
DB_SCHEMA_VERSION = 3
|
||||
|
||||
# 这些键即使个人作用域没有值,也**不**回落到实例级
|
||||
NO_FALLBACK_KEYS = {"cookie", "user_agent"}
|
||||
@@ -225,6 +232,38 @@ def _encrypt_legacy_secrets(conn):
|
||||
return done
|
||||
|
||||
|
||||
def _promote_personal_to_global(conn):
|
||||
"""把「原本个人级、现在实例级」的配置收敛到 user_id=0。幂等,可反复执行。
|
||||
|
||||
v1.3.0 把调度与采集参数从个人级升为实例级(普通用户只读)。升级时
|
||||
必须做两件事,否则会静默丢配置:
|
||||
|
||||
1. **把第一个管理员的个人值提升到实例级** —— 管理员此前设的
|
||||
`schedule_times=08:00` 若不提升,读取路径会因为「全局键不再看个人
|
||||
作用域」而直接跳过它,表现成「设置莫名其妙回到默认值」。
|
||||
2. **删掉所有个人作用域里的全局键** —— 留着不会被读(get_settings 会
|
||||
跳过),但会让后面排障的人以为「这个键是个人级的」。
|
||||
|
||||
对本来就属于实例级的键(api_base / allow_register 等)这是空操作。
|
||||
"""
|
||||
owner = _first_owner_uid(conn)
|
||||
promoted, cleaned = [], 0
|
||||
for k in sorted(config.GLOBAL_KEYS):
|
||||
if owner:
|
||||
mine = conn.execute("SELECT value FROM settings WHERE user_id=? AND key=?",
|
||||
(owner, k)).fetchone()
|
||||
if mine is not None and mine["value"] is not None:
|
||||
cur = conn.execute("SELECT value FROM settings WHERE user_id=0 AND key=?",
|
||||
(k,)).fetchone()
|
||||
if cur is None or cur["value"] != mine["value"]:
|
||||
set_setting(conn, k, mine["value"], 0) # 全局键强制落 uid=0
|
||||
promoted.append(k)
|
||||
# DELETE 的 rowcount 对「没有匹配行」返回 0,所以这里天然幂等
|
||||
cur2 = conn.execute("DELETE FROM settings WHERE user_id<>0 AND key=?", (k,))
|
||||
cleaned += cur2.rowcount or 0
|
||||
return promoted, cleaned
|
||||
|
||||
|
||||
def init_db(conn=None, create_admin=True, admin_user="admin", admin_password=None):
|
||||
"""建表 / 迁移 / 灌默认配置。可重复执行(幂等)。返回迁移说明列表。"""
|
||||
global _initialized
|
||||
@@ -244,9 +283,18 @@ def init_db(conn=None, create_admin=True, admin_user="admin", admin_password=Non
|
||||
ts = now_str()
|
||||
# 默认配置灌在**实例级**(user_id=0)。个人作用域不预置行,
|
||||
# 读取时按「个人 -> 实例 -> DEFAULTS」三级回落,语义更清楚。
|
||||
#
|
||||
# 凭证类键(cookie / user_agent)**刻意不灌**:它们是个人级的,
|
||||
# 实例级存一份没有任何读取路径会用到(NO_FALLBACK_KEYS 挡住了回落),
|
||||
# 只会让「读一下实例配置看看」的人拿到一个不该存在的凭证位。
|
||||
for k, v in config.DEFAULTS.items():
|
||||
if k in config.USER_EDITABLE_KEYS:
|
||||
continue
|
||||
conn.execute("INSERT OR IGNORE INTO settings(user_id,key,value,updated_at)"
|
||||
" VALUES(0,?,?,?)", (k, v, ts))
|
||||
# 顺手清掉历史遗留在实例级的凭证行(老版本曾把它们当实例级配置存过)
|
||||
for k in sorted(config.USER_EDITABLE_KEYS):
|
||||
conn.execute("DELETE FROM settings WHERE user_id=0 AND key=?", (k,))
|
||||
# 顺手把历史明文凭证加密(幂等;新库无事可做)
|
||||
enc = _encrypt_legacy_secrets(conn)
|
||||
if enc:
|
||||
@@ -255,6 +303,16 @@ def init_db(conn=None, create_admin=True, admin_user="admin", admin_password=Non
|
||||
" VALUES(0,?,?,?,?,?)",
|
||||
(ts, "system", "encrypt_secrets",
|
||||
"明文凭证已加密:%s" % ", ".join(enc)[:400], "127.0.0.1"))
|
||||
# 调度/采集参数在 v1.3.0 升为实例级:把管理员那份提升上去并清掉个人残留
|
||||
promoted, cleaned = _promote_personal_to_global(conn)
|
||||
if promoted or cleaned:
|
||||
note = "配置作用域收敛:提升 %s 到实例级%s" % (
|
||||
", ".join(promoted) if promoted else "(无)",
|
||||
",清理 %d 条个人级残留" % cleaned if cleaned else "")
|
||||
migrated.append(note)
|
||||
conn.execute("INSERT INTO audit_log(user_id,at,actor,action,detail,ip)"
|
||||
" VALUES(0,?,?,?,?,?)",
|
||||
(ts, "system", "promote_global_settings", note[:400], "127.0.0.1"))
|
||||
if create_admin:
|
||||
n = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||
if n == 0:
|
||||
|
||||
+41
-12
@@ -13,8 +13,12 @@
|
||||
**多用户约定**
|
||||
每个接口都只操作 `current_user()["id"]` 那份数据。查询函数要求显式传 uid,
|
||||
所以这里漏传会直接 TypeError(而不是静默返回全量)。
|
||||
`/api/settings` 是唯一的例外:它会回传实例级配置供非管理员只读展示,
|
||||
但**拒绝**非管理员写入实例级键。
|
||||
|
||||
写权限只有两条规则(`config.writable_by`,前后端与测试共用同一判断):
|
||||
|
||||
* 普通用户**只能**写 `config.USER_EDITABLE_KEYS`(本人的 cookie / user_agent);
|
||||
* 其余键(接口地址、注册策略、采集参数、调度时刻)只有管理员能写,
|
||||
任何越权写入都会被 `/api/settings` 拒绝并在审计里记一笔 `settings_rejected`。
|
||||
"""
|
||||
import os
|
||||
from datetime import datetime
|
||||
@@ -76,7 +80,13 @@ def _int(name, default, lo=1, hi=2000):
|
||||
@bp.get("/manifest")
|
||||
@login_required
|
||||
def api_manifest():
|
||||
return jsonify(query.manifest(db.get_db(), _uid()))
|
||||
u = current_user()
|
||||
m = query.manifest(db.get_db(), u["id"])
|
||||
# 角色标记只在这里补:大屏是**静态页**,拿不到 Jinja 上下文,
|
||||
# 只能靠数据接口知道自己该不该渲染「日志管理」这类管理员入口。
|
||||
# 前端隐藏只是不给死链,真正的闸门始终是服务端的 @admin_required。
|
||||
m["role"] = "admin" if u["is_admin"] else "user"
|
||||
return jsonify(m)
|
||||
|
||||
|
||||
@bp.get("/bundle")
|
||||
@@ -174,7 +184,8 @@ def api_run(run_id):
|
||||
@login_required
|
||||
def api_status():
|
||||
conn = db.get_db()
|
||||
uid = _uid()
|
||||
u = current_user()
|
||||
uid = u["id"]
|
||||
sch = scheduler.get_scheduler()
|
||||
nxt = scheduler.next_run_at(conn, uid)
|
||||
last = conn.execute("SELECT * FROM collect_runs WHERE user_id=? ORDER BY id DESC LIMIT 1",
|
||||
@@ -184,6 +195,12 @@ def api_status():
|
||||
cred = db.secret_state(conn, "cookie", uid)
|
||||
return jsonify({
|
||||
"server_time": db.now_str(),
|
||||
# 角色能力:大屏等**静态页**拿不到 Jinja 上下文,只能靠这个字段
|
||||
# 决定要不要渲染管理员专属入口(如「日志管理」)。服务端仍会
|
||||
# 对这些入口再做一次鉴权,前端隐藏只是为了不给出误导性的按钮。
|
||||
"is_admin": bool(u["is_admin"]),
|
||||
"can_edit_schedule": bool(u["is_admin"]),
|
||||
"can_view_logs": bool(u["is_admin"]),
|
||||
"scheduler": {
|
||||
"running": sch.running,
|
||||
"enabled": db.get_bool(conn, "schedule_enabled", True, uid),
|
||||
@@ -344,7 +361,9 @@ def api_settings_get():
|
||||
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["_userKeys"] = sorted(config.USER_EDITABLE_KEYS)
|
||||
s["_canEditGlobal"] = bool(u["is_admin"])
|
||||
s["_role"] = "admin" if u["is_admin"] else "user"
|
||||
return jsonify(s)
|
||||
|
||||
|
||||
@@ -363,9 +382,11 @@ def api_settings_post():
|
||||
if k not in config.DEFAULTS:
|
||||
errors.append("未知配置项:%s" % k)
|
||||
continue
|
||||
if config.is_global_key(k) and not u["is_admin"]:
|
||||
# 实例级配置(接口基址、注册开关)只有管理员能改 ——
|
||||
# 否则任意注册用户都能把大家的数据采集指向别的服务器
|
||||
if not config.writable_by(k, u["is_admin"]):
|
||||
# 普通用户只能写**本人凭证**(cookie / user_agent)。其余键 ——
|
||||
# 接口基址、注册策略、采集参数、调度时刻 —— 一律归管理员:
|
||||
# 否则任意注册用户就能把大家的数据采集指向别的服务器,
|
||||
# 或者把 page_size 调到 1000 去 hammer 云端接口。
|
||||
denied.append(k)
|
||||
continue
|
||||
if k == "cookie":
|
||||
@@ -386,16 +407,24 @@ def api_settings_post():
|
||||
db.set_setting(conn, k, val, uid)
|
||||
changed.append(k)
|
||||
if denied:
|
||||
errors.append("以下为实例级配置,仅管理员可修改:%s" % "、".join(sorted(denied)))
|
||||
errors.append("以下配置仅管理员可修改,本账号无法保存:%s。"
|
||||
"普通账号可以维护的是本人凭证(Cookie / User-Agent)。"
|
||||
% "、".join(sorted(denied)))
|
||||
if errors:
|
||||
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 user_id=? AND key LIKE ?",
|
||||
(uid, scheduler.SLOT_PREFIX + "%"))
|
||||
# 改了每日时刻:清掉**不再存在的**槽位标记(所有账号一起清)。
|
||||
# 这里刻意不做「全清」——时刻是实例级的,全清会让全部账号在宽限期内
|
||||
# 一起重采一遍;只清失效槽位,既让新时刻立即生效,又不会造成批量重采。
|
||||
if "schedule_times" in changed:
|
||||
keep = set(scheduler.slots(conn, 0))
|
||||
stale = [(r["user_id"], r["key"]) for r in conn.execute(
|
||||
"SELECT user_id,key FROM settings WHERE key LIKE ?", (scheduler.SLOT_PREFIX + "%",))
|
||||
if r["key"][len(scheduler.SLOT_PREFIX):] not in keep]
|
||||
for row_uid, skey in stale:
|
||||
conn.execute("DELETE FROM settings WHERE user_id=? AND key=?", (row_uid, skey))
|
||||
db.audit(conn, "settings", u["username"],
|
||||
"修改:" + (",".join(sorted(changed)) or "(无变化)"), request.remote_addr, uid)
|
||||
return jsonify({"ok": True, "changed": sorted(changed), "ignored": sorted(ignored)})
|
||||
|
||||
@@ -190,7 +190,9 @@
|
||||
<div class="navback">
|
||||
<a class="abtn" href="/">← 返回后台</a>
|
||||
<a class="abtn" href="/records">数据明细</a>
|
||||
<a class="abtn" href="/logs">日志管理</a>
|
||||
<!-- 日志管理仅管理员可达:角色由 /api/manifest 的 role 字段带回,
|
||||
非管理员时在 boot() 里隐藏,避免给出会 403 的死链 -->
|
||||
<a class="abtn" id="abtnLogs" href="/logs" hidden>日志管理</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="meta" data-page-node-id="4jmZuGBGC8JQmVAE2IOJ7H">
|
||||
@@ -1272,6 +1274,9 @@
|
||||
$("#err").style.display = "none";
|
||||
const mf = src.manifest || {};
|
||||
$("#mUpdate").textContent = mf.generated || "-";
|
||||
// 管理员入口按角色显隐(大屏是静态页,拿不到 Jinja 上下文)
|
||||
const logsBtn = $("#abtnLogs");
|
||||
if (logsBtn) logsBtn.hidden = (mf.role !== "admin");
|
||||
initDays();
|
||||
bind();
|
||||
renderScope();
|
||||
|
||||
@@ -61,6 +61,12 @@
|
||||
var out = {};
|
||||
Array.prototype.forEach.call(form.elements, function (el) {
|
||||
if (!el.name || el.type === "submit" || el.name === "_csrf") return;
|
||||
// 只读控件必须跳过:disabled 的 input 仍然出现在 form.elements 里,
|
||||
// 若被一起提交,服务端会因为「越权修改只读项」把**整单**拒掉 ——
|
||||
// 表现成「改了 A 却提示 A 不能改」,很难归因。
|
||||
// 注意:fieldset 被 disable 时子元素自身的 disabled 仍是 false,
|
||||
// 所以还要往上找祖先 fieldset。
|
||||
if (el.disabled || (el.closest && el.closest("fieldset[disabled]"))) return;
|
||||
if (el.type === "checkbox") { out[el.name] = el.checked ? "1" : "0"; return; }
|
||||
if (el.type === "radio") { if (el.checked) out[el.name] = el.value; return; }
|
||||
out[el.name] = el.value;
|
||||
@@ -72,7 +78,7 @@
|
||||
function hintOf(j) {
|
||||
if (!j) return "";
|
||||
if (j.error === "cookie_expired") return "\n请到「配置管理」更新 Cookie 与 User-Agent(两者须取自同一次浏览器请求)。";
|
||||
if (j.error === "busy") return "\n已有采集在进行,可稍后重试,或到「日志管理」查看进度。";
|
||||
if (j.error === "busy") return "\n已有采集在进行,可稍后重试;「任务管理」里能看到本次运行的状态。";
|
||||
if (j.errors && j.errors.length) return "\n" + j.errors.join("\n");
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -29,7 +29,11 @@
|
||||
<a href="{{ url_for('views.records') }}" class="{{ 'on' if nav=='records' }}">数据明细</a>
|
||||
<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>
|
||||
{# 日志管理里是实例运行信息(数据库路径 / 账号名 / 来源 IP),仅管理员可见;
|
||||
服务端另有 @admin_required 兜底,这里隐藏只是不给出会 403 的死链。 #}
|
||||
{% if cur.is_admin %}
|
||||
<a href="{{ url_for('views.logs') }}" class="{{ 'on' if nav=='logs' }}">日志管理</a>
|
||||
{% endif %}
|
||||
{% if cur.is_admin %}
|
||||
<a href="{{ url_for('views.users_page') }}" class="{{ 'on' if nav=='users' }}">用户管理</a>
|
||||
{% endif %}
|
||||
@@ -37,7 +41,8 @@
|
||||
<div class="me">
|
||||
<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 %}
|
||||
{% if cur.is_admin %}<span class="tag accent">管理员</span>
|
||||
{% else %}<span class="tag mute">普通账号</span>{% endif %}
|
||||
</a>
|
||||
{# 退出用 POST + CSRF:GET 型退出会被 <img src="/logout"> 这类请求静默触发 #}
|
||||
<form method="post" action="{{ url_for('views.logout_post') }}" style="margin:0">
|
||||
@@ -60,8 +65,8 @@
|
||||
</main>
|
||||
|
||||
<footer class="foot">
|
||||
<b>{{ project_title }}</b> · {{ project_name }} · 多用户 · 采集在 Web 进程内按各账号配置的时刻执行<br>
|
||||
每个账号只使用并只见得到自己的 Cookie 与用量数据;数据正本 <code>data/usage.sqlite</code>
|
||||
<b>{{ project_title }}</b> · {{ project_name }} · 多用户 · 调度与采集参数由管理员统一设定<br>
|
||||
每个账号只使用并只见得到<b>自己的</b> Cookie 与用量数据;数据正本 <code>data/usage.sqlite</code>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
<div class="pagehead">
|
||||
<div>
|
||||
<h1>配置管理</h1>
|
||||
<p class="lead">这里改的都是<b>你自己账号</b>的配置:凭证、采集参数、维护动作;改完立即生效</p>
|
||||
{% if is_admin %}
|
||||
<p class="lead">凭证、采集参数、接口地址与注册策略都在这里;改完立即生效</p>
|
||||
{% else %}
|
||||
<p class="lead">这里维护<b>你自己账号</b>的云端凭证;采集参数与调度由管理员统一设定,只读展示</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -29,6 +33,9 @@
|
||||
任选一个 <code>billing</code> 请求 → 复制 Request Headers 里的 <code>cookie</code> 与 <code>user-agent</code>
|
||||
(<b>两者必须取自同一次请求</b>),粘贴到下面。
|
||||
<br><b>请粘贴你自己账号的 Cookie</b>:采集只使用本人凭证,各账号的数据互不可见。
|
||||
{% if not is_admin %}
|
||||
<br><b>这一块是你唯一可以修改的配置</b> —— 其余参数与调度时刻由管理员统一设定。
|
||||
{% endif %}
|
||||
</p>
|
||||
<form id="formCred">
|
||||
<label class="col">Cookie
|
||||
@@ -44,18 +51,21 @@
|
||||
|
||||
<div class="grid2">
|
||||
<section class="card">
|
||||
<h2>采集参数</h2>
|
||||
<div class="cardhead">
|
||||
<h2>采集参数</h2>
|
||||
{% if is_admin %}
|
||||
<span class="tag accent">实例级 · 对所有账号生效</span>
|
||||
{% else %}
|
||||
<span class="tag mute">只读 · 仅管理员可改</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if is_admin %}
|
||||
<form id="formCollect">
|
||||
{# 实例级键:所有账号共用,只有管理员能改;普通账号只读展示 #}
|
||||
<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="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="page_size" type="number" min="{{ b.page_size[0] }}" max="{{ b.page_size[1] }}" value="{{ s.page_size }}">
|
||||
<em class="unit">{{ b.page_size[0] }}~{{ b.page_size[1] }} 条/页</em></label>
|
||||
@@ -82,8 +92,23 @@
|
||||
</label>
|
||||
<p class="hint">Cookie 就是账号凭证,关掉证书校验等于把它暴露给中间人,非必要不要关。</p>
|
||||
<button class="btn primary" type="submit">保存采集参数</button>
|
||||
<p class="hint">整日校验会按天重新拉云端 total 与本地比对,发现缺记录自动补入;设为 0 表示关闭(日常够用)。</p>
|
||||
<p class="hint">整日校验会按天重新拉云端 total 与本地比对,发现缺记录自动补入;设为 0 表示关闭(日常够用)。
|
||||
这些是<b>实例级</b>参数,改一次对本机所有账号生效。</p>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="hint">这些参数影响采集行为与云端压力,属于整机策略,普通账号只读。</p>
|
||||
<table class="kv">
|
||||
<tr><th>接口基址</th><td class="mono">{{ s.api_base }}</td></tr>
|
||||
<tr><th>接口路径</th><td class="mono">{{ s.api_path }}</td></tr>
|
||||
<tr><th>分页大小</th><td>{{ s.page_size }} 条/页</td></tr>
|
||||
<tr><th>断点回退</th><td>{{ s.rewind_minutes }} 分钟</td></tr>
|
||||
<tr><th>时间漂移容差</th><td>{{ s.drift_tolerance_minutes }} 分钟</td></tr>
|
||||
<tr><th>Prompt 截断</th><td>{{ s.max_prompt }} 字符{% if s.max_prompt == '0' %}(不截断){% endif %}</td></tr>
|
||||
<tr><th>整日校验天数</th><td>{{ s.verify_days }} 天</td></tr>
|
||||
<tr><th>请求超时</th><td>{{ s.timeout }} 秒</td></tr>
|
||||
<tr><th>校验 TLS 证书</th><td>{% if s.ssl_verify != '0' %}<span class="tag ok">校验</span>{% else %}<span class="tag bad">不校验</span>{% endif %}</td></tr>
|
||||
</table>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
@@ -118,6 +143,22 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{% if not is_admin %}
|
||||
<section class="card">
|
||||
<div class="cardhead">
|
||||
<h2>为什么采集参数是只读的</h2>
|
||||
<span class="tag mute">普通账号</span>
|
||||
</div>
|
||||
<p class="hint" style="margin:0">
|
||||
你只能维护<b>本人账号的凭证</b>(Cookie 与 User-Agent)—— 采集始终只用你自己的这份凭证,
|
||||
拿回来的数据也只会存在你自己的作用域里,别人看不到、你也看不到别人的。
|
||||
分页大小、超时、接口地址、TLS 校验、调度时刻这些属于<b>整机策略</b>:它们既关系到云端
|
||||
压力,也关系到所有人的采集是否安全,因此统一由管理员设定。
|
||||
<br>如果你确实需要调整,把上面任意一项截图给管理员即可。
|
||||
</p>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if is_admin %}
|
||||
<section class="card">
|
||||
<div class="cardhead">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div class="pagehead">
|
||||
<div>
|
||||
<h1>日志管理</h1>
|
||||
<p class="lead">采集逐次日志、应用运行日志与操作审计</p>
|
||||
<p class="lead">本机全实例的采集日志、应用运行日志与操作审计(仅管理员可见)</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn" id="btnTail" type="button">刷新应用日志</button>
|
||||
@@ -49,12 +49,13 @@
|
||||
</div>
|
||||
<div class="tablewrap">
|
||||
<table class="tbl">
|
||||
<thead><tr><th>#</th><th>开始</th><th>触发</th><th>状态</th><th class="num">耗时</th>
|
||||
<thead><tr><th>#</th><th>账号</th><th>开始</th><th>触发</th><th>状态</th><th class="num">耗时</th>
|
||||
<th class="num">新增</th><th class="num">重复</th><th class="num">冲突</th><th>结论</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for r in runs %}
|
||||
<tr>
|
||||
<td>{{ r.id }}</td>
|
||||
<td>{{ r.uname }}</td>
|
||||
<td class="mono nowrap">{{ r.started_at[5:] if r.started_at else '—' }}</td>
|
||||
<td><span class="tag {{ 'info' if r.trigger=='schedule' else ('accent' if r.trigger=='startup' else 'mute') }}">{{ r.trigger }}</span></td>
|
||||
<td class="nowrap">
|
||||
@@ -70,7 +71,7 @@
|
||||
<td><a href="{{ url_for('views.logs', run=r.id, status=status or none) }}">详情</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="10" class="empty">暂无采集日志</td></tr>
|
||||
<tr><td colspan="11" class="empty">暂无采集日志</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -58,8 +58,10 @@
|
||||
<tr><th>Cookie</th><td>{% if mf.health.cookie %}<span class="tag ok">已配置</span>{% else %}<span class="tag bad">未配置</span> <a href="{{ url_for('views.config_page') }}">去配置</a>{% endif %}</td></tr>
|
||||
<tr><th>服务器时间</th><td class="mono">{{ sch.now }}</td></tr>
|
||||
</table>
|
||||
<p class="hint">调度在 Web 进程内运行,不再需要计划任务或外部自动化。所有时刻与开关都在
|
||||
<a href="{{ url_for('views.tasks') }}">任务管理</a>里改。</p>
|
||||
<p class="hint">调度在 Web 进程内运行,不再需要计划任务或外部自动化。
|
||||
{% if current_user().is_admin %}所有时刻与开关都在
|
||||
<a href="{{ url_for('views.tasks') }}">任务管理</a>里改。{% else %}时刻由管理员统一设定,
|
||||
你可以在<a href="{{ url_for('views.tasks') }}">任务管理</a>里查看,也可以随时手动采集本人数据。{% endif %}</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
@@ -98,7 +100,8 @@
|
||||
<tbody>
|
||||
{% for r in runs %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for('views.logs', run=r.id) }}">{{ r.id }}</a></td>
|
||||
{# 运行详情在「日志管理」里,而那页仅管理员可达 —— 普通用户不要给死链 #}
|
||||
<td>{% if current_user().is_admin %}<a href="{{ url_for('views.logs', run=r.id) }}">{{ r.id }}</a>{% else %}{{ r.id }}{% endif %}</td>
|
||||
<td><span class="tag {{ 'info' if r.trigger=='schedule' else ('accent' if r.trigger=='startup' else 'mute') }}">{{ r.trigger }}</span></td>
|
||||
<td class="mono">{{ r.started_at[5:] if r.started_at else '—' }}</td>
|
||||
<td class="num">{{ ((r.duration_ms or 0) / 1000) | round(1) }}s</td>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div class="pagehead">
|
||||
<div>
|
||||
<h1>任务管理</h1>
|
||||
<p class="lead">调度在 Web 进程内执行,采集互斥由文件锁保证;这里也能手动触发与按区间回填</p>
|
||||
<p class="lead">调度在 Web 进程内执行,采集互斥由文件锁保证;这里能看到本人账号的运行历史,也能手动触发与按区间回填</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn primary" id="btnCollect" type="button">立即采集一次</button>
|
||||
@@ -15,7 +15,11 @@
|
||||
|
||||
<div class="grid2">
|
||||
<section class="card">
|
||||
<h2>采集调度</h2>
|
||||
<div class="cardhead">
|
||||
<h2>采集调度</h2>
|
||||
{% if not can_edit %}<span class="tag mute">只读 · 仅管理员可改</span>{% endif %}
|
||||
</div>
|
||||
{% if can_edit %}
|
||||
<form id="formTask">
|
||||
<label class="row"><span>启用调度</span>
|
||||
<select name="schedule_enabled">
|
||||
@@ -26,8 +30,8 @@
|
||||
<label class="row"><span>每日时刻</span>
|
||||
<input name="schedule_times" value="{{ s_times }}" placeholder="09:00,17:00" spellcheck="false">
|
||||
</label>
|
||||
<p class="hint">本地时区,逗号分隔,支持 <code>HH:MM</code>(也可只写 <code>9</code>)。保存后立即生效,
|
||||
并会清空当天已执行的槽位标记以便新时刻接管。</p>
|
||||
<p class="hint">本地时区,逗号分隔,支持 <code>HH:MM</code>(也可只写 <code>9</code>)。
|
||||
保存后立即生效;已经过去且不再存在的时刻会被清掉,新的时刻当天就会接管。</p>
|
||||
<label class="row"><span>启动补跑</span>
|
||||
<select name="catch_up">
|
||||
<option value="1" {{ 'selected' if sch.catch_up }}>开启(错过的时刻在宽限期内补跑)</option>
|
||||
@@ -39,7 +43,21 @@
|
||||
<em class="unit">小时(超过就不补,避免开机狂刷)</em>
|
||||
</label>
|
||||
<button class="btn primary" type="submit">保存调度配置</button>
|
||||
<p class="hint">这套时刻是<b>实例级</b>的:本机上所有账号在同一个时刻各自采集自己的数据。</p>
|
||||
</form>
|
||||
{% else %}
|
||||
<table class="kv">
|
||||
<tr><th>启用调度</th><td>{% if sch.enabled %}<span class="tag ok">已启用</span>{% else %}<span class="tag bad">已停用</span>{% endif %}</td></tr>
|
||||
<tr><th>每日时刻</th><td>{{ sch.times | join(' · ') if sch.times else '—' }}</td></tr>
|
||||
<tr><th>启动补跑</th><td>{% if sch.catch_up %}<span class="tag">开启</span>(宽限 {{ s_grace }} 小时){% else %}关闭{% endif %}</td></tr>
|
||||
<tr><th>下次执行</th><td class="mono">{{ sch.next_run or '—' }}</td></tr>
|
||||
</table>
|
||||
<p class="hint">
|
||||
调度时刻与采集参数由<b>管理员统一设定</b>,普通账号只读。
|
||||
你随时可以在右上角点「立即采集一次」拉取本人账号的最新用量,
|
||||
也可以在下面按区间补采自己的历史数据 —— 采集始终只用<b>你自己的</b> Cookie。
|
||||
</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
@@ -96,7 +114,10 @@
|
||||
<td class="num">{{ r.dup }}</td>
|
||||
<td class="num">{% if r.conflicts %}<span class="tag warn">{{ r.conflicts }}</span>{% else %}0{% endif %}</td>
|
||||
<td>{{ r.message or '—' }}</td>
|
||||
<td><a href="{{ url_for('views.logs', run=r.id) }}">日志</a></td>
|
||||
<td>
|
||||
{% if can_edit %}<a href="{{ url_for('views.logs', run=r.id) }}">日志</a>
|
||||
{% else %}<span class="muted">—</span>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="12" class="empty">暂无运行记录</td></tr>
|
||||
|
||||
+39
-40
@@ -8,17 +8,23 @@
|
||||
/ 概览(KPI + 入口)
|
||||
/dashboard ECharts 交互大屏(独立静态页,登录后可达,数据走 /api/bundle)
|
||||
/records 数据明细:分页、筛选、搜索、导出
|
||||
/tasks 任务管理:调度开关/时刻、手动触发、运行历史
|
||||
/config 配置管理:本人的 Cookie / UA / 采集参数
|
||||
/logs 日志管理:采集逐次明细 + 应用日志尾部
|
||||
/tasks 任务管理:运行历史、手动采集、补采(**调度配置仅管理员可改**)
|
||||
/config 配置管理:本人的 Cookie / UA(**其余参数仅管理员可改**)
|
||||
/logs 日志管理(**仅管理员**)
|
||||
/profile 个人中心:资料、密码、凭证状态
|
||||
/users 用户管理(仅管理员)
|
||||
/register 自助注册(受 allow_register 开关约束)
|
||||
/captcha.png 图形验证码
|
||||
|
||||
**多用户约定**
|
||||
所有数据类页面都只取 `current_user()["id"]` 那份数据;管理员在
|
||||
「用户管理」里能看到账号列表,但**看不到别人的用量与凭证**。
|
||||
**多用户约定(两条)**
|
||||
|
||||
1. **数据作用域**:所有数据类页面都只取 `current_user()["id"]` 那份数据;
|
||||
管理员在「用户管理」里能看到账号列表,但**看不到别人的用量与凭证**。
|
||||
|
||||
2. **写权限**:普通用户只能写 `config.USER_EDITABLE_KEYS`(本人的 cookie /
|
||||
user_agent),其余配置(调度时刻、采集参数、接口地址、注册策略)都归
|
||||
管理员。页面上的 disabled / 隐藏只是「不给误导性按钮」,真正的闸门在
|
||||
`@admin_required` 与 `config.writable_by()`,两端共用一个判断。
|
||||
"""
|
||||
import csv
|
||||
import io
|
||||
@@ -310,6 +316,7 @@ def tasks():
|
||||
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",
|
||||
can_edit=is_admin(),
|
||||
page=page, pages=pages, total=total,
|
||||
page_window=_page_window(page, pages),
|
||||
active="tasks")
|
||||
@@ -393,43 +400,33 @@ def users_page():
|
||||
active="users")
|
||||
|
||||
|
||||
# ---------------- 日志管理 ----------------
|
||||
# ---------------- 日志管理(仅管理员) ----------------
|
||||
@bp.get("/logs")
|
||||
@login_required
|
||||
@admin_required
|
||||
def logs():
|
||||
conn = db.get_db()
|
||||
u = current_user()
|
||||
uid = u["id"]
|
||||
adm = bool(u["is_admin"])
|
||||
"""日志管理 —— **仅管理员**。
|
||||
|
||||
这一页同时呈现「全实例采集日志」「进程级应用日志」「全实例操作审计」,
|
||||
都是实例运行信息(会带数据库路径、账号名、采集区间、来源 IP)。
|
||||
普通账号不该读到这些:他们要看自己的采集历史走「任务管理」,
|
||||
那页只查本人的数据。服务端用 @admin_required 兜底,导航里也会隐藏入口。
|
||||
"""
|
||||
conn = db.get_db()
|
||||
run_id = request.args.get("run")
|
||||
detail = None
|
||||
if run_id and str(run_id).isdigit():
|
||||
# 明细也必须限本人:否则改一个 ?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()
|
||||
detail = conn.execute("SELECT * FROM collect_runs WHERE id=?",
|
||||
(int(run_id),)).fetchone()
|
||||
|
||||
status = request.args.get("status") or ""
|
||||
w, p = ("WHERE user_id = ? AND status = ?", [uid, status]) \
|
||||
if status in ("ok", "warn", "error", "running") else ("WHERE user_id = ?", [uid])
|
||||
if status in ("ok", "warn", "error", "running"):
|
||||
w, p = "WHERE status = ?", [status]
|
||||
else:
|
||||
status, w, p = "", "", []
|
||||
|
||||
# 操作审计:管理员看全部(便于追责),普通用户只看自己触发的
|
||||
aw, ap = [], []
|
||||
if not adm:
|
||||
aw.append("user_id = ?")
|
||||
ap.append(uid)
|
||||
act = request.args.get("act") or ""
|
||||
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 []
|
||||
ap = [act] if act else []
|
||||
aw_sql = "WHERE action = ?" if act else ""
|
||||
|
||||
apage = _int_arg("apage", 1, 1, 10 ** 6)
|
||||
asize = 20
|
||||
@@ -439,23 +436,25 @@ def logs():
|
||||
# 注意传的是 sqlite3.Row 列表而不是纯字符串列表:模板要用 a[0]=动作、a[1]=次数,
|
||||
# 若在这里就用推导式取 r[0],模板里的 a[0] 会变成「字符串的第一个字符」。
|
||||
actions = conn.execute(
|
||||
"SELECT action, COUNT(*) n FROM audit_log %s GROUP BY action ORDER BY n DESC, action"
|
||||
% base, base_p).fetchall()
|
||||
"SELECT action, COUNT(*) n FROM audit_log GROUP BY action ORDER BY n DESC, action").fetchall()
|
||||
|
||||
page = _int_arg("page", 1, 1, 10 ** 6)
|
||||
size = 30
|
||||
total = conn.execute("SELECT COUNT(*) FROM collect_runs %s" % w, p).fetchone()[0]
|
||||
runs = conn.execute("SELECT id,trigger,status,started_at,duration_ms,fetched,added,dup,total,"
|
||||
"conflicts,exit_code,message FROM collect_runs %s"
|
||||
" ORDER BY id DESC LIMIT ? OFFSET ?" % w, p + [size, (page - 1) * size]).fetchall()
|
||||
# 带上账号名:这是实例级视图,一行没有归属人根本没法读
|
||||
runs = conn.execute("SELECT r.id,r.user_id,COALESCE(u.username,'—') AS uname,"
|
||||
" r.trigger,r.status,r.started_at,r.duration_ms,r.fetched,r.added,"
|
||||
" r.dup,r.total,r.conflicts,r.exit_code,r.message"
|
||||
" FROM collect_runs r LEFT JOIN users u ON u.id=r.user_id %s"
|
||||
" ORDER BY r.id DESC LIMIT ? OFFSET ?" % w,
|
||||
p + [size, (page - 1) * size]).fetchall()
|
||||
apages = max(1, (atotal + asize - 1) // asize)
|
||||
return render_template("logs.html", runs=runs, detail=detail, audits=audits,
|
||||
actions=actions, act=act, apage=apage, apages=apages, atotal=atotal,
|
||||
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, audit_all=adm,
|
||||
active="logs")
|
||||
status=status, active="logs")
|
||||
|
||||
|
||||
@bp.get("/logs/tail")
|
||||
|
||||
在新工单中引用
屏蔽一个用户