最近我给 FlashRisk 项目加了一个看起来很高级的东西:Testcontainers 端到端测试。
理想很丰满:
一条命令启动 MySQL、Redis、Kafka,再拉起所有微服务,最后模拟注册、登录、库存预热、下单、风控、结算。
现实很直接:
GitHub Actions:你先别急着高级,我先让你看看什么叫红色感叹号。
一、事故现场
GitHub 上 CI 挂了:
FlashRisk CI / Maven Verify (push) Failing失败点在:
Run unit and integration tests mvn -B verify -DskipITs=false本地看起来没啥问题,但 GitHub Actions 一跑完整 E2E,直接翻车。
这就是 E2E 测试的魅力:
单元测试像体检抽血,E2E 测试像让你跑 1000 米。问题藏不住。
二、第一个错误:MySQL 初始化权限不够
日志核心报错:
Access denied for user 'flashrisk'@'%' to database 'campaign_db'当时的 E2E MySQL 配置大概是这样:
privatestaticfinalMySQLContainer<?>MYSQL=newMySQLContainer<>(DockerImageName.parse("mysql:8.0")).withDatabaseName("user_db").withUsername("flashrisk").withPassword("flashrisk_dev_password").withInitScript("e2e/mysql-init.sql");问题就在withInitScript()。
它会通过 JDBC 执行初始化 SQL,而这个 JDBC 连接使用的是我们配置的业务用户:
flashrisk但初始化 SQL 里要做这些事情:
CREATEDATABASEIFNOTEXISTScampaign_db;CREATEDATABASEIFNOTEXISTSorder_db;CREATEDATABASEIFNOTEXISTSrisk_db;GRANTALLPRIVILEGES...这就很尴尬了。
业务用户flashrisk的权限定位是:
好好读写业务库,别想着当 DBA。
结果我们让它去创建数据库、授权用户。
这就像让实习生第一天上班就去改公司组织架构,系统当然说:你不配。
三、第一个修复:让 MySQL root 初始化脚本
正确做法是:
不要用withInitScript()执行多库初始化脚本,而是把 SQL 文件挂到 MySQL 官方镜像的初始化目录:
/docker-entrypoint-initdb.d/修复后代码:
privatestaticfinalMySQLContainer<?>MYSQL=newMySQLContainer<>(DockerImageName.parse("mysql:8.0")).withDatabaseName("user_db").withUsername(DB_USER).withPassword(DB_PASSWORD).withCopyFileToContainer(MountableFile.forClasspathResource("e2e/mysql-init.sql"),"/docker-entrypoint-initdb.d/001-init-flashrisk.sql");这样 SQL 会在 MySQL 容器初始化阶段执行,由镜像内部用 root 权限处理。
一句话总结:
withInitScript()适合普通建表;/docker-entrypoint-initdb.d/更适合创建数据库、创建用户、授权这种“管理员活儿”。
四、第二个错误:Gateway JWT issuer 翻车
第一个问题修完后,CI 往下跑,终于进入业务链路。
然后又挂了。
请求:
POST /api/campaigns/1/inventory/preheat返回:
500 Internal Server Error注意:注册、登录都成功了。
说明 JWT 生成没问题,问题出在带 Token 访问受保护接口时。
Gateway 里原来的代码是:
headers.set(AUTH_ISSUER_HEADER,jwt.getIssuer().toString());看起来优雅,实际上暗藏小坑。
我们的 issuer 是:
flashrisk-user-service它是一个普通字符串,不是 URL。
而 Spring Security 的jwt.getIssuer()更偏向把iss当成 URL/URI 语义处理。
普通字符串可能拿不到有效 issuer,结果这里一.toString(),直接空指针。
这个错误很有节目效果:
JWT 明明带了
iss,但是你非要它长得像 URL。
它只是个服务名,不是网址。你不能要求每个人上班都穿西装。
五、第二个修复:直接读取原始issclaim
修复前:
headers.set(AUTH_ISSUER_HEADER,jwt.getIssuer().toString());修复后:
Stringissuer=jwt.getClaimAsString("iss");if(issuer!=null&&!issuer.isBlank()){headers.set(AUTH_ISSUER_HEADER,issuer);}这样就不会强行把 issuer 当 URL 解析了。
同时补了一个单元测试,专门验证这种普通字符串 issuer:
Map.of("iss","flashrisk-user-service","sub","1001","username","alice")测试目标很明确:
issuer 不像 URL,也不准 Gateway 原地爆炸。
六、最终结果
修复后重新推送:
fix: initialize e2e mysql schemas as root fix: relay jwt issuer claim safely本地验证:
mvn-q-plgateway-service-amtestmvn-qtestGitHub Actions 最新结果:
FlashRisk CI / Maven Verify passed这次 CI 从红变绿,说明完整链路已经能在 GitHub runner 上跑通。
七、这次事故的经验
第一,Testcontainers 的withInitScript()不一定适合做数据库级初始化。
如果涉及CREATE DATABASE、CREATE USER、GRANT,优先考虑 MySQL 官方初始化目录。
第二,JWT 的iss不一定非得是 URL。
如果业务里 issuer 是服务名,例如:
flashrisk-user-service那就直接用:
jwt.getClaimAsString("iss")别强行用getIssuer()。
第三,E2E 测试很烦,但很有用。
它不会夸你架构优雅,它只会问:
你这一整套东西,真能跑起来吗?
这次它问了。
然后我们修了。
最后 CI 绿了。
技术人的快乐,有时候就是这么朴素。