feat: 新增备份恢复与公网加固
- 新增备份管理页与 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 配置
这个提交包含在:
@@ -0,0 +1,637 @@
|
||||
# -*- 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
|
||||
|
||||
在新工单中引用
屏蔽一个用户