青核后端
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

136 lines
7.4 KiB

"""只读结构与完整性盘点。依赖 PyMySQL、PyYAML;不输出连接信息或业务正文。"""
import argparse
import hashlib
import json
import re
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlsplit
def migration_manifest(directory):
result = []
versions = set()
for file in directory.glob("V*__*.sql"):
match = re.fullmatch(r"V(\d+)__(.+)\.sql", file.name)
if not match:
raise ValueError("迁移文件名不符合整数版本约定")
version = int(match[1])
if version in versions:
raise ValueError("迁移版本重复:V" + str(version))
versions.add(version)
# 统一换行,避免 Windows/Linux 检出方式产生假差异。
content = file.read_text(encoding="utf-8-sig").replace("\r\n", "\n")
result.append({"version": version, "file": file.name,
"sha256": hashlib.sha256(content.encode("utf-8")).hexdigest()})
return sorted(result, key=lambda item: item["version"])
def compare_snapshots(previous, current):
"""只比较结构和迁移指纹,不把统计变化误认为结构漂移。"""
differences = []
for key in ("tables", "columns", "indexes", "foreign_keys", "migrations"):
before = previous.get(key, [])
after = current.get(key, [])
if before != after:
differences.append(key)
return differences
def collect(connection, migrations):
statements = {
"tables": "SELECT TABLE_NAME,ENGINE,TABLE_COLLATION FROM information_schema.TABLES "
"WHERE TABLE_SCHEMA=DATABASE() AND TABLE_TYPE='BASE TABLE' ORDER BY TABLE_NAME",
"columns": "SELECT TABLE_NAME,COLUMN_NAME,COLUMN_TYPE,IS_NULLABLE,COLUMN_DEFAULT,EXTRA,"
"CHARACTER_SET_NAME,COLLATION_NAME FROM information_schema.COLUMNS "
"WHERE TABLE_SCHEMA=DATABASE() ORDER BY TABLE_NAME,ORDINAL_POSITION",
"indexes": "SELECT TABLE_NAME,INDEX_NAME,NON_UNIQUE,SEQ_IN_INDEX,COLUMN_NAME,SUB_PART "
"FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() "
"ORDER BY TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX",
"foreign_keys": "SELECT k.TABLE_NAME,k.CONSTRAINT_NAME,k.COLUMN_NAME,k.REFERENCED_TABLE_NAME,"
"k.REFERENCED_COLUMN_NAME,r.DELETE_RULE,r.UPDATE_RULE "
"FROM information_schema.KEY_COLUMN_USAGE k JOIN information_schema.REFERENTIAL_CONSTRAINTS r "
"ON r.CONSTRAINT_SCHEMA=k.CONSTRAINT_SCHEMA AND r.TABLE_NAME=k.TABLE_NAME "
"AND r.CONSTRAINT_NAME=k.CONSTRAINT_NAME WHERE k.TABLE_SCHEMA=DATABASE() "
"ORDER BY k.TABLE_NAME,k.CONSTRAINT_NAME,k.ORDINAL_POSITION",
}
checks = {
"orphan_moment_comments": "SELECT COUNT(*) n FROM moment_comment c LEFT JOIN moment m "
"ON m.id=c.moment_id WHERE m.id IS NULL",
"orphan_video_progress": "SELECT COUNT(*) n FROM video_play_record v LEFT JOIN moment m "
"ON m.id=v.video_id WHERE m.id IS NULL",
"orphan_comment_likes": "SELECT COUNT(*) n FROM moment_comment_like l LEFT JOIN moment_comment c "
"ON c.id=l.comment_id LEFT JOIN moment m ON m.id=c.moment_id "
"WHERE c.id IS NULL OR m.id IS NULL",
}
result = {"captured_at_utc": datetime.now(timezone.utc).isoformat(),
"migrations": migrations, "migration_execution": "UNCONFIRMED"}
with connection.cursor() as cursor:
cursor.execute("START TRANSACTION READ ONLY")
cursor.execute("SELECT VERSION() version, DATABASE() database_name")
result["database"] = cursor.fetchone()
for name, sql in statements.items():
cursor.execute(sql)
result[name] = cursor.fetchall()
result["integrity"] = {}
for name, sql in checks.items():
cursor.execute(sql)
result["integrity"][name] = cursor.fetchone()["n"]
# 只有真实历史表存在才读取;不创建 baseline、不 repair、不执行任何版本脚本。
if any(t["TABLE_NAME"] == "flyway_schema_history" for t in result["tables"]):
cursor.execute("SELECT installed_rank,version,script,checksum,success FROM flyway_schema_history "
"ORDER BY installed_rank")
result["flyway_history"] = cursor.fetchall()
connection.rollback()
return result
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--config", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True, help="本地受限目录,勿提交或对外分发")
parser.add_argument("--compare", type=Path, help="之前人工验收的结构快照")
args = parser.parse_args()
import pymysql
import yaml
module = Path(__file__).resolve().parents[1]
manifest = migration_manifest(module / "src/main/resources/db/migration")
config = yaml.safe_load(args.config.read_text(encoding="utf-8"))["spring"]["datasource"]
endpoint = urlsplit(config["url"].removeprefix("jdbc:"))
if endpoint.scheme != "mysql" or not endpoint.hostname or not endpoint.path.strip("/"):
raise ValueError("必须显式配置 MySQL 数据库")
if any("${" in str(config.get(k, "")) for k in ("url", "username", "password")):
raise ValueError("配置含未解析的环境变量,请使用本地已解析配置")
connection = pymysql.connect(host=endpoint.hostname, port=endpoint.port or 3306,
database=endpoint.path.strip("/"), user=config["username"],
password=str(config["password"]), charset="utf8mb4",
connect_timeout=10, read_timeout=30,
cursorclass=pymysql.cursors.DictCursor)
try:
snapshot = collect(connection, manifest)
finally:
connection.close()
# round trip 统一驱动返回的 Decimal 等类型,保证后续比较一致。
snapshot = json.loads(json.dumps(snapshot, ensure_ascii=False, default=str))
if args.compare:
previous = json.loads(args.compare.read_text(encoding="utf-8"))
if previous.get("database", {}).get("database_name") != snapshot["database"]["database_name"]:
raise ValueError("禁止将不同数据库名的快照当成同一迁移基线")
snapshot["changed_sections"] = compare_snapshots(previous, snapshot)
args.output.parent.mkdir(parents=True, exist_ok=True)
# 不覆盖已验收证据;每次使用新的文件名。
with args.output.open("x", encoding="utf-8") as file:
json.dump(snapshot, file, ensure_ascii=False, indent=2)
print(json.dumps({"tables": len(snapshot["tables"]), "integrity": snapshot["integrity"],
"changed_sections": snapshot.get("changed_sections"),
"migration_execution": "UNCONFIRMED"}, ensure_ascii=False))
return 2 if snapshot.get("changed_sections") or any(snapshot["integrity"].values()) else 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as error:
# 驱动异常可能含主机、用户名或配置内容,不将异常原文写入终端/日志。
print("数据库盘点失败:" + type(error).__name__ + ";请检查本地配置、权限、依赖和输出路径。")
raise SystemExit(1)