- 新增备份管理页与 API:在线快照、自动周期备份、按份数清理、下载、一键恢复(恢复前自动兜底) - 新增 /profile/export,普通用户可导出本人全部数据(不含 Cookie 明文) - 修复 X-Forwarded-For 可伪造导致三道 IP 防线失效,统一走 client_ip() 取客户端地址 - 取消 admin123 硬编码默认口令,留空则生成随机初始口令并仅打印一次 - .dockerignore 排除 backups/ 并加构建期断言,防止密钥随镜像分发 - 新增会话版本号,改密/停用/删除及恢复备份后其他会话立即失效 - 新增容器资源上限、采集跨度硬顶 31 天、重操作最小间隔与并发 409 - 新增访问日志、HSTS 条件下发、口令黑名单、验证码抗模板匹配、instance.json 0600 - 版本号升至 1.4.0,同步更新 README、SECURITY、.env.example 与 compose 配置
638 行
26 KiB
Python
638 行
26 KiB
Python
# -*- 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)
|
||
|
||
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
|
||
|