> 适用提交: 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_`【实测】;验签不过直接 `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-fee-rate` | `0.70` | 微信费率(Java 字段默认值是 `0.60`,与 yml 不一致) | | `...huifu.merchant-wechat-fee-rule-id` / `-individual` / `-sole-proprietor` | `756` / `775` / `761` | CID 按「支付场景 × 业务开通类型 × **主体类型**」三维定位,不能全平台共用一个号(Java 里企业那个默认值为空) | | `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 处一致的写法: ```yaml 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 内景照),且资料真的下发到通道 - [ ] 进件审核回调能到达并落库(`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/` 当前的**最大版本号** (截至本提交是 `V154`)。本仓库由多人按业务域分工,两人从同一祖先各自独立占同一段编号时 **git 不报冲突, Flyway 会直接报「同一版本两个迁移」**。 - **最大版本号 ≠ 迁移条数**。当前是 **148 个**:147 个 `.sql` + **1 个 Java 版迁移** (`src/main/java/db/migration/V125__parking_lot_registration_snapshot.java`,包名 `db.migration`)。 `1..154` 里真正的空档只有 **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. 测试纪律 ```powershell 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` 即可。** **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](../adr/0004-active-query-is-authoritative.md)(「通道状态以主动查询为权威,回调只作 加速器」),以及 `.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)。