gen_migration_sql.py 55.4 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 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
从**两份判定表**生成合库迁移用的六个产物:

    # ---- 全量迁移("零丢失",747 GB)----
    1. sql/migration/exclude_tables.txt      每个源库里"不搬"的表
    2. sql/migration/20_union_lost_rows.sql  只存在于副本、必须补进保留侧的行
    3. sql/migration/99_verify.sql           合库后校验(快)
    4. sql/migration/98_count_reconcile.sql  逐表行数对账("零丢失"的正面证据)

    # ---- 抽样开发库(每张表几百行,给开发用;见 10_dump_legacy.sh 的 SAMPLE=N)----
    5. sql/migration/struct_only_tables.txt  **只搬结构、不搬数据**的表(含密钥/口令)
    6. sql/migration/97_verify_sample.sql    抽样镜像的校验

⚠️ 第 3/4 与第 6 是**两套判据**,别混用:3/4 断言"目标行数 == 源库行数",
   在抽样镜像上**必然全红**(抽样镜像的行数本来就该远小于源库)。
   抽样镜像跑 6;只有做全量迁移时才跑 3/4。

判定表(都是 import 现算,没有第二份人工清单):

    · `check_subset.CHECKS`               —— 重名表保留哪一份(merge-conflict-plan.md)
    · `audit_junk_tables.DO_NOT_MIGRATE`  —— 废弃/备份表哪些不搬(legacy-db-actual.md §10.1)
      ⚠️ 它分两层:`空表`(0 行)与 `备份`(**有数据**,约 133 GB,业务方拍板)。
         改它必须先跑 `audit_junk_tables.py`(exit 0 才继续)再跑到这里。

## 为什么是"生成"而不是手写

`CHECKS` 是"哪张表保留哪一份"的**唯一事实来源**,`merge-conflict-plan.md` 的
判定也是基于它。手抄一份排除清单 = 第二个事实来源,两边迟早会不一致
(`AGENTS.md` §1.1 记的正是这个坑:审计脚本的判据必须跟着配置一起改)。
所以这里直接 import `CHECKS`,改名/加表只改一处。

## 列清单也现算

并集 INSERT 的列清单**从 information_schema 现算**(取两侧交集、剔除自增主键 `id`):

- 剔除 `id` 是刻意的:两侧 `id` 空间不同(`zt_order.id=4` 的
  `wx1e9001e5940605b1` 在 `zt_order_n` 是 `id=1`),带着 `id` 插必然主键冲突。
  `id` 都是 `auto_increment`,省略即由 MySQL 分配新值;
  而**业务键(`cust_id` / `appid`)保持原值**——`TdCCarSMapper` 就是
  `left join td_c_cust_person b on a.cust_id=b.cust_id`,不依赖 `id`
- 取交集是因为保留侧列更多(`td_c_cust_person` 40 vs 36),多出来的列(如
  `etc_open_state`)在副本里没有,插入时留 NULL

## ⚠️ 绝不让密钥落盘

`td_b_wx_config` 含 `app_secret` / `encoding_aes_key` / `pay_api_secret`。
本脚本**只生成 `INSERT ... SELECT`**(列名,不含任何值)——密钥值在数据库之间直接流动,
**不经过文件、不经过终端、不经过日志**。生成物里只有列名,没有一行数据。

用法:
    python3 -I sql/tools/gen_migration_sql.py --env 私密/legacy-ro.env
