Files
opsxsql/backend/app/services/schema_drift_detector.py
T
logan 4c9427e546 sync: 同步 sql-platform-pro 最新代码 — 权限体系安全修复 v0.8.1
- P0: 实例访问控制全线接入权限组 (829测试)
- P1: permissions字段激活 + 操作级权限检查 (843测试)
- 前端: 删除数据权限页面, 权限组合并为唯一入口
- 新增: CodeMirror编辑器 + 查询模板 + 慢查询分析
- 新增: MongoDB管理页面 + Dashboard增强
- 修复: Pydantic V2迁移 + 前端TS错误修复
2026-05-23 14:41:06 +08:00

315 lines
11 KiB
Python

"""Schema 漂移检测 — 检测数据库 Schema 的意外变更。
工作原理:
1. 定期捕获数据库 Schema 快照(表结构、索引、约束)
2. 将快照与基线比较
3. 发现差异时生成漂移报告
4. 发送通知告警
支持的检测类型:
- 新增表/字段
- 删除表/字段
- 字段类型变更
- 索引变更
"""
import hashlib
import json
from dataclasses import dataclass, field
from datetime import datetime
from typing import List, Optional, Dict, Any
from app.models.instance import Instance
from app.services.db_connector import DBConnector
@dataclass
class ColumnSnapshot:
"""列定义快照。"""
table_name: str
column_name: str
column_type: str
is_nullable: bool
column_default: Optional[str]
extra: str
character_set: Optional[str]
collation: Optional[str]
checksum: str
def to_dict(self) -> dict:
return {
"table_name": self.table_name,
"column_name": self.column_name,
"column_type": self.column_type,
"is_nullable": self.is_nullable,
"column_default": self.column_default,
"extra": self.extra,
"checksum": self.checksum,
}
@dataclass
class DriftItem:
"""漂移项。"""
drift_type: str # added / removed / modified
table_name: str
column_name: str
before: Optional[str]
after: Optional[str]
severity: str # low / medium / high
def to_dict(self) -> dict:
return {
"drift_type": self.drift_type,
"table_name": self.table_name,
"column_name": self.column_name,
"before": self.before,
"after": self.after,
"severity": self.severity,
}
@dataclass
class DriftReport:
"""漂移检测报告。"""
instance_id: int
database: str
checked_at: str
total_columns: int
drift_count: int
drifts: List[DriftItem]
def to_dict(self) -> dict:
return {
"instance_id": self.instance_id,
"database": self.database,
"checked_at": self.checked_at,
"total_columns": self.total_columns,
"drift_count": self.drift_count,
"drifts": [d.to_dict() for d in self.drifts],
}
class SchemaDriftDetector:
"""Schema 漂移检测器。"""
# 内存中的基线存储: {(instance_id, database): [ColumnSnapshot, ...]}
_baselines: Dict[tuple, List[ColumnSnapshot]] = {}
async def capture_snapshot(self, instance: Instance,
database: str) -> List[ColumnSnapshot]:
"""捕获数据库 Schema 快照。"""
if instance.db_type == "mysql":
return await self._capture_mysql_schema(instance, database)
elif instance.db_type in ("postgres", "postgresql"):
return await self._capture_postgres_schema(instance, database)
else:
raise ValueError(f"不支持的 Schema 捕获类型: {instance.db_type}")
async def save_baseline(self, instance_id: int,
database: str,
snapshot: List[ColumnSnapshot]) -> None:
"""保存 Schema 基线到内存。"""
self._baselines[(instance_id, database)] = snapshot
async def detect_drift(self, instance: Instance,
database: str) -> DriftReport:
"""检测 Schema 漂移,返回漂移报告。"""
current = await self.capture_snapshot(instance, database)
baseline = await self._get_baseline(instance.id, database)
checked_at = datetime.now().isoformat()
if not baseline:
# 首次检测,保存为基线
await self.save_baseline(instance.id, database, current)
return DriftReport(
instance_id=instance.id,
database=database,
checked_at=checked_at,
total_columns=len(current),
drift_count=0,
drifts=[],
)
drifts = self._compare_schemas(current, baseline)
# 更新基线为当前状态
await self.save_baseline(instance.id, database, current)
return DriftReport(
instance_id=instance.id,
database=database,
checked_at=checked_at,
total_columns=len(current),
drift_count=len(drifts),
drifts=drifts,
)
async def _capture_mysql_schema(self, instance: Instance,
database: str) -> List[ColumnSnapshot]:
"""从 MySQL INFORMATION_SCHEMA 捕获 Schema。"""
password = instance.password # should be decrypted before calling
sql = """
SELECT
TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE,
COLUMN_DEFAULT, EXTRA, CHARACTER_SET_NAME, COLLATION_NAME
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = %s
AND TABLE_NAME NOT LIKE '\\_%%' -- exclude internal tables
ORDER BY TABLE_NAME, ORDINAL_POSITION
"""
from app.core.crypto import decrypt_password
instance.password = decrypt_password(instance.password)
result = await DBConnector._query_mysql(instance, "information_schema", sql)
snapshots = []
for row in result[0]:
# 计算列定义的校验和
content = (
f"{row.get('COLUMN_TYPE', '')}|"
f"{row.get('IS_NULLABLE', '')}|"
f"{row.get('COLUMN_DEFAULT', '') or ''}|"
f"{row.get('EXTRA', '')}"
)
checksum = hashlib.md5(content.encode()).hexdigest()
snapshots.append(ColumnSnapshot(
table_name=row.get("TABLE_NAME", ""),
column_name=row.get("COLUMN_NAME", ""),
column_type=row.get("COLUMN_TYPE", ""),
is_nullable=row.get("IS_NULLABLE", "YES") == "YES",
column_default=str(row.get("COLUMN_DEFAULT", "")) if row.get("COLUMN_DEFAULT") is not None else None,
extra=row.get("EXTRA", ""),
character_set=row.get("CHARACTER_SET_NAME"),
collation=row.get("COLLATION_NAME"),
checksum=checksum,
))
return snapshots
async def _capture_postgres_schema(self, instance: Instance,
database: str) -> List[ColumnSnapshot]:
"""从 PostgreSQL information_schema 捕获 Schema。"""
sql = """
SELECT
c.table_name,
c.column_name,
CASE
WHEN c.data_type = 'character varying' AND c.character_maximum_length IS NOT NULL
THEN 'varchar(' || c.character_maximum_length || ')'
WHEN c.data_type = 'character' AND c.character_maximum_length IS NOT NULL
THEN 'char(' || c.character_maximum_length || ')'
WHEN c.data_type = 'numeric' AND c.numeric_precision IS NOT NULL
THEN 'numeric(' || c.numeric_precision || ',' || COALESCE(c.numeric_scale, 0) || ')'
ELSE c.data_type
END as column_type,
c.is_nullable,
c.column_default,
'' as extra,
NULL as character_set_name,
NULL as collation_name
FROM information_schema.columns c
WHERE c.table_schema = 'public'
AND c.table_catalog = %s
ORDER BY c.table_name, c.ordinal_position
"""
from app.core.crypto import decrypt_password
instance.password = decrypt_password(instance.password)
result = await DBConnector._query_postgres(instance, database, sql)
snapshots = []
for row in result[0]:
content = (
f"{row.get('column_type', '')}|"
f"{row.get('is_nullable', '')}|"
f"{row.get('column_default', '') or ''}"
)
checksum = hashlib.md5(content.encode()).hexdigest()
snapshots.append(ColumnSnapshot(
table_name=row.get("table_name", ""),
column_name=row.get("column_name", ""),
column_type=row.get("column_type", ""),
is_nullable=row.get("is_nullable", "YES") == "YES",
column_default=str(row.get("column_default", "")) if row.get("column_default") is not None else None,
extra=row.get("extra", ""),
character_set=None,
collation=None,
checksum=checksum,
))
return snapshots
async def _get_baseline(self, instance_id: int,
database: str) -> Optional[List[ColumnSnapshot]]:
"""获取保存的基线。"""
return self._baselines.get((instance_id, database))
def _compare_schemas(self, current: List[ColumnSnapshot],
baseline: List[ColumnSnapshot]) -> List[DriftItem]:
"""比较当前 Schema 与基线,返回漂移项。"""
drifts = []
current_dict = {(s.table_name, s.column_name): s for s in current}
baseline_dict = {(s.table_name, s.column_name): s for s in baseline}
# 检查新增列
for key, snapshot in current_dict.items():
if key not in baseline_dict:
drifts.append(DriftItem(
drift_type="added",
table_name=key[0],
column_name=key[1],
before=None,
after=f"{snapshot.column_type} NULL={snapshot.is_nullable}",
severity="medium",
))
# 检查删除列
for key, snapshot in baseline_dict.items():
if key not in current_dict:
drifts.append(DriftItem(
drift_type="removed",
table_name=key[0],
column_name=key[1],
before=f"{snapshot.column_type} NULL={snapshot.is_nullable}",
after=None,
severity="high", # 删除列是高风险操作
))
# 检查修改列
for key, current_snap in current_dict.items():
if key in baseline_dict:
baseline_snap = baseline_dict[key]
if current_snap.checksum != baseline_snap.checksum:
severity = "medium"
# 类型变更是高风险
if current_snap.column_type != baseline_snap.column_type:
severity = "high"
drifts.append(DriftItem(
drift_type="modified",
table_name=key[0],
column_name=key[1],
before=f"{baseline_snap.column_type} (default: {baseline_snap.column_default or 'None'})",
after=f"{current_snap.column_type} (default: {current_snap.column_default or 'None'})",
severity=severity,
))
# 按严重性排序
severity_order = {"high": 0, "medium": 1, "low": 2}
drifts.sort(key=lambda x: severity_order.get(x.severity, 3))
return drifts
def clear_baseline(self, instance_id: int, database: str) -> None:
"""清除指定实例的基线。"""
key = (instance_id, database)
if key in self._baselines:
del self._baselines[key]
def clear_all_baselines(self) -> None:
"""清除所有基线。"""
self._baselines.clear()