p2_audit_mapper_bind.py 12.2 KB
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
P2 审计脚本:**按接口**去查「这个 Mapper 到底绑没绑」。

## 为什么需要它(第二十七切片的血债)

第二十七切片迁 `/park/xc-car-charge-audit/query-last-xc-car-charge-audit` 时撞上

    Invalid bound statement (not found): com.zteits.park.mapper.ParkingLotWhitelistMapper.selectByExample

新栈整包**没有**这个 namespace —— 但**老门户上这条链路是通的**,所以不是存量缺陷(红线⑥
不适用),是搬丢了。根因是**一份 XML 用了别人的文件名**:

    zteits-park/src/main/resources/mybatis/mapper/HubParkingEquipmentMapper.xml
        └── <mapper namespace="com.zteits.park.mapper.ParkingLotWhitelistMapper">   ← 表也是 parking_lot_whitelist

老仓那一份文件同时是「`ParkingLotWhitelistMapper` 的**唯一**宿主」和「`HubParkingEquipmentMapper`
的一个空壳名」(后者在**老仓压根没绑定** —— 存量缺陷)。新栈按 `hub_parking_equipment` 表
**重新生成**了同名 XML,namespace 与表名都归正、顺手修掉了老仓那个缺陷,但那份文件里唯一装着的
11 条 whitelist 语句也随之被覆盖消失。⇒ 「查某个 mapper 的 XML 在不在」**不能按文件名找**。

`tools/p2_audit_mapper_ns.py` 查的是「XML 的 namespace 在不在 @MapperScan 的包下」,
方向相反 —— 它看不见「某个接口该有的 XML 没搬过来」。本脚本就是补这个方向的缺口。

## 判定口径(必须写死,否则没人能复现)

**扫描面**:两栈各 8 个模块的 `src/`(老仓对应 `zteits-{pay,order,bill,park,charge,sys,business}`),
文件名以 `Mapper.java` 结尾且含 `interface` 的算「Mapper 接口」;XML 取 `<mapper namespace=...>`。
**口径**:只统计 namespace 以 `com.zteits.{bill,business,charge,order,park,pay,sys}.mapper` 打头的 ——
这正是 `ParkingServerApplication` 第 2 条 `@MapperScan` 的 7 个包(与 `p2_audit_mapper_ns.py`
的 `SCAN_PACKAGES` 同源)。这一刀同时把 MyBatis-Plus 的
`com.zteits.parking.module.system.dal.mysql.*`(框架自带、无 XML)和 tk 的
`com.zteits.{sys,business}.util.MyMapper`(标记接口)挡在外面。

**四类结果**:

| 类 | 含义 | 期望 |
|---|---|---|
| OK | 新栈有 `namespace == 接口 FQCN` 的 XML | 610 个左右 |
| **A 丢搬** | 老栈有该 namespace、新栈没有 ⇒ **移植回归** | **必须 0** |
| B 存量就断 | 两栈都没有绑得上的 XML(老仓当年就断) | 留痕,逐个看是否死代码 |
| C 新栈独有 | 接口只在新栈存在(老仓没有)⇒ WIP 或死代码 | 留痕,看调用方 |

**退出码**:A > 0 才 `exit 1`。B/C 是存量事实/WIP,不是本脚本要拦的东西。

## B 类的两个子型(都是存量事实,别当回归修)

* **namespace 写错**:老 XML 在、但 `<mapper namespace>` 与接口所在包不一致。
  例:`zteits-pay/.../RealtimeInfoSMapper.xml` 的 namespace 是
  `com.zteits.park.mapper.smapper.RealtimeInfoSMapper`,而接口在 `com.zteits.pay.mapper.smapper`
  —— 老门户调它同样是 `Invalid bound statement`,新栈**照搬**了这个事实。
* **压根没有 SQL**:接口有、两栈都没有任何 XML。例:`TdBOrderIncomeCalculationSMapper`。

## C 类要当心:不是所有 C 都是死代码

`ParkingLotExtendAdapterMapper` 的**唯一**引用者 `ParkingLotExtendAdapterCache` 本身零引用 ⇒ 真死。
但 `TdCCouponRuleCustidCarnumRelMapper` 被 `TdCCustPersonServiceImpl` 的
`queryPersonAndCarNumForPageByUserPhone` / `saveCardNoAndCustIdAndCarNum` 调用,
而这两个方法**老仓没有**、在新栈是**新写的**(WIP)—— 一旦有人给它配个 Controller,
第一次调用就是 `Invalid bound statement` 500。本脚本把「调用方」列出来,就是为了让这条别被漏掉。

