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。
这个提交包含在:
2026-09-18 08:46:00 +08:00
父节点 df7db3582e
当前提交 1bf961f6b3
共修改 42 个文件,包含 2615 行新增和 854 行删除
+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();
+7 -1
查看文件
@@ -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 "";
}
+8 -3
查看文件
@@ -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>
+53 -12
查看文件
@@ -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">
+4 -3
查看文件
@@ -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>
+6 -3
查看文件
@@ -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>
+26 -5
查看文件
@@ -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")