"""

import argparse
import os
import re
import sys

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


def junk_excluded():
    """`{(库, 表)}` —— `legacy-db-actual.md` §10.1 拍板"不搬"的废弃/备份表。

    **事实来源是 `audit_junk_tables.DO_NOT_MIGRATE`**,与 `check_subset.CHECKS`
    同一个套路:绝不手抄第二份。那些条目分两层(详见该脚本文件头):

      · `空表`(4 张)—— 0 行,不搬 = 新库少一张没人认领的表,**一行都不丢**
      · `备份`(3 张)—— **有数据**(含约 133 GB 的历史统计快照)。
        业务方 2026-10-10 拍板"备份表,无需理会"。不搬 = 那批行只留在老库
        (只读保留、未销毁,需要时可单独导出),但**新库里确实没有**。
        要改这个决定,先改 `DO_NOT_MIGRATE`,别无第二处。
    """
    return {tuple(x.split('.', 1)) for x in DO_NOT_MIGRATE}

HERE = os.path.dirname(os.path.abspath(__file__))
OUT_DIR = os.path.normpath(os.path.join(HERE, "..", "migration"))

# 目标库名(六库合一后的唯一库,见 sql/README.md §1)
TARGET_DB = "parking_cloud"

# 需要"取并集"的表:CHECKS 的自动判定只能给"有独有行"这个结论,
# 而"补进去还是只导出留痕"是人工决定(见 merge-conflict-plan.md §5)。
# 每项 = (表, 保留侧模块, 丢弃侧模块, 判重用的业务键)
UNION_TABLES = [
    # 29 行客户只有 zt_order_n 有;order 侧无 SQL 读它,真正读它的是 pay,
    # 而 pay 的 join 键是 cust_id —— 补进去对 pay 是修复而非破坏
    ("td_c_cust_person", "PAY", "ORDER", "cust_id"),
    # 3 行 2017 年的微信支付配置;WxPayApiConfigKit 按 appid 找配置,
    # 找不到直接抛 IllegalStateException
    ("td_b_wx_config", "ORDER", "PAY", "appid"),
]


def magnitude(n):
    """把 information_schema.TABLE_ROWS 的**估算**值折算成量级标签。

    为什么不把这个数原样印进产物(两个理由都实测过,不是洁癖):

    1. **它是估算,不是实测**。MySQL 文档明说 InnoDB 的 TABLE_ROWS 对大表可差
       数十个百分点。产物里原先写的是「保留侧实测约 96,759,945 行」——把估算叫
       "实测"、印到个位数,是假精度,操作者会拿它当准数去对账。
    2. **它每次生成都不一样**。源库在线写入,估算值跟着漂移:同一张 11.9 亿行的表
       连续两次生成相差 77 行。于是「重跑生成器、看有没有 diff」这个同步检查
       **永远不可能干净**——一个每次都报 diff 的检查等于没有检查
       (与 AGENTS.md 里"一个每次都喊狼来了的守卫"同类),而且产物真被人手工改过时,
       那一处改动会淹没在这堆噪声里看不出来。

    量级标签让四个产物成为 CHECKS 的**确定性函数**,只在真的跨越量级边界时才变
    (那时 diff 出来的一行说明的是"表长大了",不是噪声)。
    """
    if not n:
        return "(未知/空)"
    for limit, label in ((10 ** 9, "十亿级"), (10 ** 8, "亿级"), (10 ** 7, "千万级"),
                         (10 ** 6, "百万级"), (10 ** 5, "十万级"), (10 ** 4, "万级")):
        if n >= limit:
            return label
    return "万行以下"


def columns(cur, db, table):
    """[(列名, EXTRA, IS_NULLABLE), ...],按表内顺序。"""
    cur.execute(
        "SELECT COLUMN_NAME, EXTRA, IS_NULLABLE FROM information_schema.COLUMNS "
        "WHERE TABLE_SCHEMA=%s AND TABLE_NAME=%s ORDER BY ORDINAL_POSITION",
        (db, table))
    return cur.fetchall()


def emit_excludes(dbs):
    """不搬的表,两类合起来:

    1. `check_subset.CHECKS` 里所有**重名表的副本**(保留侧见 merge-conflict-plan.md)
    2. `audit_junk_tables.DO_NOT_MIGRATE` 里**已拍板不搬**的废弃/备份表(§10.1)
       —— 又分两小类:`空表`(0 行,不搬零代价)与 `备份`(**有数据**,见文件头)

    两类都从源头 import 现算,产物里不存第二份事实。
    """
    dupes = {"%s.%s" % (dbs[drop_mod], table)
             for table, _km, drop_mod, _k, _n in CHECKS}
    junk = set(DO_NOT_MIGRATE)
    # ⚠️ 元组是 (层级, 理由),别把变量名写反——写反了这里会静默算出 0(实测踩过)
    n_bak = sum(1 for tier, _why in DO_NOT_MIGRATE.values() if tier == '备份')
    uniq = sorted(dupes | junk)
    path = os.path.join(OUT_DIR, "exclude_tables.txt")
    with open(path, "w", encoding="utf-8") as fh:
        fh.write("# 合库时不搬的表\n")
        fh.write("# 由 sql/tools/gen_migration_sql.py 生成,**不要手改**\n")
        fh.write("#   来源一 = check_subset.py 的 CHECKS              —— 重名表的所有副本(%d 张)\n"
                 % len(dupes))
        fh.write("#   来源二 = audit_junk_tables.DO_NOT_MIGRATE       —— 已拍板不搬(%d 张:空表 %d + 有数据的备份 %d)\n"
                 % (len(junk), len(junk) - n_bak, n_bak))
        fh.write("# 用途:mysqldump --ignore-table=<此行> ;保留侧见 merge-conflict-plan.md\n")
        fh.write("# 行数=%d\n" % len(uniq))
        if n_bak:
            fh.write("#\n"
                     "# ⚠️ 来源二里的 `备份` 那 %d 张**是有数据的**(含约 133 GB 的历史统计快照)——\n"
                     "#    不是「空表所以无所谓」,是业务方 2026-10-10 拍板「备份表,无需理会」。\n"
                     "#    数据仍在六个老库里(只读保留、未销毁);哪天要的话从**老库**单独导出,\n"
                     "#    别往新库里补。判断依据与理由串见 ../legacy-db-actual.md §10.1。\n"
                     % n_bak)
        fh.write("\n")
        for ln in sorted(dupes):
            fh.write(ln + "\n")
        if junk:
            fh.write("\n# ---- 以下为 §10.1 拍板不搬的废弃/备份表(非重名表副本,空表 %d + 备份 %d)----\n"
                     % (len(junk) - n_bak, n_bak))
            for ln in sorted(junk):
                fh.write(ln + "\n")
    return path, uniq


def emit_verify(cur, dbs):
    """生成 99_verify.sql。

    校验项同样**从 CHECKS 现算**,理由和 exclude 清单一样:任何手抄的"应保留
    X 表"清单都会和 CHECKS 漂移。这里生成三类断言:

      §3 每张重名表的**保留份**行数 == 目标库该表行数(保留份搬全了、且没多搬)
      §4 每张并集表:丢弃侧"还没进目标库"的行数 == 0(并集补全了)
      §1/§2/§5 覆盖率与字符集是静态 SQL(不依赖 CHECKS),直接写死

    注意:§3 必须**跳过并集表**——并集表的目标行数 = 保留侧 + 补进的行,
    拿它跟"保留侧"比必然不等,那是设计如此,不是错误。并集表由 §4 负责。

    ## §3 的判据是**不对称**的(彩排后改的)

    原先写的是"必须完全相等",这在**在线**源库上必然报红:verify 跑在 dump 之后
    (T2 > T1),源库只要还在写,保留侧就会比目标多。彩排实测就是这样——
    zt_sys 38 张表里 `sys_op_log` 差了 2 行(源 448435 / 目标 448433)。
    一个每次都在高写入表上喊红的校验,会训练操作者忽略红色,比没有校验更坏。

    所以判据分开:
      · 目标 > 保留侧 → **🔴 一定有问题**(新增只会出现在源侧,目标不可能凭空多)
      · 目标 < 保留侧 → **⚠️ 需要解释**(是 dump 之后的在线新增?还是真漏搬?)
      · 相等 → OK
    "少"怎么解释见 98_count_reconcile.sql 的水位线法;维护窗口内跑则应全 OK。
    """
    union_names = {t for t, _k, _d, _key in UNION_TABLES}

    # (表, 保留库),去重:parking_lot 在 CHECKS 里出现 3 次但保留侧只有 zt_park
    keep_pairs = sorted({(t, dbs[km]) for t, km, _dm, _k, _n in CHECKS})
    keep_pairs = [(t, db) for t, db in keep_pairs if t not in union_names]

    rows = []
    sizes = []
    for t, db in keep_pairs:
        # 现读体量:COUNT(*) 在 InnoDB 上是全索引扫描。小表无所谓,
        # 但 park_in_out_record 实测 ~9700 万行 / 32 GB(两条 COUNT(*) 要数分钟)——
        # 把量级写进产物头部,操作者才知道"这一步慢是正常的",
        # 而不是以为卡死了。别在这里凭感觉写"体量不大"(我写过,是错的)。
        # 注意写的是 magnitude(n) 那个**量级标签**,不是原样数字,理由见 magnitude()。
        cur.execute("SELECT TABLE_ROWS FROM information_schema.TABLES "
                    "WHERE TABLE_SCHEMA=%s AND TABLE_NAME=%s", (db, t))
        r = cur.fetchone()
        n = r[0] if r and r[0] else 0
        sizes.append((t, db, n))
        note = ""
        if n > 5_000_000:
            note = ("   -- 保留侧{m}(TABLE_ROWS 估算),这里两条 COUNT(*) 需数分钟"
                    .format(m=magnitude(n)))
        rows.append(
            "SELECT '{t}' AS `表`, '{db}' AS `保留侧`, k.`保留侧行数`, t.`目标行数`,\n"
            "       IF(t.`目标行数` = k.`保留侧行数`, 'OK',\n"
            "          IF(t.`目标行数` > k.`保留侧行数`,\n"
            "             CONCAT('🔴 多 ', t.`目标行数` - k.`保留侧行数`, ' 行——目标不该比源多,只可能是重复导入/多搬了副本'),\n"
            "             CONCAT('⚠️ 少 ', k.`保留侧行数` - t.`目标行数`,\n"
            "                    ' 行——需解释:dump 之后的在线新增,还是真漏搬?(见 98 的水位线法)'))) AS `结论`\n"
            "  FROM (SELECT COUNT(*) AS `目标行数` FROM `{tgt}`.`{t}`) t\n"
            "  JOIN (SELECT COUNT(*) AS `保留侧行数` FROM `{db}`.`{t}`) k{n}".format(
                t=t, db=db, tgt=TARGET_DB, n=note))

    # 每行自带 "-- " 前缀,模板里就不要再加 -- 了(否则首行比其余行多一层缩进)
    # 按**表名**排序,不按体量排序:体量每次生成都在漂移,拿它当排序键会让两张体量
    # 相近的表在两次生成之间互换行序,diff 看起来像"内容改了",其实只是估算值动了。
    size_note = "\n".join(
        "--   %-34s %-12s %s" % (t, db, magnitude(n))
        for t, db, n in sorted(sizes, key=lambda x: (x[0], x[1])))


    drops = []
    for t, keep_mod, drop_mod, key in UNION_TABLES:
        drop_db = dbs[drop_mod]
        drops.append(
            "SELECT '{t}' AS `表`, '{dd}' AS `丢弃侧来源`,\n"
            "       (SELECT COUNT(*) FROM `{dd}`.`{t}` d\n"
            "         WHERE NOT EXISTS (SELECT 1 FROM `{tgt}`.`{t}` k\n"
            "                            WHERE k.`{key}` = d.`{key}`)) AS `仍未进目标库的行数`".format(
                t=t, dd=drop_db, tgt=TARGET_DB, key=key))

    static = """-- ---------------------------------------------------------------------------
