Skip to content

Commit f700db5

Browse files
feat(database): add managed backup and offline restore (#6359)
1 parent f4a879b commit f700db5

27 files changed

Lines changed: 1576 additions & 80 deletions
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""数据库备份的文件系统与数据库技术适配器命名空间。"""
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
"""基于活动 SQLAlchemy 引擎的 SQLite 与 PostgreSQL 备份实现。"""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
import shutil
7+
import sqlite3
8+
import subprocess
9+
from contextlib import closing
10+
from dataclasses import dataclass
11+
from pathlib import Path
12+
from typing import Callable, Mapping, Protocol, Sequence
13+
14+
from sqlalchemy.engine import Engine
15+
16+
17+
@dataclass(frozen=True, slots=True)
18+
class DatabaseBackupCheck:
19+
"""数据库适配器返回的基础校验结果。"""
20+
21+
valid: bool
22+
method: str
23+
detail: str | None = None
24+
25+
26+
class ProcessResult(Protocol):
27+
"""数据库命令执行结果的最小合同。"""
28+
29+
returncode: int
30+
stdout: str
31+
stderr: str
32+
33+
34+
class ProcessRunner(Protocol):
35+
"""可替换的数据库命令执行边界。"""
36+
37+
def __call__(
38+
self,
39+
command: Sequence[str],
40+
*,
41+
env: Mapping[str, str],
42+
capture_output: bool,
43+
text: bool,
44+
check: bool,
45+
) -> ProcessResult:
46+
"""执行命令并返回结果。"""
47+
48+
49+
class SQLiteBackupBackend:
50+
"""使用 SQLite 在线备份 API 管理活动文件数据库。"""
51+
52+
db_type = "sqlite"
53+
suffix = ".db"
54+
55+
def __init__(self, engine: Engine) -> None:
56+
self._engine = engine
57+
database = engine.url.database
58+
if not database or database == ":memory:":
59+
raise ValueError("SQLite 内存数据库不支持文件备份")
60+
self._database = Path(database)
61+
62+
def create(self, destination: Path) -> None:
63+
"""从活动引擎指向的 SQLite 文件创建一致快照。"""
64+
source = self._engine.raw_connection()
65+
try:
66+
with closing(sqlite3.connect(destination)) as target:
67+
source.driver_connection.backup(target)
68+
target.commit()
69+
finally:
70+
source.close()
71+
72+
def verify(self, artifact: Path) -> DatabaseBackupCheck:
73+
"""通过 SQLite integrity_check 校验备份内容。"""
74+
method = "PRAGMA integrity_check"
75+
try:
76+
# 已发布前的临时快照不会再变化;immutable 避免 WAL 模式为只读校验创建旁路文件。
77+
uri = f"{artifact.resolve().as_uri()}?mode=ro&immutable=1"
78+
with closing(sqlite3.connect(uri, uri=True)) as connection:
79+
rows = connection.execute("PRAGMA integrity_check").fetchall()
80+
except sqlite3.Error as error:
81+
return DatabaseBackupCheck(False, method, str(error))
82+
valid = bool(rows) and all(row[0] == "ok" for row in rows)
83+
detail = None if valid else "; ".join(str(row[0]) for row in rows)
84+
return DatabaseBackupCheck(valid, method, detail)
85+
86+
def restore(self, artifact: Path) -> None:
87+
"""在 CLI 离线进程中原子替换活动 SQLite 文件。"""
88+
temporary = self._database.with_name(f".{self._database.name}.restore")
89+
self._engine.dispose()
90+
try:
91+
shutil.copy2(artifact, temporary)
92+
temporary.chmod(0o600)
93+
self._database.with_name(f"{self._database.name}-wal").unlink(missing_ok=True)
94+
self._database.with_name(f"{self._database.name}-shm").unlink(missing_ok=True)
95+
os.replace(temporary, self._database)
96+
finally:
97+
temporary.unlink(missing_ok=True)
98+
99+
100+
class PostgreSQLBackupBackend:
101+
"""使用 pg_dump 与 pg_restore 管理活动 PostgreSQL 数据库。"""
102+
103+
db_type = "postgresql"
104+
suffix = ".dump"
105+
106+
def __init__(
107+
self,
108+
engine: Engine,
109+
*,
110+
runner: ProcessRunner = subprocess.run,
111+
tool_resolver: Callable[[str], str | None] = shutil.which,
112+
pg_dump: str = "pg_dump",
113+
pg_restore: str = "pg_restore",
114+
) -> None:
115+
self._engine = engine
116+
self._runner = runner
117+
self._tool_resolver = tool_resolver
118+
self._pg_dump = pg_dump
119+
self._pg_restore = pg_restore
120+
121+
def create(self, destination: Path) -> None:
122+
"""创建 PostgreSQL custom-format 在线备份。"""
123+
command = [
124+
self._require_tool(self._pg_dump),
125+
"--format=custom",
126+
"--no-owner",
127+
"--no-acl",
128+
"--file",
129+
str(destination),
130+
*self._connection_arguments(),
131+
]
132+
result = self._run(command, include_password=True)
133+
if result.returncode != 0:
134+
raise RuntimeError(f"pg_dump 执行失败,退出码 {result.returncode}")
135+
if not destination.is_file() or destination.stat().st_size == 0:
136+
raise RuntimeError("pg_dump 未生成有效的备份文件")
137+
138+
def verify(self, artifact: Path) -> DatabaseBackupCheck:
139+
"""通过 pg_restore 目录读取校验 custom-format 归档。"""
140+
method = "pg_restore --list"
141+
result = self._run(
142+
[self._require_tool(self._pg_restore), "--list", str(artifact)],
143+
include_password=False,
144+
)
145+
valid = result.returncode == 0 and bool(result.stdout.strip())
146+
detail = None if valid else f"pg_restore 退出码 {result.returncode}"
147+
return DatabaseBackupCheck(valid, method, detail)
148+
149+
def restore(self, artifact: Path) -> None:
150+
"""在 CLI 离线进程中覆盖当前 PostgreSQL 数据库内容。"""
151+
command = [
152+
self._require_tool(self._pg_restore),
153+
"--clean",
154+
"--if-exists",
155+
"--no-owner",
156+
"--no-acl",
157+
"--single-transaction",
158+
"--exit-on-error",
159+
*self._connection_arguments(),
160+
str(artifact),
161+
]
162+
result = self._run(command, include_password=True)
163+
if result.returncode != 0:
164+
raise RuntimeError(f"pg_restore 执行失败,退出码 {result.returncode}")
165+
166+
def _connection_arguments(self) -> list[str]:
167+
url = self._engine.url
168+
host = str(url.query.get("host") or url.host or "")
169+
port = str(url.query.get("port") or url.port or "")
170+
arguments = [
171+
"--username",
172+
str(url.username or ""),
173+
"--dbname",
174+
str(url.database or ""),
175+
]
176+
if host:
177+
arguments.extend(["--host", host])
178+
if port:
179+
arguments.extend(["--port", port])
180+
return arguments
181+
182+
def _run(self, command: Sequence[str], *, include_password: bool) -> ProcessResult:
183+
return self._runner(
184+
command,
185+
env=self._environment(include_password=include_password),
186+
capture_output=True,
187+
text=True,
188+
check=False,
189+
)
190+
191+
def _require_tool(self, executable: str) -> str:
192+
resolved = self._tool_resolver(executable)
193+
if resolved is None:
194+
raise RuntimeError(
195+
f"未找到 {executable},请安装与服务端同主版本或更高的 "
196+
"PostgreSQL client 并加入 PATH"
197+
)
198+
return resolved
199+
200+
def _environment(self, *, include_password: bool) -> dict[str, str]:
201+
environment = dict(os.environ)
202+
environment.pop("PGPASSWORD", None)
203+
environment.pop("PGSSLMODE", None)
204+
if include_password and self._engine.url.password:
205+
environment["PGPASSWORD"] = str(self._engine.url.password)
206+
sslmode = self._engine.url.query.get("sslmode")
207+
if sslmode:
208+
environment["PGSSLMODE"] = str(sslmode)
209+
return environment
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""数据库备份单文件的受限文件系统操作。"""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
import re
7+
import tempfile
8+
from datetime import datetime
9+
from pathlib import Path
10+
11+
12+
_BACKUP_NAME = re.compile(
13+
r"^(?P<db_type>sqlite|postgresql)_"
14+
r"(?P<timestamp>\d{8}_\d{6})"
15+
r"(?:_(?P<sequence>\d+))?"
16+
r"(?P<suffix>\.db|\.dump)$"
17+
)
18+
19+
20+
class BackupFiles:
21+
"""把备份文件操作限制在一个私有根目录内。"""
22+
23+
def __init__(self, root: Path) -> None:
24+
self.root = Path(root)
25+
26+
def create_temporary(self, suffix: str) -> Path:
27+
"""在最终目录内创建私有临时文件,保证发布可使用原子替换。"""
28+
self._ensure_root()
29+
descriptor, filename = tempfile.mkstemp(
30+
prefix=".database-",
31+
suffix=f"{suffix}.partial",
32+
dir=self.root,
33+
)
34+
os.close(descriptor)
35+
path = Path(filename)
36+
path.chmod(0o600)
37+
return path
38+
39+
def publish(self, temporary: Path, name: str) -> Path:
40+
"""把已校验临时文件发布为正式备份文件。"""
41+
destination = self._resolve_name(name, require_exists=False)
42+
os.replace(temporary, destination)
43+
destination.chmod(0o600)
44+
return destination
45+
46+
def discard(self, temporary: Path) -> None:
47+
"""清理本次操作拥有的未发布临时文件。"""
48+
path = Path(temporary)
49+
if path.parent == self.root and path.name.startswith(".database-"):
50+
path.unlink(missing_ok=True)
51+
52+
def list(self) -> list[Path]:
53+
"""返回当前根目录内格式合法的正式备份文件。"""
54+
if not self.root.is_dir():
55+
return []
56+
paths = [
57+
path
58+
for path in self.root.iterdir()
59+
if path.is_file() and _BACKUP_NAME.fullmatch(path.name)
60+
]
61+
return sorted(paths, key=lambda path: (self.created_at(path.name), path.name), reverse=True)
62+
63+
def resolve(self, name: str) -> Path:
64+
"""按受限文件名解析一个必须存在的备份文件。"""
65+
return self._resolve_name(name, require_exists=True)
66+
67+
def delete(self, name: str) -> None:
68+
"""删除一个已通过名称约束的备份文件。"""
69+
self.resolve(name).unlink()
70+
71+
def available_name(self, *, db_type: str, created_at: datetime, suffix: str) -> str:
72+
"""生成包含数据库类型和秒级时间的简短可读文件名。"""
73+
timestamp = created_at.strftime("%Y%m%d_%H%M%S")
74+
base = f"{db_type}_{timestamp}"
75+
candidate = f"{base}{suffix}"
76+
sequence = 1
77+
while (self.root / candidate).exists():
78+
candidate = f"{base}_{sequence}{suffix}"
79+
sequence += 1
80+
if not _BACKUP_NAME.fullmatch(candidate):
81+
raise ValueError("数据库备份文件名无效")
82+
return candidate
83+
84+
@staticmethod
85+
def database_type(name: str) -> str:
86+
"""从受管文件名读取数据库类型。"""
87+
return BackupFiles._match(name).group("db_type")
88+
89+
@staticmethod
90+
def created_at(name: str) -> datetime:
91+
"""从受管文件名读取本地创建时间。"""
92+
return datetime.strptime(
93+
BackupFiles._match(name).group("timestamp"),
94+
"%Y%m%d_%H%M%S",
95+
)
96+
97+
def _ensure_root(self) -> None:
98+
self.root.mkdir(parents=True, exist_ok=True, mode=0o700)
99+
self.root.chmod(0o700)
100+
101+
def _resolve_name(self, name: str, *, require_exists: bool) -> Path:
102+
normalized = str(name).strip()
103+
self._match(normalized)
104+
if Path(normalized).name != normalized:
105+
raise ValueError("数据库备份文件名不能包含路径")
106+
path = self.root / normalized
107+
if require_exists and not path.is_file():
108+
raise FileNotFoundError(normalized)
109+
return path
110+
111+
@staticmethod
112+
def _match(name: str) -> re.Match[str]:
113+
matched = _BACKUP_NAME.fullmatch(str(name))
114+
if matched is None:
115+
raise ValueError("数据库备份文件名无效")
116+
return matched

app/api/endpoints/system.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
)
5454
from app.application.messaging.message import MessageHelper
5555
from app.runtime.progress import AsyncProgressHelper
56+
from app.runtime.scheduling import TimerUtils
5657
from app.application.rules import RuleHelper
5758
from app.adapters.external.server import MoviePilotServerHelper
5859
from app.runtime.state import SystemHelper
@@ -86,6 +87,13 @@
8687
_PUBLIC_SETTINGS_KEYS = {"PLUGIN_MARKET"}
8788
_LOG_DOWNLOAD_LIMIT = 10
8889
_LOG_DOWNLOAD_NAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$")
90+
_DATABASE_BACKUP_SETTING_KEYS = {
91+
"DB_BACKUP_ENABLE",
92+
"DB_BACKUP_CRON",
93+
"DB_BACKUP_PATH",
94+
"DB_BACKUP_RETENTION_DAYS",
95+
"DB_BACKUP_MAX_COUNT",
96+
}
8997

