backup.py 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. """备份管理接口(契约1.21),对齐 Java 版 BackupController。
  2. 配置读写 + 手动触发 + 进度查询 + 历史列表 + 目标连通性测试(契约1.23)。
  3. 全部仅系统管理权限可访问;手动触发在任务终态记 BACKUP_CREATE(成功/失败
  4. 含原因)、目标测试记 BACKUP_TEST_TARGET 审计。密码不回显(仅回是否已设置),
  5. PUT 空值保持不变。
  6. """
  7. from __future__ import annotations
  8. import logging
  9. from flask import Flask, current_app, request
  10. from dms.api.v1.blueprint import api_v1
  11. from dms.common.enums import AuditAction, AuditResult, AuditTarget
  12. from dms.common.errors import InvalidArgumentError
  13. from dms.common.response import success_response
  14. from dms.extensions import db
  15. from dms.models import AuditLog
  16. from dms.security.decorators import bearer_auth_required, system_management_required
  17. from dms.services.audit_service import business_audit
  18. from dms.services.backup_service import (
  19. TRIGGER_MANUAL,
  20. TYPE_FULL,
  21. TYPE_INCREMENTAL,
  22. BackupConfig,
  23. BackupEngine,
  24. BackupOutcome,
  25. )
  26. logger = logging.getLogger(__name__)
  27. _CONFIG_FIELDS = (
  28. "target_mode",
  29. "local_path",
  30. "rsync_url",
  31. "rsync_password",
  32. "schedule_enabled",
  33. "schedule_time",
  34. "full_interval_days",
  35. "retain_full_count",
  36. )
  37. def _engine() -> BackupEngine:
  38. engine = current_app.extensions["dms_backup_engine"]
  39. assert isinstance(engine, BackupEngine)
  40. return engine
  41. def _json_body() -> dict:
  42. if not request.is_json:
  43. raise InvalidArgumentError("请求体必须是JSON对象")
  44. body = request.get_json(silent=True)
  45. if not isinstance(body, dict):
  46. raise InvalidArgumentError("请求体必须是JSON对象")
  47. return body
  48. def _exact_payload(fields: tuple[str, ...]) -> dict:
  49. body = _json_body()
  50. if set(body) != set(fields):
  51. raise InvalidArgumentError(
  52. "请求体字段必须且只能包含:" + "、".join(fields)
  53. )
  54. return body
  55. def _required_text(value, field: str, max_length: int) -> str:
  56. if not isinstance(value, str) or not value.strip():
  57. raise InvalidArgumentError(f"{field}不能为空")
  58. normalized = value.strip()
  59. if len(normalized) > max_length:
  60. raise InvalidArgumentError(f"{field}长度不能超过{max_length}")
  61. return normalized
  62. def _nullable_text(value, field: str, max_length: int) -> str | None:
  63. if value is None:
  64. return None
  65. if not isinstance(value, str):
  66. raise InvalidArgumentError(f"{field}必须是字符串或null")
  67. normalized = value.strip()
  68. if len(normalized) > max_length:
  69. raise InvalidArgumentError(f"{field}长度不能超过{max_length}")
  70. return normalized or None
  71. def _boolean_value(value, field: str) -> bool:
  72. if not isinstance(value, bool):
  73. raise InvalidArgumentError(f"{field}必须是布尔值")
  74. return value
  75. def _int_value(value, field: str) -> int:
  76. if isinstance(value, bool) or not isinstance(value, int):
  77. raise InvalidArgumentError(f"{field}必须是整数")
  78. return value
  79. @api_v1.get("/backup/config")
  80. @bearer_auth_required
  81. @system_management_required
  82. def get_backup_config():
  83. return success_response(_engine().load_config().public_view())
  84. @api_v1.put("/backup/config")
  85. @bearer_auth_required
  86. @system_management_required
  87. def update_backup_config():
  88. engine = _engine()
  89. payload = _exact_payload(_CONFIG_FIELDS)
  90. current = engine.load_config()
  91. current.target_mode = _required_text(payload["target_mode"], "target_mode", 16)
  92. current.local_path = _nullable_text(payload["local_path"], "local_path", 1024) or ""
  93. current.rsync_url = _nullable_text(payload["rsync_url"], "rsync_url", 1024) or ""
  94. # 密码空值 = 保持不变(回显永远是掩码,前端不回传明文)
  95. password = _nullable_text(payload["rsync_password"], "rsync_password", 256)
  96. if password is not None:
  97. current.rsync_password = password
  98. current.schedule_enabled = _boolean_value(
  99. payload["schedule_enabled"], "schedule_enabled"
  100. )
  101. current.schedule_time = _required_text(payload["schedule_time"], "schedule_time", 5)
  102. current.full_interval_days = _int_value(
  103. payload["full_interval_days"], "full_interval_days"
  104. )
  105. current.retain_full_count = _int_value(payload["retain_full_count"], "retain_full_count")
  106. current.validate()
  107. current.save(engine.backup_config_path)
  108. return success_response(current.public_view())
  109. @api_v1.post("/backup/jobs")
  110. @bearer_auth_required
  111. @system_management_required
  112. def start_backup_job():
  113. engine = _engine()
  114. payload = _exact_payload(("type",))
  115. backup_type = _required_text(payload["type"], "type", 16)
  116. if backup_type not in (TYPE_FULL, TYPE_INCREMENTAL):
  117. raise InvalidArgumentError("type 必须为 FULL 或 INCREMENTAL")
  118. # 审计在任务终态落库(反映真实结果):此处先按请求上下文快照用户/IP/UA
  119. record = business_audit(
  120. action=AuditAction.BACKUP_CREATE,
  121. target=AuditTarget.BACKUP,
  122. target_id=0,
  123. target_name=backup_type,
  124. )
  125. app = current_app._get_current_object()
  126. engine.start_async(
  127. backup_type,
  128. TRIGGER_MANUAL,
  129. on_complete=lambda outcome: _write_completion_audit(app, record, outcome),
  130. )
  131. return success_response(engine.progress_snapshot(), status=202)
  132. def _write_completion_audit(
  133. app: Flask, record: AuditLog, outcome: BackupOutcome
  134. ) -> None:
  135. """任务终态审计落库:成功/失败(含失败原因);写入失败不否定备份结果。"""
  136. try:
  137. with app.app_context():
  138. record.target_name = outcome.type
  139. record.operation_result = (
  140. AuditResult.SUCCESS.value
  141. if outcome.success
  142. else AuditResult.FAILURE.value
  143. )
  144. record.failure_reason = (
  145. None
  146. if outcome.success or outcome.error is None
  147. else outcome.error[:1000]
  148. )
  149. record.operation_detail = {
  150. "type": outcome.type,
  151. "trigger": outcome.trigger,
  152. "backupId": outcome.backup_id,
  153. }
  154. db.session.add(record)
  155. db.session.commit()
  156. except Exception: # noqa: BLE001 - 审计写入失败不否定已完成的备份
  157. logger.warning("备份完成审计写入失败", exc_info=True)
  158. with app.app_context():
  159. db.session.rollback()
  160. @api_v1.get("/backup/jobs/current")
  161. @bearer_auth_required
  162. @system_management_required
  163. def backup_job_current():
  164. engine = _engine()
  165. snapshot = engine.progress_snapshot()
  166. snapshot["running"] = engine.is_running
  167. return success_response(snapshot)
  168. @api_v1.post("/backup/jobs/current/cancel")
  169. @bearer_auth_required
  170. @system_management_required
  171. def cancel_backup_job():
  172. engine = _engine()
  173. had_running = engine.is_running
  174. engine.cancel()
  175. return success_response({"canceled": had_running})
  176. @api_v1.get("/backup/jobs")
  177. @bearer_auth_required
  178. @system_management_required
  179. def list_backup_history():
  180. engine = _engine()
  181. config: BackupConfig = engine.load_config()
  182. return success_response({"entries": engine.list_history(config)})
  183. @api_v1.post("/backup/test-target")
  184. @bearer_auth_required
  185. @system_management_required
  186. def test_backup_target():
  187. """目标连通性测试(契约1.23):探测备份目标可写性并记审计。"""
  188. engine = _engine()
  189. detail: dict = {}
  190. try:
  191. target = engine.build_target(engine.load_config())
  192. target.check_writable()
  193. detail = {"ok": True, "target": target.describe()}
  194. except Exception as exc: # noqa: BLE001 - 探测失败返回结果而非报错
  195. detail = {"ok": False, "error": str(exc)[:200]}
  196. db.session.add(
  197. business_audit(
  198. action=AuditAction.BACKUP_TEST_TARGET,
  199. target=AuditTarget.BACKUP,
  200. target_id=0,
  201. target_name="",
  202. detail=detail,
  203. )
  204. )
  205. db.session.commit()
  206. return success_response(detail)