feat: dbbackup 模块 — 从 FOMS 单体拆分出的独立模块

This commit is contained in:
yumoqing 2026-08-26 15:28:18 +08:00
commit f16b58568f
17 changed files with 459 additions and 0 deletions

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
__pycache__/
*.pyc
*.pyo
*.egg-info/
dist/
build/
py3/
models/mysql.ddl.sql
.DS_Store

31
README.md Normal file
View File

@ -0,0 +1,31 @@
# dbbackup — 数据库备份与日志同步管理模块
FOMS 平台拆出的独立模块之一。松耦合设计:表 `cluster_id` 仅存字符串 ID不建外键
不 import foms 核心代码,可独立部署测试。
## 职责
| 功能 | 表 | 说明 |
|---|---|---|
| 数据库复制binlog 日志同步) | `dbbackup_replications` | MySQL 主从复制配置,原 `sync_databases` |
| 备份任务 | `dbbackup_jobs` | 全量/增量、cron、保留期、存储位置 |
| 备份记录 | `dbbackup_records` | 大小/耗时/校验/可恢复性 |
## 端点
| 端点 | 说明 |
|---|---|
| `/dbbackup/api/agent_report_db_health.dspy` | Agent 上报复制状态 |
| `/dbbackup/api/run_backup.dspy` | 触发备份 |
| `/dbbackup/api/report_backup_result.dspy` | Agent 上报备份结果 |
| `/dbbackup/api/cleanup_expired_backups.dspy` | 清理过期备份 |
## 加载
```python
from dbbackup.init import load_dbbackup
def init():
load_dbbackup()
```
所有表共享宿主应用数据库(`get_module_dbname('dbbackup')`)。

1
__init__.py Normal file
View File

@ -0,0 +1 @@
from .init import load_dbbackup # noqa: F401

20
build.sh Normal file
View File

@ -0,0 +1,20 @@
#!/usr/bin/env bash
# dbbackup 模块 build生成 DDL + CRUD 页面xls2ui
set -e
cdir=$(pwd)
# 1. 生成 DDL
if [ -d models ]; then
json2ddl mysql models/ > "$cdir/models/mysql.ddl.sql" 2>/dev/null || echo " skip ddl"
fi
# 2. 生成 CRUD UIxls2ui 从 json/ 生成到 wwwroot/
if [ -d json ]; then
cd "$cdir/json"
for f in *.json; do
echo " xls2ui $f"
xls2ui -m ../models -o ../wwwroot dbbackup "$f" 2>/dev/null || echo " skipped"
done
fi
echo "=== dbbackup Build Complete ==="

1
dbbackup/__init__.py Normal file
View File

@ -0,0 +1 @@
# dbbackup 模块 — 数据库备份与日志同步管理

218
dbbackup/init.py Normal file
View File

