p2_import_legacy.py 22.6 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
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
P2 第一步:把老工程 zteits-clouds 的核心模块搬进 parking-cloud。

## 设计原则(对应 AGENTS.md §3「搬迁存量代码的顺序」)

1. **只搬不改**——包名、类名、mapper XML 的 namespace / resultMap type 全部原样保留。
   老 XML 里 `namespace="com.zteits.park.mapper.TpPRegionAreaMapper"`、
   `type="com.zteits.park.domain.TpPRegionArea"` 用的是全限定名,改包名会牵动
   上千个 XML 与实体,收益为零、风险极高。因此新模块**沿用老包名**
   (`com.zteits.park.*` / `com.zteits.order.*` / `com.clouds.common.*` /
   `com.zteits.clouds.api.*`),搬迁动作只是「把文件挪到另一个 Maven 模块」。

2. **COPY 而非 MOVE**——老仓库保持可运行、可回溯,直到 P8 下线。

3. **不搬**:启动类(会与 ParkingServerApplication 抢 @SpringBootApplication)、
   Dubbo 过滤器、代码生成器、`application*.properties` / `logback-spring.xml`
   (环境配置以新工程为准,见红线第 9 条)、`context/*.xml`(里面是
   DataSource/SqlSessionFactory 的旧装配,新工程由 parking-spring-boot-starter-mybatis 接管)、
   `dubbo/*.xml`(要删的东西)、`generator/`、证书文件(红线第 11 条:不提交真实密钥)。

## ⚠️ 一次真实事故,以及本脚本现在的删除策略

本脚本原先为了「可重复执行」会 `shutil.rmtree(目标目录)`。但
`parking-module-system` 和 `parking-module-api` 这两个模块**同时装着 P1 手写的
新代码和迁移进来的老代码**,于是那一次执行把 P1 写的 96 个类(`com.zteits.parking.*`
的 system 域 + apibase 契约)连同老代码一起删了,只剩 `target/classes` 里的
.class 文件(后由 `tools/recover/` 从会话记录恢复,详见 AGENTS.md)。

因此现在的规则是:

* **默认只增不删**——直接覆盖同名文件,不删除任何东西。这是安全默认值。
* `--prune` 才会删,且**只删「本次导入会写出去的同一批相对路径」**,也就是
  目的目录里确实由老工程镜像过来的文件;凡是老工程里没有对应源的(= 手写的),
  一律留着。这样既保住了「改名/删除后不留残骸」,又不可能再误删新代码。
* 另有一层兜底:任何落在 PROTECTED_PREFIXES 下的路径,即使理论上算「待删」也拒绝删。