-- §1 覆盖率:源库里每张表都必须在目标库里出现
--
-- 注意:被排除的**副本**与保留份**同名**,所以它们在这里也会显示"已存在"
--       (存在的是保留份那份)——本查询查不出"该排除的没排除",
--       那个由 §3 的行数相等来保证。本查询专抓"表整个没了"(dump 漏表)。
--
-- ⚠️ 但 §10.1 拍板**不搬**的那几张({junk_list})
--    是**有意**不在目标库里的——其中三张是**有数据的备份**(约 133 GB),不是空表。
--    不在这里排掉,本查询就会永远报红——
--    一个每次都喊红的校验会训练操作者忽略红色,比没有校验更坏(§3 的注释同此理)。
-- 期望:0 行
-- ---------------------------------------------------------------------------
SELECT s.TABLE_SCHEMA AS `缺失的源库`, s.TABLE_NAME AS `目标库缺少的表`,
       s.TABLE_ROWS AS `源库约行数`
  FROM information_schema.TABLES s
  LEFT JOIN information_schema.TABLES t
         ON t.TABLE_SCHEMA = @tgt AND t.TABLE_NAME = s.TABLE_NAME
 WHERE FIND_IN_SET(s.TABLE_SCHEMA, @src_dbs)
   AND s.TABLE_TYPE = 'BASE TABLE'
   AND CONCAT(s.TABLE_SCHEMA, '.', s.TABLE_NAME) NOT IN ({junk_in})
   AND t.TABLE_NAME IS NULL
 ORDER BY s.TABLE_SCHEMA, s.TABLE_NAME;

-- ---------------------------------------------------------------------------
-- §2 字符集残留:合库后不应再有任何非 utf8mb4 的表或列
--    (改写在 10_dump_legacy.sh 的 rewrite_charset,自测见 tests/)
-- 期望:两条查询各 0 行
-- ---------------------------------------------------------------------------
SELECT TABLE_NAME AS `非 utf8mb4 的表`, TABLE_COLLATION AS `排序规则`
  FROM information_schema.TABLES
 WHERE TABLE_SCHEMA = @tgt AND TABLE_TYPE = 'BASE TABLE'
   AND (TABLE_COLLATION IS NULL OR TABLE_COLLATION NOT LIKE 'utf8mb4%')
 ORDER BY TABLE_NAME;

SELECT TABLE_NAME AS `含非 utf8mb4 列的表`, COLUMN_NAME AS `列`,
       CHARACTER_SET_NAME AS `列字符集`, COLLATION_NAME AS `列排序规则`
  FROM information_schema.COLUMNS
 WHERE TABLE_SCHEMA = @tgt
   AND CHARACTER_SET_NAME IS NOT NULL
   AND CHARACTER_SET_NAME NOT LIKE 'utf8mb4%'
 ORDER BY TABLE_NAME, ORDINAL_POSITION;"""

    # `static` 是当作**值**传给外层 .format() 的,里面再写 {placeholder} 不会被替换,
    # 所以这里显式替换。(不用 .format() 是刻意的:SQL 里 % 和反引号很多,
    # 万一以后谁加个花括号,.format 会直接抛,replace 不会。)
    junk = sorted(DO_NOT_MIGRATE)
    static = static.replace(
        '{junk_in}',
        ",".join("'%s'" % x for x in junk) or "''"
    ).replace(
        '{junk_list}',
        "、".join("`%s`" % x for x in junk) or "(无)"
    )

    sql = """-- 合库后校验(在**目标库所在实例**上执行;要求六个源库仍同实例在侧)
--
-- 由 sql/tools/gen_migration_sql.py 从 check_subset.py 的 CHECKS 生成 —— 不要手改
-- 判定依据见 ../merge-conflict-plan.md
--
-- 🚫 **只对"全量迁移"的库有意义,别在抽样镜像(SAMPLE=N)上跑。**
--    §3/§4 断言的是"目标行数 == 源库行数";抽样镜像的行数本来就远小于源库,
--    跑上去会**必然全红**——而一个每次都报红的校验,和一个永远不报红的一样没用
--    (AGENTS.md §1.1 推论 5)。抽样镜像请跑 97_verify_sample.sql。
--
-- 跑法:
--     mysql -h$TARGET_HOST -u$TARGET_USER -p < 99_verify.sql
--
-- 期望:§1 零行、§2 两个查询零行、§3 全部 OK、§4 全部 0。
-- 任何一条不满足 → **先别删源库**(源库是回滚路径)。
--
-- ⚠️ §3/§4 会 COUNT(*) 十余张表。它们的**实测体量**(生成时现读):
--
{size_note}
--
-- 小表瞬间返回;`park_in_out_record`(约 9700 万行)那两条 COUNT(*) 要数分钟——
-- 慢是正常的,别以为卡死。真正的大表(zt_park 的 16 亿行那些)不在这里。

SET @tgt := '{tgt}';
SET @src_dbs := '{src_dbs}';

{static}

-- ---------------------------------------------------------------------------
-- §3 重名表:目标库行数必须**等于**保留侧行数
--    多于 → 把该丢的副本也搬进来了;少于 → 保留份没搬全
--    并集表不在此列(它们的目标行数天生大于保留侧,由 §4 负责)
-- 期望:全部 OK
-- ---------------------------------------------------------------------------
{rows};

-- ---------------------------------------------------------------------------
-- §4 并集:丢弃侧还有多少行没进目标库
--    NOT EXISTS 的键与 20_union_lost_rows.sql 用的是同一个业务键
-- 期望:全部 0
-- ---------------------------------------------------------------------------
{drops};

-- ---------------------------------------------------------------------------
-- §5 目标库默认字符集
-- 期望:utf8mb4 / utf8mb4_general_ci
-- ---------------------------------------------------------------------------
SELECT DEFAULT_CHARACTER_SET_NAME AS `默认字符集`, DEFAULT_COLLATION_NAME AS `默认排序规则`
  FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = @tgt;
