文件
workbuddy-portal/workbuddy_portal/backup.py
T
wangchuanli f36149efc3 feat(安全): 对外暴露面加固 + 界面去 AI 化(v1.5.0)
界面(去 AI 味):
- 大屏页清除 114 处生成器残留属性 data-page-node-id
- 视觉系统改回工程控制台风格:去 radial/linear-gradient、去辉光、
  去标题前彩色装饰条,改为中性灰阶 + 单一蓝色强调色;KPI 色条改状态点
- 精简各页说教式长提示;修掉 profile.html 泄漏到页面上的 Markdown 星号
- 删除登录页过时的「默认账号 admin / admin123」提示(1.4.0 起已无默认口令)

安全与隐私(按「将会被公网访问」收口):
- 内部异常只回 8 位事件号,完整堆栈进服务端日志(web/api.py::_internal)
- 导出文件名收敛:防响应头注入与路径穿越;manage.py passwd 补用户名校验
- 登录对不存在的账号也走一次哑哈希,抹平用户名枚举的时序差异
- /api/* 读接口限速 240 次 / 60 秒 / 账号(挡住循环调 /api/bundle)
- 进程 umask 0077 + 目录 0700 / 文件 0600:对话正文与主密钥的落盘权限
- 表名与库文件路径只对管理员下发;大屏页所有数据插值转义
- --debug 只允许绑定回环地址;新增 Permissions-Policy 与 413 处理器

文档:
- DEPLOYMENT 新增第十三节「安全与隐私基线」;迁移表补 1.4.0 → 1.5.0 行
- SECURITY 更新支持范围、新增「信息泄漏收敛」小节与上线检查项
- .codebuddy/ 加入 .gitignore(助手工作记忆不进仓库)

版本:1.4.0 → 1.5.0(无库结构变更,user_version 仍为 4)
验证:python tools/smoke.py → ok=264 fail=0;python tools/check_docs.py → 0 处问题
2026-09-18 11:13:17 +08:00

641 行
27 KiB
Python
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
# -*- coding: utf-8 -*-
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Wang Chuanli
"""备份管理:一致性快照、自动周期、保留份数、下载与恢复。
为什么需要它
------------
原来只有「手工把 data/usage.sqlite 拷一份」这一条路,问题有三个:
* 直接 cp 一个 WAL 库拿到的**不是**一致快照(-wal 里可能还有没落盘的帧)
* 备份躺在数据卷里,`docker compose down -v` 会把正本与副本一起删掉
* 恢复没有任何护栏:覆盖上去就完了,恢复错了也没有退路
本模块的三条设计决定
--------------------
1. **快照用 SQLite 在线备份 API**(`Connection.backup`),不是文件拷贝。
它按页复制并在复制期间持有读事务,所以采集正在写的时候拿到的也是
一个「某一时刻的完整库」。手工 cp 做不到这一点。
2. **归档是一个 zip**,内含所有 `*.sqlite` + `manifest.json`(+ 可选
`instance.json`)。好处是单文件下载、可校验、可跨机器搬到别处恢复;
而 `instance.json` 在里面是必要的 —— 没有 cookie_key 就解不开
settings 里的凭证密文,那样的「恢复」等于把所有人的 Cookie 弄丢。
代价是归档本身含密钥,所以它**永不入库、永不进镜像**(见 .gitignore /
.dockerignore 与 docs/DEPLOYMENT.md)。
3. **恢复走 SQL 级替换,不做文件 swap**。把归档解出来建一个临时库、先迁移
到当前 schema,然后在**一个写事务**里整表搬过去。这样:
* 不需要停机、不需要保证没有别的连接持有文件句柄(Windows 上文件
swap 会因句柄占用直接失败);
* 备份是老版本(user_version=2)也能恢复,迁移在临时库里先做完;
* 中途失败就是一个事务回滚,不会留下半个库。
恢复前的护栏:先给**当前**库自动打一份 `pre-restore` 快照。恢复错了还能回去。
恢复后还会做一件事:把所有账号的 `session_ver` 都 +1,于是**所有既有登录会话
立即失效**。理由见 `restore()` 里的注释 —— 归档里的 sv 可能与旧 Cookie 恰好
相等,那样会话会带着「一整套已被替换掉的账号与权限」继续用下去。
"""
import hashlib
import json
import logging
import os
import shutil
import sqlite3
import tempfile
import zipfile
from datetime import datetime, timedelta
from . import collect, config, db
log = logging.getLogger("wb.backup")
SUFFIX = ".zip"
MANIFEST = "manifest.json"
INSTANCE_NAME = "instance.json"
FORMAT_VERSION = 1
# 恢复时整表搬运的表清单。
# 刻意**不含 backups 自己**:它记的是「本机备份目录里有什么」,
# 属于当前实例的运行索引,拿旧库里的那份覆盖会凭空丢掉期间新增的条目
# (而按需重建索引是幂等的,见 sync_index)。
RESTORE_TABLES = ("users", "settings", "usage_records",
"collect_runs", "audit_log", "captchas")
class BackupError(Exception):
"""备份/恢复的业务性失败(归档损坏、文件缺失、跨度不符等)。"""
# ---------------- 路径与文件名 ----------------
def backup_dir():
config.ensure_dirs()
return config.BACKUP_DIR
def safe_name(name):
"""把用户传来的文件名收敛成「备份目录下的一个 zip」。
必须防住 `../../etc/passwd`、绝对路径、`sub/..` 这类穿越写法 ——
下载与恢复接口都直接吃文件名,这里是唯一的收口点。
"""
base = os.path.basename(str(name or "").strip().replace("\\", "/"))
if not base or base in (".", "..") or not base.endswith(SUFFIX):
raise BackupError("备份文件名不合法")
if any(c not in "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ._-"
for c in base):
raise BackupError("备份文件名含非法字符")
return base
def path_of(name):
return os.path.join(backup_dir(), safe_name(name))
def human(n):
n = float(n or 0)
for unit in ("B", "KB", "MB", "GB"):
if n < 1024 or unit == "GB":
return ("%d B" % n) if unit == "B" else ("%.1f %s" % (n, unit))
n /= 1024.0
def _app_version():
from . import __version__ # 延迟导入,避开包初始化顺序
return __version__
# ---------------- 元数据 ----------------
def _db_files():
"""数据目录下所有 SQLite 库(不含 -wal / -shm 侧车)。"""
out = []
d = config.DATA_DIR
if not os.path.isdir(d):
return out
for fn in sorted(os.listdir(d)):
if not fn.endswith(".sqlite"):
continue
p = os.path.join(d, fn)
if os.path.isfile(p):
out.append(p)
return out
def _sha256(path):
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def _snapshot(src_path, dst_path):
"""用在线备份 API 生成一致快照(src 有并发写也安全)。"""
src = sqlite3.connect(src_path, timeout=8.0)
try:
dst = sqlite3.connect(dst_path)
try:
src.backup(dst)
finally:
dst.close()
finally:
src.close()
def _stats():
"""归档时的关键计数,用于「挑一份恢复」与事后核对。"""
p = config.SQLITE_PATH
if not os.path.exists(p):
return {"records": 0, "credits": 0.0, "users": 0, "schema_ver": 0}
conn = sqlite3.connect("file:%s?mode=ro" % p.replace("\\", "/"), uri=True)
try:
n, cr = conn.execute("SELECT COUNT(*), COALESCE(SUM(credits),0)"
" FROM usage_records").fetchone()
u = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
uv = conn.execute("PRAGMA user_version").fetchone()[0]
return {"records": n, "credits": round(cr or 0, 2), "users": u, "schema_ver": uv}
finally:
conn.close()
def _extract_safely(zf, dest, names):
"""只按**基名**解压到 dest —— 归档可能是别人给的,挡掉 zip slip。
`ZipFile.extractall` 会因为条目名里的 `..` / 绝对路径写到目录之外,
而恢复接口正好是「吃一个外部文件」的入口,必须在这一层挡住。
"""
for n in names:
base = os.path.basename(n.replace("\\", "/"))
if not base or base in (".", ".."):
continue
with zf.open(n) as fsrc, open(os.path.join(dest, base), "wb") as fdst:
shutil.copyfileobj(fsrc, fdst)
# ---------------- 生成备份 ----------------
def create(conn, trigger="manual", actor=None, note=""):
"""打一份新备份,返回结果 dict。失败抛 BackupError。"""
dbs = _db_files()
if not dbs:
raise BackupError("数据目录里没有找到任何 *.sqlite,没什么可备份的")
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
name = "usage-%s%s" % (stamp, SUFFIX)
dest = os.path.join(backup_dir(), name)
seq = 1
while os.path.exists(dest): # 同一秒内连打两次也不能互相覆盖
name = "usage-%s-%d%s" % (stamp, seq, SUFFIX)
dest = os.path.join(backup_dir(), name)
seq += 1
st = _stats()
tmpdir = tempfile.mkdtemp(prefix="wb-backup-")
files_meta = []
try:
for i, src in enumerate(dbs):
fn = os.path.basename(src)
# 主库用在线备份 API;其余库(当前没有,但口径要留全)同理
snap = os.path.join(tmpdir, fn)
_snapshot(src, snap)
files_meta.append({"name": fn, "bytes": os.path.getsize(snap),
"sha256": _sha256(snap), "primary": src == config.SQLITE_PATH})
inst = config.INSTANCE_FILE
if os.path.exists(inst):
shutil.copy2(inst, os.path.join(tmpdir, INSTANCE_NAME))
files_meta.append({"name": INSTANCE_NAME,
"bytes": os.path.getsize(os.path.join(tmpdir, INSTANCE_NAME)),
"sha256": _sha256(os.path.join(tmpdir, INSTANCE_NAME)),
"secret": True})
manifest = {
"format": FORMAT_VERSION,
"app": config.PROJECT_NAME,
"version": _app_version(),
"created_at": db.now_str(),
"trigger": trigger,
"actor": actor or "",
"note": note or "",
"stats": st,
"files": files_meta,
"restore_note": ("恢复会整表替换 usage_records / settings / users 等;"
"恢复前系统会自动先备份当前库。"),
}
with zipfile.ZipFile(dest, "w", zipfile.ZIP_DEFLATED) as z:
z.writestr(MANIFEST, json.dumps(manifest, ensure_ascii=False, indent=2))
for fn in [f["name"] for f in files_meta]:
z.write(os.path.join(tmpdir, fn), fn)
except Exception:
try:
os.remove(dest) # 半成品不留,否则「列表里有一份打不开的备份」
except OSError:
pass
raise
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
# 归档里是「全库 + 主密钥」,权限等同管理员口令,落盘后立刻收紧。
# (进程 umask 已收到 0077,这里额外兜住「同名文件早已存在」的情况。)
config.harden_file(dest)
size = os.path.getsize(dest)
digest = _sha256(dest)
db.audit(conn, "backup_create", actor or "system",
"生成备份 %s(%s,%d 条 / %.2f 积分)" % (name, human(size), st["records"], st["credits"]),
"127.0.0.1", 0)
return {"ok": True, "filename": name, "bytes": size, "sha256": digest,
"trigger": trigger, "stats": st,
"message": "已生成备份 %s(%s,%d 条记录)" % (name, human(size), st["records"])}
# ---------------- 索引(表 <- 磁盘) ----------------
def _row_exists(conn, filename):
return conn.execute("SELECT 1 FROM backups WHERE filename=?", (filename,)).fetchone() is not None
def sync_index(conn):
"""把磁盘上的归档登记进 backups 表,并把消失的标记 missing=1。
手工拷进来 / 手工删掉的备份都能因此被正确呈现 —— 索引只是缓存,
**磁盘才是事实来源**,所以这里做双向对齐而不是只信表。
"""
files = [f for f in os.listdir(backup_dir()) if f.endswith(SUFFIX)]
for fn in files:
if _row_exists(conn, fn):
continue
p = os.path.join(backup_dir(), fn)
meta = {"records": 0, "credits": 0.0, "users": 0, "schema_ver": 0}
created = ""
# 归档的 manifest 里记着它的真实来源(cli / manual / auto / pre-restore)。
# 直接用它的,不要一律标成 external —— 恢复前最需要判断的恰恰是
# 「这份是自动备份、还是我手工留的、还是恢复前系统自动存的那一份」。
trig = "external"
actor = None
note = "从磁盘发现"
try:
info = read_manifest(p)
meta = dict(meta, **info.get("stats", {}))
created = info.get("created_at") or ""
trig = (info.get("trigger") or "").strip() or "external"
actor = (info.get("actor") or "").strip() or None
note = (info.get("note") or "").strip() or note
except BackupError:
pass
if not created:
# 读不出 manifest 就用文件时间,至少让排序有意义
try:
created = datetime.fromtimestamp(os.path.getmtime(p)).strftime("%Y-%m-%d %H:%M:%S")
except OSError:
created = db.now_str()
conn.execute(
"INSERT OR IGNORE INTO backups(filename,bytes,sha256,created_at,trigger,actor,"
" schema_ver,records,credits,users,note,missing)"
" VALUES(?,?,?,?,?,?,?,?,?,?,?,0)",
(fn, os.path.getsize(p), "", created, trig, actor,
meta.get("schema_ver", 0), meta.get("records", 0),
meta.get("credits", 0.0), meta.get("users", 0), note))
# 磁盘上没了 -> 标记,不删行:保留「这里曾经有过一份」的记录更利于追责
for r in conn.execute("SELECT id,filename,missing FROM backups").fetchall():
gone = not os.path.exists(os.path.join(backup_dir(), r["filename"]))
want = 1 if gone else 0
if r["missing"] != want:
conn.execute("UPDATE backups SET missing=? WHERE id=?", (want, r["id"]))
return len(files)
def listing(conn):
"""备份清单(新→旧),附磁盘实际大小。"""
out = []
for r in conn.execute("SELECT * FROM backups ORDER BY created_at DESC, id DESC"):
p = os.path.join(backup_dir(), r["filename"])
exists = os.path.exists(p)
d = dict(r)
d["exists"] = exists
if exists:
d["bytes"] = os.path.getsize(p)
d["size_h"] = human(d["bytes"])
out.append(d)
return out
def total_bytes(conn):
return conn.execute("SELECT COALESCE(SUM(bytes),0) FROM backups WHERE missing=0").fetchone()[0]
# ---------------- 校验 ----------------
def _name_map(zf):
"""归档条目名 -> 真实条目名(按基名索引,容忍归档里带目录前缀)。"""
m = {}
for n in zf.namelist():
b = os.path.basename(n.replace("\\", "/"))
if b:
m[b] = n
return m
def read_manifest(path):
try:
with zipfile.ZipFile(path) as z:
entry = _name_map(z).get(MANIFEST)
if entry is None:
raise BackupError("归档里没有 %s,不是本程序生成的备份" % MANIFEST)
return json.loads(z.read(entry).decode("utf-8"))
except (zipfile.BadZipFile, KeyError, ValueError, OSError) as e:
raise BackupError("归档无法解析(%s):%s" % (os.path.basename(path), e))
def verify(path):
"""校验一份归档是否可用于恢复(不修改任何东西)。"""
if not os.path.exists(path):
raise BackupError("备份文件不存在或已被删除")
man = read_manifest(path)
if int(man.get("format") or 0) > FORMAT_VERSION:
raise BackupError("归档格式版本 %s 高于本程序支持的 %s,请先升级程序"
% (man.get("format"), FORMAT_VERSION))
files = man.get("files") or []
primary = [f for f in files if f.get("primary")] or (files[:1] if files else [])
if not primary:
raise BackupError("归档里没有数据库文件")
tmpdir = tempfile.mkdtemp(prefix="wb-verify-")
try:
with zipfile.ZipFile(path) as z:
nm = _name_map(z)
for f in files:
fn = os.path.basename(str(f.get("name") or ""))
if not fn:
raise BackupError("归档条目名不合法:%s" % f.get("name"))
if fn not in nm:
raise BackupError("归档缺少文件:%s" % f.get("name"))
_extract_safely(z, tmpdir, [nm[fn]])
db_path = os.path.join(tmpdir, os.path.basename(primary[0]["name"]))
if not os.path.exists(db_path):
raise BackupError("归档里的主数据库文件解不出来")
conn = sqlite3.connect("file:%s?mode=ro" % db_path.replace("\\", "/"), uri=True)
try:
integ = conn.execute("PRAGMA integrity_check").fetchone()[0]
uv = conn.execute("PRAGMA user_version").fetchone()[0]
n, cr = conn.execute("SELECT COUNT(*), COALESCE(SUM(credits),0)"
" FROM usage_records").fetchone()
users = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
finally:
conn.close()
except sqlite3.Error as e:
raise BackupError("归档里的数据库打不开:%s" % e)
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
if integ != "ok":
raise BackupError("归档里的数据库完整性检查未通过:%s" % integ)
if uv > db.DB_SCHEMA_VERSION:
raise BackupError("归档的库结构版本 %d 比当前程序(%d)还新,无法恢复"
% (uv, db.DB_SCHEMA_VERSION))
return {"manifest": man, "integrity": integ, "schema_ver": uv,
"records": n, "credits": round(cr or 0, 2), "users": users,
"created_at": man.get("created_at") or "",
"version": man.get("version") or ""}
# ---------------- 恢复 ----------------
def _table_cols(conn, db_name, table):
return [r["name"] for r in conn.execute("PRAGMA %s.table_info(%s)" % (db_name, table))]
def _copy_tables(conn, src_path):
"""把 src 库里的业务表整表搬到主库,一个事务内完成。
用**列名交集**而不是 `SELECT *` 对齐:老库 ALTER 出来的列顺序与新库
建表语句的列顺序不一定一致(ALTER 追加在末尾),`SELECT *` 会静默
错位 —— 那是最难查的一类数据损坏(字段整体串位,值都「合法」)。
"""
conn.execute("ATTACH DATABASE ? AS src", (src_path,))
try:
plan = []
for t in RESTORE_TABLES:
dst_cols = _table_cols(conn, "main", t)
src_cols = _table_cols(conn, "src", t)
if not src_cols:
continue # 归档里没有这张表(更老的版本)
common = [c for c in dst_cols if c in src_cols]
if not common:
continue
plan.append((t, common))
conn.execute("BEGIN IMMEDIATE")
try:
for t, cols in plan:
cl = ",".join('"%s"' % c for c in cols)
conn.execute("DELETE FROM main.%s" % t)
conn.execute("INSERT INTO main.%s(%s) SELECT %s FROM src.%s"
% (t, cl, cl, t))
conn.execute("COMMIT")
except Exception:
conn.execute("ROLLBACK")
raise
return [t for t, _ in plan]
finally:
conn.execute("DETACH DATABASE src")
def _restore_instance(src_file):
"""把归档里的 instance.json 覆盖回来(含 cookie_key / secret_key)。
必须先留一份当前文件:直接覆盖会让「本来就正常的那把钥匙」消失,
而以旧钥匙加密不了新数据 —— 那才是真正不可逆的一步。
"""
dst = config.INSTANCE_FILE
if os.path.exists(dst):
bak = "%s.pre-restore-%s" % (dst, datetime.now().strftime("%Y%m%d%H%M%S"))
try:
shutil.copy2(dst, bak)
except OSError as e:
raise BackupError("备份 instance.json 失败,已中止:%s" % e)
try:
shutil.copy2(src_file, dst)
if os.name == "posix":
os.chmod(dst, 0o600)
except OSError as e:
raise BackupError("写入 instance.json 失败:%s" % e)
return True
def restore(conn, filename, include_instance=True, actor=None):
"""从归档恢复。整表替换,恢复前自动给当前库留一份 pre-restore 快照。
副作用:所有账号的 `session_ver` 会被 +1,**所有既有登录会话立即失效**
(恢复是全局性事件,旧会话描述的账号与权限可能已经被换掉了)。
返回结果 dict;失败抛 BackupError。
"""
path = path_of(filename)
info = verify(path) # 先验,验不过就不动任何东西
# 1) 护栏:先把**当前**库完整备份一份。恢复错了还能回到恢复之前。
try:
safety = create(conn, trigger="pre-restore", actor=actor or "system",
note="恢复 %s 之前的自动快照" % filename)
except Exception as e: # noqa: BLE001
raise BackupError("恢复前的安全备份失败,已中止(不会动你的数据):%s" % e)
tmpdir = tempfile.mkdtemp(prefix="wb-restore-")
try:
with zipfile.ZipFile(path) as z:
_extract_safely(z, tmpdir, z.namelist())
primary = [f for f in info["manifest"]["files"] if f.get("primary")]
if not primary:
primary = [info["manifest"]["files"][0]]
src_path = os.path.join(tmpdir, os.path.basename(primary[0]["name"]))
if not os.path.exists(src_path):
raise BackupError("归档里缺少主数据库文件")
# 2) 在临时库上先迁移到当前 schema —— 备份是旧版本(uv=2/3)也能恢复,
# 而且迁移失败时正本一个字节都没动。
tmp_conn = sqlite3.connect(src_path)
try:
tmp_conn.row_factory = sqlite3.Row
db.init_db(conn=tmp_conn, create_admin=False)
finally:
tmp_conn.close()
# 迁移过程会把临时库带进 WAL 模式,侧车文件里可能还有未合并的帧。
# 切回 DELETE 模式让**单文件自包含** —— 后面 ATTACH 时就不依赖 -wal 了。
tmp_conn = sqlite3.connect(src_path)
try:
tmp_conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
tmp_conn.execute("PRAGMA journal_mode=DELETE")
finally:
tmp_conn.close()
# 3) 搬数据。持采集锁:搬的过程中不能让采集往里写。
with collect._Lock():
moved = _copy_tables(conn, src_path)
# 数据整表换过了 —— 让**所有**会话立即失效。
#
# 光靠「users.session_ver 从归档里搬过来」是不够的:如果某个人是在
# 打这份备份**之前**登录的,他那张 Cookie 里的 sv 正好等于归档里的值,
# 于是会话会「合法地」活下来 —— 而它描述的账号、角色、权限可能已经
# 被这次恢复整个替换过了。恢复是全局性事件,一律要求重新登录。
conn.execute("UPDATE users SET session_ver=COALESCE(session_ver,0)+1")
inst = False
if include_instance:
inst_file = os.path.join(tmpdir, INSTANCE_NAME)
if os.path.exists(inst_file):
inst = _restore_instance(inst_file)
out = _stats()
out["moved"] = moved
out["instance"] = inst
except collect.Busy as e:
raise BackupError("有采集任务正在运行,请等它结束后再恢复(%s)" % e)
except sqlite3.Error as e:
raise BackupError("恢复过程中数据库报错,已回滚:%s" % e)
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
db.audit(conn, "backup_restore", actor or "system",
"从 %s 恢复:%d 条 / %.2f 积分 / %d 个账号%s(恢复前已自动备份 %s)"
% (filename, out["records"], out["credits"], out["users"],
",含 instance.json" if out["instance"] else "",
safety.get("filename")),
"127.0.0.1", 0)
return {
"ok": True, "filename": filename, "safety_backup": safety.get("filename"),
"moved": out["moved"], "restored_instance": out["instance"],
"stats": out, "message":
"已从 %s 恢复:%d 条记录 / %.2f 积分 / %d 个账号。"
"恢复前的库已自动备份为 %s。所有既有登录会话已失效,请重新登录。"
% (filename, out["records"], out["credits"], out["users"],
safety.get("filename")),
}
# ---------------- 删除 / 清理 ----------------
def delete(conn, filename, actor=None):
"""删除一份备份(文件 + 索引行)。"""
name = safe_name(filename)
p = path_of(name)
if not os.path.exists(p):
conn.execute("UPDATE backups SET missing=1 WHERE filename=?", (name,))
raise BackupError("备份文件不存在(可能已被手工删除)")
os.remove(p)
conn.execute("DELETE FROM backups WHERE filename=?", (name,))
db.audit(conn, "backup_delete", actor or "system", "删除备份 %s" % name, "127.0.0.1", 0)
return {"ok": True, "filename": name, "message": "已删除备份 " + name}
def prune(conn, keep=None, actor=None):
"""按「保留份数」清理最旧的备份。返回删除清单。"""
if keep is None:
keep = db.get_int(conn, "backup_keep", 7)
keep = max(1, min(100, int(keep or 1)))
rows = conn.execute("SELECT * FROM backups ORDER BY created_at DESC, id DESC").fetchall()
# 只按「磁盘上真实存在」的算份数:已经手工删掉的条目不该占名额
alive = [r for r in rows if os.path.exists(os.path.join(backup_dir(), r["filename"]))]
victims = alive[keep:]
removed = []
for r in victims:
try:
os.remove(os.path.join(backup_dir(), r["filename"]))
conn.execute("DELETE FROM backups WHERE id=?", (r["id"],))
removed.append(r["filename"])
except OSError as e:
log.warning("清理旧备份 %s 失败:%s", r["filename"], e)
if removed:
db.audit(conn, "backup_prune", actor or "system",
"按保留 %d 份清理旧备份:%s" % (keep, ", ".join(removed)),
"127.0.0.1", 0)
return removed
# ---------------- 自动备份 ----------------
def last_auto_at(conn):
r = conn.execute("SELECT MAX(created_at) FROM backups WHERE trigger IN ('auto','startup')"
).fetchone()
return (r[0] if r and r[0] else "")
def due(conn, now=None):
"""自动备份是否到期。返回 (是否到期, 距离上次的小时数, 周期小时数)。"""
if not db.get_bool(conn, "backup_enabled", True):
return False, 0.0, 0
every = max(1, min(720, db.get_int(conn, "backup_interval_hours", 24)))
last = last_auto_at(conn)
now = now or datetime.now()
if not last:
return True, -1.0, every
try:
dt = datetime.strptime(last, "%Y-%m-%d %H:%M:%S")
except ValueError:
return True, -1.0, every
hrs = (now - dt).total_seconds() / 3600.0
return hrs >= every, hrs, every
def maybe_auto(conn, now=None):
"""调度器每轮调用:到期就打一份 + 按份数清理。返回结果或 None。"""
ok, hrs, every = due(conn, now)
if not ok:
return None
try:
r = create(conn, trigger="auto", actor="system",
note="自动备份(周期 %d 小时)" % every)
except Exception as e: # noqa: BLE001
log.error("自动备份失败:%s", e)
return None
removed = prune(conn, actor="system")
if removed:
log.info("自动备份后清理旧备份 %d 份", len(removed))
log.info("自动备份完成:%s", r["message"])
return r
def next_auto_at(conn, now=None):
"""下一次自动备份时间(给界面显示)。"""
if not db.get_bool(conn, "backup_enabled", True):
return None
every = max(1, min(720, db.get_int(conn, "backup_interval_hours", 24)))
last = last_auto_at(conn)
now = now or datetime.now()
if not last:
return now
try:
dt = datetime.strptime(last, "%Y-%m-%d %H:%M:%S")
except ValueError:
return now
nxt = dt + timedelta(hours=every)
return nxt if nxt > now else now