@ -0,0 +1,218 @@
"""dbbackup 模块 — 数据库全量备份 + 日志同步binlog 复制)管理。
独立模块松耦合设计
· cluster_id 仅存字符串 ID不建外键引用 foms 核心的 clusters
引用断了仅表现为集群不存在不会崩
· import foms 核心代码可独立部署独立测试
· 备份执行通过 Agent 下发Agent 部署在被管主机 mysqldump/xtrabackup 权限
管理中心只负责调度记录清理策略不直接 SSH 连数据库
dbbackup_replications 数据库复制配置binlog 日志同步 sync_databases
dbbackup_jobs 备份任务全量/增量cron保留期存储位置
dbbackup_records 备份记录大小/耗时/校验/可恢复性
"""
import json
from datetime import datetime
from appPublic.uniqueID import getID
from appPublic.timeUtils import curDateString, timestampstr
from sqlor.dbpools import DBPools, get_sor_context
from ahserver.serverenv import ServerEnv
DBNAME = 'foms'
def _ns(params_kw):
ns = dict(params_kw) if hasattr(params_kw, 'items') else {}
for k in list(ns.keys()):
if k.endswith('_text'):
ns.pop(k)
return ns
def _get(params_kw, key, default=''):
return params_kw.get(key, default) if hasattr(params_kw, 'get') else default
# ═══════════════════════════════════════════
# 复制配置 CRUDbinlog 日志同步)
# ═══════════════════════════════════════════
async def create_replication(request, params_kw):
env = ServerEnv()
async with get_sor_context(env, DBNAME) as sor:
ns = _ns(params_kw)
ns['id'] = getID()
ns['sync_status'] = 'not_configured'
ns['created_at'] = curDateString()
ns['updated_at'] = curDateString()
await sor.C('dbbackup_replications', ns)
return {'widgettype': 'Message', 'options': {'title': '成功', 'message': '复制配置创建成功', 'type': 'success'}}
async def update_replication(request, params_kw):
env = ServerEnv()
async with get_sor_context(env, DBNAME) as sor:
ns = _ns(params_kw)
ns['updated_at'] = curDateString()
await sor.U('dbbackup_replications', ns)
return {'widgettype': 'Message', 'options': {'title': '成功', 'message': '复制配置更新成功', 'type': 'success'}}
async def delete_replication(request, params_kw):
env = ServerEnv()
async with get_sor_context(env, DBNAME) as sor:
await sor.D('dbbackup_replications', {'id': _get(params_kw, 'id')})
return {'widgettype': 'Message', 'options': {'title': '成功', 'message': '复制配置已删除', 'type': 'success'}}
# ═══════════════════════════════════════════
# 备份任务 CRUD
# ═══════════════════════════════════════════
async def create_backup_job(request, params_kw):
env = ServerEnv()
async with get_sor_context(env, DBNAME) as sor:
ns = _ns(params_kw)
ns['id'] = getID()
ns['created_at'] = curDateString()
ns['updated_at'] = curDateString()
await sor.C('dbbackup_jobs', ns)
return {'widgettype': 'Message', 'options': {'title': '成功', 'message': '备份任务创建成功', 'type': 'success'}}
async def update_backup_job(request, params_kw):
env = ServerEnv()
async with get_sor_context(env, DBNAME) as sor:
ns = _ns(params_kw)
ns['updated_at'] = curDateString()
await sor.U('dbbackup_jobs', ns)
return {'widgettype': 'Message', 'options': {'title': '成功', 'message': '备份任务更新成功', 'type': 'success'}}
async def delete_backup_job(request, params_kw):
env = ServerEnv()
async with get_sor_context(env, DBNAME) as sor:
await sor.D('dbbackup_jobs', {'id': _get(params_kw, 'id')})
return {'widgettype': 'Message', 'options': {'title': '成功', 'message': '备份任务已删除', 'type': 'success'}}
# ═══════════════════════════════════════════
# 备份执行(调度 + 记录 + 清理)
# ═══════════════════════════════════════════
async def run_backup(request, params_kw):
"""执行一次备份:创建 running 记录 → 下发 Agent → 等 Agent 回报结果。
备份动作本身由 Agent 在被管主机执行mysqldump / xtrabackup管理中心只调度
这里创建记录并返回命令描述Agent 通过 poll_backup_commands 取任务
"""
env = ServerEnv()
job_id = _get(params_kw, 'job_id')
async with get_sor_context(env, DBNAME) as sor:
jobs = await sor.R('dbbackup_jobs', {'id': job_id})
if not jobs:
return {'status': 'error', 'message': '备份任务不存在'}
job = jobs[0]
rec_id = getID()
now = curDateString()
await sor.C('dbbackup_records', {
'id': rec_id,
'job_id': job_id,
'cluster_id': getattr(job, 'cluster_id', ''),
'db_name': getattr(job, 'db_name', ''),
'backup_type': getattr(job, 'backup_type', 'full'),
'status': 'running',
'started_at': now,
})
await sor.U('dbbackup_jobs', {'id': job_id, 'last_run_at': now, 'updated_at': now})
return {'status': 'ok', 'message': '备份已触发', 'record_id': rec_id,
'command': f"backup db={getattr(job, 'db_name', '')} type={getattr(job, 'backup_type', 'full')}",
'storage_path': getattr(job, 'storage_path', '')}
async def report_backup_result(request, params_kw):
"""Agent 上报备份结果"""
env = ServerEnv()
rec_id = _get(params_kw, 'record_id')
async with get_sor_context(env, DBNAME) as sor:
await sor.U('dbbackup_records', {
'id': rec_id,
'file_path': _get(params_kw, 'file_path'),
'file_size_mb': _get(params_kw, 'file_size_mb'),
'duration_sec': _get(params_kw, 'duration_sec'),
'checksum': _get(params_kw, 'checksum'),
'status': _get(params_kw, 'status', 'failed'),
'error': _get(params_kw, 'error'),
'completed_at': curDateString(),
})
return {'status': 'ok'}
async def cleanup_expired_backups(request, params_kw=None):
"""按任务保留天数清理过期备份记录(保留元数据,标记清理,实际文件由 Agent 删)"""
env = ServerEnv()
async with get_sor_context(env, DBNAME) as sor:
jobs = await sor.R('dbbackup_jobs', {})
cleaned = 0
for job in (jobs or []):
retention = int(getattr(job, 'retention_days', '7') or 7)
old = await sor.sqlExe(
"SELECT id FROM dbbackup_records WHERE job_id=${jid}$ AND status='success' "
"AND completed_at < DATE_SUB(NOW(), INTERVAL ${d}$ DAY)",
{'jid': getattr(job, 'id', ''), 'd': retention})
for r in (old or []):
await sor.U('dbbackup_records', {'id': getattr(r, 'id', ''), 'status': 'expired'})
cleaned += 1
return {'status': 'ok', 'cleaned': cleaned}
# ═══════════════════════════════════════════
# Agent 上报:复制状态(原 agent_report_health 的 db 分支)
# ═══════════════════════════════════════════
async def agent_report_db_health(request, params_kw):
"""Agent 上报 MySQL 复制状态"""
env = ServerEnv()
db_statuses = _get(params_kw, 'db_statuses', [])
if isinstance(db_statuses, str):
db_statuses = json.loads(db_statuses)
async with get_sor_context(env, DBNAME) as sor:
for db_stat in db_statuses:
await sor.U('dbbackup_replications', {
'id': db_stat.get('id'),
'sync_status': db_stat.get('status'),
'seconds_behind': db_stat.get('seconds_behind'),
'last_error': db_stat.get('error'),
'updated_at': curDateString(),
})
return {'status': 'ok'}
# ═══════════════════════════════════════════
# 注册
# ═══════════════════════════════════════════
def load_dbbackup():
env = ServerEnv()
# 复制配置 CRUD
env.create_replication = create_replication
env.create_dbbackup_replications = create_replication
env.update_replication = update_replication
env.update_dbbackup_replications = update_replication
env.delete_replication = delete_replication
env.delete_dbbackup_replications = delete_replication
# 备份任务 CRUD
env.create_backup_job = create_backup_job
env.create_dbbackup_jobs = create_backup_job
env.update_backup_job = update_backup_job
env.update_dbbackup_jobs = update_backup_job
env.delete_backup_job = delete_backup_job
env.delete_dbbackup_jobs = delete_backup_job
# 备份执行
env.run_backup = run_backup
env.report_backup_result = report_backup_result
env.cleanup_expired_backups = cleanup_expired_backups
# Agent
env.agent_report_db_health = agent_report_db_health
return True

