charging-cabinet/tools/db_schema_diff.py
12451 ee92102f3c feat(scope)+fix(db)+feat(security): 数据范围(项目级)生效、库列类型对齐、安全加固、状态位分层展示、能耗实时聚合
- user_scopes 由"假开关"做成生效:粒度到项目级(组织隔离 ∧ 项目∈范围,取交集);未配置=不限制(逐字向后兼容);
  总管理员豁免;脏数据保守忽略不锁死;配置期校验存在性+归属;逐接口叠加(列表/详情/写操作/Excel 导出落 scope_project_ids);
  前端新增"数据范围"弹窗入口。顺带修 GET /api/charge-orders 此前完全没有组织隔离(跨租户泄露)、
  GET /api/projects/{id}/cabinets 的 500
- 数据库列类型系统性对齐:扫描并修复 pms_dev 32 项 / pms 18 项漂移(时间列统一 DATETIME,sqlx NaiveDateTime 只兼容 DATETIME);
  DECIMAL→BIGINT 与列注释口径拉平;修复生产 GET /api/charge-orders、GET /api/device-logs 必然 500 的根因;
  恢复 7 张表丢失的 updated_at ON UPDATE(行为变化,已记入 AGENTS.md);修正 SCHEMA_SQL 前向引用(空库建表必崩);
  新增 tools/db_schema_diff.py(漂移检查,退出码可接部署前置)与 test/schema_alignment_e2e.py
- 安全加固四项:上传去 .svg + 魔数嗅探(堵存储型 XSS);登录失败锁定(5次/15分)+challenge 限流+并发闸门+验证码容差收紧;
  服务降权 root→pms;nginx 安全头 + limit_req + CORS 白名单收敛
- TCP 连接登录门控(上行+下行同一规则):login 前只放行 auth_str/login(PRE_LOGIN_ALLOWED_ACTS)
- 状态位按归属层级展示:柜级 status1/status2 显示在柜子(含柜门 + 开门按钮,独立动作不联动业务)、
  仓体级 batt_status/pow_status 显示在仓体;两端解析函数同源同名
- 能耗报表改实时聚合 charge_records(原 energy_stats 全表只读不写导致报表恒 0)+ 补复合索引
- 组织可见性:新增 GET /api/my-organizations(工厂全量、客户仅本组织)
- 仓体 status 仅表占用,充满/故障由 Redis 通道状态派生
- 订单/占用/队列运维缺口:h5 建单漏单、214 仓体自愈、device:reports 上限、死信 TTL、LimitNOFILE、
  每日 mysqldump + 回滚点保留 5 次
- 文档:AGENTS.md 补层级可见性/数据范围/状态位约定/数据库工具/行为变化;docs/API.md 删除废弃 TCP 章节、接口总表对齐 mod.rs
2026-09-20 02:44:51 +08:00

