audit_junk_tables.py 44 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
「看着像废弃/备份表」的逐张确认 —— 六库合一前,给名字可疑的表逐个出证据。

## 为什么需要它

[`legacy-db-actual.md`](../legacy-db-actual.md) §8 数出这批残留:
`del<日期>` 前缀 2 张、`_<日期>` 后缀 12 张、`_copyN` 8 张、`_bak` 14 张、
`temp` 词 13 张、`test` 词 1 张。同一节的警告是:

> ⚠️ 这批**不能仅凭名字删**。`month_dchz`、`table_15_free` 这类"看着像临时表"的,
> 实际是被代码正常引用的业务表。**逐张确认后再决定**。

⚠️⚠️ **反方向的风险更大**,这是本脚本真正的存在理由:
**也 不能仅凭名字搬**。把 `sys_org_copy` 这类历史副本原样搬进新库,
它就会永久留在新库里——而 `00` 建库脚本与后来的建表语句都不知道它存在,
等于往新库注入一批**无来源、无归属、以后没人敢删**的表。

## 判据:名字只是"嫌疑",证据是三条

1. **精确行数**(`COUNT(*)`,不是 `TABLE_ROWS` 估算)。
   估算对大表可差数十个百分点,而"这张表到底是不是空的"恰恰是这里的关键判断——
   用估算值判空等于没判。
2. **代码引用**:老工程搬进 `parking-module-*` 之后的 mapper XML 与 Java 里逐字搜。
   命中分**表位置**(硬证据)与**疑似**(大概率是 SQL 别名)两级,见下面坑 4。
3. **建表时间**:`_bak` 不等于"陈旧"——实测有 `sys_op_advice_bak` 是 **2026-02-25**
   建的(109 万行 / 200 MB)。近期建的备份表很可能是**运维正在用**的,不是历史残渣。
   (这一条 2026-10-10 已由业务方拍板「备份表,无需理会」,见 `DO_NOT_MIGRATE`。)

**代码引用数是硬约束**:只要落在表位置就不许按名字丢(见 `verdict()`);
只有疑似命中的降为 🟠 并贴出原文,仍不自动放行。

## 「不搬」分两层,守卫能查的东西不一样(2026-10-10 加)

`DO_NOT_MIGRATE` 是**唯一**的"哪些表不搬进新库"的事实来源,值带层级:

| 层级 | 判据来源 | 守卫能查什么 | 不搬的代价 |
|---|---|---|---|
| `空表` | **数据自证**:0 行 + 无表位置引用 | 逐条复核仍存在 / 仍 0 行 / 仍无引用,任一不成立 exit 2 | **零**(本来就 0 行) |
| `备份` | **外部拍板**:业务方说"这是备份,无需理会" | 只能守**前提**——仍存在、仍无表位置引用。**证明不了决定本身对不对** | 那批历史行只留在老库。老库只读保留(回退路径)、**没有被销毁**,但新库里确实没有它们 |

⚠️ 这是 `AGENTS.md` §1.1 推论 4 的另一面:手工维护的清单必须有一个**不消费它**的
独立信号去反查。`空表` 那层有——行数与引用都是独立读出来的;`备份` 那层**没有**
——"这是不是你需要的备份"是个业务判断,任何查询都答不了。所以这层只能做两件事:

1. 把**谁、什么时候、凭什么**写进理由串。这是**唯一**的留痕处(报告是从这份清单生成的,
   别处没有第二份记录);
2. 死盯"代码里还是没人读它"这个前提。前提一破就 exit 2,必须重新问业务方,
   **不许**因为"清单上已经写了不搬"就放行。

## ⚠️ 本工具踩过的五个坑(都是它自己犯的,记下来免得后人重犯)

1. **`temp` 用朴素子串匹配** → 把 `charger_ticket_template`、`td_c_msg_template`
   两张**真业务表**报成"名含 temp"。
   **本工具的整个存在理由就是"别仅凭名字下结论",而它第一版自己就犯了。**
   现在 `temp` / `test` 按**独立词**匹配(前后不能是字母)。
2. **代码引用用朴素子串匹配** → 名为 `temp` 的表报出「924 处引用 / 924 个文件」,
   因为 `temp` 是 `template`、`tempFile`、Java 局部变量 `temp` 的子串。
   现在:XML 里按**词边界**匹配;Java 里**只认带引号的字面量**
   (表名在 Java 里只以字符串字面量出现,裸标识符一定是变量)。
3. **自检报了假警报** → 第一版的"分表族漏排"检查用族**前缀**判断,
   于是把 `pay_order_success_1202` 这类**有意放行**的变体表报成 🔴。
   误报的守卫等于没有守卫(`AGENTS.md` §1.1)。现在的自检换成了
   **两个独立实现互证**:MySQL 的 `LIKE` 与 Python 正则必须对"数字后缀的族成员"
   给出一致答案——它们才可能真的不一致。
4. **词边界仍然挡不住 SQL 别名** → 修完坑 2,`zt_park.temp`(7 行的真垃圾表)
   还是报出 6 个文件,因为 `duty_roster_base_temp temp` 里的 `temp` 是**表别名**,
   前后都是空格,词边界判据完全无法区分。
   现在命中分两级:`表位置`(`FROM/JOIN/…` 之后 + Java 带引号字面量)算硬证据,
   其余算**疑似**(🟠,不自动放行),并把**命中行原文**贴进报告——
   看的人一眼能分辨 `FROM xxx temp`(别名)与 `FROM temp`(真引用)。