View File

@ -0,0 +1,17 @@
{
"tblname": "dbbackup_jobs",
"alias": "dbbackup_jobs_list",
"title": "数据库备份任务",
"params": {
"sortby": ["updated_at desc"],
"browserfields": {
"exclouded": ["id"],
"alters": {
"backup_type": {"uitype": "code", "data": [{"value": "full", "text": "全量"}, {"value": "incremental", "text": "增量"}]},
"compress": {"uitype": "code", "data": [{"value": "1", "text": "压缩"}, {"value": "0", "text": "不压缩"}]},
"enabled": {"uitype": "code", "data": [{"value": "1", "text": "启用"}, {"value": "0", "text": "禁用"}]},
"cluster_id": {"uitype": "code", "valueField": "id", "textField": "name"}
}
}
}
}

View File

@ -0,0 +1,16 @@
{
"tblname": "dbbackup_records",
"alias": "dbbackup_records_list",
"title": "数据库备份记录",
"params": {
"sortby": ["started_at desc"],
"browserfields": {
"exclouded": ["id"],
"alters": {
"backup_type": {"uitype": "code", "data": [{"value": "full", "text": "全量"}, {"value": "incremental", "text": "增量"}]},
"status": {"uitype": "code", "data": [{"value": "running", "text": "进行中"}, {"value": "success", "text": "成功"}, {"value": "failed", "text": "失败"}, {"value": "expired", "text": "已过期"}]},
"job_id": {"uitype": "code", "valueField": "id", "textField": "job_name"}
}
}
}
}

