config_backup.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  1. """DTU 运行数据备份/快照工具。
  2. 功能:
  3. - 原子写 JSON 配置(write_json_atomic)
  4. - 写前自动快照(snapshot_before_write): 内容变化才快照, 每类保留最近 N 份, 节流去重
  5. - 快照列表/读取/删除(list_snapshots/read_snapshot/delete_snapshot/find_latest_snapshot)
  6. - 整机备份包构建与校验(build_bundle/validate_bundle)
  7. 整机备份包(bundle)结构:
  8. {
  9. "format": "dzxj_dtu_backup",
  10. "format_version": 1,
  11. "created_at": "2026-10-13T14:00:00+0800",
  12. "device": {"dtu_id": ..., "customer_id": ..., ...},
  13. "effective_uid_prefix": "A1B2", # 导入时固化, 保证换机后 jumper_uid 前缀不变
  14. "files": {
  15. "dtu_config": {...}, "serial_config": {...}, "mqtt_config": {...},
  16. "devices": {...}, "system_env": "..."
  17. }
  18. }
  19. """
  20. import json
  21. import logging
  22. import os
  23. import re
  24. import time
  25. logger = logging.getLogger('serial_mqtt_gateway')
  26. BUNDLE_FORMAT = 'dzxj_dtu_backup'
  27. BUNDLE_FORMAT_VERSION = 1
  28. MAX_BUNDLE_BYTES = 2 * 1024 * 1024
  29. DEFAULT_KEEP = 20
  30. DEFAULT_MIN_INTERVAL_S = 30
  31. # backend/backups(模块位于 backend/modules/ 下)
  32. BACKUP_DIR = os.path.join(
  33. os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'backups'
  34. )
  35. _SNAPSHOT_RE = re.compile(r'^(.+)_(\d{8}_\d{6})(?:_(\d+))?\.json$')
  36. _UID_RE = re.compile(r'^[0-9a-fA-F]{24}$')
  37. _PREFIX_RE = re.compile(r'^[0-9a-fA-F]{4}$')
  38. BUNDLE_FILES = ('dtu_config', 'serial_config', 'mqtt_config', 'devices', 'system_env')
  39. def ensure_backup_dir(backup_dir=BACKUP_DIR):
  40. """确保备份目录存在"""
  41. try:
  42. os.makedirs(backup_dir, exist_ok=True)
  43. except OSError as e:
  44. logger.error(f"创建备份目录失败: {e}")
  45. return backup_dir
  46. def write_json_atomic(path, data):
  47. """原子写 JSON 文件(tmp + os.replace), 避免写一半损坏"""
  48. tmp_path = f"{path}.tmp"
  49. with open(tmp_path, 'w', encoding='utf-8') as f:
  50. json.dump(data, f, indent=2, ensure_ascii=False)
  51. os.replace(tmp_path, path)
  52. def read_json_file(path):
  53. """读取 JSON 文件, 失败抛出异常"""
  54. with open(path, 'r', encoding='utf-8') as f:
  55. return json.load(f)
  56. def _snapshot_name(raw_name):
  57. """校验快照文件名, 防路径穿越; 非法返回 None"""
  58. if not raw_name or os.path.basename(raw_name) != raw_name:
  59. return None
  60. return raw_name if _SNAPSHOT_RE.match(raw_name) else None
  61. def snapshot_stem(name):
  62. """从快照文件名提取原始配置文件 stem, 非法返回 None"""
  63. safe = _snapshot_name(name)
  64. if not safe:
  65. return None
  66. return _SNAPSHOT_RE.match(safe).group(1)
  67. def list_snapshots(backup_dir=BACKUP_DIR, stem=None):
  68. """列出快照, 按修改时间倒序(最新在前)
  69. 返回: [{'name','stem','size','mtime'}, ...]
  70. """
  71. result = []
  72. if not os.path.isdir(backup_dir):
  73. return result
  74. for name in os.listdir(backup_dir):
  75. m = _SNAPSHOT_RE.match(name)
  76. if not m:
  77. continue
  78. if stem and m.group(1) != stem:
  79. continue
  80. path = os.path.join(backup_dir, name)
  81. try:
  82. st = os.stat(path)
  83. except OSError:
  84. continue
  85. result.append({
  86. 'name': name,
  87. 'stem': m.group(1),
  88. 'size': st.st_size,
  89. 'mtime': st.st_mtime,
  90. })
  91. result.sort(key=lambda x: x['mtime'], reverse=True)
  92. return result
  93. def find_latest_snapshot(backup_dir=BACKUP_DIR, stem=None):
  94. """返回最新一份快照信息, 无则 None"""
  95. snaps = list_snapshots(backup_dir, stem=stem)
  96. return snaps[0] if snaps else None
  97. def read_snapshot(name, backup_dir=BACKUP_DIR):
  98. """读取快照内容(JSON), 返回 (data, error)"""
  99. safe = _snapshot_name(name)
  100. if not safe:
  101. return None, '非法快照文件名'
  102. path = os.path.join(backup_dir, safe)
  103. if not os.path.exists(path):
  104. return None, '快照不存在'
  105. try:
  106. with open(path, 'r', encoding='utf-8') as f:
  107. return json.load(f), None
  108. except Exception as e:
  109. return None, f'快照解析失败: {e}'
  110. def delete_snapshot(name, backup_dir=BACKUP_DIR):
  111. """删除快照, 返回 (ok, message)"""
  112. safe = _snapshot_name(name)
  113. if not safe:
  114. return False, '非法快照文件名'
  115. path = os.path.join(backup_dir, safe)
  116. if not os.path.exists(path):
  117. return False, '快照不存在'
  118. try:
  119. os.remove(path)
  120. return True, '已删除'
  121. except OSError as e:
  122. return False, f'删除失败: {e}'
  123. def _rotate_snapshots(backup_dir, stem, keep):
  124. """每类快照只保留最近 keep 份"""
  125. snaps = list_snapshots(backup_dir, stem=stem) # 倒序
  126. for item in snaps[keep:]:
  127. try:
  128. os.remove(os.path.join(backup_dir, item['name']))
  129. logger.info(f"清理旧配置快照: {item['name']}")
  130. except OSError:
  131. pass
  132. def snapshot_before_write(path, backup_dir=BACKUP_DIR, keep=DEFAULT_KEEP,
  133. min_interval_s=DEFAULT_MIN_INTERVAL_S, now=None):
  134. """写文件前对现有内容做快照。
  135. 规则:
  136. - 目标文件不存在或为空 -> 不快照
  137. - 内容与最新快照相同 -> 跳过(去重)
  138. - 距最新快照不足 min_interval_s -> 跳过(节流)
  139. 返回快照文件名, 未创建返回 None
  140. """
  141. now = time.time() if now is None else now
  142. try:
  143. if not os.path.exists(path) or os.path.getsize(path) == 0:
  144. return None
  145. with open(path, 'rb') as f:
  146. current = f.read()
  147. stem = os.path.splitext(os.path.basename(path))[0]
  148. latest = find_latest_snapshot(backup_dir, stem=stem)
  149. if latest:
  150. try:
  151. with open(os.path.join(backup_dir, latest['name']), 'rb') as f:
  152. if f.read() == current:
  153. return None
  154. except OSError:
  155. pass
  156. if now - latest['mtime'] < min_interval_s:
  157. return None
  158. ensure_backup_dir(backup_dir)
  159. base = os.path.join(
  160. backup_dir,
  161. f"{stem}_{time.strftime('%Y%m%d_%H%M%S', time.localtime(now))}.json"
  162. )
  163. target = base
  164. counter = 1
  165. while os.path.exists(target):
  166. target = base[:-5] + f"_{counter}.json"
  167. counter += 1
  168. with open(target, 'wb') as f:
  169. f.write(current)
  170. try:
  171. os.utime(target, (now, now)) # 统一 mtime 与逻辑时间, 保证节流/轮转排序一致
  172. except OSError:
  173. pass
  174. _rotate_snapshots(backup_dir, stem, keep)
  175. logger.info(f"配置快照已保存: {os.path.basename(target)}")
  176. return os.path.basename(target)
  177. except Exception as e:
  178. logger.error(f"配置快照失败({path}): {e}")
  179. return None
  180. def latest_snapshot_content(path, backup_dir=BACKUP_DIR):
  181. """取某配置文件最新快照的内容(用于损坏恢复), 返回 (data, name) 或 (None, None)"""
  182. stem = os.path.splitext(os.path.basename(path))[0]
  183. latest = find_latest_snapshot(backup_dir, stem=stem)
  184. if not latest:
  185. return None, None
  186. data, err = read_snapshot(latest['name'], backup_dir=backup_dir)
  187. if err:
  188. logger.warning(f"读取快照 {latest['name']} 失败: {err}")
  189. return None, latest['name']
  190. return data, latest['name']
  191. def build_bundle(files, device_meta=None, uid_prefix=None, now=None):
  192. """构建整机备份包"""
  193. now = time.time() if now is None else now
  194. prefix = (uid_prefix or '').strip().upper()
  195. return {
  196. 'format': BUNDLE_FORMAT,
  197. 'format_version': BUNDLE_FORMAT_VERSION,
  198. 'created_at': time.strftime('%Y-%m-%dT%H:%M:%S', time.localtime(now)),
  199. 'device': device_meta or {},
  200. 'effective_uid_prefix': prefix if _PREFIX_RE.match(prefix) else None,
  201. 'files': files,
  202. }
  203. def _validate_address_range(value):
  204. if not isinstance(value, dict):
  205. return 'address_range 必须是对象'
  206. start = value.get('start')
  207. end = value.get('end')
  208. if not isinstance(start, int) or not isinstance(end, int):
  209. return 'address_range.start/end 必须是整数'
  210. if not (1 <= start <= 247) or not (1 <= end <= 247) or start > end:
  211. return f'address_range 非法: {start}-{end}(需 1-247 且 start<=end)'
  212. return None
  213. def validate_bundle(obj):
  214. """校验整机备份包, 返回 (ok, message)"""
  215. if not isinstance(obj, dict):
  216. return False, '备份内容不是 JSON 对象'
  217. if obj.get('format') != BUNDLE_FORMAT:
  218. return False, '不是本系统的备份文件(format 不匹配)'
  219. fv = obj.get('format_version')
  220. if not isinstance(fv, int) or not (1 <= fv <= BUNDLE_FORMAT_VERSION):
  221. return False, f'备份格式版本不支持: {fv}'
  222. files = obj.get('files')
  223. if not isinstance(files, dict) or not files:
  224. return False, '缺少 files 字段或为空'
  225. unknown = set(files) - set(BUNDLE_FILES)
  226. if unknown:
  227. return False, f'包含未知文件项: {sorted(unknown)}'
  228. if not any(v not in (None, {} , '') for v in files.values()):
  229. return False, '备份包内没有任何有效文件'
  230. for key, val in files.items():
  231. if val is None:
  232. continue
  233. if key == 'system_env':
  234. if not isinstance(val, str):
  235. return False, 'system_env 必须是文本内容'
  236. continue
  237. if not isinstance(val, dict):
  238. return False, f'{key} 必须是 JSON 对象'
  239. dtu = files.get('dtu_config')
  240. if isinstance(dtu, dict):
  241. if 'address_range' in dtu and dtu['address_range'] is not None:
  242. err = _validate_address_range(dtu['address_range'])
  243. if err:
  244. return False, err
  245. if 'uid_prefix' in dtu and dtu['uid_prefix'] is not None:
  246. if not _PREFIX_RE.match(str(dtu['uid_prefix'])):
  247. return False, f"uid_prefix 非法: {dtu['uid_prefix']}"
  248. pm = dtu.get('port_mappings')
  249. if pm is not None and not isinstance(pm, dict):
  250. return False, 'port_mappings 必须是对象'
  251. dev = files.get('devices')
  252. if isinstance(dev, dict):
  253. for uid, addr in dev.items():
  254. if not _UID_RE.match(str(uid)):
  255. return False, f'devices 中存在非法线架 UID: {uid}'
  256. if not isinstance(addr, int) or not (1 <= addr <= 247):
  257. return False, f'devices 中地址非法: {uid} -> {addr}'
  258. sc = files.get('serial_config')
  259. if isinstance(sc, dict) and 'port' in sc and not isinstance(sc['port'], str):
  260. return False, 'serial_config.port 必须是字符串'
  261. mc = files.get('mqtt_config')
  262. if isinstance(mc, dict) and 'broker' in mc and not isinstance(mc['broker'], str):
  263. return False, 'mqtt_config.broker 必须是字符串'
  264. try:
  265. size = len(json.dumps(obj, ensure_ascii=False).encode('utf-8'))
  266. except (TypeError, ValueError):
  267. return False, '备份内容无法序列化'
  268. if size > MAX_BUNDLE_BYTES:
  269. return False, f'备份内容过大({size} 字节, 上限 {MAX_BUNDLE_BYTES})'
  270. return True, 'ok'