""".format(tgt=TARGET_DB,
           src_dbs=",".join(dbs[m] for m in ("SYS", "PARK", "PAY", "ORDER", "BILL", "CHARGE")
                            if m in dbs),
           size_note=size_note,
           static=static,
           rows="\nUNION ALL\n".join(rows),
           drops="\nUNION ALL\n".join(drops))

    path = os.path.join(OUT_DIR, "99_verify.sql")
    with open(path, "w", encoding="utf-8") as fh:
        fh.write(sql)
    return path


def emit_reconcile(cur, dbs):
    """生成 98_count_reconcile.sql:**逐表**行数对账——"零丢失"的正面证据。

    与 99_verify.sql 的分工(为什么不并在一个文件里):

      · `99` §3 只数 17 张**重名表**的保留侧,秒级,适合反复跑
      · 本文件数**所有**被搬的表(709 张减去不搬的副本)。非重名表(如 zt_sys 的
        38 张)**只有这里才被数到行数**——`99` §1 只查"表在不在",不查行数。
        彩排正是靠逐表 COUNT(*) 才看出 `sys_op_log` 差 2 行
      · 代价:709 张表各两条 COUNT(*),zt_park 那 16 亿行要让服务端做全索引扫描,
        总时长为**小时级**。所以拆成独立文件、并按库分段(见文件头),
        别把它塞进要反复跑的 99

    ## 水位线法:把"少 N 行"从"可能是 churn"变成"已核对"

    对**有 `auto_increment` 主键 `id`** 的追加型表,"少"的部分是不是 dump 之后的
    在线新增,可以精确判定:

        差值 == 源库中 id > 目标库 MAX(id) 的行数   ⇒ 少掉的全是水位线之后的新增,没丢

    彩排的 `sys_op_log` 就是这么判的(目标 id∈[187152,635584]、源 id∈[187152,635586],
    `id > 635584` 恰好 2 行 == 差值 2 ⇒ 未丢)。**没有** auto_increment `id` 的表
    判不了,只能落到 ⚠️ 让人看——不假装它 OK。
    """
    # 不搬的表:与 exclude_tables.txt 同一判据(CHECKS 的副本 + §10.1 拍板不搬的
    # 废弃/备份表),别手抄。漏掉后一类,本文件会为那 7 张表各报一行"目标库没有它"的假缺口。
    # 顺带一个实打实的好处:那两张统计快照(3.86 亿 + 2.41 亿 = 6.27 亿行)不数了,
    # 而 zt_park 段总共才约 16 亿行——本文件最慢的一段因此少掉约四成开销。
    excluded = {(dbs[drop_mod], t) for t, _km, drop_mod, _k, _n in CHECKS}
    excluded |= junk_excluded()
    # 并集表跳过:目标行数 = 保留侧 + 补进的行,与"源侧"比必然不等(由 99 §4 负责)
    union_names = {t for t, _k, _d, _key in UNION_TABLES}

    sections = []
    src_dbs = [dbs[m] for m in ("SYS", "PARK", "PAY", "ORDER", "BILL", "CHARGE") if m in dbs]
    for db in src_dbs:
        cur.execute(
            "SELECT TABLE_NAME, TABLE_ROWS FROM information_schema.TABLES "
            "WHERE TABLE_SCHEMA=%s AND TABLE_TYPE='BASE TABLE' ORDER BY TABLE_NAME", (db,))
        tables = [(n, r or 0) for n, r in cur.fetchall()]

        # "这张表有没有 auto_increment 的 `id`" —— **按库一次问完**,别在下面的循环里
        # 逐表查(679 张 × 30ms/往返 ≈ 20s,改一次判定表就要重跑一次;
        # 与红线第 10 条"禁止在循环中执行数据库查询"同一个道理)。
        cur.execute(
            "SELECT TABLE_NAME FROM information_schema.COLUMNS "
            "WHERE TABLE_SCHEMA=%s AND COLUMN_NAME='id' "
            "AND EXTRA LIKE '%%auto_increment%%'", (db,))
        has_id_set = {n for (n,) in cur.fetchall()}

        rows, total = [], 0
        for t, approx in tables:
            if (db, t) in excluded or t in union_names:
                continue
            total += approx
            has_id = t in has_id_set

            if has_id:
                wm_sub = ("(SELECT COUNT(*) FROM `{db}`.`{t}` x "
                          "WHERE x.`id` > t.`目标最大id`)".format(db=db, t=t))
                wm_col = wm_sub
                ok_wm = "k.`源侧行数` - t.`目标行数` = " + wm_sub
                wm_note = "`id` 是 auto_increment,可判"
                target_sub = ("(SELECT COUNT(*) AS `目标行数`, MAX(`id`) AS `目标最大id`"
                              " FROM `{tgt}`.`{t}`)".format(tgt=TARGET_DB, t=t))
            else:
                # 没有 auto_increment `id` 就**别引用 `id`**——否则这里就是一句 SQL 报错,
                # 把整段对账打断。给个 NULL 列,让它落到 ⚠️ 由人判。
                wm_col = "NULL"
                ok_wm = "0 = 1"
                wm_note = "无 auto_increment `id`,判不了"
                target_sub = ("(SELECT COUNT(*) AS `目标行数`, NULL AS `目标最大id`"
                              " FROM `{tgt}`.`{t}`)".format(tgt=TARGET_DB, t=t))

            rows.append(
                "SELECT '{db}' AS `源库`, '{t}' AS `表`, k.`源侧行数`, t.`目标行数`,\n"
                "       t.`目标最大id`, {wm_col} AS `水位线之后新增`,\n"
                "       IF(t.`目标行数` = k.`源侧行数`, 'OK',\n"
                "          IF(t.`目标行数` > k.`源侧行数`,\n"
                "             CONCAT('🔴 多 ', t.`目标行数` - k.`源侧行数`, ' 行——只可能是重复导入'),\n"
                "          IF({ok_wm},\n"
                "             'OK(少掉的全是 dump 之后源库新增的行,水位线已核对)',\n"
                "             CONCAT('⚠️ 少 ', k.`源侧行数` - t.`目标行数`, ' 行,需人工核对({wm_note})')))) AS `结论`\n"
                "  FROM (SELECT COUNT(*) AS `源侧行数` FROM `{db}`.`{t}`) k\n"
                "  JOIN {target_sub} t\n".format(db=db, t=t, wm_col=wm_col, ok_wm=ok_wm,
                                                wm_note=wm_note, target_sub=target_sub))

        if not rows:
            continue
        heavy = ""
        if total > 50_000_000:
            heavy = ("--   ⚠️ 本段源侧总量 {m}(TABLE_ROWS 估算,且大型 InnoDB 表这个估算值\n"
                     "--      偏低)。这一段是**小时级**的,别以为卡死。\n").format(m=magnitude(total))
        sections.append(
            "-- @@DB {db}\n"
            "-- {db}:{n} 张表参与对账,源侧总量 {m}\n"
            "{heavy}"
            "{body};\n"
            "-- @@END {db}\n".format(db=db, n=len(rows), m=magnitude(total), heavy=heavy,
                                     body="\nUNION ALL\n".join(rows)))

    header = """-- 逐表行数对账(全表,重,但这是"零丢失"的正面证据)
--
-- 由 sql/tools/gen_migration_sql.py 从 check_subset.py 的 CHECKS + 源库元数据生成 —— 不要手改
--
-- 🚫 **只对"全量迁移"的库有意义,别在抽样镜像(SAMPLE=N)上跑。** 判据是
--    "目标行数 == 源库行数",抽样镜像必然每一行都报差异 → 一份永远全红的对账表,
--    下一个人只会把它加进忽略列表(AGENTS.md §1.1 推论 5)。抽样镜像跑 97_verify_sample.sql。
--
-- 位置:合库的**最后一步**,在 10_dump_legacy.sh load 与 20_union_lost_rows.sql 之后。
--       在**目标库所在实例**上执行(要能同时看到源库与 parking_cloud)。
--
-- 跑法(可只跑一个库,别一上来就整跑 709 张表):
--     # 先看有哪些段
--     grep '^-- @@DB' 98_count_reconcile.sql
--     # 只跑 zt_sys 那一段
--     sed -n '/^-- @@DB zt_sys$/,/^-- @@END zt_sys$/p' 98_count_reconcile.sql \\
--       | mysql -h$TARGET_HOST -u$TARGET_USER -p
--
-- 读法(**判据是不对称的**,理由见 99_verify.sql §3 的说明与 AGENTS.md §1.1):
--   · OK                                  —— 相等
--   · OK(水位线已核对)                  —— 目标少了,但少掉的正好是 dump 之后源库新增的
--                                            行,逐行对得上水位线 ⇒ 没丢
--   · 🔴 多 N 行                          —— 目标比源还多,只可能是重复导入,一定要查
--   · ⚠️ 少 N 行,需人工核对              —— 没有 auto_increment `id` 判不了水位线;
--                                            源库在维护窗口内静默时,这一项应该消失
--
-- ⚠️ 若某张表整体没搬进来,本文件会在这张表上以 `1146 Table ... doesn't exist` 报错并停下
--    (mysql 客户端默认遇错即停——**别加 --force**,那会把错误藏起来)。
--    出现 1146 说明 `99_verify.sql` §1 本该先报;先去查 §1。
--
-- ⚠️ 每一段都自带 `-- @@DB <库>` / `-- @@END <库>` 标记,便于分段执行;别删标记。

"""
    path = os.path.join(OUT_DIR, "98_count_reconcile.sql")
    with open(path, "w", encoding="utf-8") as fh:
        fh.write(header)
        fh.write("\n".join(sections))
    return path, sections


def emit_unions(cur, dbs):
    chunks = []
    for table, keep_mod, drop_mod, key in UNION_TABLES:
        keep_db, drop_db = dbs[keep_mod], dbs[drop_mod]
        kcols = {}   # 列名 -> (extra, nullable)
        for name, extra, nullable in columns(cur, keep_db, table):
            kcols[name] = (extra, nullable)
        common = [n for n, _e, _n2 in columns(cur, drop_db, table) if n in kcols]
        # 剔除两侧任一侧是 auto_increment 的列(就是 id)——让目标库重新分配
        ins = [c for c in common
               if "auto_increment" not in kcols[c][0].lower()]
        if key not in ins:
            raise SystemExit("%s:业务键 %s 不在可插入列里" % (table, key))

        collist = ", ".join("`%s`" % c for c in ins)
        select_cols = ", ".join("d.`%s`" % c for c in ins)
        chunks.append("""