308 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""PMS 数据库结构漂移检查(只读)
为什么需要这个工具
------------------
"测试库与生产库结构不一致" 这个坑本项目已经栽了 **5 次**:
BRPOP 卡死、`iccid` NULL、`SCHEMA_SQL` 前向引用、测试环境导出 500、
以及生产 `/api/charge-orders` 一有订单就 500 —— 每一次都是靠人肉发现。
`CREATE TABLE IF NOT EXISTS` 对已存在的表**不改任何列定义**,
`add_columns_if_not_exists` 又只看"列在不在"、不看列的定义,
所以结构漂移会**长期潜伏**,直到某个接口在生产上第一次被调用才炸。
本工具把"比对"变成一条命令,两种口径都支持:
1. **跨库比对**(默认):`pms_dev`(测试) vs `pms`(生产) 的**全部列**,
维度 = 类型 / 可空性 / 默认值 / EXTRA(`ON UPDATE`) / 注释。
2. **对 DDL 比对**(`--vs-ddl`):把库与 `software/api-server/src/db/migrate.rs`
里的权威清单 `LEGACY_COLUMN_TARGETS` 逐列比对 —— 这才是"漂移"的最终定义:
代码期望什么,库里就得是什么。
另外无论哪种模式都会做一次 **TIMESTAMP 巡检**:本项目所有时间列在 Rust 侧都是
`NaiveDateTime`/`Option<NaiveDateTime>`,而 sqlx 的 `NaiveDateTime` **只与 `DATETIME` 兼容**
(`TIMESTAMP` 只与 `DateTime<Utc>` 兼容)⇒ 任何一个 `TIMESTAMP` 列都会让对应接口 500。
用法
----
python tools/db_schema_diff.py # 默认:pms_dev 与 pms 全列比对
python tools/db_schema_diff.py --db pms # 被检查库换成 pms(参照库自动取另一个)
python tools/db_schema_diff.py --reference-db pms # 显式指定参照库
python tools/db_schema_diff.py --vs-ddl # 只与 migrate.rs 的权威清单比对
python tools/db_schema_diff.py --db pms --vs-ddl # 生产库 vs DDL
python tools/db_schema_diff.py --quiet # 只打印漂移项与结论
退出码
------
0 = 无漂移 ;1 = 发现漂移(可接进 CI / 部署前置检查);2 = 参数或连接错误
只发 SELECT,绝不写库。
"""
import argparse
import os
import re
import sys
def die(message, code=2):
"""参数/配置/连接类错误 → 退出码 2(与"发现漂移"的 1 区分开)"""
print(message, file=sys.stderr)
sys.exit(code)
try:
import pymysql
except ImportError: # pragma: no cover - 环境缺依赖时给出可执行的提示
die("缺少依赖:pip install pymysql")
# ---- 默认连接参数(ZeroTier 直连,见 AGENTS.md「数据库连接」)------------------
DEFAULT_HOST = "10.8.0.252"
DEFAULT_PORT = 3306
DEFAULT_USER = "root"
DEFAULT_PASSWORD = "Hbhyg731024@"
# 仓库内权威 DDL 的来源文件(相对本脚本所在目录)
DEFAULT_MIGRATE_RS = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "..", "software", "api-server", "src", "db", "migrate.rs"
)
# 权威清单常量名(migrate.rs 里的 `const LEGACY_COLUMN_TARGETS: &[(&str, &str, &str)]`)
TARGETS_CONST = "LEGACY_COLUMN_TARGETS"
# 比对维度(顺序即输出顺序)
DIMENSIONS = ("COLUMN_TYPE", "IS_NULLABLE", "COLUMN_DEFAULT", "EXTRA", "COLUMN_COMMENT")
def connect(args, database):
"""建立只读连接(本工具只执行 SELECT)"""
return pymysql.connect(
host=args.host,
port=args.port,
user=args.user,
password=args.password,
database=database,
charset="utf8mb4",
)
def fetch_all_columns(cur, db):
"""取某库全部基础表的列定义:{(表, 列): (类型, 可空, 默认, EXTRA, 注释)}"""
cur.execute(
"""
SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE,
IFNULL(CAST(COLUMN_DEFAULT AS CHAR), '<NULL>'),
IFNULL(CAST(EXTRA AS CHAR), ''),
IFNULL(CAST(COLUMN_COMMENT AS CHAR), '')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = %s
AND TABLE_NAME IN (SELECT TABLE_NAME FROM information_schema.TABLES
WHERE TABLE_SCHEMA = %s AND TABLE_TYPE = 'BASE TABLE')
ORDER BY TABLE_NAME, ORDINAL_POSITION
""",
(db, db),
)
return {(r[0], r[1]): tuple(r[2:]) for r in cur.fetchall()}
def scan_timestamp_columns(snapshot):
"""TIMESTAMP 巡检:返回 [(表, 列, 类型)] —— 非空即为漂移(会导致接口 500)"""
return sorted(
(table, column, values[0])
for (table, column), values in snapshot.items()
if values[0].startswith("timestamp")
)
def parse_targets(migrate_rs_path):
"""从 migrate.rs 解析权威清单 LEGACY_COLUMN_TARGETS
只认 `("表", "列", "目标定义"),` 这种字面量行;解析失败会明确报错而不是静默返回空,
避免"清单没解析出来 -> 报无漂移"这种最危险的假阴性。
"""
try:
with open(migrate_rs_path, "r", encoding="utf-8") as f:
source = f.read()
except OSError as e:
die("无法读取 migrate.rs(%s):%s" % (migrate_rs_path, e))
start = source.find("const %s" % TARGETS_CONST)
if start < 0:
die("在 %s 里找不到 const %s" % (migrate_rs_path, TARGETS_CONST))
end = source.find("];", start)
if end < 0:
die("const %s 的定义没有正常结束(找不到结尾的 `];`)" % TARGETS_CONST)
block = source[start:end]
targets = []
# 逐条匹配 ("table", "column", "target");目标定义里可能带单引号注释,用非贪婪到行尾的引号+逗号收尾
for m in re.finditer(
r'\(\s*"([^"]+)"\s*,\s*"([^"]+)"\s*,\s*"((?:[^"\\]|\\.)*)"\s*,?\s*\)', block
):
table, column, target = m.group(1), m.group(2), m.group(3)
targets.append((table, column, target))
if not targets:
die("const %s 里一条目标都没解析出来(清单格式变了?)" % TARGETS_CONST)
return targets
def expect_from_target(target):
"""把目标定义原文解析成判定字段 —— 与 migrate.rs 里 ColumnSpec::from_target 同口径
- 类型:第一个词,去掉长度(`VARCHAR(50)` -> `VARCHAR`,information_schema 的 DATA_TYPE 不带长度)
- 可空性:不含 `NOT NULL` 即可空
- 默认值:`DEFAULT <token>` 的 token,无该子句为空串
- ON UPDATE:是否含 `ON UPDATE CURRENT_TIMESTAMP`
- 注释:`COMMENT '<文本>'` 的文本,无该子句为空串
"""
first = target.split()[0]
data_type = first.split("(")[0]
nullable = "NOT NULL" not in target
m = re.search(r"DEFAULT\s+(\S+)", target)
default_value = m.group(1) if m else ""
on_update = "ON UPDATE CURRENT_TIMESTAMP" in target
m = re.search(r"COMMENT\s+'([^']*)'", target)
comment = m.group(1) if m else ""
return data_type, nullable, default_value, on_update, comment
def diff_against_ddl(cur, db, targets):
"""库 vs DDL 权威清单:返回 [(表, 列, 维度, 库里, 期望)]"""
drifts = []
for table, column, target in targets:
cur.execute(
"""
SELECT IFNULL(CAST(COLUMN_TYPE AS CHAR), ''), IFNULL(CAST(IS_NULLABLE AS CHAR), ''),
IFNULL(CAST(COLUMN_DEFAULT AS CHAR), '<NULL>'), IFNULL(CAST(EXTRA AS CHAR), ''),
IFNULL(CAST(COLUMN_COMMENT AS CHAR), '')
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND COLUMN_NAME = %s
""",
(db, table, column),
)
row = cur.fetchone()
if row is None:
drifts.append((table, column, "存在性", "列不存在", "目标定义 %s" % target))
continue
column_type, is_nullable, default, extra, comment = row
e_type, e_nullable, e_default, e_on_update, e_comment = expect_from_target(target)
# ⚠️ 无默认值时 COLUMN_DEFAULT 是 SQL NULL(本查询用 '<NULL>' 占位),
# 而"目标定义里没有 DEFAULT 子句"的期望值是空串 —— 两者语义相同,必须先归一化,
# 否则会报出假漂移(migrate.rs 里对应的是 `unwrap_or_default()`)。
if default == "<NULL>":
default = ""
# DATA_TYPE 的口径:information_schema 的 COLUMN_TYPE 带长度(bigint(20)/datetime/varchar(50))
actual_type = column_type.split("(")[0]
if actual_type.lower() != e_type.lower():
drifts.append((table, column, "DATA_TYPE", actual_type, e_type))
if (is_nullable == "YES") != e_nullable:
drifts.append((table, column, "IS_NULLABLE", is_nullable, "YES" if e_nullable else "NO"))
if default.lower() != e_default.lower():
drifts.append((table, column, "COLUMN_DEFAULT", default, e_default or "<NULL>"))
has_on_update = "on update current_timestamp" in extra.lower()
if has_on_update != e_on_update:
drifts.append((table, column, "ON UPDATE", "有" if has_on_update else "无",
"需要" if e_on_update else "不需要"))
if e_comment and e_comment not in comment:
drifts.append((table, column, "COLUMN_COMMENT", comment or "<空>", e_comment))
return drifts
def compare_databases(cur_a, db_a, cur_b, db_b, quiet):
"""跨库全列比对:返回 [(表, 列, 维度, 被检查库的值, 参照库的值)]"""
snap_a = fetch_all_columns(cur_a, db_a)
snap_b = fetch_all_columns(cur_b, db_b)
drifts = []
for key in sorted(set(snap_a) | set(snap_b)):
a, b = snap_a.get(key), snap_b.get(key)
if a == b:
continue
for idx, dim in enumerate(DIMENSIONS):
va = a[idx] if a else "<列不存在>"
vb = b[idx] if b else "<列不存在>"
if va != vb:
drifts.append((key[0], key[1], dim, va, vb))
if not quiet:
print("库 %s: %d 列 / 库 %s: %d 列" % (db_a, len(snap_a), db_b, len(snap_b)))
return drifts, snap_a
def main():
ap = argparse.ArgumentParser(
description="PMS 数据库结构漂移检查(只读):跨库比对 或 与 migrate.rs 权威 DDL 比对",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="退出码:0=无漂移;1=发现漂移;2=参数/连接错误",
)
ap.add_argument("--db", default="pms_dev", choices=["pms", "pms_dev"],
help="被检查的库(默认 pms_dev)")
ap.add_argument("--reference-db", choices=["pms", "pms_dev"],
help="跨库比对的参照库(默认取另一个:--db pms_dev 时参照 pms)")
ap.add_argument("--vs-ddl", action="store_true",
help="改为与 migrate.rs 的 LEGACY_COLUMN_TARGETS 权威清单比对(不连参照库)")
ap.add_argument("--migrate-rs", default=DEFAULT_MIGRATE_RS, help="migrate.rs 路径")
ap.add_argument("--host", default=DEFAULT_HOST)
ap.add_argument("--port", type=int, default=DEFAULT_PORT)
ap.add_argument("--user", default=DEFAULT_USER)
ap.add_argument("--password", default=DEFAULT_PASSWORD)
ap.add_argument("--quiet", action="store_true", help="只打印漂移项与结论")
args = ap.parse_args()
reference_db = args.reference_db or ("pms" if args.db == "pms_dev" else "pms_dev")
if args.vs_ddl and reference_db == args.db:
die("--vs-ddl 模式不需要参照库,请勿把 --reference-db 设成与 --db 相同")
drifts = []
try:
if args.vs_ddl:
targets = parse_targets(args.migrate_rs)
if not args.quiet:
print("权威清单:%s 的 %s(%d 条)" % (os.path.normpath(args.migrate_rs),
TARGETS_CONST, len(targets)))
conn = connect(args, args.db)
cur = conn.cursor()
snapshot = fetch_all_columns(cur, args.db)
drifts = diff_against_ddl(cur, args.db, targets)
conn.close()
else:
conn_a = connect(args, args.db)
cur_a = conn_a.cursor()
conn_b = connect(args, reference_db)
cur_b = conn_b.cursor()
drifts, snapshot = compare_databases(cur_a, args.db, cur_b, reference_db, args.quiet)
conn_a.close()
conn_b.close()
except pymysql.MySQLError as e:
die("数据库连接/查询失败:%s" % e)
# ---- TIMESTAMP 巡检(两种模式都做)----
ts_columns = scan_timestamp_columns(snapshot)
if ts_columns:
drifts.extend((t, c, "TIMESTAMP", typ,
"必须是 DATETIME(sqlx 的 NaiveDateTime 只与 DATETIME 兼容)")
for t, c, typ in ts_columns)
mode = "库 %s vs migrate.rs DDL 权威清单" % args.db if args.vs_ddl \
else "库 %s vs 库 %s" % (args.db, reference_db)
print("=" * 100)
print("结构漂移检查:%s" % mode)
print("=" * 100)
if drifts:
print("❌ 发现 %d 处漂移:" % len(drifts))
print(" %-16s %-22s %-16s %-42s %s" % ("表", "列", "维度", "实际", "期望"))
for table, column, dim, actual, expected in drifts:
print(" %-16s %-22s %-16s %-42s %s" % (table, column, dim, actual, expected))
print()
print("修复:重启新版 api-server(启动迁移会按 migrate.rs 的清单自动对齐),")
print(" 或按上表手工 ALTER;对齐逻辑见 software/api-server/src/db/migrate.rs")
return 1
print("✅ 无漂移")
if not args.vs_ddl:
print(" (全库列定义 + 0 个 TIMESTAMP 列)")
print(" 被检查库 %s:%d 列,TIMESTAMP 列 0 个" % (args.db, len(snapshot)))
return 0
if __name__ == "__main__":
sys.exit(main())