5. **分表族清单只登记了 1/2,而自检发现不了** → `FAMILY` 只列了
   `tp_p_eqp_inout_record`,漏了 `tp_p_eqp_log`(113 张)。漏网的族表恰好因为
   "数字后缀不带下划线"没被 `_<日期> 后缀` 判据命中,**纯属侥幸**
   (改天族名里带个下划线就中招)。
   比错报更糟的是:自检拿 MySQL `LIKE` 去核对 Python 正则,而**两者共用同一份
   不完整的清单**——这就是 `AGENTS.md` §1.1 说的「一个能通过一切输出的守卫等于没有守卫」。
   现在加了实现 C `sharded_in_code()`:**不看清单**,只问"代码里有没有 `"前缀"` 这个
   字面量",再**双向**反查清单(漏登记的、登记了却没在拼的,都 exit 2)。
   (另有一版实现 B `structural_families()` 只看名字形状,它**会**把人工快照
   误报成族,所以只当候选生成器、不下结论——「一个每次都喊狼来了的守卫,
   一样等于没有守卫」。)

6. **自检告警写进了报告,却打在生成流程的前半段** → `bad` 的最后一批
   (`DO_NOT_MIGRATE` 复核)是在后面几条循环里才追加的,而告警块原先放在
   「自检」小节。实测注入 4 条假条目:**退出码是 2,报告里却一个字都没提**。
   现在告警统一收在文末「## 自检告警」一节,报告上面留了指路。
   教训:「守卫真的会拦」不能靠读代码相信——**注入坏数据看它响不响**。

> 坑 1→2→4 是同一个病的三次发作:**"命中文本"不等于"引用表"**。
> 每次修都是在给判据补上下文(整词 → 词边界+引号 → 表位置)。
> 别再想着用一条正则把这件事做对了。
>
> 坑 3→5 是另一个病:**守卫与被守卫对象共享同一份错误假设**,于是守卫必然通过。
> 判据(清单、枚举、白名单)一旦是手工维护的,就必须有一个**不消费它**的
> 独立信号去反查它。
>
> 坑 6 是第三个病:**守卫没哑,只是报告没把它的声音放出来**。
> 守卫和报告是两件事,两边都要验。

## ⚠️ 必须排除分表族,否则会把活表当垃圾

老库有 **2 个族、共 228 张**运行期拼后缀的活表(实测,与
`legacy-db-actual.md` §6 一致):

| 前缀 | 后缀 | 张数 | 范围 |
|---|---|---:|---|
| `tp_p_eqp_inout_record` | `yyyyMM` | 115 | 201709 ~ 202703 |
| `tp_p_eqp_log` | `yyyyMM` | 113 | 201711 ~ 202703 |

它们**长得和"`_<日期>` 后缀的备份表"一模一样**,但每一张都是活表。
注意数字后缀是**直接拼接**的(`tp_p_eqp_log201711`),没有下划线,
所以连 `_<6位年月> 后缀` 判据都碰不到它们——**漏登记的族不会报错,只会静默多出 113 张"嫌疑表"**。

> ⚠️ 这张表踩过两次,都记在坑 5:第一版只登记了 `tp_p_eqp_inout_record`
> (漏掉 `tp_p_eqp_log` 113 张);`parking_running_statistic` 曾被当成第三个族,
> **那是错的**——代码从没拼过它,那几张是人工快照。现在清单由 `sharded_in_code()`
> **双向**反查(见坑 5)。

## ⚠️ 只读

只发 `SELECT COUNT(*)` 与读 `information_schema`。**不 SELECT 任何列值**,
所以即便嫌疑表里有敏感数据(如 `td_b_wx_config` 那类),输出里也不可能带出来。

用法:
    python3 -I sql/tools/audit_junk_tables.py                    # 报告打屏
    python3 -I sql/tools/audit_junk_tables.py --emit docs/xxx.md
    python3 -I sql/tools/audit_junk_tables.py --exact-limit 0    # 不限量,全取精确行数

退出码:0 = 报告已生成(**不代表"已处置"**,处置结论要人拍板后写回文档)。
        2 = 自检失败(分表族判定两套实现不一致),此时报告内容不可信。