-- ---------------------------------------------------------------------------
-- %s:把只存在于 %s.%s 的行补进 %s.%s
--   判重键 %s(两侧 id 空间不同,不能按 id 判重,也不能带 id 插入)
--   可插入列 %d 个(= 两侧交集 − auto_increment 的 id)
-- ---------------------------------------------------------------------------
INSERT INTO `%s`.`%s` (%s)
SELECT %s
FROM `%s`.`%s` d
WHERE NOT EXISTS (
        SELECT 1 FROM `%s`.`%s` k WHERE k.`%s` = d.`%s`
      );""" % (table, drop_db, table, TARGET_DB, table, key, len(ins),
               TARGET_DB, table, collist, select_cols, drop_db, table,
               TARGET_DB, table, key, key))

    path = os.path.join(OUT_DIR, "20_union_lost_rows.sql")
    with open(path, "w", encoding="utf-8") as fh:
        fh.write("""-- 补进"只存在于副本"的行(六库合一的唯一数据搬运动作)
--
-- 由 sql/tools/gen_migration_sql.py 生成 —— 列清单现算,不要手改本文件
-- 判定依据见 ../merge-conflict-plan.md §2.2 / §2.3 / §5
--
-- 🚫 **抽样镜像(SAMPLE=N)不要跑本文件。** 它的前提是"保留侧已被完整灌入"——
--    抽样镜像里保留侧只抽了几百行,此时往丢弃侧取"目标库还没有的行",取到的会是
--    几乎所有行(几亿行里的绝大部分),既没有意义也可能把开发库撑爆。
--
-- 前提:本文件在**目标库所在实例**上执行,且六个源库还在同一实例上
--       (六库同实例已实测,见 ../legacy-db-actual.md §1)
-- 顺序:先按 10_dump_legacy.sh 把保留侧灌进 parking_cloud,再执行本文件
--
-- ⚠️ **执行时机(别当成一个可以随便穿插的步骤)**
--
--   本文件的判据是"**丢弃侧**里目标库还没有的行"(NOT EXISTS)。也就是说:
--   本文件跑完之后,谁再往丢弃侧写一行,那行就永远不会进目标库——
--   而这正是它要搬的那 29 行客户数据同一类的漏。
--
--   所以正确顺序是:
--       停写/切流 → ③ load → ④ 本文件 → ⑤ 99 校验(+ 98 全表对账)→ 放流量到新库
--   不是"搬完随手跑一下"。绝不能在写流量还指向老库副本时先跑一遍了事。
--
-- ⚠️ td_b_wx_config 含微信支付密钥。本文件只有列名、没有任何值——
--    密钥值由 INSERT ... SELECT 在库间直接流动,不落盘。
--    请勿"为了方便"改成带值的 INSERT。
""")
        fh.write("\n".join(chunks))
        fh.write("\n")
    return path


# ---------------------------------------------------------------------------
# 抽样开发库(`10_dump_legacy.sh` 的 SAMPLE=N)
#
# 全量迁移是 747 GB / 2~3 天的活;开发不需要那个精度,只需要"每张表有几百行、
# 结构一模一样"。抽样镜像的**正确性主张变了**,所以校验的判据也必须跟着变:
#
#   全量迁移主张:源库有多少行,目标库就有多少行      → 98/99 §3§4
#   抽样镜像主张:每张表都在、列结构一致、每张表都有样例行 → 97
#
# 把 98/99 套在抽样镜像上跑,会得到一个**永远全红**的校验——而永远报红的校验和
# 永远不报红的一样没用(AGENTS.md §1.1 推论 5),它只会训练人忽略红色。
# ---------------------------------------------------------------------------

# 含密钥/口令的**列名**判据。这个正则控制的是"哪些表只搬结构不搬数据",
# 所以两个方向的代价**极不对称**,取判据时一律**从严**:
#   · 多判进来一张 → 开发库里少一张表的样例行。代价低,而且看得见。
#   · 漏判一张     → 生产的密钥/口令被写进开发机的 dump 文件。代价高,而且看不见。
# 判据**只读 information_schema 的列名**,一行数据都不读——所以本脚本永远
# 不会把密钥读进内存,更不会写进任何产物(同 §"绝不让密钥落盘")。
SECRET_COLUMN_RE = (
    r"secret|passwd|password|_aes|aes_|private_key|pulic_key|pub_key"
    r"|apikey|api_key|sdk_key|sign_key|access_key|accesskey"
    r"|id_card|idcard|bank_card|bankcard|credential|^token$|_token$")


# ---------------------------------------------------------------------------
# 列类型归一化:**MySQL 5.7 与 8 对整型的 COLUMN_TYPE 写法不一样**
#
#   5.7(源库):`int(11)` / `bigint(32)` —— 带"显示宽度"
#   8.0(目标库):`int` / `bigint` —— 8.0.19 起显示宽度被废弃,也不再写进 COLUMN_TYPE
#
# 显示宽度**不影响存储、不影响取值**(官方文档:deprecated, no effect),
# 但它会出现在 COLUMN_TYPE 字符串里。不归一化的话,§2 会把**每一个整型列**都报成
# "类型变了" —— 第一次在本地跑就报了上万行。一份每次都报差异的守卫等于没有守卫
# (AGENTS.md §1.1 推论 5),所以 §2 比的是**归一化后**的类型。
#
# `tinyint(1)` 在 8.0 里被特意保留(BOOLEAN 的别名),但两边**都**归一化成
# `tinyint`,所以不必为它开例外——少一个特例就少一处能漂的地方。
#
# 规则只写一份(INT_TYPES),Python 侧与生成到 SQL 里的表达式都从它来,
# 避免"两边各写一遍正则、改了一边"这种漂法(推论 7:按字符串比对的判据要防手滑)。
# ---------------------------------------------------------------------------
INT_TYPES = ("tinyint", "smallint", "mediumint", "int", "integer", "bigint")

COLTYPE_NORM_RE = r"^(%s)\([0-9]+\)" % "|".join(INT_TYPES)

# SQL 里的同一个正则:MySQL 字符串字面量中 `\\` 才是一个反斜杠,
# 即正则引擎最终收到的是 `\(`——**不能**只写一个反斜杠,那样 `(` 会被当成分组。
COLTYPE_NORM_SQL = r"^(%s)\\([0-9]+\\)" % "|".join(INT_TYPES)


def norm_coltype(ctype):
    """Python 侧归一化。与 SQL 侧的 `sql_norm_coltype` 必须同一条规则——
    两者一旦漂开,§2 要么静默放行、要么永远报红,两种都是坏的。"""
    return re.sub(COLTYPE_NORM_RE, r"\1", ctype)


def sql_norm_coltype(expr):
    """SQL 侧归一化表达式(`REGEXP_REPLACE` 是 MySQL 8 才有的;目标库就是 MySQL 8)。

        bigint(32) unsigned → bigint unsigned
        int(11)            → int
        varchar(64)        → varchar(64)   (不动)
        decimal(10,2)      → decimal(10,2) (不动)
        enum('0','1')      → enum('0','1') (不动)
    """
    return "REGEXP_REPLACE(%s, '%s', '$1')" % (expr, COLTYPE_NORM_SQL)


def kept_tables(cur, dbs):
    """`[(源库, 表名)]` —— 六库合一后**应该出现在目标库**的物理表。

    判据与 `exclude_tables.txt` 同源(六库全部物理表 − CHECKS 丢弃的副本
    − DO_NOT_MIGRATE 拍板不搬的),所以不会出现"排除清单排了、这里还留着"。
    """
    excluded = {(dbs[drop_mod], t) for t, _km, drop_mod, _k, _n in CHECKS}
    excluded |= {tuple(x.split('.', 1)) for x in DO_NOT_MIGRATE}
    out = []
    for db in sorted(set(dbs.values())):
        cur.execute(
            "SELECT TABLE_NAME FROM information_schema.TABLES "
            "WHERE TABLE_SCHEMA=%s AND TABLE_TYPE='BASE TABLE'", (db,))
        for (t,) in cur.fetchall():
            if (db, t) not in excluded:
                out.append((db, t))
    return sorted(out)


def detect_struct_only(cur, dbs):
    """`{(源库, 表名)}` —— 含密钥/口令,抽样时**只搬结构不搬数据**。

    `LOWER(COLUMN_NAME) REGEXP <SECRET_COLUMN_RE>`,写在 SQL 里而不是 Python 里,
    是为了让判据本身可以被 `SHOW` 出来复现——手抄一份"敏感表清单"就是第二个事实来源。
    """
    dbl = sorted(set(dbs.values()))
    cur.execute(
        "SELECT DISTINCT TABLE_SCHEMA, TABLE_NAME FROM information_schema.COLUMNS "
        "WHERE TABLE_SCHEMA IN (%s) AND LOWER(COLUMN_NAME) REGEXP %%s"
        % ",".join(["%s"] * len(dbl)),
        tuple(dbl) + (SECRET_COLUMN_RE,))
    return {(s, t) for s, t in cur.fetchall()}


def source_empty(cur, dbs, tables):
    """`{(源库, 表名)}` —— 在**源库里就一行都没有**的表(精确判定)。

    为什么不能只看 `information_schema.TABLE_ROWS`:那是**估算**(AGENTS.md §1.1
    推论 3 明确禁过"把估算当实测")。所以每张表都要发一条**真查询**。

    ⚠️ 但**别**写成"每张表一条 `SELECT ... LIMIT 1`":源库在张北,一次往返约 30ms,
    679 张就是 20 秒,而这个生成器改一次判定表就要重跑一次
    (与红线第 10 条"禁止在循环中执行数据库查询"同一个道理)。
    改成**一条** `UNION ALL` 把 679 个 `EXISTS(...)` 问完,实测 21s → 不到 1s。

    顺带把"估算当候选筛子"那一步**整个去掉**了:既然现在一次问完所有表,
    就不必先用 `TABLE_ROWS` 缩范围。那一步本身还带个隐患——估算会滞后
    (刚被清空的表可能仍报非 0),漏掉一张真空表就会被当成 kind=1,
    进而在 §4 报一条**假红**。现在没有这个筛子,也就没有这个隐患。

    这个集合的作用:抽样镜像里"空表"是允许的,但只允许**源库本来就空**的那些。
    不把它们挑出来,§4 就会为它们**永远报红**(推论 5)。
    """
    if not tables:
        return set()
    exprs = " UNION ALL ".join(
        "SELECT %s AS db, %s AS tbl, EXISTS(SELECT 1 FROM `%s`.`%s`) AS has_row"
        % (sql_lit(db), sql_lit(t), db, t) for db, t in sorted(tables))
    cur.execute("SELECT db, tbl FROM (%s) x WHERE x.has_row = 0" % exprs)
    return {(db, t) for db, t in cur.fetchall()}


def emit_struct_only(struct_only):
    """产物 5:`struct_only_tables.txt`(格式同 `exclude_tables.txt`,喂 `--ignore-table`)。

    样例只会列出**表名**,不会出现列名之外的任何东西——产物里不含一行数据。
    """
    path = os.path.join(OUT_DIR, "struct_only_tables.txt")
    with open(path, "w", encoding="utf-8") as fh:
        fh.write("# 抽样镜像里**只搬结构、不搬数据**的表\n")
        fh.write("# 由 sql/tools/gen_migration_sql.py 生成,**不要手改**\n")
        fh.write("#   判据 = 表里存在列名匹配 %s\n" % SECRET_COLUMN_RE)
        fh.write("#   为什么:这些表装的是支付/短信/云账号的密钥与口令,\n")
        fh.write("#   抽样会把生产的真实凭据写进**开发机的 dump 文件**。列一行都不搬。\n")
        fh.write("#   判据从严:多判进来只是少几张表的样例行,漏判是密钥落盘。\n")
        fh.write("# 用途:10_dump_legacy.sh 的 SAMPLE= 模式下,数据那一遍 --ignore-table\n")
        fh.write("# 行数=%d\n" % len(struct_only))
        for db, t in sorted(struct_only):
            fh.write("%s.%s\n" % (db, t))
    return path


def source_columns(cur, tables):
    """`[(源库, 表名, 列名, 列序, 列类型)]` —— 生成时把源库的列结构**冻结**下来。

    ⚠️ 为什么要在生成时冻结、而不是让校验 SQL 当场去 information_schema 读源库:
       抽样镜像跑在**开发机**上,那台机器上**没有** `zt_park`/`zt_bill` 这些源库。
       若校验 SQL 直接 join 源 schema,每一列都会报"目标库没有这一列"——
       又是一份**永远全红**的校验(AGENTS.md §1.1 推论 5)。
       冻成 `_cols` 之后,97 是一份**自包含**的校验:目标库上有 mysql 就能跑。
       代价:源库改结构后要重跑本生成器,否则快照过期——那是**要报出来的信号**,
       不是假阳性(AGENTS.md 推论 3:生成物要么确定,要么标明它为什么会漂)。

    `COLUMN_TYPE` 不含字符集(`varchar(64)` 就是 `varchar(64)`),正合用:
    字符集是生成时**故意**改的,比它会把每列都报出来。
    **存进去的是归一化后的类型**(见 `norm_coltype`)——5.7 的 `int(11)` 与
    8.0 的 `int` 是同一个东西,不是同一串字符。

    性能:**按库一次查完**(`TABLE_NAME IN (...)`),别写成"每张表一条 SELECT"。
    源库在张北、生成器在本地,一次往返约 30ms——679 条就是 20 秒,
    而这个生成器是要被反复重跑的(改了判定表就要重跑)。
    这和项目红线第 10 条"禁止在循环中执行数据库查询"是同一个道理,
    只是那条管的是业务代码、这里管的是工具代码。
    """
    by_db = {}
    for db, t in tables:
        by_db.setdefault(db, []).append(t)

    out = []
    for db in sorted(by_db):
        names = sorted(by_db[db])
        qmarks = ",".join(["%s"] * len(names))
        cur.execute(
            "SELECT TABLE_NAME, COLUMN_NAME, ORDINAL_POSITION, COLUMN_TYPE "
            "FROM information_schema.COLUMNS "
            "WHERE TABLE_SCHEMA=%s AND TABLE_NAME IN (%s) "
            "ORDER BY TABLE_NAME, ORDINAL_POSITION" % (("%s", qmarks)),
            tuple([db] + names))
        for t, name, pos, ctype in cur.fetchall():
            out.append((db, t, name, pos, norm_coltype(ctype)))
    # ⚠️ 调用方按 (tbl, col) 建主键、生成的文件也按这个顺序——
    #    查询结果已经是 (db,tbl,pos) 有序,这里再显式排一次,别依赖隐式顺序。
    out.sort(key=lambda r: (r[0], r[1], r[3]))
    return out


def sql_lit(s):
    """MySQL 字符串字面量。**必须转义单引号**:`COLUMN_TYPE` 里就有,
    例如 `enum('0','1')` / `set('a','b')`——不转义会直接把 INSERT 语句撕成两半。"""
    return "'" + str(s).replace("'", "''") + "'"


def emit_verify_sample(cur, dbs, struct_only):
    """产物 6:`97_verify_sample.sql` —— 抽样镜像的校验(判据见 §抽样开发库)。

    生成的是 `_kept` + `_cols` 两张临时表 + 五段断言,期望**全部 0 行**。
    `_kept` 里每张表带一个 kind:0=只搬结构 / 1=抽样 / 2=源库本来就空。
    `_cols` 是生成时冻结的源库列结构(见 `source_columns`)。
    """
    path = os.path.join(OUT_DIR, "97_verify_sample.sql")
    db_list = sorted(set(dbs.values()))
    kept = kept_tables(cur, dbs)
    so = struct_only & set(kept)
    sampled = [kt for kt in kept if kt not in so]
    empty = source_empty(cur, dbs, sampled)
    cols = source_columns(cur, kept)

    def kind(kt):
        if kt in so:
            return 0
        return 2 if kt in empty else 1

    with open(path, "w", encoding="utf-8") as fh:
        fh.write("""-- 抽样开发库校验(**不是**全量迁移校验,别跟 98 / 99 混着看)
