yeg
2026-09-22 d94ab46657791d105204b3c291b7014808f48d7e
docs(huifu): 补服务器环境实测记录与部署四件事

新增 §8:在空库上跑完 148 个迁移、确认 MySQL 8.4 兼容(含三类「看着吓人但不是失败」的日志、日志条数不等于迁移条数、以及 V125 实为 Java 版迁移的更正)。

记下三个部署踩坑:MYSQL_URL 缺 rewriteBatchedStatements 时批处理退化成逐条(实测差约 100 倍)、REDIS_PORT/REDIS_PASSWORD 不注入会让 /actuator/health 永远 503、CORS_ALLOWED_ORIGINS 漏配部署 origin 会让登录直接 403(同源 POST 也带 Origin 头)。并补凭据口径:随机生成不保证四类字符齐全、Redis CONFIG SET 会 NOAUTH、空库部署后必须先换掉引导账号。

§8.7 更正:非公网环境收不到汇付回调是 ADR-0004 的既定设计,不是部署缺陷(原文误写成「只能等 5 分钟」)。
1个文件已修改
131 ■■■■■ 已修改文件
docs/huifu/08-上线与环境.md 131 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/huifu/08-上线与环境.md
@@ -205,6 +205,12 @@
- **编号全仓共享**。新增迁移前**先看** `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`、
@@ -249,3 +255,128 @@
一句话:**测试环境、跑测试的命令、重置库、已知基线失败、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)。