用法:  python3 tools/p2_import_legacy.py [--dry-run] [--prune]
"""

import argparse
import os
import shutil
import sys

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

# ---------------------------------------------------------------------------
# 模块映射:新模块 → (老模块, 要搬的相对路径列表)
# ---------------------------------------------------------------------------
SOURCES = [
    # 契约层:416 个 Dubbo 接口 + 947 个 DTO,包名 com.zteits.clouds.api.* 原样保留
    ("parking-module-api", "zteits-api", ["src/main/java"]),
    # 框架层:com.clouds.common.*,单独一个 Maven 模块,不往 parking-common 里揉
    ("parking-framework/parking-legacy-common", "zteits-framework", ["src/main/java"]),
    # 业务模块
    ("parking-module-park", "zteits-park", ["src/main/java", "src/main/resources/mybatis"]),
    ("parking-module-order", "zteits-order", ["src/main/java", "src/main/resources/mybatis"]),
    ("parking-module-pay", "zteits-pay", ["src/main/java", "src/main/resources/mybatis"]),
    ("parking-module-bill", "zteits-bill", ["src/main/java", "src/main/resources/mybatis"]),
    ("parking-module-charge", "zteits-charge", ["src/main/java", "src/main/resources/mybatis"]),
    ("parking-module-business", "zteits-business", ["src/main/java", "src/main/resources/mybatis"]),
    # 系统域:老 zteits-sys 并进已存在的 parking-module-system
    ("parking-module-system", "zteits-sys", ["src/main/java", "src/main/resources/mybatis"]),
]

# 老工程里 com/clouds/common 下需要额外搬的资源(PDF 字体、二维码底图等运行时资源)
FRAMEWORK_RESOURCES = [
    "fdfs_client.conf",
    "fonts.xml",
    "images",
    "reportpdf",
]

# ---------------------------------------------------------------------------
# 按文件精选的随迁清单(不是整目录搬)
# ---------------------------------------------------------------------------
# 用于「老模块整体不搬,但其中有一小撮类被核心模块在运行期调用」的场景。
# 每条 = (目标模块, 老模块, [相对 src/main/java 的文件路径...])。
#
# 目前只有企业微信一处。老工程 zteits-wxcp-portal 是**独立 JVM**(端口 18031),
# 通过 Dubbo 向 park / order / pay / bill 提供
# `com.zteits.clouds.api.service.wxcp.SendMessageService`(收费员越界、设备故障、
# 优惠券到期等告警)。合成单 Jar、Dubbo 移除后这些调用变成同进程注入,
# 实现必须搬进来 —— 否则启动即
# `No qualifying bean of type ...SendMessageService`(有 4 处是裸 @Autowired 的调用方)。
#
# 为什么不整目录搬:这个模块里大部分是**门户 Web 层**
# (`controller/`、`SwaggerConfig`、`FilterRegistrationConfig` + `filter/`、启动类),
# 属于方案 §4.1「portal 不整体搬迁」的范畴;且 `SwaggerConfig` 走 springfox,
# 而新栈是 springdoc,搬过来只会多一个编译不过的类。
# 所以只搬「企业微信服务子系统」:config + handler + builder + service + utils。
EXPLICIT_SOURCES = [
    ("parking-module-system", "zteits-wxcp-portal", [
        # 配置:WxCpProperties 是 @ConfigurationProperties(wechat.cp);
        # WxCpConfiguration 负责建 WxCpService,并对外提供静态
        # getCpService(agentId) —— 4 个发送实现全靠它拿 service。
        "com/zteits/wxcp/config/WxCpConfiguration.java",
        "com/zteits/wxcp/config/WxCpProperties.java",
        # 消息路由处理器:WxCpConfiguration 构造器注入其中 7 个(都带 @Component),
        # EnterAgentHandler 由路由直接 new。它们服务的是**接收**侧(消息回调),
        # 但路由本身挂在 getCpService 的同一个类里,属于同一子系统,故一并搬。
        "com/zteits/wxcp/handler/AbstractHandler.java",
        "com/zteits/wxcp/handler/ContactChangeHandler.java",
        "com/zteits/wxcp/handler/EnterAgentHandler.java",
        "com/zteits/wxcp/handler/LocationHandler.java",
        "com/zteits/wxcp/handler/LogHandler.java",
        "com/zteits/wxcp/handler/MenuHandler.java",
        "com/zteits/wxcp/handler/MsgHandler.java",
        "com/zteits/wxcp/handler/NullHandler.java",
        "com/zteits/wxcp/handler/ScanHandler.java",
        "com/zteits/wxcp/handler/SubscribeHandler.java",
        "com/zteits/wxcp/handler/UnsubscribeHandler.java",
        # MsgHandler 回复消息时用到的响应构造器
        "com/zteits/wxcp/builder/AbstractBuilder.java",
        "com/zteits/wxcp/builder/ImageBuilder.java",
        "com/zteits/wxcp/builder/TextBuilder.java",
        # 发送实现:SendMessageService 的实现类 + 按消息类型分派的 4 个内部分支
        "com/zteits/wxcp/service/impl/SendMessageServiceImpl.java",
        "com/zteits/wxcp/service/impl/inner/InnerMessageSendService.java",
        "com/zteits/wxcp/service/impl/inner/impl/NewsMSendMessageServiceImpl.java",
        "com/zteits/wxcp/service/impl/inner/impl/PnewsMSendMessageServiceImpl.java",
        "com/zteits/wxcp/service/impl/inner/impl/TextCardMsgSendServiceImpl.java",
        "com/zteits/wxcp/service/impl/inner/impl/TextSendMessageServiceImpl.java",
        # 工具与常量:JsonUtils 被 WxCpProperties.toString() 用,走 gson(已在 classpath)
        "com/zteits/wxcp/utils/JsonUtils.java",
        "com/zteits/wxcp/utils/constants/InnerMessageSendRoutes.java",
    ]),
]

# ---------------------------------------------------------------------------
# 排除规则
# ---------------------------------------------------------------------------

# 整文件排除(按文件名)
SKIP_FILENAMES = {
    # 启动类:与 ParkingServerApplication 冲突
    "ParkApplication.java",
    "OrderApplication.java",
    "PayApplication.java",
    "BillApplication.java",
    "ChargeApplication.java",
    "BusinessApplication.java",
    "Application.java",          # zteits-sys/src/main/java/com/zteits/sys/Application.java
    "RefreshCacheApplication.java",
    "FrameworkMain.java",
    # 代码生成器:新工程不再用(P6 统一进 parking-module-infra)
    "MybatisGenerateRun.java",
    "MybatisGenerateRun2.java",
    # 上面两个入口类的伴生类(mybatis-generator-core 的 PluginAdapter / IntrospectedTable 等)。
    # 入口类不搬之后这些成了孤儿:全仓 grep 过,除了彼此没有别的引用。
    # 留着会把 mybatis-generator-core 拖进单 Jar(只为一段跑不到的代码),所以一并排除。
    "MybatisGeneratorCommon.java",
    "ServiceAndControllerGeneratorPlugin.java",
    "OverWriteIsMergeablePlugin.java",
    # Zookeeper 分布式锁客户端:唯一使用者是 park 的 RefreshCache,
    # 后者已改用 Redisson(去掉 Dubbo/ZK 的必然动作),此类随之作废。
    "ZkClientFactory.java",
    # 错放在 src/main/java 里的 JUnit 测试类(两个 @Test 方法、全仓 0 引用、
    # 类注释自己写着 TEST-JOB)。老 zteits-order/pom.xml 里 starter-test 是
    # <scope>test</scope>,所以它在老仓库的 main 编译里**也编译不过**,
    # 只在 IDE 里跑得起来。搬过来会逼着把 junit 提成 compile 依赖、进而混进单 Jar,
    # 只为两个跑不到的调试方法。测试代码的迁移是 P4/P5 的事(按页面与用例重建)。
    "CloudsJobTest.java",
    # Dubbo 过滤器:Dubbo 整体废弃
    "RPCConsumerFilter.java",
    "RPCProviderFilter.java",
    # 老 mybatis 装配(DataSource/SqlSessionFactory),新工程由 starter 接管
    "core-context-sys.xml",
    # 老 Redis 装配:jedis 版 RedisConnectionFactory + RedisTemplate,
    # 全仓无人引用,新工程由 parking-spring-boot-starter-redis 提供(且新栈是
    # Lettuce/Redisson,不是 jedis)。留着会多出一个 RedisConnectionFactory Bean,
    # 且它头上还挂着 @EnableAutoConfiguration。
    "RedisConfig.java",
    # 老 Redisson 装配:类上 @Configuration,但 redissonClient() **没挂 @Bean**,
    # 即注册不出任何 Bean;老仓库 + 新仓库双向 grep `RedissonConfig` / `redissonClient()`
    # 均为零引用(`RedissonClient` 靠 redisson-spring-boot-starter 的自动配置提供,
    # 存量 20+ 处 @Autowired RedissonClient 与它无关)。
    # ⚠️ 它同时是 `spring.redis.password` 这个占位符的**唯一**索取者,而本地 Redis 无密码,
    #    必须"整行不定义"才能避免 Redisson 发 AUTH ""(见 application-local.yaml 的注释),
    #    两者不可兼得 —— 删掉这个死类,占位符需求随之消失。详见 AGENTS.md 坑 15。
    "RedissonConfig.java",
    # Boot 1.x 专有错误处理:继承 org.springframework.boot.autoconfigure.web.AbstractErrorController、
    # 用 autoconfigure.web.ErrorAttributes —— 这两个类在 Boot 2 已挪到
    # org.springframework.boot.web.servlet.error.*。全仓无人引用,异常统一由
    # starter-web 的 GlobalExceptionHandler + GlobalResponseBodyHandler 承担
    # (与方案 §4.5「响应语义收敛到 CommonResult」一致),故不再搬运。
    "ControllerExceptionHandler.java",
    # 老 MVC 装配:@Configuration + **@EnableWebMvc** + 继承 WebMvcConfigurerAdapter。
    # @EnableWebMvc 的语义就是「关掉 Boot 的 MVC 自动配置」,而新框架的
    # GlobalExceptionHandler / Jackson 配置 / Knife4j / URL 前缀映射全靠 Boot 那套,
    # 扫进来等于把整个 web 层拆了。老工程的 portal 启动类也是靠
    # excludeFilters={@Filter(ASSIGNABLE_TYPE, value=WebMvcConfigurer.class)} 把它排除的。
    # 引用者只有未搬迁的 portal / data-collection / glock / lz 的启动类,
    # 7 个业务模块全程不引用(已全仓 + 老仓库双向 grep 核对)。
    "WebMvcConfigurer.java",
    # 老数据源装配(每个存量模块各一份,老仓库共 10 份)。在单 Jar 里就是
    # **7 个同名 @Configuration + 7 个同名 dataSource() Bean**,直接
    # ConflictingBeanDefinitionException。且新工程的数据源由
    # druid-spring-boot-starter(application-local.yaml 的 spring.datasource.druid)
    # + parking-spring-boot-starter-mybatis 接管,这些类里 @AutoConfigureBefore(
    # DataSourceAutoConfiguration.class) 的老写法在 Boot 2.7 也已无意义
    # (@AutoConfigureBefore 只在 spring.factories/AutoConfiguration.imports 里才生效)。
    # ⚠️ 代价:老 DruidAutoConfiguration 里手工设的池参数(initialSize/maxActive/
    #    validationQuery/filters=stat,wall 等)不再生效,需在 P3 按新 yaml 对齐,
    #    已记入 AGENTS.md 待办。
    "DruidAutoConfiguration.java",
    "DruidProperties.java",
    # 老 xxl-job 装配(老仓库:XxlJobConfig ×4、XxlJobConfiguration ×6、XxlJobCondition ×6)。
    # 单 Jar 只允许**一个**执行器,而这 4 份 XxlJobConfig 逐字相同、只差包名,
    # 且各自 @Value 取的是**各模块专属**的部署参数(xxl.job.executor.appname=zteits-park /
    # zteits-order、port=9991/9992、logpath=/home/zteits/...)。
    # 合并后 appname/port/logpath 该取什么值是**新部署的决策**,不是搬迁能定的:
    #   * 保留任意一份 → 必须新造一套 xxl.job.* 配置(还要知道新环境的 admin 地址),
    #     且会把「park 的执行器」当成整个平台的执行器,语义也不对;
    #   * 全不保留 → 105 个 @JobHandler 照常编译、只是没有执行器去拉起它们。
    # 方案里 xxl-job → Quartz 的收敛是 P5,届时统一落一份平台级配置,故此处全部排除。
    # ⚠️ 代价:这 105 个定时任务在 P5 之前**不会运行**,已记入 AGENTS.md 待办。
    "XxlJobConfig.java",
    "XxlJobConfiguration.java",
    "XxlJobCondition.java",
}

# 整目录排除(相对 src/main/java 或 src/main/resources 的路径片段)
SKIP_DIR_PARTS = {
    "META-INF",  # zteits-order/src/main/java/META-INF/MANIFEST.MF
}

# 非 Java/XML 的资源后缀一律不搬(证书、私钥)
SKIP_SUFFIXES = (".p12", ".jks", ".pkcs12", ".keystore")

# 手写代码所在路径:这些目录下的文件**永远**不删(--prune 也不例外)。
# com/zteits/parking 是新工程的包名,老工程里根本不存在,所以「老工程里没有对应源」
# 这条规则本身就足以保护它;这里再列一遍是为了防住「万一以后有人往老工程也放了同名包」。
PROTECTED_PREFIXES = (
    "com/zteits/parking",
)


def is_protected(rel):
    return any(rel.replace(os.sep, "/").startswith(p) for p in PROTECTED_PREFIXES)


def should_skip(path):
    name = os.path.basename(path)
    if name in SKIP_FILENAMES:
        return True
    if name.endswith(SKIP_SUFFIXES):
        return True
    parts = set(path.split(os.sep))
    if parts & SKIP_DIR_PARTS:
        return True
    return False


def source_rels(src):
    """老工程这一份源码对应的全部相对路径(应用排除规则后),即「本次导入管得着的文件」。"""
    out = set()
    for root, dirs, files in os.walk(src):
        dirs[:] = [d for d in dirs if d not in SKIP_DIR_PARTS]
        for f in files:
            rel = os.path.relpath(os.path.join(root, f), src)
            if not should_skip(rel):
                out.add(rel)
    return out


def prune_mirror(src, dst, dry, stats):
    """删掉目的目录里「老工程镜像过来的、但本次不再产出」的文件(改名/删除的残骸)。

    判据是**相对路径是否在老工程源码里**:
      * 在  → 这个文件由导入负责,老工程里已没了,可以删
      * 不在 → 手写文件(如 com/zteits/parking/**),绝不删
    """
    keep = source_rels(src)
    stale = []
    for root, _, files in os.walk(dst):
        for f in files:
            rel = os.path.relpath(os.path.join(root, f), dst)
            if rel in keep or should_skip(rel):
                continue
            # 本次会重新产出的路径不删;排除掉的文件也不管(本来就不该由导入管理)
            if is_protected(rel):
                stats["protected"] += 1
                continue
            stale.append(os.path.join(root, f))
    for p in stale:
        stats["pruned"] += 1
        if dry:
            print(f"    prune {os.path.relpath(p, dst)}")
        else:
            os.remove(p)
    # 删空目录(老代码改名后可能留下空包目录)
    if not dry:
        for root, dirs, _ in os.walk(dst, topdown=False):
            for d in dirs:
                p = os.path.join(root, d)
                if is_protected(os.path.relpath(p, dst)):
                    continue
                if not os.listdir(p):
                    os.rmdir(p)


def copy_tree(src, dst, dry, stats):
    """拷贝 src → dst,逐文件应用排除规则。"""
    for root, dirs, files in os.walk(src):
        dirs[:] = [d for d in dirs if d not in SKIP_DIR_PARTS]
        for f in files:
            s = os.path.join(root, f)
            rel = os.path.relpath(s, src)
            t = os.path.join(dst, rel)
            if should_skip(rel):
                stats["skipped"] += 1
                if dry:
                    print(f"    skip  {rel}")
                continue
            stats["copied"] += 1
            if not dry:
                os.makedirs(os.path.dirname(t), exist_ok=True)
                shutil.copy2(s, t)


def copy_explicit(dry, stats):
    """按 EXPLICIT_SOURCES 逐文件拷贝(老模块不整体搬,只挑若干类)。

    与 copy_tree 的区别:只搬列出来的文件,**不碰**目的目录里其它任何东西;
    因此一个源文件被删掉时这里只会「少拷贝一个」,不会清理残骸 —— 需要清残骸时
    仍走 copy_tree + --prune 的那一套。少而明确,所以够用。
    """
    for dst_mod, src_mod, rels in EXPLICIT_SOURCES:
        print(f"\n=== {src_mod}  ->  {dst_mod}(按文件精选)===")
        base = os.path.join(LEGACY, src_mod, "src/main/java")
        for rel in rels:
            s = os.path.join(base, rel)
            if not os.path.isfile(s):
                print(f"  !! 源文件不存在: {src_mod}/{rel}")
                continue
            if should_skip(rel):
                stats["skipped"] += 1
                print(f"    skip  {rel}")
                continue
            stats["copied"] += 1
            # 与 SOURCES 的落位保持一致:老模块的 src/main/java 对应新模块的 src/main/java
            t = os.path.join(CLOUD, dst_mod, "src/main/java", rel)
            print(f"  {'(dry)' if dry else 'copy'}  {rel}")
            if not dry:
                os.makedirs(os.path.dirname(t), exist_ok=True)
                shutil.copy2(s, t)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--dry-run", action="store_true")
    ap.add_argument(
        "--prune",
        action="store_true",
        help="删除老工程镜像过来、但本次已不产出的文件(只删这一类,手写代码不动)",
    )
    ap.add_argument(
        "--only-explicit",
        action="store_true",
        help="只跑 EXPLICIT_SOURCES 的按文件精选项,不重新覆盖 SOURCES 的整目录镜像",
    )
    args = ap.parse_args()

    if not os.path.isdir(LEGACY):
        sys.exit(f"老工程目录不存在: {LEGACY}")

    grand = {"copied": 0, "skipped": 0, "pruned": 0, "protected": 0}

    # ⚠️ --only-explicit 存在的理由:整目录镜像(SOURCES)是**覆盖式**的,
    # 凡是在镜像目录里手改过的文件(已发生过的例子:com/zteits/charge/config/
    # CallKeLinServiceProxyConfiguration.java 删掉无参 @ComponentScan、9 个 mapper 的
    # 显式 bean 名)再跑一次就会被老源码原样盖回去。只想补跑按文件精选项时用它。
    if args.only_explicit:
        copy_explicit(args.dry_run, grand)
        print(f"\n合计:拷贝 {grand['copied']} 个文件,排除 {grand['skipped']} 个。")
        if args.dry_run:
            print("(--dry-run,未落盘)")
        return

    for dst_mod, src_mod, rels in SOURCES:
        dst_root = os.path.join(CLOUD, dst_mod)
        src_root = os.path.join(LEGACY, src_mod)
        if not os.path.isdir(src_root):
            print(f"!! 跳过 {src_mod}(不存在)")
            continue

        print(f"\n=== {src_mod}  ->  {dst_mod} ===")
        for rel in rels:
            s = os.path.join(src_root, rel)
            if not os.path.isdir(s):
                continue
            d = os.path.join(dst_root, rel)
            stats = {"copied": 0, "skipped": 0, "pruned": 0, "protected": 0}
            # 先清残骸再拷(--prune 才清;默认只增不删)
            if args.prune and os.path.isdir(d):
                prune_mirror(s, d, args.dry_run, stats)
            copy_tree(s, d, args.dry_run, stats)
            msg = f"  {rel:<34} 拷贝 {stats['copied']:>5}  排除 {stats['skipped']:>3}"
            if args.prune:
                msg += f"  清理残骸 {stats['pruned']:>4}"
                if stats["protected"]:
                    msg += f"  保护未删 {stats['protected']}"
            print(msg)
            for k in grand:
                grand[k] += stats[k]

    # 按文件精选的随迁(见 EXPLICIT_SOURCES 的注释)
    copy_explicit(args.dry_run, grand)

    # 框架资源
    print("\n=== zteits-framework 运行时资源 ===")
    for r in FRAMEWORK_RESOURCES:
        s = os.path.join(LEGACY, "zteits-framework", "src/main/resources", r)
        if not os.path.exists(s):
            continue
        d = os.path.join(CLOUD, "parking-framework/parking-legacy-common/src/main/resources", r)
        if not args.dry_run:
            shutil.rmtree(d, ignore_errors=True) if os.path.isdir(d) else None
            os.makedirs(os.path.dirname(d), exist_ok=True)
            if os.path.isdir(s):
                shutil.copytree(s, d)
            else:
                shutil.copy2(s, d)
        print(f"  {r}")

    print(f"\n合计:拷贝 {grand['copied']} 个文件,排除 {grand['skipped']} 个。")
    if args.prune:
        print(
            f"清理老代码残骸 {grand['pruned']} 个;"
            f"因属手写代码而保护未删 {grand['protected']} 个。"
        )
    else:
        print("(未传 --prune:只增不删,不会删除任何现有文件)")
    if args.dry_run:
        print("(--dry-run,未落盘)")


if __name__ == "__main__":
    main()