--
-- 由 sql/tools/gen_migration_sql.py 从判定表 + 源库元数据生成 —— 不要手改
--
-- 用途:`10_dump_legacy.sh` 的 **SAMPLE=N** 模式灌完之后跑。
--
-- ⚠️ 它与 98_count_reconcile.sql / 99_verify.sql §3§4 的判据**正好相反**。
--    那两份断言"目标行数 == 源库行数",是为**零丢失的全量迁移**写的;
--    抽样镜像的行数**本来就该**远小于源库,在那两份上必然全红。
--    两个主张不一样,就别拿同一把尺子量:
--        全量迁移主张 = 源库多少行,目标库就多少行
--        抽样镜像主张 = 每张表都在、列结构一致、每张表都落了样例行
--
-- 跑法(两个开关都别省):
--     mysql --force -N -h$TARGET_HOST -u$TARGET_USER -p < 97_verify_sample.sql
--   · `--force` —— 一段出错**不要中止**。§4 那段逐表 COUNT(*) 是硬编码表名的
--     UNION ALL,撞到一张不存在的表就是 `ERROR 1146`,**不加 --force 时 mysql 会
--     从这里直接退出,§5 永远不会执行**(实测踩到:只看到 §1 的输出加一条 ERROR,
--     还以为是 §5 通过了)。
--   · `-N` —— 不印列名。要的是"干净时**完全没有输出**"这个判决;
--     印列名的话空结果集也会留一行表头,"空=通过"就不成立了。
-- 期望:§1~§5 **全部 0 行**。任何一段有输出 → 见该段的标题说明。
--
-- 反向用例(**证明这五段真会报红,不是空转**)见 tests/97_negative_test.sh。