View File

@ -0,0 +1,16 @@
{
"tblname": "dbbackup_replications",
"alias": "dbbackup_replications_list",
"title": "数据库复制配置",
"params": {
"sortby": ["updated_at desc"],
"browserfields": {
"exclouded": ["id", "repl_password"],
"alters": {
"sync_status": {"uitype": "code"},
"enabled": {"uitype": "code", "data": [{"value": "1", "text": "启用"}, {"value": "0", "text": "禁用"}]},
"cluster_id": {"uitype": "code", "valueField": "id", "textField": "name"}
}
}
}
}

33
models/dbbackup_jobs.json Normal file
View File

@ -0,0 +1,33 @@
{
"summary": [
{
"name": "dbbackup_jobs",
"title": "数据库备份任务",
"primary": ["id"],
"catelog": "entity"
}
],
"fields": [
{"name": "id", "title": "任务ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "cluster_id", "title": "所属集群", "type": "str", "length": 32, "nullable": "no"},
{"name": "job_name", "title": "任务名称", "type": "str", "length": 128, "nullable": "no"},
{"name": "db_name", "title": "数据库名", "type": "str", "length": 128, "nullable": "no"},
{"name": "backup_type", "title": "备份类型", "type": "str", "length": 16, "nullable": "no", "default": "full"},
{"name": "schedule", "title": "调度(cron表达式)", "type": "str", "length": 64},
{"name": "retention_days", "title": "保留天数", "type": "int", "nullable": "no", "default": "7"},
{"name": "storage_path", "title": "备份存储路径", "type": "str", "length": 512, "nullable": "no"},
{"name": "compress", "title": "压缩", "type": "str", "length": 1, "nullable": "no", "default": "1"},
{"name": "enabled", "title": "启用", "type": "str", "length": 1, "nullable": "no", "default": "1"},
{"name": "last_run_at", "title": "上次执行时间", "type": "datetime"},
{"name": "created_at", "title": "创建时间", "type": "datetime"},
{"name": "updated_at", "title": "更新时间", "type": "datetime"}
],
"indexes": [
{"name": "idx_dbbak_job_cluster", "idxtype": "index", "idxfields": ["cluster_id"]},
{"name": "idx_dbbak_job_name", "idxtype": "index", "idxfields": ["db_name"]}
],
"codes": [
{"field": "backup_type", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='backup_type'"},
{"field": "cluster_id", "table": "clusters", "valuefield": "id", "textfield": "name"}
]
}

View File

@ -0,0 +1,34 @@
{
"summary": [
{
"name": "dbbackup_records",
"title": "数据库备份记录",
"primary": ["id"],
"catelog": "entity"
}
],
"fields": [
{"name": "id", "title": "记录ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "job_id", "title": "备份任务", "type": "str", "length": 32},
{"name": "cluster_id", "title": "所属集群", "type": "str", "length": 32, "nullable": "no"},
{"name": "db_name", "title": "数据库名", "type": "str", "length": 128, "nullable": "no"},
{"name": "backup_type", "title": "备份类型", "type": "str", "length": 16},
{"name": "file_path", "title": "备份文件路径", "type": "str", "length": 512},
{"name": "file_size_mb", "title": "文件大小(MB)", "type": "int"},
{"name": "duration_sec", "title": "耗时(秒)", "type": "int"},
{"name": "checksum", "title": "校验和", "type": "str", "length": 128},
{"name": "status", "title": "状态", "type": "str", "length": 16, "nullable": "no", "default": "running"},
{"name": "error", "title": "错误信息", "type": "text"},
{"name": "started_at", "title": "开始时间", "type": "datetime"},
{"name": "completed_at", "title": "完成时间", "type": "datetime"}
],
"indexes": [
{"name": "idx_dbbak_rec_job", "idxtype": "index", "idxfields": ["job_id"]},
{"name": "idx_dbbak_rec_cluster", "idxtype": "index", "idxfields": ["cluster_id"]}
],
"codes": [
{"field": "backup_type", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='backup_type'"},
{"field": "status", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='backup_status'"},
{"field": "job_id", "table": "dbbackup_jobs", "valuefield": "id", "textfield": "job_name"}
]
}

View File

@ -0,0 +1,35 @@
{
"summary": [
{
"name": "dbbackup_replications",
"title": "数据库复制配置",
"primary": ["id"],
"catelog": "entity"
}
],
"fields": [
{"name": "id", "title": "配置ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "cluster_id", "title": "所属集群", "type": "str", "length": 32, "nullable": "no"},
{"name": "db_name", "title": "数据库名", "type": "str", "length": 128, "nullable": "no"},
{"name": "master_host", "title": "主库主机", "type": "str", "length": 255},
{"name": "master_port", "title": "主库端口", "type": "int", "default": "3306"},
{"name": "slave_host", "title": "从库主机", "type": "str", "length": 255},
{"name": "slave_port", "title": "从库端口", "type": "int", "default": "3306"},
{"name": "repl_user", "title": "复制用户", "type": "str", "length": 64},
{"name": "repl_password", "title": "复制密码", "type": "str", "length": 255},
{"name": "sync_status", "title": "同步状态", "type": "str", "length": 32, "default": "not_configured"},
{"name": "seconds_behind", "title": "延迟秒数", "type": "int"},
{"name": "last_error", "title": "最后错误", "type": "text"},
{"name": "enabled", "title": "启用", "type": "str", "length": 1, "nullable": "no", "default": "1"},
{"name": "created_at", "title": "创建时间", "type": "datetime"},
{"name": "updated_at", "title": "更新时间", "type": "datetime"}
],
"indexes": [
{"name": "idx_dbbak_rep_cluster", "idxtype": "index", "idxfields": ["cluster_id"]},
{"name": "idx_dbbak_rep_name", "idxtype": "index", "idxfields": ["db_name"]}
],
"codes": [
{"field": "sync_status", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='sync_status'"},
{"field": "cluster_id", "table": "clusters", "valuefield": "id", "textfield": "name"}
]
}

16
pyproject.toml Normal file
View File

@ -0,0 +1,16 @@
[build-system]
requires = ["setuptools>=45", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "dbbackup"
version = "1.0.0"
description = "数据库备份与日志同步管理模块 — 全量/增量备份、binlog 复制配置、备份记录与清理"
requires-python = ">=3.8"
dependencies = [
"sqlor",
]
[tool.setuptools.packages.find]
where = ["."]
include = ["dbbackup*"]

View File

@ -0,0 +1,3 @@
# Agent 上报 MySQL 复制状态binlog 日志同步)
result = await agent_report_db_health(request, params_kw)
return json.dumps(result, ensure_ascii=False)

View File

@ -0,0 +1,3 @@
# 按保留天数清理过期备份
result = await cleanup_expired_backups(request, params_kw)
return json.dumps(result, ensure_ascii=False)

View File

@ -0,0 +1,3 @@
# Agent 上报备份结果
result = await report_backup_result(request, params_kw)
return json.dumps(result, ensure_ascii=False)

View File

@ -0,0 +1,3 @@
# 触发一次数据库备份管理中心调度Agent 执行)
result = await run_backup(request, params_kw)
return json.dumps(result, ensure_ascii=False)