"""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()