"""

import argparse
import datetime
import os
import pathlib
import re
import sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from legacy_db import connect, db_names, load_env  # noqa: E402

# ---------------------------------------------------------------------------
# 嫌疑判据:唯一的事实来源。改这里,不要改报告。
#
# 顺序有意义:先报最像"人为备份"的,再报词命中的——报告里按这个顺序排。
PATTERNS = [
    ('del 前缀带日期',   re.compile(r'^del_?\d{6,8}', re.I)),
    ('_copy 副本',       re.compile(r'_copy', re.I)),
    ('_bak 备份',        re.compile(r'_bak', re.I)),
    ('_<8位日期> 后缀',  re.compile(r'_\d{8}$')),
    ('_<6位年月> 后缀',  re.compile(r'_\d{6}$')),
    ('_<4位数字> 后缀',  re.compile(r'_\d{4}$')),
    # 裸数字后缀(**无下划线**)≥4 位:这正是"运行期拼后缀"的形状
    # (`tp_p_eqp_log201711`)。活族成员在前面就被 FAMILY 拦掉了,
    # 能走到这里的说明**没有登记过的族认领它**——实测抓到
    # `parking_running_statistic2023` / `…2025` 两张人工快照。
    ('裸<数字>后缀',     re.compile(r'(?<=[a-z_])\d{4,}$', re.I)),
    # ⚠️ `temp` / `test` 必须按**独立词**匹配,不能用朴素子串——
    # 见文件头坑 1:朴素子串会把 `template` 报成临时表。
    ('名含 temp',        re.compile(r'(?<![a-z])temp(?![a-z])', re.I)),
    ('名含 test',        re.compile(r'(?<![a-z])test(?![a-z])|test$', re.I)),
]

# ⚠️ 分表族前缀的**唯一事实来源**,必须与 `legacy-db-actual.md` §6 一致。
# 数字后缀**直接拼在名字后面**(`tp_p_eqp_log201711`),没有下划线——
# 这也是它们能躲过 `_<6位年月> 后缀` 判据的原因。
#
# 漏登记一个前缀 = 那个族的**每一张表**都会被报成"疑似备份表"。
# 本清单的完整性由 `structural_families()` 反向校验(它不看这份清单)。
FAMILY_PREFIXES = (
    # 库           前缀                      后缀     张数
    'tp_p_eqp_inout_record',   # zt_park  yyyyMM   115  201709~202703
    'tp_p_eqp_log',            # zt_park  yyyyMM   113  201711~202703
)
# ⚠️ §6 一度把 `parking_running_statistic`(2 张:…2023、…2025)也当成族,
# **那是错的**(§6 已于 2026-10-10 改正):全仓只有 `FROM parking_running_statistic`
# (裸表名,SQL 里),没有任何代码把它的表名拼出来。真正的族在 Java 里必然有
# `"前缀" + 日期` 的拼装。那两张是人工快照,已改列入嫌疑判据,
# 并按 §10.1 拍板**不搬**(见下面 `DO_NOT_MIGRATE` 的 `备份` 层)。
# 判据见 `sharded_in_code()`:**代码里有没有 `"前缀"` 这个字面量**。
FAMILY = re.compile(r'^(%s)\d+$' % '|'.join(FAMILY_PREFIXES), re.I)

# 另有一批**以族前缀开头、但不是族成员**的表:`tp_p_eqp_inout_record_temp`、
# `pay_order_success` 等。它们照常走嫌疑判据,但要在报告里显式交代一句,
# 免得看的人以为是漏排。
# `pay_order` 是历史包袱前缀(代码里并无 `pay_order{yyyy}` 分表),
# 留着只为让这批表出现在"有意放行"清单里。
FAMILY_PREFIX = re.compile(
    r'^(%s)' % '|'.join(FAMILY_PREFIXES + ('pay_order',)), re.I)

# 结构判据用:名字以 >=4 位数字结尾(可带一个下划线)即"像族成员"。
STRUCT_FAMILY = re.compile(r'^(.+?)_?(\d{4,})$')

# 建表时间在这个月数内的,单独标出来——`_bak` 不等于陈旧,见文件头判据 3。
RECENT_MONTHS = 12

CODE_EXTS = ('.java', '.xml')
DATE_FMT = '%Y-%m-%d'

# ---------------------------------------------------------------------------
# 已拍板的处置:**不搬进新库**的表。`gen_migration_sql.py` 直接 import 这份
# (与 `check_subset.CHECKS` 同一个套路:单一事实来源,不手抄第二份)。
#
# 值 = (层级, 理由),层级**决定守卫能查什么**(详见文件头「「不搬」分两层」):
#
#   `空表` —— 0 行 + 无表位置引用。判据是**数据自证**的,守卫能完整复核。
#   `备份` —— **有数据**的,判据是**外部拍板**。守卫只能守它的前提(仍存在、
#             仍无引用);"这是不是你需要的备份"本工具证明不了。
#
# ⚠️ 两层的风险不对称:
#   空表不搬 —— **一行都不丢**,只是新库少一张没人认领的表。
#   备份不搬 —— 那批历史行**只留在老库**(老库只读保留,是回退路径,没有销毁,
#               需要时可以单独导出),但新库里确实没有。**理由串是唯一留痕处**。
# 有数据、又没拿到业务方明确答复的,一律按 §10 的"先全搬"走——"没引用"只是
# **当前代码**的结论,外部报表/BI/运维 SQL 有没有在读,本工具证明不了。
#
# ⚠️ 加/删条目后必须重跑本脚本:`main()` 会逐条复核"它还存不存在、还是不是空表、
#    还有没有代码引用",任一条不成立就 exit 2。清单会漂,守卫得跟着一起动。
# 层级标签的**全集**(下面 `DO_NOT_MIGRATE` 的值的第一个元素只许取这里的)。
# 为什么要有这个常量:标签是**字符串比较**的判据,写错一个字(`'备份'` → `'备份表'`)
# 不会抛任何错——只会让"按层级统计"的数字静默变成 0,而那个数字会写进
# `exclude_tables.txt` 的头部。生成物里的假数字比报错难查得多(实测刚踩过)。
# 本脚本与 `gen_migration_sql.py` 都拿它校验,不各写一份。
DO_NOT_MIGRATE_TIERS = ('空表', '备份')

DO_NOT_MIGRATE = {
    # ---- 层级一:空表(数据自证)----
    'zt_order.td_b_pay_blncac_diff_copy':
        ('空表', '_copy 副本,0 行(zt_order)'),
    'zt_order_n.td_b_pay_blncac_result_bak':
        ('空表', '_bak 备份,0 行(zt_order_n)'),
    'zt_park.del20181116td_g_first_guide_info_config':
        ('空表', 'del<日期> 残留,0 行(zt_park)'),
    'zt_park.del20181116td_g_guide_park_berths':
        ('空表', 'del<日期> 残留,0 行(zt_park)'),
    # ---- 层级二:有数据的备份(**外部拍板**,见 legacy-db-actual.md §10.1)----
    # 这三张的共同形态:代码全仓只读裸表名(无后缀那张),带后缀的是**人工滚动快照**,
    # 建表时间与"无后缀那张"只差几分钟——是 `CREATE TABLE … SELECT` 出来存档的。
    # 前提(代码零引用)由 main() 逐条盯着,破了就 exit 2,必须重新问。
    'zt_park.parking_running_statistic2023': (
        '备份',
        '滚动快照(建表 2024-05-22,3.86 亿行 / 约 83 GB),代码零引用;'
        '**2026-10-10 业务方拍板「备份表,无需理会」**'),
    'zt_park.parking_running_statistic2025': (
        '备份',
        '滚动快照(建表 2026-04-17,2.41 亿行 / 约 50 GB),代码零引用;'
        '**2026-10-10 业务方拍板「备份表,无需理会」**'),
    'zt_sys.sys_op_advice_bak': (
        '备份',
        '滚动前的旧表副本(建表 2026-02-25,109 万行 / 200 MB),代码零引用;'
        '**2026-10-10 业务方拍板「备份表,无需理会」**'),
}


def do_not_migrate_tables():
    """合库时"不搬"的 `库.表` 列表,供 `gen_migration_sql.py` 并入排除清单。

    **单一事实来源就是上面的 `DO_NOT_MIGRATE`**,别在别处再抄一份。
    """
    return sorted(DO_NOT_MIGRATE)


def do_not_migrate_tier(db_table):
    """`'空表'` / `'备份'` / `None`。守卫按层级决定查什么。"""
    e = DO_NOT_MIGRATE.get(db_table)
    return e[0] if e else None


def module_of(path, root):
    """从路径取模块名:`src` 之前最深的那个 `parking-*`。"""
    parts = path.relative_to(root).parts
    try:
        si = parts.index('src')
    except ValueError:
        return None
    for p in reversed(parts[:si]):
        if p.startswith('parking-'):
            return p
    return parts[0] if parts else None


# ---------------------------------------------------------------------------
# 一、从库里取嫌疑表

def all_tables(conn, db):
    with conn.cursor() as cur:
        cur.execute(
            "SELECT TABLE_NAME, TABLE_ROWS, DATA_LENGTH, CREATE_TIME "
            "FROM information_schema.TABLES WHERE TABLE_SCHEMA=%s "
            "AND TABLE_TYPE='BASE TABLE' ORDER BY TABLE_NAME", (db,))
        return cur.fetchall()


def family_catalog(conn, db):
    """独立实现 A:**用 MySQL 的 LIKE** 数分表族的数字后缀成员。

    刻意不复用 Python 的 FAMILY 正则——自检要的就是"两套独立实现互证"。
    """
    cond = ' OR '.join(
        "TABLE_NAME LIKE '%s%%%%'" % p.replace('_', r'\_') for p in FAMILY_PREFIXES)
    sql = ("SELECT TABLE_NAME FROM information_schema.TABLES "
           "WHERE TABLE_SCHEMA=%%s AND TABLE_TYPE='BASE TABLE' AND (%s)" % cond)
    with conn.cursor() as cur:
        cur.execute(sql, (db,))
        names = [r[0] for r in cur.fetchall()]
    by_prefix = {p: [] for p in FAMILY_PREFIXES}
    for n in names:
        low = n.lower()
        for p in by_prefix:
            if low.startswith(p) and low[len(p):].isdigit():
                by_prefix[p].append(n)
                break
    return names, by_prefix


def structural_families(names, min_members=2):
    """独立实现 B(**不依赖 `FAMILY_PREFIXES`**):只看名字形状。

    名字以 >=4 位数字结尾者,按**去掉数字后的前缀**归组;同前缀 >= `min_members`
    张就算"结构上像族"。**它只是候选**——人工快照(`tp_p_eqp_20180915`)也长这样,
    必须再用 `sharded_in_code()` 判一次,见自检。
    """
    groups = {}
    for n in names:
        m = STRUCT_FAMILY.match(n.lower())
        if m:
            groups.setdefault(m.group(1), []).append(n)
    return {p: sorted(v) for p, v in groups.items() if len(v) >= min_members}


SHARD_SKIP = {'target', 'node_modules', '.git', 'logs', '.dump'}


def strip_comments(txt, is_xml):
    """抹掉注释,只留下**可执行文本**(字符串字面量保留原样)。

    ⚠️ 2026-10-23 修正:`sharded_in_code()` 原先直接对**原文**跑正则,
    而本仓 Java 的 Javadoc 习惯用 Markdown 反引号写表名(`` `tp_p_eqp` ``),
    反引号恰好被当成"引号字面量"⇒ 三个**静态表**被判成"代码在拼分表族",
    报告退出码 2、整份不可信。

    判据的本意是「运行期拼表名 ⇒ 代码里必有 `"前缀"` 字符串字面量」,
    注释里的反引号不是字面量,必须先剥掉。字符串内部的字符原样保留
    (包括 `\\"` 转义、Java 字符字面量),XML 只剥 `<!-- -->`。
    """
    out = []
    i, n = 0, len(txt)
    in_str = False       # Java 双引号字符串 / XML 单引号 SQL 字符串共用
    in_chr = False       # 仅 Java:字符字面量
    quote = '"'
    while i < n:
        c = txt[i]
        if in_str or in_chr:
            out.append(c)
            if c == '\\' and i + 1 < n:
                out.append(txt[i + 1])
                i += 2
                continue
            if c == quote:
                if in_str:
                    in_str = False
                else:
                    in_chr = False
            i += 1
            continue
        if c == '"':
            in_str, quote = True, '"'
            out.append(c)
            i += 1
            continue
        if c == "'" and not is_xml:
            in_chr, quote = True, "'"
            out.append(c)
            i += 1
            continue
        if not is_xml and txt.startswith('//', i):
            j = txt.find('\n', i)
            i = n if j < 0 else j
            continue
        if not is_xml and txt.startswith('/*', i):
            j = txt.find('*/', i + 2)
            i = n if j < 0 else j + 2
            out.append('\n')
            continue
        if is_xml and txt.startswith('<!--', i):
            j = txt.find('-->', i + 4)
            i = n if j < 0 else j + 3
            out.append('\n')
            continue
        out.append(c)
        i += 1
    return ''.join(out)


def sharded_in_code(root, prefixes):
    """独立实现 C(**权威判据**):**可执行代码**里有没有 `"<前缀>"` 这个字面量。

    运行期分表必然要在 Java(或 XML 的动态 SQL)里把表名拼出来,
    即 `"tp_p_eqp_log" + yyyyMM` 这种写法——于是代码里一定存在
    `"tp_p_eqp_log"` 这个独立字符串。反过来,**静态表**在 SQL 里永远只以
    裸名字出现(`FROM parking_running_statistic`),Java 里不会有它的引号字面量。

    这条判据和 `scan_code()` 里 Java 分支的口径一致(都是"只认带引号的字面量"),
    但**不消费 `FAMILY_PREFIXES`**,所以能反过来证伪那份清单。
    ⚠️ 注释(含 Javadoc 里的反引号写法的表名)不是字面量,先 `strip_comments()`。
    """
    if not prefixes:
        return set()
    rx = re.compile(r'["\'`](%s)["\'`]'
                    % '|'.join(re.escape(p) for p in prefixes), re.I)
    hits = set()
    for dirpath, dirnames, filenames in os.walk(root):
        dirnames[:] = [d for d in dirnames if d not in SHARD_SKIP]
        if os.sep + 'src' + os.sep not in dirpath + os.sep:
            continue
        for fn in filenames:
            if not (fn.endswith('.java') or fn.endswith('.xml')):
                continue
            try:
                txt = open(os.path.join(dirpath, fn), encoding='utf-8',
                           errors='ignore').read()
            except OSError:
                continue
            txt = strip_comments(txt, fn.endswith('.xml'))
            for m in rx.finditer(txt):
                hits.add(m.group(1).lower())
    return hits


def candidates(conn, dbs):
    """{库: [(表名, 判据, 估算, 字节, 建表时间)]},已排除分表族。

    另返回自检所需:families(按族排除的)、familyish(前缀属族但未按族排除的)。
    """
    out, families, familyish = {}, [], []
    for mod, db in sorted(dbs.items()):
        hits = []
        for name, est, size, created in all_tables(conn, db):
            if FAMILY.match(name):
                families.append('%s.%s' % (db, name))
                continue
            if FAMILY_PREFIX.match(name):
                familyish.append('%s.%s' % (db, name))
            for label, rx in PATTERNS:
                if rx.search(name):
                    hits.append((name, label, est, size or 0, created))
                    break
        if hits:
            out[db] = hits
    return out, families, familyish


# ---------------------------------------------------------------------------
# 二、从代码里找引用

def scan_code(root, names):
    """{表名小写: {相对路径: (模块, kind, 行号, 命中行原文)}}。

    一次遍历、对每个文件跑一条"所有名字的并集"正则——比每个表名各 grep 一遍
    少几百倍 IO。

    **命中分强弱两类**(见文件头坑 2、坑 4):
      - `sql`  —— 出现在**表位置**:`FROM/JOIN/INTO/UPDATE/TABLE/EXISTS` 后面,
        或 Java 里的**带引号字面量**。这是强证据。
      - `other`—— 其它位置的词边界命中。**弱证据**,主要来源是 **SQL 别名**:
        `FROM duty_roster_base_temp temp` 里的 `temp` 是别名,不是表名,
        实测就靠这个把 7 行的 `zt_park.temp` 报成了「有代码引用」。

    两类都报,**不据此自动下结论**——但把命中行原文一起贴出来,
    看的人一眼就能分辨是 `FROM xxx temp`(别名)还是 `FROM temp`(真引用)。
    """
    if not names:
        return {}
    alt = '|'.join(re.escape(n) for n in sorted(names, key=len, reverse=True))
    rx_sql = re.compile(
        r'(?i)\b(?:from|join|into|update|table|exists)\s+`?(%s)(?![a-z0-9_`])' % alt)
    rx_xml = re.compile(r'(?<![a-z0-9_])(%s)(?![a-z0-9_])' % alt, re.I)
    rx_java = re.compile(r'["\'`](%s)["\'`]' % alt, re.I)

    def line_at(txt, pos):
        return txt.count('\n', 0, pos) + 1

    found = {}
    skip = {'target', 'node_modules', '.git', 'logs', '.dump'}
    for dirpath, dirnames, filenames in os.walk(root):
        dirnames[:] = [d for d in dirnames if d not in skip]
        if os.sep + 'src' + os.sep + 'main' + os.sep not in dirpath + os.sep:
            continue
        for fn in filenames:
            is_java = fn.endswith('.java')
            if not (is_java or fn.endswith('.xml')):
                continue
            fp = os.path.join(dirpath, fn)
            try:
                txt = open(fp, encoding='utf-8', errors='ignore').read()
            except OSError:
                continue
            per_file = {}
            for kind, rx in (('sql', rx_java if is_java else rx_sql),
                             ('other', None if is_java else rx_xml)):
                if rx is None:
                    continue
                for m in rx.finditer(txt):
                    n = m.group(1).lower()
                    cur = per_file.setdefault(n, ['other', 0, ''])
                    if kind == 'sql':                      # 强证据覆盖弱证据
                        cur[0] = 'sql'
                    if not cur[2]:                         # 只在首条命中时存原文
                        ln = line_at(txt, m.start())
                        cur[1] = ln
                        cur[2] = txt.splitlines()[ln - 1].strip()[:160]
            if not per_file:
                continue
            module = module_of(pathlib.Path(fp), root) or '?'
            rel = os.path.relpath(fp, root)
            for n, (kind, ln, snip) in per_file.items():
                found.setdefault(n, {})[rel] = (module, kind, ln, snip)
    return found


# ---------------------------------------------------------------------------
# 三、结论

def verdict(n_rows, n_files, n_sql, months_old):
    """处置建议。**代码引用数是硬约束**:只要有人查,就不许按名字丢。

    引用分两级(见 `scan_code`):`sql` = 表位置命中,是硬证据;
    只有 `other` 命中(典型是 SQL 别名 `FROM xxx temp`)则降为 🟠——
    大概率是噪声,但**不自动放行**,仍需人眼确认一条。
    """
    if n_sql:
        return '🔴 有代码引用', '**不能按名字处置**——先看引用是活代码还是死代码'
    if n_files:
        return '🟠 疑似引用(非表位置)', (
            '%d 个文件命中,但**没有一处出现在表位置**(`FROM/JOIN/…` 之后或 Java 字面量),'
            '多半是 SQL 别名或变量——人工扫一眼明细即可判' % n_files)
    if months_old is not None and months_old <= RECENT_MONTHS:
        return '🟠 近期创建', ('建表距今 %d 个月,很可能是运维在用而非历史残渣,'
                              '先问运维' % months_old)
    if n_rows == 0:
        return '🟢 空表且无引用', '可丢(建议仍导出留痕)'
    if n_rows is None:
        return '🟡 无引用但行数未知', '行数超限未数,定处置前先取精确 COUNT(*)'
    return '🟡 有数据但无引用', '导出留痕后丢,或随迁(默认`先全搬`)'


def months_since(created, now):
    if not created:
        return None
    return (now.year - created.year) * 12 + (now.month - created.month)


def extract_rows(conn, db, table, limit, est):
    """精确行数。超过 limit 就先不数——报告里显式标出来,别拿估算冒充。"""
    if limit and (est or 0) > limit:
        return None
    with conn.cursor() as cur:
        cur.execute('SELECT COUNT(*) FROM `%s`.`%s`' % (db, table))
        return cur.fetchone()[0]


def human(n):
    for unit in ('B', 'KB', 'MB', 'GB', 'TB'):
        if n < 1024:
            return '%.1f %s' % (n, unit)
        n /= 1024.0
    return '%.1f PB' % n


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('--env', default=os.path.join(
        os.path.dirname(os.path.abspath(__file__)), '..', '..', '私密', 'legacy-ro.env'))
    ap.add_argument('--root', default=os.path.join(
        os.path.dirname(os.path.abspath(__file__)), '..', '..'),
        help='merged 仓库根(默认 parking-cloud/)')
    ap.add_argument('--emit', help='把报告写到文件(默认打到 stdout)')
    ap.add_argument('--exact-limit', type=int, default=5_000_000,
                    help='估算行数超过它就不取精确值(0=不限)')
    a = ap.parse_args()

    cfg = load_env(a.env)
    dbs = db_names(cfg)
    conn = connect(cfg, read_timeout=900)
    now = datetime.datetime.now()
    bad = []
    try:
        cand, families, familyish = candidates(conn, dbs)

        # ---- 自检 1/2:MySQL LIKE(实现 A) vs Python 正则(实现 B)----
        like_total = regex_total = 0
        for mod, db in sorted(dbs.items()):
            _, by_prefix = family_catalog(conn, db)
            like_total += sum(len(v) for v in by_prefix.values())
        regex_total = len(families)
        if like_total != regex_total:
            bad.append('分表族计数不一致:MySQL LIKE 数出 %d,Python 正则数出 %d'
                       % (like_total, regex_total))

        # ---- 自检 2/2:结构候选 × 代码判据(实现 C,不看清单)----
        # 这一条才是真守卫:A 与 B 都消费 FAMILY_PREFIXES,清单漏了族它们一起漏。
        names_all = []
        for mod, db in sorted(dbs.items()):
            names_all += [n for n, _r, _s, _c in all_tables(conn, db)]
        struct = structural_families(names_all)
        sharded = sharded_in_code(pathlib.Path(a.root),
                                  set(struct) | set(FAMILY_PREFIXES))

        # (a) 代码在拼、清单没登记 → 漏了族,该族的表会被整批错报成备份表
        for p in sorted(sharded - set(FAMILY_PREFIXES)):
            v = struct.get(p, [])
            bad.append('`%s` 在代码里被拼装(分表族),但清单未登记'
                       '(结构上 %d 张%s)——漏一个族,它的表会整批被错报成备份表'
                       % (p, len(v),
                          ':%s%s' % ('、'.join(v[:3]), '…' if len(v) > 3 else '')
                          if v else ''))
        # (b) 清单登记了、代码却没拼 → 名字像族但不是族,那几张是人工快照/残留
        for p in sorted(set(FAMILY_PREFIXES) - sharded):
            v = struct.get(p, [])
            bad.append('清单登记了 `%s`,但**代码里没有任何地方拼这个表名**——'
                       '它不是分表族,那 %d 张(%s%s)应从嫌疑清单里逐张确认,'
                       '现在是被错放行了' % (p, len(v), '、'.join(v[:3]),
                                        '…' if len(v) > 3 else ''))
        # 结构上像族、代码不拼、清单也没登记 → 人工快照,照常走嫌疑判据(不告警)
        snapshots = sorted(set(struct) - sharded - set(FAMILY_PREFIXES))

        names = {n.lower() for hits in cand.values() for n, _, _, _, _ in hits}
        code = scan_code(pathlib.Path(a.root), names)

        lines = []
        w = lines.append
        w('# 废弃/备份嫌疑表 —— 逐张确认')
        w('')
        w('> 本文件由 `sql/tools/audit_junk_tables.py` 生成,**不要手改**(重跑会覆盖)。')
        w('> 判据与结论口径见该脚本文件头。**名字只是嫌疑**,证据是精确行数 + 代码引用 + 建表时间。')
        w('')
        w('## 自检')
        w('')
        w('- 分表族(运行期拼后缀的活表)已排除 **%d 张**;'
          '独立用 MySQL `LIKE` 复查得 **%d 张**——%s'
          % (regex_total, like_total, '✅ 一致' if like_total == regex_total
             else '🔴 不一致,先修 `FAMILY` 再读结论'))
        for p in sorted(FAMILY_PREFIXES):
            v = struct.get(p, [])
            w('  - `%s` **%d 张**(%s ~ %s)'
              % (p, len(v), v[0] if v else '—', v[-1] if v else '—'))
        w('- 族清单完整性:用 `sharded_in_code()`(**代码里有没有 `"前缀"` 字面量**)'
          '双向反查——%s'
          % ('✅ 登记的都真在拼、在拼的都登记了'
             if not any('在代码里被拼装' in b or '代码里没有任何地方拼' in b for b in bad)
             else '🔴 见下方告警'))
        if snapshots:
            w('- 名字像族、**但代码没拼**(人工快照,不是族,照常走嫌疑判据):')
            for p in snapshots:
                v = struct[p]
                w('  - `%s` %d 张:%s' % (p, len(v),
                                        '、'.join('`%s`' % x for x in v[:6])
                                        + ('…' if len(v) > 6 else '')))
        w('- 分表族前缀下**未按族排除**的成员(有意放行,照常走嫌疑判据):')
        for t in familyish:
            w('  - `%s`' % t)
        w('- 代码引用口径:命中分两级——`表位置`(`FROM/JOIN/INTO/UPDATE/TABLE/EXISTS` 之后,'
          '或 Java 里的**带引号字面量**)记为硬证据;'
          '其余词边界命中记为**疑似**,因为 `FROM duty_roster_base_temp temp` 里的 `temp` '
          '是**别名不是表名**,实测就靠这个把 7 行的 `zt_park.temp` 报成了「有引用」。'
          '两级都会贴出命中行原文,一眼可判')
        w('- 扫描根:`%s`' % a.root)
        w('')
        w('> ⚠️ 自检项分散在生成过程的各处(代码引用要扫完仓才知道、'
          '`DO_NOT_MIGRATE` 要复核完才敢确认),所以'
          '**告警统一在文末「自检告警」一节列出**——别只看这上面几行。'
          '(踩过:告警块原先写在这一节里,`DO_NOT_MIGRATE` 那批复核是在它**之后**'
          '才追加进 `bad` 的,于是退出码 2、报告里一个字都没提。)')
        w('')

        total = sum(len(v) for v in cand.values())
        w('## 总计 %d 张嫌疑表' % total)
        w('')
        w('| 库 | 张数 |')
        w('|---|---:|')
        for db, hits in sorted(cand.items()):
            w('| `%s` | %d |' % (db, len(hits)))
        w('')

        buckets, refd, seen = {}, [], {}
        for db, hits in sorted(cand.items()):
            w('## `%s`(%d 张)' % (db, len(hits)))
            w('')
            w('| 表 | 命中判据 | 精确行数 | 大小 | 建表时间 | 代码引用 | 建议处置 |')
            w('|---|---|---:|---:|---|---|---|')
            for name, label, est, size, created in hits:
                n = extract_rows(conn, db, name, a.exact_limit, est)
                refs = code.get(name.lower(), {})       # {rel: (模块, kind, 行号, 原文)}
                n_sql = sum(1 for r in refs.values() if r[1] == 'sql')
                key = '%s.%s' % (db, name)
                seen[key] = (n, n_sql, len(refs))
                v, why = verdict(n, len(refs), n_sql, months_since(created, now))
                decided = DO_NOT_MIGRATE.get(key)
                if decided:
                    # 已拍板不搬:盖掉"待确认/建议"那类结论——那条问题**已经有人回答了**,
                    # 报告里再留着"先问运维"会让人以为还悬着。
                    # 但**不盖证据**:万一有(哪怕疑似)代码命中,原样附在理由后面。
                    v = '⬛ 已拍板不搬(%s)' % decided[0]
                    why = decided[1]
                    if refs:
                        why += ('(⚠️ 但代码里有 %d 处命中,其中表位置 %d 处'
                                '——见上面明细,不搬的理由存疑)' % (len(refs), n_sql))
                buckets[v] = buckets.get(v, 0) + 1
                if refs:
                    refd.append((db, name, refs))
                n_txt = ('{:,}'.format(n) if n is not None
                         else '超限未数(估 {:,})'.format(est or 0))
                if not refs:
                    ref_txt = '—'
                elif n_sql:
                    ref_txt = '表位置 **%d** / 疑似 %d' % (n_sql, len(refs) - n_sql)
                else:
                    ref_txt = '表位置 **0** / 疑似 %d' % len(refs)
                w('| `%s` | %s | %s | %s | %s | %s | %s —— %s |' % (
                    name, label, n_txt, human(size),
                    created.strftime(DATE_FMT) if created else '—',
                    ref_txt, v, why))
            w('')

        if refd:
            w('## 🔴/🟠 有命中的:明细(要逐条判断是活代码还是死代码)')
            w('')
            w('> `表位置` = 硬证据;`疑似` = 命中词不在表位置,'
              '**看下面贴的原文**:`FROM tmp_x temp` 这种是别名,不算引用。')
            w('')
            for db, name, refs in refd:
                sql_hits = {k: r for k, r in refs.items() if r[1] == 'sql'}
                other_hits = {k: r for k, r in refs.items() if r[1] != 'sql'}
                w('### `%s`.`%s`' % (db, name))
                w('')
                for tag, group in (('表位置', sql_hits), ('疑似', other_hits)):
                    if not group:
                        continue
                    w('**%s**(%d)' % (tag, len(group)))
                    w('')
                    for f in sorted(group)[:12]:
                        module, _, ln, snip = group[f]
                        w('- `%s`:%d — `%s`' % (f, ln, snip))
                    if len(group) > 12:
                        w('- …另 %d 个文件' % (len(group) - 12))
                    w('')

        # ---- 复核 DO_NOT_MIGRATE:它列的前提,现在还成立吗?----
        # 两层共有的前提是"**代码里没人读它**";`空表` 层多一条"还是 0 行"。
        unknown_tiers = sorted({t for t, _w in DO_NOT_MIGRATE.values()
                                if t not in DO_NOT_MIGRATE_TIERS})
        if unknown_tiers:
            bad.append('`DO_NOT_MIGRATE` 里有未知层级 %s——只许 %s。'
                       '标签是字符串比较的判据,写错一个字不会报错,'
                       '只会让按层级的统计(以及生成物头部的数字)静默变 0'
                       % ('、'.join(unknown_tiers), '、'.join(DO_NOT_MIGRATE_TIERS)))
        for db_table, (tier, _why) in sorted(DO_NOT_MIGRATE.items()):
            if db_table not in seen:
                bad.append('`%s` 已不在嫌疑清单里(改名/删表/判据变了)——'
                           '先查清再动 `DO_NOT_MIGRATE`' % db_table)
                continue
            n, n_sql, n_files = seen[db_table]
            if n_sql:
                bad.append('`%s` 现在有 %d 处**表位置**代码引用,"不搬"的理由不成立了'
                           '——这是两层共有的前提,比行数重要' % (db_table, n_sql))
            elif n_files:
                bad.append('`%s` 现在有 %d 处**疑似**命中(都不在表位置)——'
                           '先人眼扫一遍报告里的明细,确认是 SQL 别名还是真引用,'
                           '再决定这份清单要不要改' % (db_table, n_files))
            if tier == '空表' and n != 0:
                bad.append('`%s` 现在是%s,**已经不是空表**了——'
                           '要么改层级成 `备份`(得有业务方拍板),要么从清单里去掉'
                           % (db_table,
                              '有 %s 行' % '{:,}'.format(n) if n is not None
                              else '行数超限未数'))
        n_empty = sum(1 for t, _w in DO_NOT_MIGRATE.values() if t == '空表')
        w('## 已拍板:不搬进新库的表(%d 张 = 空表 %d + 有数据的备份 %d)'
          % (len(DO_NOT_MIGRATE), n_empty, len(DO_NOT_MIGRATE) - n_empty))
        w('')
        w('> 单一事实来源是 `audit_junk_tables.py` 的 `DO_NOT_MIGRATE`,'
          '迁移脚本(`gen_migration_sql.py`)直接 import 它并写进 '
          '`sql/migration/exclude_tables.txt`(`mysqldump --ignore-table`)、'
          '`99_verify.sql` §1 的 `NOT IN`、`98_count_reconcile.sql` 的排除集。')
        w('>')
        w('> **两层的守卫强度不一样**(见脚本文件头):')
        w('> `空表` 靠**数据自证**——仍存在、仍 0 行、仍无表位置引用,逐条复核;')
        w('> `备份` 是**外部拍板**,本工具只能守它的前提——仍存在、仍无表位置引用。'
          '**"这是不是你需要的备份"没有任何查询能回答**。')
        w('>')
        w('> ⚠️ 所以 `备份` 那几张**是有数据的**:不搬 = 那批历史行只留在老库'
          '(老库只读保留、未被销毁,需要时可单独导出),但**新库里确实没有**。'
          '理由串里的"谁、什么时候、凭什么"是这件事**唯一**的留痕处。')
        w('')
        w('| 表 | 层级 | 理由 |')
        w('|---|---|---|')
        for db_table, (tier, why) in sorted(DO_NOT_MIGRATE.items()):
            # 把"有没有代码命中"也标在这张表里:它是读者最先看的一处,
            # 只写理由串会让人以为干净(退出码 2 的告警在**文末**,容易先被略过)。
            st = seen.get(db_table)
            flag = (' ⚠️ **有 %d 处命中(表位置 %d)**,见上文明细;自检已报红'
                    % (st[2], st[1])) if st and st[2] else ''
            w('| `%s` | %s | %s%s |' % (db_table, tier, why, flag))
        w('')

        w('## 结论分布')
        w('')
        w('| 结论 | 张数 |')
        w('|---|---:|')
        for k, c in sorted(buckets.items()):
            w('| %s | %d |' % (k, c))
        w('')
        w('> 处置决定拍板后,写回 [`../legacy-db-actual.md`](../legacy-db-actual.md) §8,'
          '并在 `check_subset.py` / 排除清单里落地——**别只改这份报告**(它是生成物)。')
        w('')
        # ⚠️ 告警必须放文末:`bad` 的最后一批(DO_NOT_MIGRATE 复核)是在
        #    上面几条循环之后才追加的。放在「自检」小节里会**看不见**——
        #    实测踩过:退出码是 2,报告里却一个字都没提。
        w('## 自检告警')
        w('')
        if bad:
            w('> 🔴 **自检未通过(退出码 2),以下结论不可信**:')
            for b in bad:
                w('> - %s' % b)
        else:
            w('✅ 全部自检通过(退出码 0)。')
            w('')
            w('即:分表族两套实现一致、族清单被代码判据双向确认、'
              '`DO_NOT_MIGRATE` 逐条的**前提**仍成立——两层共有的前提是'
              '**代码里没人读它**,`空表` 层另有"仍是 0 行"。')
            w('')
            w('⚠️ 注意这条自检的**边界**:它只能证明"不就已知前提报警",'
              '证明不了"不搬这个决定本身是对的"——`备份` 层的理由("这是备份,不用管")'
              '是业务判断,报告里的任何一行都不是它的证据。')

        text = '\n'.join(lines) + '\n'
        if a.emit:
            with open(a.emit, 'w', encoding='utf-8') as fh:
                fh.write(text)
            print('已写出 %s(%d 张嫌疑表,排除分表族 %d 张%s)'
                  % (a.emit, total, regex_total, ',⚠️ 自检未通过' if bad else ''))
        else:
            print(text)
    finally:
        conn.close()
    return 2 if bad else 0


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