backup_service.py 46 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163
  1. """备份引擎(契约1.21):链式增量备份 + 自有格式数据库导出 + LOCAL/RSYNC 目标。
  2. 对齐 Java 版 com.dms.backup 包(BackupConfig/BackupManifest/BackupTarget/
  3. LocalBackupTarget/RsyncBackupTarget/SqlDataDumper/BackupEngine),两端读写同一
  4. 份 dms-backup-config.json(snake_case 字段)与同一备份目录结构,互为可恢复:
  5. - 数据库导出为自有格式数据脚本 database/dms-data.sql(sql_dump 模块,
  6. 驱动直连零外部工具;Java 版 com.dms.backup.SqlDataDumper 逐条对齐);
  7. - 备份包先构建于存储根下 backup-staging 暂存目录,完成后经目标送达;
  8. - 增量 = 当前存储全清单 − 上一备份(链尾)manifest 的全清单,只传新增文件
  9. (内容寻址存储 hash 命名、写入后不可变,清单对比无需哈希校验);
  10. - manifest.json 完成时最后原子写入(无 manifest 的目录视为中断残留);
  11. - 历史摘要 index.json 存于目标根;保留策略按全量链整链清理;
  12. - 定时器为后台守护线程(30 秒轮询配置,界面修改即时生效)。
  13. """
  14. from __future__ import annotations
  15. import json
  16. import logging
  17. import os
  18. import re
  19. import shutil
  20. import subprocess
  21. import sys
  22. import tempfile
  23. import threading
  24. from dataclasses import dataclass, field
  25. from datetime import datetime, timedelta
  26. from pathlib import Path
  27. from typing import Any, Callable
  28. from urllib.parse import unquote, urlsplit
  29. from zoneinfo import ZoneInfo
  30. from dms.common.errors import (
  31. BackupConfigInvalidError,
  32. BackupFailedError,
  33. BackupRunningError,
  34. BackupTargetUnavailableError,
  35. BackupToolMissingError,
  36. )
  37. from dms.services import sql_dump
  38. logger = logging.getLogger(__name__)
  39. INDEX_FILE = "index.json"
  40. TRIGGER_MANUAL = "MANUAL"
  41. TRIGGER_SCHEDULE = "SCHEDULE"
  42. TYPE_FULL = "FULL"
  43. TYPE_INCREMENTAL = "INCREMENTAL"
  44. # 六目录全备(preview 可重建但按要求一并备份),仅排除 temporary
  45. BACKUP_SUBDIRECTORIES = (
  46. "original",
  47. "preview",
  48. "extracted",
  49. "quarantine",
  50. "recycle",
  51. "voucher",
  52. )
  53. _TIME_FORMAT = "%Y-%m-%d %H:%M:%S"
  54. _ID_FORMAT = "%Y%m%d-%H%M%S"
  55. _SCHEDULE_TICK_SECONDS = 30
  56. _SCHEDULE_RETRY_BACKOFF_MINUTES = 30
  57. # ==================================================================== 配置
  58. @dataclass
  59. class BackupConfig:
  60. """部署级备份配置(JSON 文件持久化,与 .env 同级;双后端共用同一份)。"""
  61. target_mode: str = "LOCAL"
  62. local_path: str = ""
  63. rsync_url: str = ""
  64. rsync_password: str = ""
  65. schedule_enabled: bool = False
  66. schedule_time: str = "02:00"
  67. full_interval_days: int = 7
  68. retain_full_count: int = 2
  69. _TIME_PATTERN = re.compile(r"^([01]\d|2[0-3]):[0-5]\d$")
  70. @staticmethod
  71. def load(path: Path) -> "BackupConfig":
  72. """文件缺失或损坏返回默认配置(触发时再报配置错误)。"""
  73. if not path.is_file():
  74. return BackupConfig()
  75. try:
  76. data = json.loads(path.read_text(encoding="utf-8"))
  77. if not isinstance(data, dict):
  78. return BackupConfig()
  79. config = BackupConfig()
  80. config.target_mode = str(data.get("target_mode", config.target_mode))
  81. config.local_path = str(data.get("local_path", "") or "")
  82. config.rsync_url = str(data.get("rsync_url", "") or "")
  83. config.rsync_password = str(data.get("rsync_password", "") or "")
  84. config.schedule_enabled = bool(data.get("schedule_enabled", False))
  85. config.schedule_time = str(data.get("schedule_time", config.schedule_time))
  86. config.full_interval_days = int(data.get("full_interval_days", 7))
  87. config.retain_full_count = int(data.get("retain_full_count", 2))
  88. return config
  89. except (OSError, ValueError):
  90. return BackupConfig()
  91. def save(self, path: Path) -> None:
  92. self.validate()
  93. path.parent.mkdir(parents=True, exist_ok=True)
  94. temp = path.with_name(path.name + ".tmp")
  95. temp.write_text(
  96. json.dumps(self.to_dict(), ensure_ascii=False), encoding="utf-8"
  97. )
  98. os.replace(temp, path)
  99. def validate(self) -> None:
  100. if self.target_mode not in ("LOCAL", "RSYNC"):
  101. raise BackupConfigInvalidError("target_mode 必须为 LOCAL 或 RSYNC")
  102. if self.target_mode == "LOCAL" and not self.local_path.strip():
  103. raise BackupConfigInvalidError("LOCAL 模式必须配置 local_path 备份根目录")
  104. if self.target_mode == "RSYNC":
  105. if not self.rsync_url.startswith("rsync://"):
  106. raise BackupConfigInvalidError(
  107. "RSYNC 模式必须配置 rsync://user@host:873/module/ 形式的 rsync_url"
  108. )
  109. if "?" in self.rsync_url or " " in self.rsync_url:
  110. raise BackupConfigInvalidError("rsync_url 不支持携带查询参数或空白字符")
  111. if not self._TIME_PATTERN.match(self.schedule_time or ""):
  112. raise BackupConfigInvalidError("schedule_time 必须为 HH:MM(24小时制)")
  113. if not 1 <= self.full_interval_days <= 365:
  114. raise BackupConfigInvalidError("full_interval_days 必须在 1~365 之间")
  115. if not 1 <= self.retain_full_count <= 100:
  116. raise BackupConfigInvalidError("retain_full_count 必须在 1~100 之间")
  117. def to_dict(self) -> dict[str, Any]:
  118. return {
  119. "target_mode": self.target_mode,
  120. "local_path": self.local_path,
  121. "rsync_url": self.rsync_url,
  122. "rsync_password": self.rsync_password,
  123. "schedule_enabled": self.schedule_enabled,
  124. "schedule_time": self.schedule_time,
  125. "full_interval_days": self.full_interval_days,
  126. "retain_full_count": self.retain_full_count,
  127. }
  128. def public_view(self) -> dict[str, Any]:
  129. """接口回显视图:密码永不回明文,仅回是否已设置。"""
  130. view = self.to_dict()
  131. del view["rsync_password"]
  132. view["rsync_password_set"] = bool(self.rsync_password)
  133. return view
  134. # ==================================================================== 清单
  135. @dataclass
  136. class BackupManifest:
  137. """备份清单(manifest.json):files 为合并后的全量清单。"""
  138. id: str = ""
  139. type: str = TYPE_FULL
  140. parent: str | None = None
  141. started_at: str = ""
  142. finished_at: str = ""
  143. database_file: str = "database/dms-data.sql"
  144. database_size: int = 0
  145. transferred_files: int = 0
  146. transferred_bytes: int = 0
  147. storage_file_count: int = 0
  148. storage_total_bytes: int = 0
  149. files: list[str] = field(default_factory=list)
  150. @staticmethod
  151. def parse(data: dict[str, Any]) -> "BackupManifest":
  152. return BackupManifest(
  153. id=str(data.get("id", "")),
  154. type=str(data.get("type", TYPE_FULL)),
  155. parent=data.get("parent"),
  156. started_at=str(data.get("started_at", "")),
  157. finished_at=str(data.get("finished_at", "")),
  158. database_file=str(data.get("database_file", "database/dms-data.sql")),
  159. database_size=int(data.get("database_size", 0)),
  160. transferred_files=int(data.get("transferred_files", 0)),
  161. transferred_bytes=int(data.get("transferred_bytes", 0)),
  162. storage_file_count=int(data.get("storage_file_count", 0)),
  163. storage_total_bytes=int(data.get("storage_total_bytes", 0)),
  164. files=[str(item) for item in data.get("files", [])],
  165. )
  166. def to_dict(self, include_files: bool = True) -> dict[str, Any]:
  167. data: dict[str, Any] = {
  168. "id": self.id,
  169. "type": self.type,
  170. "parent": self.parent,
  171. "started_at": self.started_at,
  172. "finished_at": self.finished_at,
  173. "database_file": self.database_file,
  174. "database_size": self.database_size,
  175. "transferred_files": self.transferred_files,
  176. "transferred_bytes": self.transferred_bytes,
  177. "storage_file_count": self.storage_file_count,
  178. "storage_total_bytes": self.storage_total_bytes,
  179. }
  180. if include_files:
  181. data["files"] = self.files
  182. return data
  183. def write(self, path: Path) -> None:
  184. """原子写入:目录存在但无 manifest 即中断残留的判定依据。"""
  185. temp = path.with_name(path.name + ".tmp")
  186. temp.write_text(
  187. json.dumps(self.to_dict(), ensure_ascii=False), encoding="utf-8"
  188. )
  189. os.replace(temp, path)
  190. # ==================================================================== 目标
  191. class BackupTarget:
  192. """备份目标抽象:LOCAL 本机目录 / RSYNC 异机专用协议。"""
  193. def check_writable(self) -> None:
  194. raise NotImplementedError
  195. def push_directory(
  196. self, local_directory: Path, name: str,
  197. reporter: Callable[[int], None] | None = None,
  198. ) -> None:
  199. raise NotImplementedError
  200. def fetch_file(self, remote_relative_path: str) -> bytes | None:
  201. raise NotImplementedError
  202. def fetch_directory(self, name: str, local_directory: Path) -> None:
  203. raise NotImplementedError
  204. def push_file(self, local_file: Path, remote_relative_path: str) -> None:
  205. raise NotImplementedError
  206. def list_backup_directories(self) -> list[str]:
  207. raise NotImplementedError
  208. def delete_backup_directory(self, name: str) -> None:
  209. raise NotImplementedError
  210. def describe(self) -> str:
  211. raise NotImplementedError
  212. def interrupt(self) -> None:
  213. """中断当前传输(备份取消);无进程语义的目标为空操作。"""
  214. class LocalBackupTarget(BackupTarget):
  215. """LOCAL 模式:直接写本机目录(开发/测试/单机)。"""
  216. def __init__(self, root: Path) -> None:
  217. self.root = root.expanduser().resolve()
  218. def _resolve_safe(self, relative: str) -> Path:
  219. if not relative or "\x00" in relative:
  220. raise BackupConfigInvalidError("备份目标相对路径无效")
  221. normalized = relative.replace("\\", "/")
  222. for part in normalized.split("/"):
  223. if ":" in part or part == "..":
  224. raise BackupConfigInvalidError("备份目标相对路径包含非法段")
  225. candidate = (self.root / normalized).resolve()
  226. if not candidate.is_relative_to(self.root):
  227. raise BackupConfigInvalidError("备份目标相对路径超出根目录")
  228. return candidate
  229. def check_writable(self) -> None:
  230. self.root.mkdir(parents=True, exist_ok=True)
  231. probe = self.root / ".dms-writable-probe"
  232. probe.write_bytes(b"")
  233. probe.unlink(missing_ok=True)
  234. def push_directory(
  235. self, local_directory: Path, name: str,
  236. reporter: Callable[[int], None] | None = None,
  237. ) -> None:
  238. destination = self._resolve_safe(name)
  239. destination.mkdir(parents=True, exist_ok=True)
  240. shutil.copytree(local_directory, destination, dirs_exist_ok=True)
  241. if reporter is not None:
  242. reporter(100)
  243. def fetch_file(self, remote_relative_path: str) -> bytes | None:
  244. file = self._resolve_safe(remote_relative_path)
  245. return file.read_bytes() if file.is_file() else None
  246. def fetch_directory(self, name: str, local_directory: Path) -> None:
  247. source = self._resolve_safe(name)
  248. if not source.is_dir():
  249. raise BackupTargetUnavailableError(f"备份目录不存在:{name}")
  250. local_directory.mkdir(parents=True, exist_ok=True)
  251. shutil.copytree(source, local_directory, dirs_exist_ok=True)
  252. def push_file(self, local_file: Path, remote_relative_path: str) -> None:
  253. destination = self._resolve_safe(remote_relative_path)
  254. destination.parent.mkdir(parents=True, exist_ok=True)
  255. shutil.copyfile(local_file, destination)
  256. def list_backup_directories(self) -> list[str]:
  257. if not self.root.is_dir():
  258. return []
  259. return sorted(
  260. item.name for item in self.root.iterdir() if item.is_dir()
  261. )
  262. def delete_backup_directory(self, name: str) -> None:
  263. directory = self._resolve_safe(name)
  264. if directory.is_dir():
  265. shutil.rmtree(directory)
  266. def describe(self) -> str:
  267. return str(self.root)
  268. _PERCENT_PATTERN = re.compile(r"(\d+)%")
  269. _LIST_DIRECTORY_PATTERN = re.compile(r"^d[-xrwSsTt]{9,}.*$")
  270. class RsyncBackupTarget(BackupTarget):
  271. """RSYNC 模式:rsync:// 专用备份协议推送异机(生产)。
  272. rsync daemon 模块形态,密码经 RSYNC_PASSWORD 环境变量传递(不进命令行);
  273. -a --partial 提供增量传输与断点续传;进度解析 --info=progress2 输出。
  274. """
  275. def __init__(self, executable: str, url: str, password: str) -> None:
  276. self.executable = executable
  277. self.url = url if url.endswith("/") else url + "/"
  278. self.password = password or ""
  279. self._current_process: subprocess.Popen[bytes] | None = None
  280. @staticmethod
  281. def locate_executable() -> str | None:
  282. configured = os.environ.get("DMS_RSYNC_EXECUTABLE", "").strip()
  283. if configured:
  284. return configured
  285. found = shutil.which("rsync")
  286. if found:
  287. return found
  288. # 发行包内置工具:exe 同目录与工作目录下的 tools/rsync/(Windows 随包携带
  289. # cwRsync,Linux 可放置静态二进制;run 脚本 cd 到 exe 目录后两者同址)。
  290. for root in (Path(sys.executable).resolve().parent, Path.cwd()):
  291. for name in ("rsync.exe", "rsync"):
  292. candidate = root / "tools" / "rsync" / name
  293. if candidate.is_file():
  294. return str(candidate)
  295. return None
  296. @staticmethod
  297. def _local_path(path: Path) -> str:
  298. """本地路径转 rsync 可用形态:Windows 盘符路径(C:\\x 或 C:/x)转为 Cygwin
  299. /cygdrive/<盘>/x——发行包内置 cwRsync 为 Cygwin 构建,盘符冒号会被 rsync 按
  300. 远端 host:path 语法解析,报 "source and destination cannot both be remote"
  301. (退出码 1 用法错误,main.c);Linux 原生路径原样返回。"""
  302. raw = str(path)
  303. if len(raw) >= 2 and raw[1] == ":" and raw[0].lower() in "abcdefghijklmnopqrstuvwxyz":
  304. return "/cygdrive/" + raw[0].lower() + raw[2:].replace("\\", "/")
  305. return raw
  306. def check_writable(self) -> None:
  307. # 可达性:--list-only 模块根;可写性:覆盖推送固定探测文件
  308. self.run([self.executable, "--list-only", self.url])
  309. probe = Path(tempfile.mkstemp(prefix="dms-rsync-probe-")[1])
  310. try:
  311. self.push_file(probe, ".dms-writable-probe")
  312. finally:
  313. probe.unlink(missing_ok=True)
  314. def push_directory(
  315. self, local_directory: Path, name: str,
  316. reporter: Callable[[int], None] | None = None,
  317. ) -> None:
  318. # 尾斜杠 = 推目录内容;--partial 断点续传;progress2 整体进度
  319. self.run(
  320. [
  321. self.executable, "-a", "--partial", "--info=progress2",
  322. self._local_path(local_directory) + "/", self.url + name + "/",
  323. ],
  324. reporter=reporter,
  325. )
  326. def fetch_file(self, remote_relative_path: str) -> bytes | None:
  327. fd, temp_name = tempfile.mkstemp(prefix="dms-rsync-fetch-")
  328. os.close(fd)
  329. temp = Path(temp_name)
  330. try:
  331. code = self.run(
  332. [self.executable, self.url + remote_relative_path, self._local_path(temp)],
  333. check=False,
  334. )
  335. if code in (23, 3):
  336. # 23=部分传输(源不存在),3=协议/路径错误:统一按不存在处理
  337. return None
  338. if code != 0:
  339. raise BackupTargetUnavailableError(
  340. f"拉取 {remote_relative_path} 失败(rsync 退出码 {code})"
  341. )
  342. return temp.read_bytes() if temp.is_file() else None
  343. finally:
  344. temp.unlink(missing_ok=True)
  345. def fetch_directory(self, name: str, local_directory: Path) -> None:
  346. local_directory.mkdir(parents=True, exist_ok=True)
  347. self.run(
  348. [
  349. self.executable, "-a", "--partial",
  350. self.url + name + "/", self._local_path(local_directory.resolve()) + "/",
  351. ]
  352. )
  353. def push_file(self, local_file: Path, remote_relative_path: str) -> None:
  354. self.run([self.executable, self._local_path(local_file), self.url + remote_relative_path])
  355. def list_backup_directories(self) -> list[str]:
  356. output = self.run(
  357. [self.executable, "--list-only", self.url], capture=True
  358. )
  359. names: list[str] = []
  360. for raw_line in output.splitlines():
  361. line = raw_line.strip()
  362. if not line or not _LIST_DIRECTORY_PATTERN.match(line):
  363. continue
  364. name = line[line.rfind(" ") + 1:]
  365. if name and name not in (".", ".."):
  366. names.append(name)
  367. return names
  368. def delete_backup_directory(self, name: str) -> None:
  369. # rsync 无直接删除:空目录 --delete 镜像即可清空远端目录内容
  370. empty = Path(tempfile.mkdtemp(prefix="dms-rsync-empty-"))
  371. try:
  372. self.run(
  373. [self.executable, "-a", "--delete", self._local_path(empty) + "/", self.url + name + "/"]
  374. )
  375. finally:
  376. shutil.rmtree(empty, ignore_errors=True)
  377. def describe(self) -> str:
  378. return self.url
  379. def interrupt(self) -> None:
  380. process = self._current_process
  381. if process is not None and process.poll() is None:
  382. process.terminate()
  383. def run(
  384. self,
  385. args: list[str],
  386. *,
  387. reporter: Callable[[int], None] | None = None,
  388. capture: bool = False,
  389. check: bool = True,
  390. ) -> str | int:
  391. """执行 rsync:合流读输出防管道阻塞,按行喂进度解析,返回输出或退出码。"""
  392. if self.executable is None:
  393. raise BackupToolMissingError(
  394. "未找到 rsync 可执行文件(RSYNC 模式需安装 rsync 或配置 DMS_RSYNC_EXECUTABLE)"
  395. )
  396. env = dict(os.environ)
  397. if self.password:
  398. env["RSYNC_PASSWORD"] = self.password
  399. try:
  400. process = subprocess.Popen(
  401. args,
  402. stdout=subprocess.PIPE,
  403. stderr=subprocess.STDOUT,
  404. env=env,
  405. )
  406. except OSError as exc:
  407. raise BackupToolMissingError(f"启动 rsync 失败:{exc}") from exc
  408. self._current_process = process
  409. captured: list[bytes] = []
  410. tail = ""
  411. assert process.stdout is not None
  412. try:
  413. for raw_line in process.stdout:
  414. line = raw_line.decode("utf-8", errors="replace").strip()
  415. if not line:
  416. continue
  417. if capture:
  418. captured.append(raw_line)
  419. if reporter is not None:
  420. match = _PERCENT_PATTERN.search(line)
  421. if match:
  422. reporter(int(match.group(1)))
  423. tail = line
  424. finally:
  425. self._current_process = None
  426. try:
  427. code = process.wait()
  428. except KeyboardInterrupt:
  429. process.terminate()
  430. raise
  431. if check and code != 0:
  432. raise BackupTargetUnavailableError(
  433. f"rsync 传输失败(退出码 {code}):{tail[-300:]}"
  434. )
  435. if capture:
  436. return b"".join(captured).decode("utf-8", errors="replace")
  437. return code
  438. # ==================================================================== 数据库导出
  439. # 自有格式数据库备份(dms-data.sql 数据脚本)实现于 sql_dump 模块;
  440. # DmBackupTool(dexp/dimp 外部工具封装)已移除——导出/恢复全部走驱动直连,
  441. # 不再依赖达梦客户端工具。保留 BackupToolMissingError 供 rsync 目标使用。
  442. # ==================================================================== 引擎
  443. def _dm_userid(database_url: str) -> tuple[str, str]:
  444. """解析达梦连接串为 (userid, schema):dm+dmPython://user:pwd@host:port。"""
  445. parts = urlsplit(database_url)
  446. if parts.username is None or parts.hostname is None:
  447. raise BackupConfigInvalidError(
  448. f"无法从数据库连接串解析达梦凭据:{database_url}"
  449. )
  450. username = unquote(parts.username)
  451. password = unquote(parts.password or "")
  452. return f"{username}/{password}@{parts.hostname}:{parts.port}", username
  453. @dataclass(frozen=True)
  454. class BackupOutcome:
  455. """备份任务终态快照(完成回调入参;backup_id 在早期失败时为 None)。"""
  456. success: bool
  457. type: str
  458. trigger: str
  459. backup_id: str | None
  460. error: str | None
  461. class BackupEngine:
  462. """链式增量备份引擎:单任务互斥、进度快照、定时轮询。"""
  463. def __init__(
  464. self,
  465. *,
  466. storage_root: Path,
  467. business_timezone: str,
  468. database_url: str,
  469. backup_config_path: Path,
  470. env_path: Path,
  471. ) -> None:
  472. self.storage_root = storage_root.expanduser().resolve()
  473. self.timezone = ZoneInfo(business_timezone)
  474. self.database_url = database_url
  475. self.backup_config_path = backup_config_path
  476. self.env_path = env_path
  477. self._running = threading.Event()
  478. self._lock = threading.Lock()
  479. self._cancel_requested = False
  480. self._active_target: BackupTarget | None = None
  481. # 进度快照恒为全量12字段形状(从未运行时业务字段为null/0),与Java版对齐
  482. self._progress: dict[str, Any] = {
  483. "backupId": None,
  484. "type": None,
  485. "trigger": None,
  486. "phase": None,
  487. "bytesTotal": 0,
  488. "bytesCopied": 0,
  489. "filesTotal": 0,
  490. "filesCopied": 0,
  491. "pushPercent": None,
  492. "error": None,
  493. "startedAt": None,
  494. "finishedAt": None,
  495. }
  496. self._scheduler_stop = threading.Event()
  497. # ------------------------------------------------------------ 公共 API
  498. @property
  499. def is_running(self) -> bool:
  500. return self._running.is_set()
  501. def progress_snapshot(self) -> dict[str, Any]:
  502. with self._lock:
  503. return dict(self._progress)
  504. def load_config(self) -> BackupConfig:
  505. return BackupConfig.load(self.backup_config_path)
  506. def start_async(
  507. self,
  508. backup_type: str,
  509. trigger: str,
  510. on_complete: Callable[[BackupOutcome], None] | None = None,
  511. ) -> None:
  512. """异步触发备份;on_complete 在任务终态恰调一次(先于 running 复位)。"""
  513. if self._running.is_set():
  514. raise BackupRunningError()
  515. self._running.set()
  516. self._cancel_requested = False
  517. thread = threading.Thread(
  518. target=self._run_guarded,
  519. args=(backup_type, trigger, on_complete),
  520. name="dms-backup",
  521. daemon=True,
  522. )
  523. thread.start()
  524. def _run_guarded(
  525. self,
  526. backup_type: str,
  527. trigger: str,
  528. on_complete: Callable[[BackupOutcome], None] | None,
  529. ) -> None:
  530. try:
  531. outcome = self._run_backup(backup_type, trigger)
  532. except Exception as exc: # noqa: BLE001 - 后台线程兜底
  533. logger.exception("备份任务异常终止")
  534. self._finish("FAILED", str(exc))
  535. outcome = BackupOutcome(False, backup_type, trigger, None, str(exc))
  536. try:
  537. if on_complete is not None:
  538. on_complete(outcome)
  539. except Exception: # noqa: BLE001 - 回调失败不否定备份结果
  540. logger.warning("备份完成回调执行失败", exc_info=True)
  541. finally:
  542. self._running.clear()
  543. def cancel(self) -> None:
  544. if not self._running.is_set():
  545. return
  546. self._cancel_requested = True
  547. target = self._active_target
  548. if target is not None:
  549. target.interrupt()
  550. def list_history(self, config: BackupConfig) -> list[dict[str, Any]]:
  551. target = self.build_target(config)
  552. try:
  553. return self._sorted_index(target)
  554. except (BackupFailedError, BackupTargetUnavailableError, BackupToolMissingError):
  555. raise
  556. except Exception as exc:
  557. raise BackupTargetUnavailableError(f"读取备份历史失败:{exc}") from exc
  558. def decide_scheduled_type(self, config: BackupConfig) -> str:
  559. """距最近全量 ≥ full_interval_days 天(或无历史)→ FULL。"""
  560. target = self.build_target(config)
  561. entries = self._sorted_index(target)
  562. last_full = max(
  563. (entry["id"] for entry in entries if entry["id"].endswith("-F")),
  564. default=None,
  565. )
  566. if last_full is None:
  567. return TYPE_FULL
  568. last_full_date = datetime.strptime(
  569. last_full.removeprefix("dms-bak-")[:8], "%Y%m%d"
  570. ).date()
  571. threshold = (
  572. datetime.now(self.timezone) - timedelta(days=config.full_interval_days)
  573. ).date()
  574. return TYPE_FULL if last_full_date < threshold else TYPE_INCREMENTAL
  575. def build_target(self, config: BackupConfig) -> BackupTarget:
  576. if config.target_mode == "RSYNC":
  577. rsync = RsyncBackupTarget.locate_executable()
  578. if rsync is None:
  579. raise BackupToolMissingError(
  580. "未找到 rsync(RSYNC 模式需安装 rsync 或配置 DMS_RSYNC_EXECUTABLE)"
  581. )
  582. return RsyncBackupTarget(rsync, config.rsync_url, config.rsync_password)
  583. return LocalBackupTarget(Path(config.local_path))
  584. # ------------------------------------------------------------ 定时器
  585. def start_scheduler(
  586. self, audit_writer: Callable[[BackupOutcome], None]
  587. ) -> None:
  588. """启动定时轮询守护线程(audit_writer 在任务终态写审计,入参 BackupOutcome)。"""
  589. def loop() -> None:
  590. triggered_date = None
  591. last_attempt: datetime | None = None
  592. while not self._scheduler_stop.wait(_SCHEDULE_TICK_SECONDS):
  593. try:
  594. now = datetime.now(self.timezone)
  595. today = now.date()
  596. if today == triggered_date:
  597. continue
  598. config = self.load_config()
  599. if not config.schedule_enabled:
  600. continue
  601. if now.time() < datetime.strptime(
  602. config.schedule_time, "%H:%M"
  603. ).time():
  604. continue
  605. if (
  606. last_attempt is not None
  607. and last_attempt
  608. + timedelta(minutes=_SCHEDULE_RETRY_BACKOFF_MINUTES)
  609. > now
  610. ):
  611. continue
  612. last_attempt = now
  613. backup_type = self.decide_scheduled_type(config)
  614. self.start_async(
  615. backup_type, TRIGGER_SCHEDULE, on_complete=audit_writer
  616. )
  617. triggered_date = today
  618. logger.info(
  619. "定时备份已触发:type=%s schedule_time=%s",
  620. backup_type,
  621. config.schedule_time,
  622. )
  623. except BackupRunningError:
  624. # 手动任务占用:不退避,下一轮再试
  625. logger.info("定时备份等待手动任务结束")
  626. except Exception as exc: # noqa: BLE001 - 定时轮询兜底
  627. logger.error(
  628. "定时备份触发失败(%d分钟后重试):%s",
  629. _SCHEDULE_RETRY_BACKOFF_MINUTES,
  630. exc,
  631. )
  632. thread = threading.Thread(target=loop, name="dms-backup-scheduler", daemon=True)
  633. thread.start()
  634. def stop_scheduler(self) -> None:
  635. self._scheduler_stop.set()
  636. # ------------------------------------------------------------ 核心流程
  637. def _run_backup(self, requested_type: str, trigger: str) -> BackupOutcome:
  638. now = datetime.now(self.timezone)
  639. config = self.load_config()
  640. config.validate()
  641. target = self.build_target(config)
  642. self._active_target = target
  643. try:
  644. target.check_writable()
  645. index = self._read_index(target)
  646. except (BackupFailedError, BackupTargetUnavailableError, BackupToolMissingError):
  647. self._finish("FAILED", "备份目标不可用")
  648. return BackupOutcome(
  649. False, requested_type, trigger, None, "备份目标不可用"
  650. )
  651. except Exception as exc:
  652. message = f"备份目标不可用:{exc}"
  653. self._finish("FAILED", message)
  654. return BackupOutcome(False, requested_type, trigger, None, message)
  655. parent_manifest: BackupManifest | None = None
  656. if (
  657. requested_type == TYPE_INCREMENTAL
  658. and index
  659. ):
  660. manifest_data = self._fetch_manifest(target, index[-1]["id"])
  661. if manifest_data is not None:
  662. parent_manifest = BackupManifest.parse(manifest_data)
  663. # 无历史时增量自动升级全量(增量必须有父)
  664. backup_type = (
  665. TYPE_INCREMENTAL
  666. if requested_type == TYPE_INCREMENTAL and parent_manifest is not None
  667. else TYPE_FULL
  668. )
  669. backup_id = (
  670. "dms-bak-"
  671. + now.strftime(_ID_FORMAT)
  672. + ("-F" if backup_type == TYPE_FULL else "-I")
  673. )
  674. with self._lock:
  675. self._progress = {
  676. "backupId": backup_id,
  677. "type": backup_type,
  678. "trigger": trigger,
  679. "phase": "SCANNING",
  680. "bytesTotal": 0,
  681. "bytesCopied": 0,
  682. "filesTotal": 0,
  683. "filesCopied": 0,
  684. "pushPercent": None,
  685. "error": None,
  686. "startedAt": now.strftime(_TIME_FORMAT),
  687. "finishedAt": None,
  688. }
  689. logger.info(
  690. "备份开始:id=%s type=%s trigger=%s 目标=%s",
  691. backup_id, backup_type, trigger, target.describe(),
  692. )
  693. staging_root = self.storage_root / "backup-staging"
  694. try:
  695. shutil.rmtree(staging_root, ignore_errors=True)
  696. staging = staging_root / backup_id
  697. database_dir = staging / "database"
  698. env_dir = staging / "env"
  699. (staging / "storage").mkdir(parents=True, exist_ok=True)
  700. database_dir.mkdir(parents=True, exist_ok=True)
  701. env_dir.mkdir(parents=True, exist_ok=True)
  702. # 1. 扫描存储:合并全清单(相对路径→字节,有序)
  703. current = self._scan_storage()
  704. # 2. 数据库全量导出(自有格式数据脚本)+ .env 副本(best-effort)
  705. self._set_phase("EXPORTING_DB")
  706. _, schema = _dm_userid(self.database_url)
  707. dump_file = sql_dump.export_dump(self.database_url, database_dir, schema)
  708. self._copy_env_best_effort(env_dir)
  709. # 3. 拷贝文件到暂存(全量=全部;增量=相对父清单新增)
  710. self._set_phase("COPYING")
  711. parent_files = set(parent_manifest.files) if parent_manifest else set()
  712. transfer_list = [
  713. relative for relative in current if relative not in parent_files
  714. ]
  715. total_bytes = sum(current[relative] for relative in transfer_list)
  716. with self._lock:
  717. self._progress["filesTotal"] = len(transfer_list)
  718. self._progress["bytesTotal"] = total_bytes
  719. copied_bytes = 0
  720. copied_files = 0
  721. for relative in transfer_list:
  722. self._check_cancelled()
  723. source = self.storage_root / relative
  724. if not source.is_file():
  725. # 扫描后被删除的文件:跳过(内容寻址下该记录不会指向它)
  726. logger.warning("备份拷贝跳过已消失文件:%s", relative)
  727. continue
  728. destination = staging / "storage" / relative
  729. destination.parent.mkdir(parents=True, exist_ok=True)
  730. shutil.copyfile(source, destination)
  731. copied_bytes += current[relative]
  732. copied_files += 1
  733. with self._lock:
  734. self._progress["bytesCopied"] = copied_bytes
  735. self._progress["filesCopied"] = copied_files
  736. # 4. manifest 最后原子写入(无 manifest 即中断残留)
  737. manifest = BackupManifest(
  738. id=backup_id,
  739. type=backup_type,
  740. parent=parent_manifest.id if parent_manifest else None,
  741. started_at=self._progress["startedAt"],
  742. finished_at=datetime.now(self.timezone).strftime(_TIME_FORMAT),
  743. database_size=dump_file.stat().st_size,
  744. transferred_files=copied_files,
  745. transferred_bytes=copied_bytes,
  746. storage_file_count=len(current),
  747. storage_total_bytes=sum(current.values()),
  748. files=list(current.keys()),
  749. )
  750. manifest.write(staging / "manifest.json")
  751. # 5. 推送整个备份目录
  752. self._set_phase("PUSHING")
  753. target.push_directory(
  754. staging,
  755. backup_id,
  756. reporter=lambda percent: self._set_progress("pushPercent", percent),
  757. )
  758. # 6. 更新历史索引 + 保留链清理 + 残留清理
  759. self._set_phase("CLEANING")
  760. index.append(manifest.to_dict(include_files=False))
  761. self._write_index(target, index)
  762. self._cleanup_retention_policy(target, index, config.retain_full_count)
  763. except (BackupFailedError, BackupTargetUnavailableError, BackupToolMissingError) as exc:
  764. logger.error("备份失败:id=%s %s", backup_id, exc)
  765. self._finish("FAILED", str(exc))
  766. shutil.rmtree(staging_root, ignore_errors=True)
  767. return BackupOutcome(False, backup_type, trigger, backup_id, str(exc))
  768. except Exception as exc: # noqa: BLE001 - 后台线程兜底
  769. logger.exception("备份失败:id=%s", backup_id)
  770. message = f"备份执行异常:{exc}"
  771. self._finish("FAILED", message)
  772. shutil.rmtree(staging_root, ignore_errors=True)
  773. return BackupOutcome(False, backup_type, trigger, backup_id, message)
  774. shutil.rmtree(staging_root, ignore_errors=True)
  775. with self._lock:
  776. self._progress["phase"] = "SUCCESS"
  777. self._progress["finishedAt"] = datetime.now(self.timezone).strftime(
  778. _TIME_FORMAT
  779. )
  780. logger.info("备份完成:id=%s", backup_id)
  781. return BackupOutcome(True, backup_type, trigger, backup_id, None)
  782. def _scan_storage(self) -> dict[str, int]:
  783. """扫描六个备份目录,返回 相对路径→字节(有序保证清单顺序稳定)。"""
  784. files: dict[str, int] = {}
  785. for directory in BACKUP_SUBDIRECTORIES:
  786. base = self.storage_root / directory
  787. if not base.is_dir():
  788. continue
  789. for path in base.rglob("*"):
  790. if path.is_file():
  791. relative = path.relative_to(self.storage_root).as_posix()
  792. files[relative] = path.stat().st_size
  793. return dict(sorted(files.items()))
  794. def _copy_env_best_effort(self, env_dir: Path) -> None:
  795. try:
  796. if self.env_path.is_file():
  797. shutil.copyfile(self.env_path, env_dir / ".env")
  798. except OSError as exc:
  799. logger.warning("备份 .env 副本跳过:%s", exc)
  800. def _read_index(self, target: BackupTarget) -> list[dict[str, Any]]:
  801. content = target.fetch_file(INDEX_FILE)
  802. if content is None:
  803. return []
  804. data = json.loads(content.decode("utf-8"))
  805. return data if isinstance(data, list) else []
  806. def _sorted_index(self, target: BackupTarget) -> list[dict[str, Any]]:
  807. entries = self._read_index(target)
  808. entries.sort(key=lambda entry: str(entry.get("id", "")))
  809. return entries
  810. def _fetch_manifest(self, target: BackupTarget, backup_id: str) -> dict[str, Any] | None:
  811. content = target.fetch_file(f"{backup_id}/manifest.json")
  812. if content is None:
  813. raise BackupFailedError("上一备份缺少 manifest,无法执行增量")
  814. return json.loads(content.decode("utf-8"))
  815. def _write_index(self, target: BackupTarget, entries: list[dict[str, Any]]) -> None:
  816. fd, temp_name = tempfile.mkstemp(prefix="dms-backup-index-")
  817. os.close(fd)
  818. temp = Path(temp_name)
  819. try:
  820. temp.write_text(
  821. json.dumps(entries, ensure_ascii=False), encoding="utf-8"
  822. )
  823. target.push_file(temp, INDEX_FILE)
  824. finally:
  825. temp.unlink(missing_ok=True)
  826. def _cleanup_retention_policy(
  827. self,
  828. target: BackupTarget,
  829. index: list[dict[str, Any]],
  830. retain_full_count: int,
  831. ) -> None:
  832. """保留最近 retain_full_count 条全量链;目标上不在 index 内的目录为残留。"""
  833. try:
  834. # index 按时间升序:遇 FULL 链号+1,其后增量归属当前链
  835. total_chains = sum(
  836. 1 for entry in index if entry.get("type") == TYPE_FULL
  837. )
  838. excess_chains = total_chains - retain_full_count
  839. if excess_chains > 0:
  840. chain_number = -1
  841. removed: list[str] = []
  842. for entry in index:
  843. if entry.get("type") == TYPE_FULL:
  844. chain_number += 1
  845. if chain_number < excess_chains:
  846. removed.append(entry["id"])
  847. for backup_id in removed:
  848. target.delete_backup_directory(backup_id)
  849. logger.info("保留策略删除过期备份:%s", backup_id)
  850. index[:] = [entry for entry in index if entry["id"] not in removed]
  851. # 残留清理:目录存在但不在 index(中断备份的半成品目录)
  852. index_ids = {entry["id"] for entry in index}
  853. for directory in target.list_backup_directories():
  854. if directory not in index_ids:
  855. target.delete_backup_directory(directory)
  856. logger.info("清理中断备份残留目录:%s", directory)
  857. except Exception as exc: # noqa: BLE001 - 清理失败不否定已成功的备份
  858. logger.warning("备份保留策略清理失败(不影响本次备份):%s", exc)
  859. def _check_cancelled(self) -> None:
  860. if self._cancel_requested:
  861. raise BackupFailedError("备份已取消")
  862. def _set_phase(self, phase: str) -> None:
  863. with self._lock:
  864. self._progress["phase"] = phase
  865. def _set_progress(self, key: str, value: Any) -> None:
  866. with self._lock:
  867. self._progress[key] = value
  868. def _finish(self, phase: str, error: str) -> None:
  869. with self._lock:
  870. self._progress["phase"] = phase
  871. self._progress["error"] = error
  872. self._progress["finishedAt"] = datetime.now(self.timezone).strftime(
  873. _TIME_FORMAT
  874. )
  875. # ==================================================================== 恢复(离线 CLI)
  876. def run_restore(args: list[str]) -> int:
  877. """dms-server restore-backup [--backup-id dms-bak-xxx] [--yes]。
  878. 沿 parent 回溯备份链拉取到本地临时目录 → 链尾数据库脚本驱动直连导入 →
  879. 清空存储六目录按链序叠加还原文件。退出码:0 成功 / 1 取消 / 2 失败。
  880. """
  881. backup_id: str | None = None
  882. assume_yes = False
  883. remaining = list(args[1:])
  884. while remaining:
  885. item = remaining.pop(0)
  886. if item == "--backup-id" and remaining:
  887. backup_id = remaining.pop(0)
  888. elif item == "--yes":
  889. assume_yes = True
  890. else:
  891. print(f"错误:无法识别的参数 {item}")
  892. print("用法:dms-server restore-backup [--backup-id dms-bak-xxx] [--yes]")
  893. return 1
  894. engine = _build_standalone_engine()
  895. config = engine.load_config()
  896. try:
  897. config.validate()
  898. except BackupConfigInvalidError as exc:
  899. print(f"错误:备份配置无效({engine.backup_config_path}):{exc}")
  900. return 2
  901. try:
  902. target = engine.build_target(config)
  903. index = engine._sorted_index(target)
  904. except Exception as exc: # noqa: BLE001 - CLI 汇总报错
  905. print(f"错误:读取备份历史失败:{exc}")
  906. return 2
  907. if not index:
  908. print("错误:备份目标上没有任何历史备份。")
  909. return 2
  910. if backup_id is None:
  911. backup_id = max(entry["id"] for entry in index)
  912. print(f"未指定 --backup-id,恢复最新备份:{backup_id}")
  913. # 沿 parent 回溯整条链(新→旧收集,再反转为 旧→新)
  914. chain: list[BackupManifest] = []
  915. try:
  916. cursor: str | None = backup_id
  917. while cursor is not None and len(chain) < 1000:
  918. content = target.fetch_file(f"{cursor}/manifest.json")
  919. if content is None:
  920. print(f"错误:备份 {cursor} 缺少 manifest,链不完整。")
  921. return 2
  922. manifest = BackupManifest.parse(json.loads(content.decode("utf-8")))
  923. if manifest.id != cursor:
  924. print(f"错误:备份 {cursor} 的 manifest 内容与目录不符。")
  925. return 2
  926. chain.append(manifest)
  927. cursor = manifest.parent
  928. except Exception as exc: # noqa: BLE001 - CLI 汇总报错
  929. print(f"错误:读取备份链失败:{exc}")
  930. return 2
  931. chain.reverse()
  932. tail = chain[-1]
  933. print(f"备份目标:{target.describe()}")
  934. print(f"恢复计划({len(chain)} 个备份,链头→链尾):")
  935. for member in chain:
  936. print(
  937. f" - {member.id} {member.type}"
  938. f" 文件 {member.transferred_files} 个 / {member.transferred_bytes} 字节,"
  939. f"数据库导出 {member.database_size} 字节"
  940. )
  941. print(f"链尾合并清单:{tail.storage_file_count} 个文件 / {tail.storage_total_bytes} 字节")
  942. print("恢复将执行:")
  943. print(f" 1. 导入链尾数据库脚本 {tail.id}/database/{sql_dump.DUMP_FILE_NAME}"
  944. f"(禁用外键→清空业务表→导入→恢复外键;目标库为空时先执行 alembic 迁移建库)")
  945. print(f" 2. 清空并重建存储目录:{'、'.join(BACKUP_SUBDIRECTORIES)}")
  946. print(f" 3. 按链序叠加还原 {tail.storage_file_count} 个文件至 {engine.storage_root}")
  947. if not assume_yes:
  948. answer = input("确认执行恢复?(y/N):").strip().lower()
  949. if answer not in ("y", "yes"):
  950. print("已取消。")
  951. return 1
  952. workspace = Path(tempfile.mkdtemp(prefix="dms-restore-"))
  953. try:
  954. # 1. 拉取整条链
  955. print("拉取备份链……")
  956. for member in chain:
  957. print(f" 拉取 {member.id} ……")
  958. target.fetch_directory(member.id, workspace / member.id)
  959. # 2. 数据库恢复:链尾数据脚本即最新状态(空库自动 alembic 建库)
  960. print("导入数据库……")
  961. _, schema = _dm_userid(engine.database_url)
  962. def ensure_schema_ready() -> None:
  963. from dms.db_upgrade import run_upgrade_silent
  964. print("目标库为空,先执行 alembic 迁移建库……")
  965. run_upgrade_silent(engine.database_url)
  966. sql_dump.restore_dump(
  967. engine.database_url,
  968. workspace / tail.id / "database" / sql_dump.DUMP_FILE_NAME,
  969. schema,
  970. ensure_schema_ready=ensure_schema_ready,
  971. )
  972. # 3. 存储恢复:清空六目录,链序叠加(各成员物理持有 = 自身清单 − 父清单)
  973. print("重建存储目录……")
  974. for directory in BACKUP_SUBDIRECTORIES:
  975. shutil.rmtree(engine.storage_root / directory, ignore_errors=True)
  976. (engine.storage_root / directory).mkdir(parents=True, exist_ok=True)
  977. restored = 0
  978. parent_files: set[str] = set()
  979. for member in chain:
  980. member_storage = workspace / member.id / "storage"
  981. for relative in member.files:
  982. if relative in parent_files:
  983. continue
  984. destination = engine.storage_root / relative
  985. destination.parent.mkdir(parents=True, exist_ok=True)
  986. shutil.copyfile(member_storage / relative, destination)
  987. restored += 1
  988. parent_files.update(member.files)
  989. print(f"存储恢复完成:{restored} 个文件。")
  990. # 4. .env 副本落地为 .env.restored(不覆盖当前 .env,防止恢复机数据库地址不同)
  991. env_backup = workspace / tail.id / "env" / ".env"
  992. if env_backup.is_file():
  993. shutil.copyfile(
  994. env_backup, Path.cwd() / ".env.restored"
  995. )
  996. print("备份时的 .env 已存至 .env.restored(未覆盖当前 .env)。")
  997. except Exception as exc: # noqa: BLE001 - CLI 汇总报错
  998. print(f"错误:恢复失败:{exc}")
  999. return 2
  1000. finally:
  1001. shutil.rmtree(workspace, ignore_errors=True)
  1002. print("恢复完成。建议重启 DMS 服务后核验数据。")
  1003. return 0
  1004. def _build_standalone_engine() -> BackupEngine:
  1005. """离线 CLI 场景构造引擎(不依赖 Flask 上下文)。"""
  1006. from flask import Flask
  1007. from dms import init_dms # 延后导入避免循环依赖
  1008. app = Flask(__name__)
  1009. init_dms(app)
  1010. engine = app.extensions["dms_backup_engine"]
  1011. assert isinstance(engine, BackupEngine)
  1012. # init_dms 会启动定时线程,CLI 场景立即停止
  1013. engine.stop_scheduler()
  1014. return engine