9098

9199
def _validate_llm_server_tool_config(env: dict) -> Optional[str]:
@@ -129,6 +137,38 @@ def _validate_llm_server_tool_config(env: dict) -> Optional[str]:
129137
)
130138

131139

140+
def _validate_database_backup_config(env: dict) -> Optional[str]:
141+
"""在批量写入前校验数据库备份策略,避免只保存部分字段。"""
142+
if not _DATABASE_BACKUP_SETTING_KEYS.intersection(env):
143+
return None
144+
145+
cron = str(env.get("DB_BACKUP_CRON", settings.DB_BACKUP_CRON) or "").strip()
146+
if cron:
147+
try:
148+
TimerUtils.normalize_schedule_trigger("cron", cron, settings.TZ)
149+
except (TypeError, ValueError):
150+
return "数据库备份周期格式不正确"
151+
152+
backup_path = env.get("DB_BACKUP_PATH", settings.DB_BACKUP_PATH)
153+
if backup_path is not None and not isinstance(backup_path, str):
154+
return "数据库备份目录必须是路径字符串"
155+
156+
for key, label in (
157+
("DB_BACKUP_RETENTION_DAYS", "数据库备份过期天数"),
158+
("DB_BACKUP_MAX_COUNT", "数据库备份最大保留份数"),
159+
):
160+
value = env.get(key, getattr(settings, key))
161+
if isinstance(value, bool):
162+
return f"{label}必须是大于等于 0 的整数"
163+
try:
164+
converted = int(value)
165+
except (TypeError, ValueError):
166+
return f"{label}必须是大于等于 0 的整数"
167+
if converted < 0 or str(value).strip() != str(converted):
168+
return f"{label}必须是大于等于 0 的整数"
169+
return None
170+
171+
132172
def _is_allowed_plugin_market_wiki_url(wiki_url: str) -> bool:
133173
"""
134174
校验插件市场 Wiki 地址是否属于固定文档源。
@@ -796,6 +836,9 @@ async def set_env_setting(
796836
更新系统环境变量(仅管理员)
797837
"""
798838
validation_error = _validate_llm_server_tool_config(env)
839+
if validation_error:
840+
return _SchemaResponse(success=False, message=validation_error)
841+
validation_error = _validate_database_backup_config(env)
799842
if validation_error:
800843
return _SchemaResponse(success=False, message=validation_error)
801844

0 commit comments

Comments
 (0)