适用提交: 6c42f8f | 最后验证: 2026-09-18 | 何时会失效: application.yml 里 app.payment.huifu 块的配置键、回调端点数、或任一 @Scheduled 任务的配置键发生变化时

08 上线与环境

面向「接手后怎么把环境跑起来、怎么上线」。排障看 07;协议契约看 05;实现细节见附录 A
(talent-card-server/docs/huifu-payment-integration.md),冲突时以附录 A 为准。

本文件不写任何凭据值。 私钥、密钥、商户号、汇付号、AppID、公网 IP、银行卡号一律脱敏或省略。
置信度四档同 07;凡标【实测】的都是我们自己跑出来的,**汇付侧未书面确认**。

0. 本文件用到的术语

以根目录 CONTEXT.md 为准,这里只列上线验收时会直接用到、且最容易混的四个:

术语 一句话 别写成
收款能力 通道侧对这家子商户当下是否可用的**判定事实**(商户状态 + 取现/结算开关);不由平台决定,会随控台操作改变 收款就绪
收款就绪 商户可以接受微信付款的**某一时点判定结果**,除收款能力正常外还要求微信支付已开通、微信实名与授权已完成 进件通过
延迟户 / 交易确认 未核销的钱停在通道**延迟户**(不可结算、不可取现);核销后调**交易确认**分给商户基本户 中间户
微信待实名 微信支付开通过程中仍需法人本人扫码授权并满足 authorize_stat=1 的阶段,**不是开通失败** 开通失败

1. 环境与凭据

1.1 五个回调地址