SET @tgt := '%s';
-- ⚠️ 与 10_dump_legacy.sh 的 SAMPLE= 保持一致(只用于标出"某张表抽多了")
SET @sample_n := 500;

-- ⚠️ **必须选一个默认库**:下面要 `DROP` / `CREATE TEMPORARY TABLE`,而这两条语句
--    都要有默认库,否则直接 `ERROR 1046 (3D000) No database selected`。
--    (其余表名全是 `parking_cloud`.`x` 全限定写法,这个 USE 只是给临时表一个落脚点,
--      所以跑法里**不用**再传库名参数——传了也不会错,但别以为它是必需的。)
USE `%s`;

-- _kept:六库合一后**应该有**的表。kind 决定这张表该有多少行。
--   0 = 只搬结构(表里有密钥/口令列,数据一律不搬)→ 必须 0 行
--   1 = 抽样                                       → 必须 >= 1 行且 <= @sample_n
--   2 = 源库本来就空(已逐张 LIMIT 1 实查确认)     → 必须 0 行
--
-- ⚠️ 这里比 98_count_reconcile.sql 多 **2 张**(`td_c_cust_person` / `td_b_wx_config`)。
--    那两张在**全量**迁移里要被"取并集"(20_union_lost_rows.sql 往保留侧补行),
--    98 因此跳过它们(目标行数 = 保留侧 + 补进的,跟源侧比必然不等)。
--    抽样镜像不做并集,它们就是普通表 → 必须在这里,别照着 98 的清单把这两张删掉。
DROP TEMPORARY TABLE IF EXISTS `_kept`;
CREATE TEMPORARY TABLE `_kept` (
  `db`   VARCHAR(64) NOT NULL,
  `tbl`  VARCHAR(64) NOT NULL,
  `kind` TINYINT     NOT NULL,
  PRIMARY KEY (`db`, `tbl`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO `_kept` VALUES
""" % (TARGET_DB, TARGET_DB))

        rows = ["('%s','%s',%d)" % (db, t, kind((db, t))) for db, t in kept]
        for i in range(0, len(rows), 200):
            fh.write(",".join(rows[i:i + 200]))
            fh.write(",\n" if i + 200 < len(rows) else ";\n")

        # _cols:**生成时冻结**的源库列结构。为什么不直接 join 源库 schema,见 §2 的说明。
        fh.write("""
-- _cols:源库列结构的**生成时快照**(不是现在去读源库,理由见 §2)。
--   列序 pos 一并带上——只比列名+类型会漏掉"列序被打乱",而列序也是结构的一部分。
DROP TEMPORARY TABLE IF EXISTS `_cols`;
CREATE TEMPORARY TABLE `_cols` (
  `db`   VARCHAR(64)  NOT NULL,
  `tbl`  VARCHAR(64)  NOT NULL,
  `col`  VARCHAR(64)  NOT NULL,
  `pos`  SMALLINT     NOT NULL,
  `type` VARCHAR(255) NOT NULL,
  PRIMARY KEY (`tbl`, `col`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO `_cols` VALUES
""")

        # ⚠️ 必须转义单引号:COLUMN_TYPE 里就有(`enum('0','1')` / `set('a','b')`),
        #    不转义会把这批 INSERT 直接撕成两半。
        crows = ["(%s,%s,%s,%d,%s)" % (sql_lit(db), sql_lit(t), sql_lit(c),
                                       p, sql_lit(ty)) for db, t, c, p, ty in cols]
        for i in range(0, len(crows), 100):
            chunk = crows[i:i + 100]
            fh.write(",".join(chunk))
            fh.write(",\n" if i + 100 < len(crows) else ";\n")

        # `%NORM%` 是目标侧的归一化列类型表达式;`%NORMDESC%` 是它用的正则(写进注释)。
        # 用 replace 而不是 %-format / .format:SQL 里 % 和反引号很多,
        # 与本文件其它地方(见 emit_verify 的 {junk_in})保持同一种做法。
        _norm = sql_norm_coltype("t.COLUMN_TYPE")
        fh.write("""
-- ---------------------------------------------------------------------------
-- §1 表都在:`_kept` 里每张表都要在目标库出现
-- ---------------------------------------------------------------------------
SELECT k.`db` AS `源库`, k.`tbl` AS `表` FROM `_kept` k
  LEFT JOIN information_schema.TABLES t
    ON t.TABLE_SCHEMA=@tgt AND t.TABLE_NAME=k.`tbl` AND t.TABLE_TYPE='BASE TABLE'
 WHERE t.TABLE_NAME IS NULL
 ORDER BY 1, 2;

-- ---------------------------------------------------------------------------
-- §2 列结构一致:列名 + 列类型 + 列序,对的是**生成时冻结的源库快照**(`_cols`)
--    ⚠️ 这里**不读源库 schema**:抽样镜像跑在开发机上,那台机器**没有** zt_park 等源库,
--    join 过去会每列都报"缺列"——一份永远全红的守卫等于没有守卫(AGENTS.md §1.1 推论 5)。
--    所以源库的列结构在 gen_migration_sql.py 里冻结成了 `_cols`。
--    `_cols` 过期(源库改过结构但没重跑生成器)时 §2 会报差异——那是**要的信号**,不是假阳性。
--
--    ⚠️ 也**不比字符集/排序规则**——源库是 utf8/utf8_bin/latin1,目标是 utf8mb4,
--    字符集是生成时**故意**改的(见 10_dump_legacy.sh 的 rewrite_charset)。
--    比了会把每一列都报出来。COLUMN_TYPE 不含字符集(`varchar(64)` 就是 `varchar(64)`),正合用。
--
--    ⚠️ **整型的显示宽度也要先归掉**:源库 5.7 报 `int(11)`,目标 8.0 报 `int`
--    (8.0.19 起显示宽度被废弃)。不归的话**每一个整型列都会报差异**——
--    本地第一次跑就报了上万行,正是"永远全红的守卫"。两边用同一条规则:
--        %NORMDESC%
--    (规则定义只写一份,见 gen_migration_sql.py 的 INT_TYPES / sql_norm_coltype。)
-- ---------------------------------------------------------------------------
-- §2a 源库有、目标库没有(或类型/列序变了)的列
SELECT c.`db` AS `源库`, c.`tbl` AS `表`, c.`col` AS `列`, c.`pos` AS `源库列序`,
       c.`type` AS `源类型`,
       IFNULL(%NORM%, '(目标库没有这一列)') AS `目标类型(已归一化)`,
       IFNULL(t.ORDINAL_POSITION, 0) AS `目标列序`
  FROM `_cols` c
  LEFT JOIN information_schema.COLUMNS t
    ON t.TABLE_SCHEMA=@tgt AND t.TABLE_NAME=c.`tbl` AND t.COLUMN_NAME=c.`col`
 WHERE t.COLUMN_NAME IS NULL
    OR %NORM% <> c.`type`
    OR t.ORDINAL_POSITION <> c.`pos`
 ORDER BY 1, 2, 4;

-- §2b 目标库多出来的列(源库没有)
SELECT t.TABLE_NAME AS `表`, t.COLUMN_NAME AS `多出来的列`,
       t.COLUMN_TYPE AS `类型`, t.ORDINAL_POSITION AS `列序`
  FROM information_schema.COLUMNS t
  JOIN `_kept` k ON k.`tbl` = t.TABLE_NAME
  LEFT JOIN `_cols` c
    ON c.`tbl` = t.TABLE_NAME AND c.`col` = t.COLUMN_NAME
 WHERE t.TABLE_SCHEMA=@tgt AND c.`col` IS NULL
 ORDER BY 1, 4;

-- ---------------------------------------------------------------------------
-- §3/§4 行数:结构表必须 0 行、源库本空的必须 0 行、抽样表必须 >= 1 行
--    这是该文件最值钱的一段:dump **静默漏掉某张表**时,§1/§2 全绿,
--    只有这里会响。
-- ---------------------------------------------------------------------------
SELECT * FROM (
  SELECT k.`db` AS `源库`, k.`tbl` AS `表`,
         CASE k.`kind` WHEN 0 THEN '只搬结构' WHEN 2 THEN '源库本来就空' ELSE '抽样' END AS `类型`,
         x.`行数`,
         CASE WHEN k.`kind` IN (0, 2) AND x.`行数` <> 0 THEN '🔴 不该有数据'
              WHEN k.`kind` = 1 AND x.`行数` = 0       THEN '🔴 抽样没落地'
              WHEN k.`kind` = 1 AND x.`行数` > @sample_n THEN '⚠️ 超过抽样条数'
              ELSE 'OK' END AS `结论`
    FROM `_kept` k
    JOIN (
""".replace("%NORM%", _norm).replace("%NORMDESC%", COLTYPE_NORM_RE))
        # 目标库逐表 COUNT(*):与 98_count_reconcile.sql 同一套写法,
        # 不用 PREPARE + GROUP_CONCAT —— 那条路的 group_concat_max_len 默认只有
        # 1024 字节,677 张表拼出来的 SQL 会被**静默截断**,得到一个看不出错的错。
        exprs = ["      SELECT '%s' AS `表`, COUNT(*) AS `行数` FROM `%s`.`%s`"
                 % (t, TARGET_DB, t) for _db, t in kept]
        fh.write("\n    UNION ALL\n".join(exprs))
        fh.write("""
    ) x ON x.`表` = k.`tbl`
) z WHERE z.`结论` <> 'OK'
 ORDER BY z.`源库`, z.`表`;

-- ---------------------------------------------------------------------------
-- §5 目标库字符集:全部 utf8mb4(表级 + 列级)
-- ---------------------------------------------------------------------------
SELECT TABLE_NAME AS `表`, '表级' AS `位置`, TABLE_COLLATION AS `实际`
  FROM information_schema.TABLES
 WHERE TABLE_SCHEMA=@tgt AND TABLE_TYPE='BASE TABLE' AND TABLE_COLLATION NOT LIKE 'utf8mb4%'
UNION ALL
SELECT TABLE_NAME, CONCAT('列 ', COLUMN_NAME), COLLATION_NAME
  FROM information_schema.COLUMNS
 WHERE TABLE_SCHEMA=@tgt AND COLLATION_NAME IS NOT NULL AND COLLATION_NAME NOT LIKE 'utf8mb4%'
 ORDER BY 1, 2;

DROP TEMPORARY TABLE IF EXISTS `_cols`;
DROP TEMPORARY TABLE IF EXISTS `_kept`;
""")
    return path, len(so), len(empty)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--env", default=os.path.join(
        HERE, "..", "..", "私密", "legacy-ro.env"))
    a = ap.parse_args()

    cfg = load_env(a.env)
    dbs = db_names(cfg)
    # 层级标签写错一个字不会抛错,只会让下面的分类计数静默变 0,
    # 而那个数字要写进 exclude_tables.txt 的头部——成品里的假数字最难查。
    unknown = sorted({t for t, _w in DO_NOT_MIGRATE.values()
                      if t not in DO_NOT_MIGRATE_TIERS})
    if unknown:
        print("DO_NOT_MIGRATE 里有未知层级:%s(只许 %s)"
              % ("、".join(unknown), "、".join(DO_NOT_MIGRATE_TIERS)), file=sys.stderr)
        return 2
    need = ({c[1] for c in CHECKS} | {c[2] for c in CHECKS}
            | {u[1] for u in UNION_TABLES} | {u[2] for u in UNION_TABLES})
    missing = [m for m in sorted(need) if m not in dbs]
    if missing:
        print("env 里缺模块:%s" % ", ".join(missing), file=sys.stderr)
        return 2

    if not os.path.isdir(OUT_DIR):
        os.makedirs(OUT_DIR)

    conn = connect(cfg, read_timeout=120)
    try:
        with conn.cursor() as cur:
            p1, uniq = emit_excludes(dbs)
            p2 = emit_unions(cur, dbs)
            p3 = emit_verify(cur, dbs)
            p4, sections = emit_reconcile(cur, dbs)
            # ---- 抽样开发库那一套(判据不同,见文件头)----
            struct_only = detect_struct_only(cur, dbs)
            p5 = emit_struct_only(struct_only)
            p6, n_so, n_empty = emit_verify_sample(cur, dbs, struct_only)
    finally:
        conn.close()

    print("已生成(全量迁移):")
    print("  %s  (%d 张表不搬)" % (p1, len(uniq)))
    print("  %s  (%d 处并集)" % (p2, len(UNION_TABLES)))
    print("  %s  (合库后校验,快)" % p3)
    print("  %s  (逐表行数对账,%d 段 / 慢)" % (p4, len(sections)))
    print("已生成(抽样开发库,SAMPLE=N 用;判据与上面那份相反):")
    print("  %s  (%d 张表只搬结构·含密钥口令)" % (p5, len(struct_only)))
    print("  %s  (抽样校验;另有 %d 张源库本来就空,已按「允许为空」入表)"
          % (p6, n_empty))
    return 0


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