## 用法

    python3 -I tools/p2_audit_mapper_bind.py
    python3 -I tools/p2_audit_mapper_bind.py --legacy-dir /path/to/zteits-clouds
"""

import argparse
import os
import re
import sys

CLOUD = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DEFAULT_LEGACY = "/Users/wangbiao/IdeaProjects/parking/zteits-clouds"

# 与 ParkingServerApplication 的 @MapperScan / p2_audit_mapper_ns.py 的 SCAN_PACKAGES 同源
PKGS = ["bill", "business", "charge", "order", "park", "pay", "sys"]
PREFIXES = tuple("com.zteits.%s.mapper" % k for k in PKGS)

NEW_MODULES = ["parking-module-park", "parking-module-order", "parking-module-pay",
               "parking-module-bill", "parking-module-charge", "parking-module-business",
               "parking-module-system", "parking-module-infra"]
LEGACY_MODULES = ["zteits-pay", "zteits-order", "zteits-bill", "zteits-park",
                  "zteits-charge", "zteits-sys", "zteits-business"]

SKIP_DIR_NAMES = {"target", "out", "node_modules", ".git"}
NS_RE = re.compile(r'namespace\s*=\s*"([^"]+)"')
PKG_RE = re.compile(r'package\s+([\w.]+);')


def _walk(roots, ext):
    for root0 in roots:
        if not os.path.isdir(root0):
            continue
        for root, dirs, files in os.walk(root0):
            dirs[:] = [d for d in dirs if d not in SKIP_DIR_NAMES]
            for name in files:
                if name.endswith(ext):
                    yield os.path.join(root, name)


def collect(roots):
    """→ (接口 FQCN 集合, {namespace: [xml 路径]})"""
    ifaces, namespaces = set(), {}
    for p in _walk(roots, ".java"):
        if not p.endswith("Mapper.java"):
            continue
        src = open(p, encoding="utf-8", errors="surrogateescape").read()
        m = PKG_RE.search(src)
        if m and re.search(r"\binterface\b", src):
            ifaces.add(m.group(1) + "." + os.path.basename(p)[:-5])
    for p in _walk(roots, ".xml"):
        head = open(p, encoding="utf-8", errors="surrogateescape").read(8192)
        m = NS_RE.search(head)
        if m:
            namespaces.setdefault(m.group(1), []).append(p)
    return ifaces, namespaces


def callers_of(simple_name, roots):
    """新栈里引用了这个简单名的文件(排除同名文件自己)。

    ⚠️ 纯文本匹配、不做语义分析:命中可能是注释、可能是同名的别的类型。所以下面只拿它当
    「有没有人提过这个名字」的粗筛,结论要顺着引用链再走一跳(`dead_chain`)。
    """
    hits = []
    for p in _walk(roots, ".java"):
        if os.path.basename(p) == simple_name + ".java":
            continue
        if simple_name in open(p, encoding="utf-8", errors="surrogateescape").read():
            hits.append(p)
    return hits


def dead_chain(caller_path, roots):
    """引用方的类名**自己**有没有人引用?没有 ⇒ 整条链是死的(第二十七切片实测:
    `ParkingLotExtendAdapterMapper` ← `ParkingLotExtendAdapterCache` ← 无人引用)。"""
    return not callers_of(os.path.basename(caller_path)[:-5], roots)


def misnamed_legacy_xml(legacy, namespaces):
    """老仓「文件名 != namespace 末段(忽略大小写)」的 XML —— 按文件名搬运必丢的那批。"""
    out = []
    for ns, paths in namespaces.items():
        simple = ns.rsplit(".", 1)[-1]
        for p in paths:
            stem = os.path.basename(p)[:-4]
            if stem.lower() != simple.lower():
                out.append((ns, stem, os.path.relpath(p, legacy)))
    return sorted(out)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--legacy-dir", default=DEFAULT_LEGACY)
    ap.add_argument("--show-ok", action="store_true", help="连绑好的接口一起列(610 行,一般不用)")
    args = ap.parse_args()

    new_roots = [os.path.join(CLOUD, m, "src") for m in NEW_MODULES]
    legacy_roots = [os.path.join(args.legacy_dir, m, "src") for m in LEGACY_MODULES]
    if not os.path.isdir(args.legacy_dir):
        print("!! 老仓目录不存在:%s(用 --legacy-dir 指定)" % args.legacy_dir)
        return 2

    new_if, new_ns = collect(new_roots)
    leg_if, leg_ns = collect(legacy_roots)
    new_if = {x for x in new_if if x.startswith(PREFIXES)}
    leg_if = {x for x in leg_if if x.startswith(PREFIXES)}

    bound = sorted(x for x in new_if if x in new_ns)
    unbound = sorted(x for x in new_if if x not in new_ns)
    cls_a = [x for x in unbound if x in leg_if and x in leg_ns]      # 老有、新丢
    cls_b = [x for x in unbound if x in leg_if and x not in leg_ns]  # 两栈都断
    cls_c = [x for x in unbound if x not in leg_if]                  # 新栈独有

    print("口径:namespace 以 %s 打头" % (" / ".join(PREFIXES)))
    print("  新栈 mapper 接口 %d(已绑定 %d,未绑定 %d)" % (len(new_if), len(bound), len(unbound)))
    print("  老栈 mapper 接口 %d,老栈 namespace %d" % (len(leg_if), len(leg_ns)))

    if args.show_ok:
        print("\n== OK(新栈有同名 namespace 的 XML)==")
        for x in bound:
            print("  " + x)

    print("\n== A 丢搬(老有 namespace、新栈没有)== 必须 0")
    if cls_a:
        for x in cls_a:
            print("  !! %s" % x)
            for p in leg_ns[x]:
                print("       老仓 XML:%s" % os.path.relpath(p, args.legacy_dir))
        print("  ⇒ 这些是**移植回归**:老门户上通、新栈 500。按文件名找 XML 会漏,")
        print("     要去老仓 grep `namespace=\"<FQCN>\"` 定位真正装它的那份文件。")
    else:
        print("  ✔ 0 个")

    print("\n== B 存量就断(两栈都没有绑得上的 XML)== 存量事实,留痕不修(红线⑥)")
    if cls_b:
        for x in cls_b:
            simple = x.rsplit(".", 1)[-1]
            same_name = [p for p in _walk(legacy_roots, ".xml")
                         if os.path.basename(p)[:-4].lower() == simple.lower()]
            if same_name:
                wrong = []
                for p in same_name:
                    head = open(p, encoding="utf-8", errors="surrogateescape").read(8192)
                    m = NS_RE.search(head)
                    wrong.append(m.group(1) if m else "?")
                print("  · %s" % x)
                print("       老仓有同名 XML,但 namespace 写成了:%s" % " / ".join(wrong))
            else:
                print("  · %s(两栈都没有任何同名 XML:接口有、SQL 压根没写过)" % x)
    else:
        print("  ✔ 0 个")

    print("\n== C 新栈独有(老仓没有这个接口)== 老仓没得抄,看调用方:WIP 还是死代码")
    if cls_c:
        for x in cls_c:
            simple = x.rsplit(".", 1)[-1]
            callers = callers_of(simple, new_roots)
            print("  · %s" % x)
            if not callers:
                print("       ✔ 零引用 ⇒ 死代码")
                continue
            for p in callers:
                rel = os.path.relpath(p, CLOUD)
                if dead_chain(p, new_roots):
                    print("       被引用:%s" % rel)
                    print("              └ 但**它自己**零引用 ⇒ 死链,不可达(真死代码)")
                else:
                    print("       被引用:%s" % rel)
                    print("              └ ★ 它自己有引用方 ⇒ **可达**:谁来调这条链路,")
                    print("                 第一次就是 `Invalid bound statement` 500(WIP 没补 XML)")
    else:
        print("  ✔ 0 个")

    mis = misnamed_legacy_xml(args.legacy_dir, leg_ns)
    print("\n== 附录:老仓「文件名 != namespace」的 XML %d 份(忽略大小写比较)==" % len(mis))
    print("   这份清单 = 「按文件名找 XML 会踩空」的候选。列在这里**不等于** namespace 丢了 ——")
    print("   是否真丢,看上面的 A 类;这里只说明文件名不是绑定关系的可靠索引。")
    for ns, stem, rel in mis:
        print("  %-50s  <- %s" % (ns, rel))
    print("  第二十七切片补搬的正是 `ParkingLotWhitelistMapper` 那条:老文件名是")
    print("  HubParkingEquipment(新栈按该表重生成了同名文件 ⇒ 覆盖掉这条 namespace)。")
    print("  另一条 `TbAreaMangeSMapper.xml` 是**拼写**错(Mange/Manage),不是大小写,")
    print("  两栈都原样带着这个文件名 ⇒ 它绑得好好的(A 类里没有它)。")

    return 1 if cls_a else 0


if __name__ == "__main__":
    sys.exit(main())