* LICENSE —— MIT
* THIRD-PARTY-NOTICES —— 依赖清单、再分发合规说明(含随仓库分发的
Apache ECharts 5.6.0 / Apache-2.0)与自查清单
* CONTRIBUTING.md —— 开发环境、五层验证、必须遵守的不变量、提交规范
* SECURITY.md —— 漏洞私有报告渠道、已有措施、已知非目标
* CODE_OF_CONDUCT.md —— 改编自 Contributor Covenant 2.1
* .github/ —— Bug 报告 / 功能建议表单 + PR 模板
* .editorconfig —— 与 .gitattributes 保持一致
* 全部 Python / Shell 源文件加 SPDX-License-Identifier: MIT 头
* README 增加「开源与许可」章节与许可标识
447 行
17 KiB
Python
447 行
17 KiB
Python
# -*- coding: utf-8 -*-
|
||
# SPDX-License-Identifier: MIT
|
||
# Copyright (c) 2026 Wang Chuanli
|
||
|
||
"""JSON API —— ECharts 大屏与后台页面的数据入口。
|
||
|
||
约定:
|
||
* 全部需要登录;POST 另需 CSRF(security.check_csrf 统一拦截)
|
||
* 参数 from/to 为 'YYYY-MM-DD';缺省则不限(即全量)
|
||
* 列表类接口默认不返回 prompt 全文(占传输量约 80%),只有 /api/top 与
|
||
/api/records/<request_id> 会带
|
||
"""
|
||
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
|
||
|
||
bp = Blueprint("api", __name__, url_prefix="/api")
|
||
|
||
|
||
def _arg(name, default=None):
|
||
v = request.args.get(name)
|
||
return v if v not in (None, "") else default
|
||
|
||
|
||
class BadParam(ValueError):
|
||
"""查询参数不合法 -> 由 __init__ 的 ValueError 处理器统一转成 400。"""
|
||
|
||
|
||
def _win(required=False):
|
||
"""解析 from/to,返回归一化后的 (from, to)。
|
||
|
||
归一化放在这里,视图与聚合层就不必再各自防御:
|
||
无法识别的写法('abc'、'2026-09-08 12:00:00' 里的空格等)此前会一路冒到
|
||
`datetime.strptime` 变成 HTTP 500,现在统一 400 并带上人话说明。
|
||
"""
|
||
raw_f, raw_t = _arg("from"), _arg("to")
|
||
f, t = query.norm_day(raw_f), query.norm_day(raw_t)
|
||
if raw_f and not f:
|
||
raise BadParam("参数 from 不是合法日期:%s(正确写法 2026-09-08)" % raw_f)
|
||
if raw_t and not t:
|
||
raise BadParam("参数 to 不是合法日期:%s(正确写法 2026-09-08)" % raw_t)
|
||
if f and t and f > t:
|
||
f, t = t, f
|
||
if required and (not f or not t):
|
||
raise BadParam("需要同时提供 from 与 to(YYYY-MM-DD)")
|
||
return f, t
|
||
|
||
|
||
def _int(name, default, lo=1, hi=2000):
|
||
try:
|
||
return max(lo, min(hi, int(request.args.get(name, default))))
|
||
except (TypeError, ValueError):
|
||
return default
|
||
|
||
|
||
@bp.get("/manifest")
|
||
@login_required
|
||
def api_manifest():
|
||
return jsonify(query.manifest(db.get_db()))
|
||
|
||
|
||
@bp.get("/bundle")
|
||
@login_required
|
||
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)))
|
||
|
||
|
||
@bp.get("/summary")
|
||
@login_required
|
||
def api_summary():
|
||
conn = db.get_db()
|
||
frm, to = _win()
|
||
if not frm or not to:
|
||
t = query.totals(conn)
|
||
frm, to = t["firstDay"], t["lastDay"]
|
||
return jsonify(query.summary(conn, frm, to))
|
||
|
||
|
||
@bp.get("/daily")
|
||
@login_required
|
||
def api_daily():
|
||
return jsonify({"days": query.daily(db.get_db(), *_win())})
|
||
|
||
|
||
@bp.get("/dims")
|
||
@login_required
|
||
def api_dims():
|
||
conn = db.get_db()
|
||
d = query.dims(conn, *_win())
|
||
dim = _arg("dim")
|
||
if dim in d:
|
||
return jsonify({dim: d[dim]})
|
||
return jsonify(d)
|
||
|
||
|
||
@bp.get("/top")
|
||
@login_required
|
||
def api_top():
|
||
conn = db.get_db()
|
||
return jsonify(query.top(conn, *_win(), n=_int("n", 50, 1, 1000)))
|
||
|
||
|
||
@bp.get("/records")
|
||
@login_required
|
||
def api_records():
|
||
conn = db.get_db()
|
||
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"),
|
||
q=_arg("q"), page=page, size=size, order=_arg("order", "ts_desc"),
|
||
with_prompt=False if _arg("lean") == "1" else True)
|
||
return jsonify(r)
|
||
|
||
|
||
@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()
|
||
if row is None:
|
||
return jsonify({"ok": False, "message": "记录不存在"}), 404
|
||
return jsonify(dict(row))
|
||
|
||
|
||
@bp.get("/runs")
|
||
@login_required
|
||
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),))
|
||
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()
|
||
if row is None:
|
||
return jsonify({"ok": False, "message": "运行记录不存在"}), 404
|
||
return jsonify(dict(row))
|
||
|
||
|
||
@bp.get("/status")
|
||
@login_required
|
||
def api_status():
|
||
conn = db.get_db()
|
||
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]
|
||
return jsonify({
|
||
"server_time": db.now_str(),
|
||
"scheduler": {
|
||
"running": sch.running,
|
||
"enabled": db.get_bool(conn, "schedule_enabled", True),
|
||
"times": scheduler.slots(conn),
|
||
"next_run": nxt.strftime("%Y-%m-%d %H:%M:%S") if nxt else None,
|
||
"catch_up": db.get_bool(conn, "catch_up", True),
|
||
"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()),
|
||
})
|
||
|
||
|
||
@bp.post("/collect")
|
||
@login_required
|
||
def api_collect():
|
||
"""手动触发一次采集(后台线程之外同步执行,页面等待结果)。"""
|
||
body = request.get_json(silent=True) or {}
|
||
if not isinstance(body, dict):
|
||
return jsonify({"ok": False, "message": "请求体必须是对象"}), 400
|
||
frm, to = body.get("from"), body.get("to")
|
||
try:
|
||
kw = {}
|
||
if frm:
|
||
d = query.norm_day(frm)
|
||
if not d:
|
||
raise BadParam("起始日期不合法:%s(正确写法 2026-09-08)" % frm)
|
||
kw["from_dt"] = datetime.strptime(d, "%Y-%m-%d")
|
||
if to:
|
||
d = query.norm_day(to)
|
||
if not d:
|
||
raise BadParam("结束日期不合法:%s(正确写法 2026-09-08)" % to)
|
||
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)
|
||
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.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)
|
||
return jsonify({"ok": True, "result": r})
|
||
|
||
|
||
@bp.get("/audit")
|
||
@login_required
|
||
def api_audit():
|
||
"""操作审计分页(日志管理页用;原来只能看最近 40 条)。"""
|
||
conn = db.get_db()
|
||
action = _arg("action")
|
||
page = _int("page", 1, 1, 100000)
|
||
size = _int("size", 50, 1, 500)
|
||
w, p = "", []
|
||
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,
|
||
p + [size, (page - 1) * size])
|
||
actions = [r[0] for r in conn.execute(
|
||
"SELECT DISTINCT action FROM audit_log ORDER BY action")]
|
||
return jsonify({"total": total, "page": page, "size": size,
|
||
"pages": max(1, (total + size - 1) // size),
|
||
"actions": actions,
|
||
"items": [dict(r) for r in rows]})
|
||
|
||
|
||
# ---------------- 维护动作(原来只有 CLI 能做) ----------------
|
||
@bp.post("/maintenance/<action>")
|
||
@login_required
|
||
def api_maintenance(action):
|
||
"""把 CLI 里的维护动作搬到页面上:补全 prompt / VACUUM / 导出 CSV。"""
|
||
conn = db.get_db()
|
||
user = (current_user() or {}).get("username")
|
||
try:
|
||
if action == "fill-prompt":
|
||
try:
|
||
n = collect.fill_prompt(conn, log=lambda m: None)
|
||
except collect.ApiError as e:
|
||
return jsonify({"ok": False, "error": "api", "message": str(e)}), 502
|
||
msg = "补全 %d 条 User Prompt" % n
|
||
elif action == "vacuum":
|
||
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")
|
||
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)
|
||
msg = "已导出 %d 条到 %s" % (n, os.path.relpath(path, config.BASE_DIR))
|
||
elif action == "recount":
|
||
n = collect.record_count(conn)
|
||
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)
|
||
return jsonify({"ok": True, "message": msg})
|
||
|
||
|
||
def _human(n):
|
||
for unit in ("B", "KB", "MB", "GB"):
|
||
if n < 1024 or unit == "GB":
|
||
return "%.1f %s" % (n, unit) if unit != "B" else "%d B" % n
|
||
n /= 1024.0
|
||
|
||
|
||
@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) # 不回传明文凭证
|
||
# 内部簿记键(slot:09:00 这类调度槽位标记)不属于配置项,绝不外泄
|
||
for k in [k for k in list(s) if config.is_internal_key(k)]:
|
||
s.pop(k, None)
|
||
return jsonify(s)
|
||
|
||
|
||
@bp.post("/settings")
|
||
@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 = [], [], []
|
||
for k, v in body.items():
|
||
if config.is_internal_key(k):
|
||
ignored.append(k)
|
||
continue # slot:* 是调度簿记,不允许前台写
|
||
if k == "cookie":
|
||
if not str(v).strip():
|
||
continue # 空值不动,避免误清
|
||
if str(v).strip().lower() in ("__clear__", "-"):
|
||
db.set_setting(conn, "cookie", "")
|
||
changed.append(k)
|
||
continue
|
||
val, err = config.normalize_setting(k, v)
|
||
if err:
|
||
errors.append(err)
|
||
continue
|
||
db.set_setting(conn, k, val)
|
||
changed.append(k)
|
||
if errors:
|
||
db.audit(conn, "settings_rejected", (current_user() or {}).get("username"),
|
||
";".join(errors)[:500], request.remote_addr)
|
||
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)
|
||
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()
|
||
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 ""):
|
||
return jsonify({"ok": False, "message": "原密码不正确"}), 400
|
||
new = (body.get("new") or "").strip()
|
||
err = _check_password(new, body.get("new2"))
|
||
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)
|
||
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.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]})
|
||
|
||
|
||
@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 {}
|
||
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"))
|
||
if err:
|
||
return jsonify({"ok": False, "message": err}), 400
|
||
exist = conn.execute("SELECT id FROM users WHERE username=?", (name,)).fetchone()
|
||
if exist:
|
||
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})
|
||
|
||
|
||
@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()
|
||
if row is None:
|
||
return jsonify({"ok": False, "message": "用户不存在"}), 404
|
||
body = request.get_json(silent=True) or {}
|
||
me = current_user()
|
||
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 "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
|
||
conn.execute("UPDATE users SET is_admin=? WHERE id=?", (v, uid))
|
||
changed.append("管理员")
|
||
pwd = (body.get("password") or "").strip()
|
||
if pwd:
|
||
err = _check_password(pwd, body.get("password2"))
|
||
if err:
|
||
return jsonify({"ok": False, "message": err}), 400
|
||
conn.execute("UPDATE users SET password_hash=? WHERE id=?", (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)
|
||
return jsonify({"ok": True, "message": "已更新:" + "、".join(changed)})
|
||
|
||
|
||
@bp.post("/users/<int:uid>/delete")
|
||
@admin_required
|
||
def api_user_delete(uid):
|
||
conn = db.get_db()
|
||
me = current_user()
|
||
row = conn.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone()
|
||
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:
|
||
return jsonify({"ok": False, "message": "至少要保留一个账号"}), 400
|
||
conn.execute("DELETE FROM users WHERE id=?", (uid,))
|
||
db.audit(conn, "user_delete", me["username"], "删除用户 " + row["username"],
|
||
request.remote_addr)
|
||
return jsonify({"ok": True, "message": "已删除 " + row["username"]})
|