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 同步
这个提交包含在:
+302
-37
@@ -2,7 +2,7 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
# Copyright (c) 2026 Wang Chuanli
|
||||
|
||||
"""SQLite 访问层。
|
||||
"""SQLite 访问层(多用户版)。
|
||||
|
||||
并发约定(重要):
|
||||
* WAL 模式 —— 采集写入期间页面查询不会被 `database is locked` 挡住
|
||||
@@ -10,18 +10,39 @@
|
||||
(由 scheduler / CLI 共享的 collect.lock 保证)
|
||||
* busy_timeout=8s —— 偶发并发时等待而不是立刻报错
|
||||
* 每个线程独立连接(sqlite3 默认禁止跨线程复用连接)
|
||||
|
||||
多用户约定(改代码前务必先读):
|
||||
* `user_id = 0` 在 settings / collect_runs / audit_log 里表示**实例级**;
|
||||
usage_records 里 0 是「历史遗留数据尚未归属」的兜底值,正常不会出现。
|
||||
* `get_settings()` 会把 `ENCRYPTED_KEYS`(Cookie)**一律置空**;
|
||||
要拿明文只有 `get_secret()` 一条路。这样任何「顺手打印一下全部配置」
|
||||
的代码都不可能把凭证带出去。
|
||||
* `NO_FALLBACK_KEYS`(Cookie / User-Agent)**不参与实例级回退**:
|
||||
Cookie 是账号凭证,回落等于串号,是最严重的一类越权。
|
||||
"""
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
from datetime import datetime
|
||||
|
||||
from . import config
|
||||
from . import config, crypto
|
||||
|
||||
_local = threading.local()
|
||||
_init_lock = threading.Lock()
|
||||
_initialized = False
|
||||
|
||||
# 库结构版本。写在 PRAGMA user_version 里,用来判断是否需要迁移。
|
||||
# 1 -> 单用户布局(settings 以 key 为主键,usage_records 以 request_id 为主键)
|
||||
# 2 -> 多用户布局(见 schema.sql 顶部说明)
|
||||
DB_SCHEMA_VERSION = 2
|
||||
|
||||
# 这些键即使个人作用域没有值,也**不**回落到实例级
|
||||
NO_FALLBACK_KEYS = {"cookie", "user_agent"}
|
||||
|
||||
|
||||
class SecretUnreadable(Exception):
|
||||
"""密文解不开 —— 通常是 data/instance.json 里的 cookie_key 被换过。"""
|
||||
|
||||
|
||||
def now_str():
|
||||
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
@@ -58,87 +79,331 @@ def close_thread_conn():
|
||||
_local.conn = None
|
||||
|
||||
|
||||
# ---------------- 初始化 ----------------
|
||||
def _schema_sql():
|
||||
with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "schema.sql"),
|
||||
"r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
# ---------------- 初始化 / 迁移 ----------------
|
||||
def _table_cols(conn, table):
|
||||
return {r["name"] for r in conn.execute("PRAGMA table_info(%s)" % table)}
|
||||
|
||||
|
||||
def _has_table(conn, name):
|
||||
return bool(conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (name,)).fetchone())
|
||||
|
||||
|
||||
def _first_owner_uid(conn):
|
||||
"""历史数据归谁:优先第一个管理员,其次第一个账号。"""
|
||||
row = conn.execute("SELECT id FROM users WHERE is_admin=1 ORDER BY id LIMIT 1").fetchone()
|
||||
if row:
|
||||
return row["id"]
|
||||
row = conn.execute("SELECT id FROM users ORDER BY id LIMIT 1").fetchone()
|
||||
return row["id"] if row else 0
|
||||
|
||||
|
||||
def _drop_all_user_indexes(conn):
|
||||
"""删掉本项目自建的全部索引(idx_*)。
|
||||
|
||||
必须先删:`ALTER TABLE ... RENAME TO` 会**把索引一起带走**(名字仍指向
|
||||
改名后的表),于是后面 `CREATE INDEX IF NOT EXISTS` 会被当成「已存在」
|
||||
静默跳过,最终新表上一个索引都没有 —— 表面完全正常,只是慢几百倍。
|
||||
`sqlite_autoindex_*` 是主键/唯一约束的隐式索引,不能动,靠前缀过滤掉。
|
||||
"""
|
||||
names = [r["name"] for r in conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'idx_%'")]
|
||||
for n in names:
|
||||
conn.execute('DROP INDEX IF EXISTS "%s"' % n)
|
||||
return names
|
||||
|
||||
|
||||
def _migrate(conn):
|
||||
"""把老库升到 DB_SCHEMA_VERSION。幂等,返回迁移说明列表。
|
||||
|
||||
顺序不能变:
|
||||
1. 删索引(否则改名会把索引名占住,新表建不出索引)
|
||||
2. 改名主键变了的表(settings / usage_records)
|
||||
3. ALTER 加列(collect_runs / audit_log / users)
|
||||
4. 跑 schema.sql —— 此时所有列都齐了,表与索引一次建全
|
||||
5. 回填数据、删掉 _v1_ 旧表
|
||||
"""
|
||||
ver = conn.execute("PRAGMA user_version").fetchone()[0]
|
||||
if ver >= DB_SCHEMA_VERSION:
|
||||
return []
|
||||
done = []
|
||||
ts = now_str()
|
||||
owner = _first_owner_uid(conn)
|
||||
|
||||
tset = _table_cols(conn, "settings")
|
||||
tur = _table_cols(conn, "usage_records")
|
||||
rebuild_settings = "user_id" not in tset
|
||||
rebuild_records = "user_id" not in tur
|
||||
|
||||
_drop_all_user_indexes(conn)
|
||||
if rebuild_settings:
|
||||
conn.execute("ALTER TABLE settings RENAME TO _v1_settings")
|
||||
if rebuild_records:
|
||||
conn.execute("ALTER TABLE usage_records RENAME TO _v1_usage_records")
|
||||
|
||||
# ---- 只加列的表用 ALTER,代价小得多。放在建表之前,好让 schema.sql
|
||||
# 里的 CREATE INDEX 一次就成功(索引引用了这些新列)----
|
||||
if "user_id" not in _table_cols(conn, "collect_runs"):
|
||||
conn.execute("ALTER TABLE collect_runs ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0")
|
||||
conn.execute("UPDATE collect_runs SET user_id=?", (owner,))
|
||||
done.append("collect_runs 增加 user_id")
|
||||
if "user_id" not in _table_cols(conn, "audit_log"):
|
||||
conn.execute("ALTER TABLE audit_log ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0")
|
||||
done.append("audit_log 增加 user_id")
|
||||
tusers = _table_cols(conn, "users")
|
||||
for col, ddl in (("email", "TEXT"),
|
||||
("status", "TEXT NOT NULL DEFAULT 'active'"),
|
||||
("register_ip", "TEXT"),
|
||||
("last_login_ip", "TEXT")):
|
||||
if col not in tusers:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN %s %s" % (col, ddl))
|
||||
done.append("users 增加 %s" % col)
|
||||
conn.execute("UPDATE users SET status='active' WHERE status IS NULL OR status=''")
|
||||
|
||||
conn.executescript(_schema_sql()) # 建出新表 + 全部索引
|
||||
|
||||
if rebuild_settings:
|
||||
# 老布局里 cookie / user_agent 是实例级的 —— 留在实例级等于
|
||||
# 「所有人共用管理员的凭证」,必须归到 owner 名下,且之后永不回落。
|
||||
conn.execute("INSERT INTO settings(user_id,key,value,updated_at)"
|
||||
" SELECT 0,key,value,updated_at FROM _v1_settings"
|
||||
" WHERE key NOT IN ('cookie','user_agent')")
|
||||
conn.execute("INSERT INTO settings(user_id,key,value,updated_at)"
|
||||
" SELECT ?,key,value,updated_at FROM _v1_settings"
|
||||
" WHERE key IN ('cookie','user_agent') AND value IS NOT NULL AND value <> ''",
|
||||
(owner,))
|
||||
conn.execute("DROP TABLE _v1_settings")
|
||||
done.append("settings 改为 (user_id,key) 复合主键:旧值归实例级,"
|
||||
"Cookie/UA 已归属账号 #%d" % owner)
|
||||
|
||||
if rebuild_records:
|
||||
conn.execute("INSERT INTO usage_records(user_id,request_id,ts,day,hour,model,client,"
|
||||
"credits,prompt,first_seen,last_seen,cloud_ts)"
|
||||
" SELECT ?,request_id,ts,day,hour,model,client,credits,prompt,"
|
||||
"first_seen,last_seen,cloud_ts FROM _v1_usage_records", (owner,))
|
||||
conn.execute("DROP TABLE _v1_usage_records")
|
||||
done.append("usage_records 增加 user_id,主键改为 (user_id, request_id),"
|
||||
"历史数据归属账号 #%d" % owner)
|
||||
|
||||
conn.execute("UPDATE users SET is_admin=1 WHERE id=?", (owner,))
|
||||
conn.execute("PRAGMA user_version=%d" % DB_SCHEMA_VERSION)
|
||||
if done:
|
||||
conn.execute("INSERT INTO audit_log(user_id,at,actor,action,detail,ip)"
|
||||
" VALUES(0,?,?,?,?,?)",
|
||||
(ts, "system", "schema_migrate", ";".join(done)[:500], "127.0.0.1"))
|
||||
return done
|
||||
|
||||
|
||||
def _encrypt_legacy_secrets(conn):
|
||||
"""把历史**明文**凭证就地加密。幂等,可反复执行。
|
||||
|
||||
老版本把 Cookie 直接明文写进 settings 表。升级后即便功能正常,
|
||||
「库里躺着一段明文凭证」本身就是风险:备份文件、磁盘镜像、
|
||||
误提交、排障时的一次 dump 都会把它带出去。
|
||||
这里对 `config.ENCRYPTED_KEYS` 里所有**非 v1. 前缀**的值加密一次;
|
||||
已加密的值会因前缀判定被跳过,所以每次启动跑一遍是安全的。
|
||||
"""
|
||||
if not config.ENCRYPTED_KEYS:
|
||||
return []
|
||||
key = config.encryption_key()
|
||||
done = []
|
||||
for r in conn.execute("SELECT user_id,key,value FROM settings").fetchall():
|
||||
if r["key"] not in config.ENCRYPTED_KEYS:
|
||||
continue
|
||||
raw = r["value"]
|
||||
if not raw or crypto.is_encrypted(raw):
|
||||
continue
|
||||
conn.execute("UPDATE settings SET value=?,updated_at=? WHERE user_id=? AND key=?",
|
||||
(crypto.encrypt(raw, key), now_str(), r["user_id"], r["key"]))
|
||||
done.append("settings[uid=%s].%s" % (r["user_id"], r["key"]))
|
||||
return done
|
||||
|
||||
|
||||
def init_db(conn=None, create_admin=True, admin_user="admin", admin_password=None):
|
||||
"""建表 + 灌默认配置。可重复执行(幂等)。"""
|
||||
"""建表 / 迁移 / 灌默认配置。可重复执行(幂等)。返回迁移说明列表。"""
|
||||
global _initialized
|
||||
own = conn is None
|
||||
conn = conn or connect()
|
||||
try:
|
||||
with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "schema.sql"),
|
||||
"r", encoding="utf-8") as f:
|
||||
conn.executescript(f.read())
|
||||
# 默认配置(不覆盖已有值)
|
||||
if not _has_table(conn, "users"):
|
||||
# 全新库:schema.sql 一次到位(避免走迁移路径去 ALTER 不存在的表)
|
||||
conn.executescript(_schema_sql())
|
||||
conn.execute("PRAGMA user_version=%d" % DB_SCHEMA_VERSION)
|
||||
migrated = []
|
||||
else:
|
||||
migrated = _migrate(conn)
|
||||
# 无论如何再跑一次:幂等补齐(例如后续版本新增了表/索引,
|
||||
# 而老库的 user_version 已经是最新,就不会走 _migrate 了)
|
||||
conn.executescript(_schema_sql())
|
||||
ts = now_str()
|
||||
# 默认配置灌在**实例级**(user_id=0)。个人作用域不预置行,
|
||||
# 读取时按「个人 -> 实例 -> DEFAULTS」三级回落,语义更清楚。
|
||||
for k, v in config.DEFAULTS.items():
|
||||
conn.execute("INSERT OR IGNORE INTO settings(key,value,updated_at) VALUES(?,?,?)",
|
||||
(k, v, ts))
|
||||
conn.execute("INSERT OR IGNORE INTO settings(user_id,key,value,updated_at)"
|
||||
" VALUES(0,?,?,?)", (k, v, ts))
|
||||
# 顺手把历史明文凭证加密(幂等;新库无事可做)
|
||||
enc = _encrypt_legacy_secrets(conn)
|
||||
if enc:
|
||||
migrated.append("明文凭证已加密:%s" % ", ".join(enc))
|
||||
conn.execute("INSERT INTO audit_log(user_id,at,actor,action,detail,ip)"
|
||||
" VALUES(0,?,?,?,?,?)",
|
||||
(ts, "system", "encrypt_secrets",
|
||||
"明文凭证已加密:%s" % ", ".join(enc)[:400], "127.0.0.1"))
|
||||
if create_admin:
|
||||
n = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||
if n == 0:
|
||||
from .security import hash_password
|
||||
pwd = admin_password or "admin123"
|
||||
conn.execute(
|
||||
"INSERT INTO users(username,password_hash,display_name,is_admin,created_at)"
|
||||
" VALUES(?,?,?,1,?)", (admin_user, hash_password(pwd), "管理员", ts))
|
||||
"INSERT INTO users(username,password_hash,display_name,is_admin,status,"
|
||||
" created_at) VALUES(?,?,?,1,'active',?)",
|
||||
(admin_user, hash_password(pwd), "管理员", ts))
|
||||
_initialized = True
|
||||
return migrated
|
||||
finally:
|
||||
if own:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------- 配置读写 ----------------
|
||||
def get_setting(conn, key, default=None):
|
||||
row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
|
||||
if row is None or row["value"] is None:
|
||||
return config.DEFAULTS.get(key, default)
|
||||
return row["value"]
|
||||
# ---------------- 配置读写(按作用域) ----------------
|
||||
def get_setting(conn, key, default=None, uid=0):
|
||||
"""取单个配置。
|
||||
|
||||
**加密键一律返回空串**:想拿 Cookie 明文只能用 get_secret(),
|
||||
避免任何「顺手读一下配置」的代码把凭证带进日志或响应体。
|
||||
"""
|
||||
if key in config.ENCRYPTED_KEYS:
|
||||
return ""
|
||||
uid = 0 if config.is_global_key(key) else (uid or 0)
|
||||
row = conn.execute("SELECT value FROM settings WHERE user_id=? AND key=?",
|
||||
(uid, key)).fetchone()
|
||||
if row is not None and row["value"] is not None:
|
||||
return row["value"]
|
||||
if uid and key not in NO_FALLBACK_KEYS:
|
||||
row = conn.execute("SELECT value FROM settings WHERE user_id=0 AND key=?",
|
||||
(key,)).fetchone()
|
||||
if row is not None and row["value"] is not None:
|
||||
return row["value"]
|
||||
return config.DEFAULTS.get(key, default)
|
||||
|
||||
|
||||
def get_settings(conn, keys=None):
|
||||
rows = conn.execute("SELECT key,value FROM settings").fetchall()
|
||||
got = {r["key"]: r["value"] for r in rows}
|
||||
def get_settings(conn, keys=None, uid=0):
|
||||
"""取该账号的**有效配置**(DEFAULTS -> 实例级 -> 个人级 三级合并)。
|
||||
|
||||
Cookie 等加密键固定为空串,页面/接口可以直接整体回传。
|
||||
"""
|
||||
out = dict(config.DEFAULTS)
|
||||
out.update(got)
|
||||
for r in conn.execute("SELECT key,value FROM settings WHERE user_id=0"):
|
||||
out[r["key"]] = r["value"]
|
||||
if uid:
|
||||
for r in conn.execute("SELECT key,value FROM settings WHERE user_id=?", (uid,)):
|
||||
if config.is_global_key(r["key"]):
|
||||
continue # 个人作用域里不该有全局键,有也不认
|
||||
out[r["key"]] = r["value"]
|
||||
for k in config.ENCRYPTED_KEYS:
|
||||
out[k] = ""
|
||||
if keys:
|
||||
return {k: out.get(k) for k in keys}
|
||||
return out
|
||||
|
||||
|
||||
def set_setting(conn, key, value):
|
||||
conn.execute("INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at",
|
||||
(key, "" if value is None else str(value), now_str()))
|
||||
def set_setting(conn, key, value, uid=0):
|
||||
"""写单个配置。全局键强制落到 user_id=0;加密键自动加密后落库。"""
|
||||
uid = 0 if config.is_global_key(key) else (uid or 0)
|
||||
text = "" if value is None else str(value)
|
||||
if key in config.ENCRYPTED_KEYS and text:
|
||||
text = crypto.encrypt(text, config.encryption_key())
|
||||
conn.execute("INSERT INTO settings(user_id,key,value,updated_at) VALUES(?,?,?,?) "
|
||||
"ON CONFLICT(user_id,key) DO UPDATE SET value=excluded.value,"
|
||||
" updated_at=excluded.updated_at", (uid, key, text, now_str()))
|
||||
|
||||
|
||||
def set_settings(conn, pairs):
|
||||
def set_settings(conn, pairs, uid=0):
|
||||
for k, v in pairs.items():
|
||||
set_setting(conn, k, v)
|
||||
set_setting(conn, k, v, uid)
|
||||
|
||||
|
||||
def get_int(conn, key, default=0):
|
||||
def get_int(conn, key, default=0, uid=0):
|
||||
try:
|
||||
return int(float(get_setting(conn, key, default)))
|
||||
return int(float(get_setting(conn, key, default, uid)))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def get_float(conn, key, default=0.0):
|
||||
def get_float(conn, key, default=0.0, uid=0):
|
||||
try:
|
||||
return float(get_setting(conn, key, default))
|
||||
return float(get_setting(conn, key, default, uid))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def get_bool(conn, key, default=False):
|
||||
v = str(get_setting(conn, key, "1" if default else "0")).strip().lower()
|
||||
def get_bool(conn, key, default=False, uid=0):
|
||||
v = str(get_setting(conn, key, "1" if default else "0", uid)).strip().lower()
|
||||
return v in ("1", "true", "yes", "on", "是")
|
||||
|
||||
|
||||
# ---------------- 凭证(加密存储) ----------------
|
||||
def get_secret(conn, key, uid=0):
|
||||
"""取凭证明文。仅在真正要用它对外发请求时调用。"""
|
||||
row = conn.execute("SELECT value FROM settings WHERE user_id=? AND key=?",
|
||||
(uid or 0, key)).fetchone()
|
||||
if row is None or not row["value"]:
|
||||
return ""
|
||||
try:
|
||||
return crypto.decrypt(row["value"], config.encryption_key())
|
||||
except crypto.DecryptError as e:
|
||||
raise SecretUnreadable("%s 无法解密:%s" % (key, e))
|
||||
|
||||
|
||||
def set_secret(conn, key, value, uid=0):
|
||||
set_setting(conn, key, value, uid)
|
||||
|
||||
|
||||
def secret_state(conn, key, uid=0):
|
||||
"""给界面用的凭证状态:只回「有没有 / 多少字符 / 尾部 4 位」,绝不含明文。"""
|
||||
row = conn.execute("SELECT value FROM settings WHERE user_id=? AND key=?",
|
||||
(uid or 0, key)).fetchone()
|
||||
if row is None or not row["value"]:
|
||||
return {"set": False, "chars": 0, "tail": "", "broken": False, "at": ""}
|
||||
try:
|
||||
plain = crypto.decrypt(row["value"], config.encryption_key())
|
||||
except crypto.DecryptError:
|
||||
return {"set": True, "chars": 0, "tail": "", "broken": True, "at": ""}
|
||||
at = conn.execute("SELECT updated_at FROM settings WHERE user_id=? AND key=?",
|
||||
(uid or 0, key)).fetchone()
|
||||
return {"set": bool(plain), "chars": len(plain),
|
||||
"tail": plain[-4:] if len(plain) >= 4 else "",
|
||||
"broken": False, "at": (at["updated_at"] if at else "") or ""}
|
||||
|
||||
|
||||
# ---------------- 账号 ----------------
|
||||
def user_by_id(conn, uid):
|
||||
return conn.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone()
|
||||
|
||||
|
||||
def user_by_name(conn, username):
|
||||
return conn.execute("SELECT * FROM users WHERE username=?", (username,)).fetchone()
|
||||
|
||||
|
||||
def active_users(conn):
|
||||
"""启用状态的账号(调度器按人遍历)。"""
|
||||
return conn.execute("SELECT * FROM users WHERE status='active' ORDER BY id").fetchall()
|
||||
|
||||
|
||||
def user_count(conn):
|
||||
return conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||
|
||||
|
||||
# ---------------- 审计 ----------------
|
||||
def audit(conn, action, actor=None, detail=None, ip=None):
|
||||
conn.execute("INSERT INTO audit_log(at,actor,action,detail,ip) VALUES(?,?,?,?,?)",
|
||||
(now_str(), actor, action, detail, ip))
|
||||
def audit(conn, action, actor=None, detail=None, ip=None, uid=0):
|
||||
conn.execute("INSERT INTO audit_log(user_id,at,actor,action,detail,ip)"
|
||||
" VALUES(?,?,?,?,?,?)", (uid or 0, now_str(), actor, action, detail, ip))
|
||||
|
||||
|
||||
# ---------------- Flask 集成 ----------------
|
||||
|
||||
在新工单中引用
屏蔽一个用户