五个端点都在 PaymentCallbackController(/api/payments/huifu),地址各自一个配置键。
当前它们全部指向同一个公网 IP 与端口(形如 http://221.x.x.x:11451/...)【实测】。
app.payment.huifu.notify-url 之外的四个键如果留空,HuifuPaymentProperties 会**从 notify-url 推导**出同 host
同路径下的地址——所以「地址为空」不会报错,只会静默消失。启动时 HuifuCallbackUrlAudit 会把五个端点逐条念一遍。

端点 配置键 环境变量 承接什么通知
POST /notify notify-url HUIFU_NOTIFY_URL 支付成功 + 退款结果
POST /merchant-business-notify merchant-business-notify-url HUIFU_MERCHANT_BUSINESS_NOTIFY_URL 绑卡(业务开通)结果
POST /merchant-onboarding-notify merchant-onboarding-notify-url HUIFU_MERCHANT_ONBOARDING_NOTIFY_URL 进件审核结果
POST /merchant-wechat-config-notify merchant-wechat-config-notify-url HUIFU_MERCHANT_WECHAT_CONFIG_NOTIFY_URL 微信小程序配置结果
POST /merchant-business-open-notify merchant-business-open-notify-url HUIFU_MERCHANT_BUSINESS_OPEN_NOTIFY_URL 商户业务开通结果

三条上线必须做到的事:

  1. 公网可达:平台目前**没有公网服务器**【实测】,这五个地址在生产/联调环境必须换成真实公网 HTTPS 地址,
    并在汇付控台逐条注册。缺 merchant-business-notify-url 曾经是**生产 bug**(键原本不存在,
    靠 notify-url 推导,于是「通道侧到底注册了哪个地址」取决于另一个配置);现在它显式写在 yml 里【实测】。
  2. 不能改写报文:入口/WAF 必须允许 POST 表单(resp_data + sign),且**不得重写**这两个字段。
  3. 解答格式:统一回纯文本 RECV_ORD_ID_<req_seq_id>【实测】;验签不过直接 403,落不进 payment_callback_event。

通道侧实际注册的地址,本地看不出来——HuifuCallbackUrlAudit 只能报「我们配了什么」。
必须在控台逐条比对并留档。[待确认]

1.2 支付链路关键开关

注意层级:fund-confirmation-retry 与 stranded-refund 在 app.payment 下,**不在** app.payment.huifu 下。

配置键 yml 默认值 作用
app.payment.huifu.enabled true 总开关。置 false 时**不发起任何通道调用**,收款能力只校验本地规则
app.payment.huifu.environment prod 决定 SDK 用生产还是沙箱模式(Java 字段默认值是 test,两处不一致)
...huifu.reconciliation.enabled true 支付与退款唯一的后台兜底;关掉它只剩「用户点了取消/重申支付」这一个补救入口
...huifu.reconciliation.pending-after-minutes 5 慢车道:支付挂起超过这么久才查一次通道
...huifu.reconciliation.fixed-delay-ms 60000 扫描间隔。它同时是**关单补偿的重试下限**(通道不允许关闭一分钟以内的订单),调慢会让「关单每分钟重试一次」不成立
...huifu.capability-refresh.enabled true 收款能力快照的定时核实
...huifu.capability-refresh.stale-hours 6 只扫「超过这么久没核实」的进件。**它是被控台关停的商户最长约 6 小时的感知空窗**,代价与通道查询量成反比
...huifu.capability-refresh.fixed-delay-ms 1800000 能力核实每 30 分钟一轮
...huifu.merchant-capability-required true 是否要求通道确认能力;设 false 仅用于刻意跳过通道确认的部署
app.payment.fund-confirmation-retry.enabled true 交易确认补偿(钱冻在延迟户时的重试)
app.payment.fund-confirmation-retry.fixed-delay-ms 300000 5 分钟一轮
app.payment.stranded-refund.enabled true 悬账自动退款(退回付款人);通道拒绝则升级告警转人工
app.payment.stranded-refund.fixed-delay-ms 300000 5 分钟一轮
...huifu.merchant-use-chains-flag 空(不下发) 不要下发 3:那要求上级本身有商户级微信配置,我们的上级是渠道商,会被拒「失败:复用交易信息,上级商户号为空」【实测】
...huifu.merchant-wechat-scenes 1,2,3 微信支付场景:线下反扫/线下公众号/线下小程序。**2026-09-24 起统一走线下**;回退线上配 4,5,12(场景、CID、费率、fee-type、进件场景是一套口径,要一起换)
...huifu.merchant-wechat-fee-type 02 业务开通类型:线下 02/线上 01。它同时决定 AppID 绑定的 fee_type、实名与实名查询的 pay_scene、以及判定时按 fee_type 匹配配置项的口径——四处必须同一个值
...huifu.merchant-wechat-fee-rate 0.30 微信费率(线下)。Java 字段默认值已与 yml 统一为 0.30(线上是 0.70)。实际生效值以商户详情 qry_wx_conf_list[].fee_rate 读回为准
...huifu.merchant-wechat-fee-rule-id / -individual / -sole-proprietor 758 / 770 / 762 CID 按「支付场景 × 业务开通类型 × 主体类型」三维定位:**线下(类型 02)企业 758/小微 770/个体户 762**;线上(回退用)是 756/775/761。不能全平台共用一个号
...huifu.merchant-onboarding-scene-type OFFLINE 进件的 scene_type:线下报备口径(要实体经营场所材料)。回退线上配 ONLINE
...huifu.merchant-micro-business-type MICRO_TYPE_STORE 小微经营类型(个人主体开通微信业务必填):线下门店场所取 MICRO_TYPE_STORE,线上商品服务交易才是 MICRO_TYPE_ONLINE
app.order.pending-expiry.fixed-delay-ms yml 里没有这一行 订单到期清理,靠注解默认 60000 生效;它**没有** enabled 开关

两处过期的注释要当心:enabled: true 上方仍写着 Keep disabled until the Huifu test/production product, merchant and keys are provisioned.,
而它已经是 true 且 environment: prod,指向真实通道;merchant-use-chains-flag 的注释里那条 6666xxxxxxxxxxxx
是**渠道商号**,属脱敏范围,不要抄进任何外部文档。

1.3 ${ENV:default} 覆盖机制

配置里统一用 ${ENV_VAR:default} 写法:环境变量存在就用它,不存在就退回冒号后的默认值。
逐键覆盖、无需整块替换,所以部署侧只要注入必要的几个变量即可,其余沿用默认。
判定某键是否已被外部注入,看它**是否以 ${ 开头**即可。

同一份文件里目前两种做法并存:全文件共 42 处使用 ${...} 占位(连 onboarding-upper-huifu-id 都是占位),
但 app.payment.huifu 块下**四个最敏感的键是字面量明文、不是占位**:

键 现状(**不抄值**)
system-id 字面量明文,形如 6666xxxxxxxxxxxx
merchant-id 字面量明文,形如 6666xxxxxxxxxxxx
private-key 字面量明文 RSA 私钥(***)
platform-public-key 字面量明文(***)

这四个值随 application.yml 进了 git 历史。同块里更晚新增的键都自觉用了占位——**没有一并改掉存量那四个**。
详见 .scratch/huifu-handover/issues/01-plaintext-credentials-in-application-yml.md。

2. 凭据从哪里来、为什么不能写在 yml 里

凭据的唯一合法来源是部署侧的密钥存储(K8s Secret / 环境变量注入 / 配置中心),由部署流程注入上面那张表里的
环境变量名。application.yml 只允许出现 ${HUIFU_PRIVATE_KEY} 这类**占位**,不允许出现任何真实值。

两阶段处理,**不要合并成一步**(完整论证见
.scratch/huifu-handover/issues/01-plaintext-credentials-in-application-yml.md):

第一阶段:改占位(小、可独立评审)。 把四个键改成与同文件其他 42 处一致的写法:

      system-id: ${HUIFU_SYSTEM_ID}
      merchant-id: ${HUIFU_MERCHANT_ID}
      private-key: ${HUIFU_PRIVATE_KEY}
      platform-public-key: ${HUIFU_PLATFORM_PUBLIC_KEY}
  • 部署侧必须先注入、确认线上非空**再合并**——改成 ${VAR} 而部署没配,启动会因空值报错或用空串签名。
  • application.yml 是 AGENTS.md 点名的**全仓共享文件**,这一改要单独一个工单、单独一次评审,不要夹在别的任务里。

第二阶段:按流程轮换(必须做,需要汇付侧配合)。
改占位不等于止血——私钥已经进过 git 历史,任何拿到仓库(现在或将来)的人都能对汇付侧伪造请求签名;
而平台身份对目标商户有数据权限,泄露的私钥能实际操作商户数据。仓库是多人按业务域协作的,历史里还有合并、
fork、worktree,**无法确认副本的分布范围**。所以必须申请新的 system-id / merchant-id / 私钥 / 平台公钥,
并在控台重新绑定回调地址。轮换前先拿到汇付侧的轮换窗口与操作顺序(旧凭据还能用多久)、确认
onboarding-upper-huifu-id 与渠道商关系是否随换号变动、准备好回滚方案(换号期间支付与进件回调都可能中断)。

长期:加一条提交前检查——*.yml 里 private-key/platform-public-key 的值只要不以 ${ 开头就拒绝提交;
日志与报错出口统一脱敏(测试曾经打印过运行配置里的明文私钥,后改为使用 keySummary 摘要函数)。

3. 定时任务开关表

全仓共 5 个 @Scheduled,全部走 fixedDelayString(上一轮跑完再计时)。

任务 配置键 注解默认值 yml 有没有写 作用
PaymentReconciliationScheduler app.payment.huifu.reconciliation.enabled true 有 支付与退款的后台兜底
同上 ...reconciliation.fixed-delay-ms 60000 有 扫描间隔(同时是关单补偿的下限)
MerchantPaymentCapabilityScheduler app.payment.huifu.capability-refresh.enabled true 有 收款能力快照核实
同上 ...capability-refresh.fixed-delay-ms 1800000 有 30 分钟一轮
PaymentFundConfirmationRetry app.payment.fund-confirmation-retry.enabled true 有 延迟户资金确认补偿
同上 ...fund-confirmation-retry.fixed-delay-ms 300000 有 5 分钟一轮
StrandedPaymentRefundRetry app.payment.stranded-refund.enabled true 有 悬账自动退款
同上 ...stranded-refund.fixed-delay-ms 300000 有 5 分钟一轮
PendingOrderExpiryScheduler app.order.pending-expiry.fixed-delay-ms 60000 没有 订单到期清理;也**没有** enabled 开关

这条约束必须守住:注解上的默认值必须与 yml 里的默认值一致。 原因是一段真实历史:
定时对账原先**注解默认 false、靠 yml 那一行打开**,于是**删掉那行配置,支付与退款唯一的后台兜底就静默消失**;
交易确认补偿的开关更极端——javadoc 里宣称有 app.payment.fund-confirmation-retry.enabled,
但**代码从未读取这个键**,任务永远在跑。
现在两者都已对齐成「注解与 yml 默认值都是开」,并由 PaymentSchedulingConfigurationTests 钉住:
它直接读 application.yml 的文本与 @Value 表达式字符串做断言,所以任何人改动默认值都会红。
注意 PendingOrderExpiryScheduler 不在这个测试的覆盖范围内(它既没有 enabled 也没有 yml 条目),
上线前请确认这个任务的间隔是否符合预期。[待确认]

4. 上线验收清单

每条主链路都要**在真实通道上**走一遍。括号里是「看哪里能证明它成立」。

A. 进件能走通(8 项)

  • [ ] 企业主体与个人(小微)主体各提交一条,均不再报 Unknown column ...(merchant_onboarding_application 有行、无 500)
  • [ ] 企业主体与个人主体各自都能提交门店材料(门头照 F22 / 内景照 F24 / 收银台 F105),且**三张都真的下发到通道**(进件请求的 store_*_pic 与业务开通的 extended_material_list)
  • [ ] 进件审核回调能到达并落库(payment_callback_event 出现 merchant-onboarding-notify 的请求流水号)
  • [ ] 商户业务开通被受理时**不会**被记成失败(90000000 是「受理/审核中」,「微信支付」阶段不落在 failed)
  • [ ] 「微信小程序配置」排在「商户业务开通」之后执行,不被驳「根据渠道号或微信子商户号未查到入驻记录」
  • [ ] 微信待实名的授权二维码能取回并显示(通道回的是**裸 base64 JPEG**,不要写死 PNG 前缀假设)
  • [ ] 实名授权完成后,authorize_stat=1 能让页面从「待办理」翻成已确认,二维码收起
  • [ ] 六项阶段全绿时进件页不再出现「有待办理项」的自相矛盾文案(缺项会点名 bank/merchantNormal)

B. 收款能力能判定(5 项)

  • [ ] 进件通过后能力快照被填上(capability_verified_at 非空,merchant_stat、cash_switch_status 有值)
  • [ ] 快照过期后由 capability-refresh 在 stale-hours 内刷新
  • [ ] 控台把商户停用后,页面与下单闸门给出**同一句**拒绝文案(ADR-0002 同源同文案)
  • [ ] 「自动结算开着」不再拦住收款(延迟户已兜住未核销的钱)
  • [ ] 商户端「立即核实」有 5 分钟冷却,连点不会反复打通道

C. 支付能成功且回调能到(7 项)

  • [ ] 支付成功:payment_transaction.status = SUCCEEDED、benefit_order.order_status = 1、paid_at 有值
  • [ ] 回调落库且验签通过:payment_callback_event.process_status = PROCESSED
  • [ ] 重复回调:状态与金额不变,只更新 received_at
  • [ ] 金额不符的回调:订单**保持待支付**,payment_reconciliation_issue 新增 PAYMENT_AMOUNT_MISMATCH
  • [ ] 收款子商户不符的回调:订单保持待支付,新增 PAYMENT_MERCHANT_MISMATCH
  • [ ] 回调被人为阻断时,对账查单能在 pending-after-minutes 后把状态收敛或记一条 open 问题记录
  • [ ] 一笔订单同一时刻只有一张活着的预支付单(重复点支付不会造出第二张可付凭证)

D. 关单 / 取消收敛(5 项)

  • [ ] 点「取消」后不再永久卡在「支付结果确认中」:钱没收下就立即生效,钱收下了则拒绝取消
  • [ ] 停在关单中的尝试能被重试到有结论,且不被重复计数到上限
  • [ ] 关单回执为「原订单已为终态」(23000000/10000016)时落成 CLOSED,**不再**交回客户端
  • [ ] 关单请求本身失败(90000000)时记为 CANCEL_UNCONFIRMED(不是 CLOSED)
  • [ ] 重启进程后订单不会被自动取消成僵尸(CANCEL_UNCONFIRMED 在「不自动取消」之列)

E. 退款收敛(6 项)

  • [ ] 退款成功:order_refund.refund_status = 2,且 provider_refund_no 非空
  • [ ] 退款查询不会把 provider_refund_no 覆盖成空串
  • [ ] 部分退款后剩余金额仍可退(不再「有过退款记录就拒绝」)
  • [ ] 悬账(订单已取消、通道确认收款)被 stranded-refund 自动退回付款人,问题记录被结掉
  • [ ] 通道拒绝悬账退款时,新增 STRANDED_REFUND_REJECTED 并留 open,**不静默吞掉**
  • [ ] 撤销核销:钱确认退回延迟户后 funds_confirmation_status 回到 pending;被通道拒绝时给出**可分辨**的文案(不是笼统「撤销失败」)

F. 结算记录能查到(4 项)

  • [ ] 后台「结算记录」页能显示通道流水(pageSize 已被钉在 50 以内)
  • [ ] 被关停的商户不会让整页失败,只落到 failures 里
  • [ ] 日期区间与四个筛选条件的**标签与实际筛选字段一致**(日期筛的是结算日期,不是申请时间)
  • [ ] 快速切换标签时表格与分页总数不会被慢响应覆盖

5. 迁移编号规约

  • 编号全仓共享。新增迁移前**先看** talent-card-server/src/main/resources/db/migration/ 当前的**最大版本号**
    (截至本提交是 V155)。本仓库由多人按业务域分工,两人从同一祖先各自独立占同一段编号时 git 不报冲突,
    Flyway 会直接报「同一版本两个迁移」
    。
  • 最大版本号 ≠ 迁移条数。当前是 149 个:148 个 .sql + 1 个 Java 版迁移
    (src/main/java/db/migration/V125__parking_lot_registration_snapshot.java,包名 db.migration)。
    1..155 里真正的空档只有 6 个:97 / 105 / 109 / 110 / 111 / 126。
    数迁移时不要只数 resources/db/migration/*.sql——那样会漏掉 Java 版迁移,把 V125 误判成空档
    (这个错 2026-09-22 真的发生过)。要核条数就看 Flyway 启动日志里的
    Successfully validated N migrations。经过见 §8.2。
  • 让位规则:合并时**本地让号**,因为远端那批可能已在别人的环境里跑过。刚发生过一次四连让位:
    V148–V151 → V151–V154(V148__merchant_capability_sync_until→V151、
    V149__payment_attempt_convergence→V152、V150__merchant_numeric_code_attempt→V153、
    V151__order_cancel_request→V154)。经过见 .scratch/pending-order-payment-flow/README.md。
    票面与 issue 里出现的旧编号按当时的事实保留(历史记录),不要回改。
  • 已执行的迁移只追加不修改:改一个字符就 checksum 不匹配,测试库直接起不来。
    要调整就**新增**一个迁移;历史上的 scripts/repair-local-mysql-v*.sql 就是为此付出的代价。
  • 不能写 ADD COLUMN IF NOT EXISTS:那是 MariaDB 扩展,MySQL 8.0.36 上直接 ERROR 1064
    (同一条坑在 V143/V145/V146 的头注释里被记了三次)。迁移只执行一次,本来也不需要它。
  • 迁移必须同时兼容 5.7 与 8.0:开发库是 MySQL 5.7.44 且 flyway.enabled=false,测试库是 8.0.36 且 Flyway 打开。
    只在 8.0 上跑绿**不代表**开发库能执行,每次新增迁移都值得在 5.7 上实跑一遍。
  • 开发库结构不由迁移维护:缺表缺列只能靠启动初始化器 best-effort 补,或手工补 DDL
    (.\scripts\apply-sql-file.ps1 -File <某个 .sql> -Yes,默认连开发库,不带 -Yes 只打印)。
    只补缺失的 DDL,**不要**删表重建。

6. 测试纪律

cd talent-card-server
.\scripts\run-server-tests.ps1                              # 全量
.\scripts\run-server-tests.ps1 -Test PaymentSettlementTests # 单个类
.\scripts\run-server-tests.ps1 -Reset                       # 重置库后再跑
  • 必须走这个脚本(或等价地只跑一个 Maven 进程):**同一个 checkout 一次只跑一个 Maven 构建**。
    并发跑两个会写坏 target/,症状是看起来不可能的报错,例如
    No qualifying bean of type 'HuifuPaymentProperties'——遇到这类「不可能」的 bean/classpath 错误,
    先 -Clean,再怀疑代码。另外 mvnw.cmd 总是用 PowerShell 5.1 引导,5.1 会把 Maven 打到 stderr 的
    警告当成终止错误,编译中途莫名中断;本机装了 PowerShell 7,用 pwsh 跑。
  • 不要 drop database,也不要手删 flyway_schema_history 里的某一行;用
    .\scripts\reset-test-db.ps1(它对名为 talent_card 的库一律拒绝——开发库里有迁移从未创建的手建表,
    2026-09-13 就这样丢过一批数据)。
  • 测试全红先看 WSL:测试库那个 8.0.36 跑在 WSL 的 docker 里,VM 空闲会自己关掉、容器跟着停。
    起容器**不够**,要挂一个长命进程持有 VM(wsl.exe -d Ubuntu-26.04 -u root -- bash -lc "sleep 1800")。
  • 3 项已知基线失败,与本轮改动无关,不要为证明它们「是既有的」而重建 worktree:见
    docs/agents/testing.md 的「已知基线失败」表;修好其中任何一项时,删掉对应行。
  • 测试库状态是**共享且从不清理**的:断言按自己创建的 ID 过滤,不要断言「全表只有一行」;
    断言「后台任务只处理了我这条」时先把自己之外的相关行移出扫描窗口。

7. 本文件与 docs/agents/testing.md 的分工

一句话:**测试环境、跑测试的命令、重置库、已知基线失败、WSL 的坑一律以 docs/agents/testing.md 为准**
(那里是唯一维护点,要更新就改它);本文件只讲**部署与上线**(凭据、回调地址、开关、验收清单、迁移编号规约),
测试部分只做索引,**不重复维护**。

8. 服务器环境实测记录(攀枝花政务云,2026-09-22)

这不是生产部署。 这台机器目前承担的是**开发测试环境**:没有域名、没有备案、没有 TLS,
公网入向只开了 8081/8082,外部访问仍要过堡垒机,进程还是 systemd-run 临时单元(重启不自动拉起)。
本节只记录「环境能起来、迁移能在 8.4 上跑通」这一层事实,**不代表上线验收**(验收见 §4)。

第一次在**空库**上把迁移集完整跑一遍,验证 MySQL 8.4 兼容性。目标机 Ubuntu 26.04.1、
MySQL 8.4.11-0ubuntu0.26.04.1(原生 apt,非容器;datadir 迁至 /data/mysql,AppArmor 走
/etc/apparmor.d/local/usr.sbin.mysqld 覆盖而非改主 profile)、Redis 8.0.5、JDK 17.0.20。

8.1 结论:迁移集在 8.4 上通过

  • Flyway 报 Successfully applied 148 migrations to schema talent_card, now at version v154(60.9 秒)。
  • flyway_schema_history 148 行、success 全为 1、最高版本 154;建出 97 张表。
  • 紧接着 jpa.hibernate.ddl-auto: validate 通过,应用正常启动 —— 实体与迁移建出的结构自洽。
  • 再次启动时 Successfully validated 148 migrations + Schema talent_card is up to date. No migration necessary.(checksum 一致,可重复校验)。

8.2 「154」不是迁移条数

  • 154 是**最大版本号**;1..154 里真正的空档是 6 个:97 / 105 / 109 / 110 / 111 / 126。
  • .sql 文件 147 个,另有 1 个 Java 版迁移:V125__parking_lot_registration_snapshot
    (src/main/java/db/migration/,它在 flyway_schema_history 里的 type 是 JDBC 而不是 SQL)。
  • Flyway 实际执行 148 个 = 147 + 1 = 154 − 6。
  • 教训:只数 resources/db/migration/*.sql 会把 Java 版迁移漏掉,从而把 V125 误判成空档 —— 这个错
    2026-09-22 真的发生过一次,并一度被当成「合并时丢了迁移」。要核条数就看 Flyway 的
    Successfully validated N migrations。

8.3 日志里三类「看着吓人但不是失败」的输出

输出 含义
PROCEDURE ... does not exist(1305),15 条 15 个迁移用「DROP PROCEDURE IF EXISTS → CREATE → CALL → DROP」做幂等 ALTER。**全新库上第一次 DROP IF EXISTS 必然打这条**(MySQL 返回 warning 而非 error,Flyway 把任何返回消息都记成 WARN)。只在全新库出现,预期内。
Apparent connection leak detected(HikariCP) yml 里 leak-detection-threshold: 10000,而启动期主线程会长占连接(Flyway 长迁移、支行目录导入)。日志随后就跟着 ... was returned to the pool (unleaked),**误报**。
VALUES function is deprecated(1287),7 条 这条要认真对待:INSERT ... ON DUPLICATE KEY UPDATE ... VALUES(col) 自 MySQL 8.0.20 起弃用,**未来版本会移除**。8.4 上仍能运行。

8.4 部署侧四件必须做的事

1. MYSQL_URL 必须带 rewriteBatchedStatements=true(Connector/J 默认 false)。不加这个参数,
代码里的 batchUpdate 在驱动层会退化成逐条发送。实测对比(同一张 bank_branch_directory,约 15.4 万行):

语句执行次数 耗时 单行耗时
不带该参数 52,915 986.4 秒 18.6 ms
带该参数 155 28.5 秒 0.18 ms

约 100 倍差距(performance_schema.events_statements_summary_by_digest 实测)。副作用:批处理被改写后
executeBatch() 返回 SUCCESS_NO_INFO,BankBranchDirectoryInitializer 会打出
Imported 0 bank branch directory rows —— 数据是全的,只是那个计数器失真了,不要据此判断导入失败。

2. REDIS_PORT / REDIS_PASSWORD 必须注入,不能因为「业务代码零调用 Redis」就跳过。
Actuator 注册了 Redis 健康指示器:应用默认连 localhost:6368 + 密码,而 Ubuntu 装的 Redis 在 6379
且默认无密码 → /actuator/health 永远 503(management.endpoint.health.probes.enabled: true 时
liveness/readiness 一并受影响)。实测把这五项都注入后 health 恢复 {"status":"UP"}:
MYSQL_URL / MYSQL_USERNAME / MYSQL_PASSWORD / REDIS_PORT / REDIS_PASSWORD。

3. CORS_ALLOWED_ORIGINS 必须包含部署后浏览器实际访问的那个 origin**,哪怕是同源。**

这条曾经被想当然地漏掉(理由是「前端和 API 走同一个端口 = 同源 = 不需要配 CORS」),
结果**登录直接 403 Invalid CORS request**。机制是:**浏览器对同源的非简单请求也会带 Origin 头**
(POST + Content-Type: application/json 就是),而 ApiCorsConfig 给 /api/** 注册的 CorsFilter
只要看到 Origin 就按白名单校验,不在名单里就 403。**「同源」并不等于「不会有 Origin 头」。**

实测判据(改前 / 改后):

请求 改前 改后
POST /api/admin/auth/login + Origin: <部署域名> 403 Invalid CORS request 400 account must not be blank(已进业务代码)
OPTIONS 预检 + 同 Origin — 200,带 Access-Control-Allow-Origin
POST + Origin: http://evil.example.com 403 仍 403(白名单照样生效)

注意 yml 里还有 allowed-origin-patterns(默认 http://localhost:[*]、http://127.0.0.1:[*]),
它覆盖本机随机端口的调试来源、**与精确白名单是两个键**,覆盖 CORS_ALLOWED_ORIGINS 不会动它——
所以本地 vite / HBuilderX 调试不受影响。**上线时把真实域名加进 CORS_ALLOWED_ORIGINS 即可。**

这条坑会跟着域名的每一次变化重演。 origin 由「协议 + 主机 + 端口」三者共同决定,**任何一个变了
都是一个新 origin,都要单独加**。2026-09-22 一天里演了两次:先用 IP 访问(http://117.172.73.106:8081)
踩了一次;把域名 www.dqrcfwpt.com 解析好、**改用域名访问又踩了一次**(登录再次 403,表现与第一次
一模一样)。所以:**换域名、换端口、上 HTTPS 都要同步改这个变量**;http://host:8081 与 https://host
是两个不同 origin,端口不同也是两个。

4. 凭据要逐条过一遍「弱口令」口径。 政务云的安全扫描不是形式主义——2026-09-22 它**真的登进来了一次**
(抓到请求体 {"account":"admin","password":"123456"},响应 200 带 accessToken)。部署时以下三件事一起做:

凭据 要求
APP_PRIVACY_ENCRYPTION_KEY 必须在**写入任何隐私数据之前**覆盖仓库里的开发默认值(见 §8.6)。
MySQL / Redis 口令 服务商口径是「大小写 + 数字 + 特殊符号」组合。**注意纯随机生成不保证四类齐全**:32 位随机实测有约 1% 概率一个特殊符号都没有(本次第一次就漏了,重生成到第 5 次才齐),所以生成时要**循环到四类都出现**再落库。特殊符号只用 @%^*+_.-,避开会破坏 systemd EnvironmentFile 解析与 shell 展开的 $ " ' \ ! 和空格。
后台引导账号 V76 种下的 admin 口令**经实测就是 123456**(用 node 按迁移里的参数算 PBKDF2,结果逐字符相同),而 where not exists 意味着**每个新库都会重种一遍** —— 空库部署后第一件事就是把它换掉。根因与修法见 .scratch/weak-credentials-hardening/issues/01-*。

改 Redis 口令有个坑:库上**已经设过** requirepass 时,redis-cli config set requirepass <新> 会报
NOAUTH Authentication required(未认证不许改)。要么先 redis-cli -a <旧> config set ...,
要么**直接改 /etc/redis/redis.conf 的 requirepass 再 systemctl restart redis-server**(更省事)。
轮换后**旧口令仍会留在备份文件里**(已失效但还在磁盘上),记得同步刷新,只留一份当前状态的备份。
不必担心把自己锁死:MySQL 的 root@localhost 走 auth_socket,口令改坏了也能用 root 身份重设。

8.5 这台机器的性能约束(影响所有部署)

虚拟 CPU 缺 SSE4.2 / POPCNT / AVX。仓库 AGENTS.md 只把它记在「以 x86-64-v2 为基线的镜像跑不起来」上,
但它**同样拖慢原生 MySQL**:InnoDB 页校验退化为软件实现,写入路径明显变慢。根治要请服务商把虚拟机
CPU 型号改成 host-passthrough。

另外 innodb_buffer_pool_size 是 MySQL 默认的 128MB,在 15G 内存的机器上当生产库太小,上线前应调大。

8.6 上线前仍未完成的安全项

  • APP_PRIVACY_ENCRYPTION_KEY 必须在写入任何隐私数据之前覆盖:默认值是开发用的 key,
    事后再换会让已加密的数据解不开。
  • app.payment.huifu 的四个键与 app.wechat.mini-program.app-secret 在 application.yml 里是字面量明文,
    已随 fat jar 落到服务器。轮换需要汇付侧配合,见 .scratch/huifu-handover/issues/01-*.md。

8.7 本机收不到回调 —— 这是既定设计,不是部署缺陷

不要把它当成这台机器的问题。 非公网环境收不到回调是**已经记录在案的既定事实**,而且整套设计本来就不
依赖回调:见 ADR 0004(「通道状态以主动查询为权威,回调只作
加速器」),以及 .scratch/pending-order-payment-flow/README.md 的 F 系列清单 —— F1 正是「回调地址指向
公网 IP,开发环境不可达」。那里还定过一条边界:**不允许为了开发便利开「跳过真实通道」的置付捷径**,
所以开发/测试环境的收敛只能走真实查询,不能靠伪造资金事实。

不依赖回调的收敛路径(**订单不会因为没回调而卡住**):

  • 读接口惰性查询:进入待支付页 / 刷新支付状态时,PendingOrderPortalController 会当场经
    PaymentReconciliationService 问一次通道;顺带把「关单处理中」也问出结论(concludeUndecidedCloses)。
  • 服务端对账快车道:reconciliation.fixed-delay-ms 默认 60 秒一轮。
  • 慢车道退避:每行另有 pending-after-minutes(默认 5)与 last_reconciled_at,只对长时间没被问过的行
    生效 —— 它是最坏情况的下限,不是通常要等的时间。
  • 人工即时入口:后台订单列表的「立即对账」,跑的是同一套真实对账逻辑。

唯一要守住的是 app.payment.huifu.reconciliation.enabled 保持默认 true:它是支付与退款唯一的后台兜底,
关掉就只剩「用户点了取消 / 重申支付」这一个入口(见 §1.2)。

9. 存量商户迁移到线下口径(2026-09-24)

微信支付口径整体从线上切到线下(见 §1 的环境变量表:场景 1,2,3、类型 02、费率 0.30、
CID 758/770/762、进件 scene_type=OFFLINE)。**存量商户是在控台手工改的口径**,所以发布后要逐个迁移:

  1. 部署新代码,在商户详情点一次「刷新进度」。
  2. 跑下面这条 SQL(开发库,需 MySQL 5.7+)。列出来的就是「通道侧仍是线上 4/5/12、按线下判定会变
    missing」的商户 —— 也就是**收款闸门会挡住收款**的那批:
select a.provider_huifu_id as huifu_id,
       a.merchant_name,
       json_extract(p.stages_json, '$.wechat') as wechat_stage,
       p.last_synced_at
from merchant_onboarding_application a
join merchant_onboarding_progress p on p.application_id = a.id
where a.provider_huifu_id is not null and a.provider_huifu_id <> ''
  and json_extract(p.stages_json, '$.wechat') <> 'confirmed';
  1. 在汇付控台把每条商户的微信场景改成线下(1/2/3),并**确认人才端小程序 AppID 绑到了该商户实际收款的
    那个微信子商户**(改场景会重建微信子商户号,见 05 第 19c 条)—— 不绑就会出现「业务执行失败[失败]」。
  2. 回商户详情再点「刷新进度」,wechat 回到 confirmed 即恢复收款。

注意:判定完全跟随配置,所以回退线上要连场景、CID、费率、fee-type、进件 scene_type 一起改回去
(05 第 19b 条),只改一处会出现「通道已开通、阶段永远 missing」。