| | |
| | | - **编号全仓共享**。新增迁移前**先看** `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`、 |
| | |
| | | 一句话:**测试环境、跑测试的命令、重置库、已知基线失败、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)。 |