合成下单/扣库存贯通案例:配置、安全、事务与异步恢复
U79 / #116 与 U80 / #117。教材入口:配置、安全与事务下单(U79 前 3 阶段)与异步通知与故障恢复(U80 阶段 4)。本实验复用既有已验路线:Boot 4.0.9 配置绑定与校验(U23)、Security 7.0.8 资源服务器与租户授权(U29/U30)、MyBatis 3.5.19 与 Spring 事务(T14/U18),在同一条合成下单/扣库存业务上把三者串起来。U80 在同一工程上增加 outbox/inbox/notification 三表与真实 Kafka 投递(spring-kafka 4.0.8 + spring-kafka-test 4.0.8 单节点 KRaft broker),验证提交/发送/ack 三类边界故障与恢复后业务不变量。不重写第二套机制教材:机制细节回指各权威页,本工程只提供组合后的正常路径、配置/输入/身份/权限/跨租户负例和并发/重复不变量。
运行与输入
需要 JDK21、Maven3.9、Python3.9+,以及本机 127.0.0.1:3306 可用的 MySQL(DBngin 启动,本次 8.4.7,root 空密码)。MySQL 未启动时入口如实失败并提示通过 DBngin 启动;入口自己不启动/停止任何服务、不使用 Docker。
cd <仓库根目录>
python3 labs/order-flow/run.py
# 密码非空时用环境变量传入,不写入命令行或日志:
CASE_DB_PASSWORD=*** python3 labs/order-flow/run.py入口记录 JDK/Maven 版本,执行 mvn clean test,核对三份 Surefire 报告(合计 24 测试:U79 19 + U80 5)、解析依赖版本、校验 28 条 U79/U80 观察行(含两个专用库的清理断言),写入 evidence/u80-2026-10-28.json(U80 起;U79 首轮证据保留在 evidence/u79-2026-10-27.json)。测试 JVM、JDK HttpClient 与随机端口 Servlet 在用例内关闭;进程组由 runner 回收。
公开源文件:POM、入口、应用、业务配置、安全链、就绪探针、控制器、错误映射、下单事务、Mapper 接口、Mapper XML、主配置、HTTP 与并发测试、配置失败测试、合成签发与建表夹具、运行证据。
分组与真实观察
| 组 | 真实运行入口 | 实际观察 |
|---|---|---|
| 正常与配置 | 随机端口真实 Servlet 链 + 本机 MySQL 8.4.7 专用库 | 合法配置启动;租户取自 JWT sub(本实验把租户绑定到 sub,token 的 tenant claim 不参与,缺 sub 时 SQL 查不到任何行→409,不是 401);金额由服务端计算;库存扣减与订单同事务可见;liveness/readiness 均 UP(readiness 组显式包含自定义 dbReadyIndicator) |
| 输入/身份/权限/跨租户 | 真实签名 HS256 合成 token | 无 token 401;过期/错 issuer/错 audience 各 401;缺 scope 403;write-only token 读单 403(方法级拒绝显式映射);跨租户创建 409(不泄漏存在性)、跨租户读取 404 与不存在同响应、本租户读取 200;非法字段 400 且字段级消息 |
| 事务与并发 | 12/16 线程真实并发 + 行锁占用 | 同幂等键 12 并发:订单 1 条、库存只扣 1 件、200=9(胜者+幂等重放)、409=3(同步撞唯一约束),无 500;16 线程不同键扣 8 件库存:成功 8、拒绝 8、库存 0 不为负;中途唯一冲突全回滚(库存 10 不变、订单 0);应用超时先到的锁等待 503 而非库存不足 |
| 配置失败 | 独立短命上下文(web=none / 坏 URL,不连真实 MySQL) | 必填配置置空→启动阶段绑定校验失败;坏数据源 URL→上下文可启动但 readiness DOWN、liveness 仍 UP(端口监听不冒充可服务) |
17 个测试走真实随机端口 HTTP 与真实 MySQL;另有 2 个独立短命上下文测试(配置失败启动与坏数据源探针,坏 URL 用例按设计不连真实 MySQL) 与真实 MySQL 8.4.7 观察行为:没有 mock 控制器、没有内存替身数据库、没有把 stub 当上游。MySQL 只使用一次性 study_u79_* 专用库,@AfterAll DROP 并在观察行里确认本库已删除;不触碰用户库、不改全局与会话配置。
本机环境(一次运行的实际值)
BellSoft Liberica JDK 21.0.11(/usr/libexec/java_home -v21)、Maven 3.9.9、macOS 27 arm64;Boot 4.0.9、Framework 7.0.10、Security 7.0.8、Tomcat 11.0.27、HikariCP 7.0.2、MyBatis 3.5.19 / mybatis-spring 4.0.0 / starter 4.0.1、mysql-connector-j 9.7.0、nimbus-jose-jwt 10.4、JUnit Jupiter 6.0.3;服务端 MySQL Community 8.4.7(REPEATABLE-READ、innodb_flush_log_at_trx_commit=1)。实际解析版本、退出码、观察行与文件 SHA-256 见入口生成的证据 JSON。
边界
- 本票是通用贯通案例的前 3 阶段(配置与启动、接口与安全、数据与事务)。消息投递/outbox/恢复已由 U80 在本工程上补运行(见下节);死信/重试主题仍未运行;观测/负载/部署/迁移/回滚归 U81。
- MySQL MVCC、锁深机制、日志/崩溃恢复/复制/备份迁移归 U52/#89(尚未实施):本票只使用条件 UPDATE、行锁与超时的最小事实,不预支 U52 结论。
- 锁等待观察是应用侧语句超时先到(
@Transactional(timeout=5)→ QueryTimeoutException → 503);服务器innodb_lock_wait_timeout分支未运行。 - MyBatis 一级/二级缓存、BATCH、Cursor、插件链未在本实验启用或运行;只验证事务内 SqlSession 复用与
DuplicateKey→DataIntegrityViolationException转换。 - HS256 对称密钥只服务合成实验;生产 RS 应使用 AS 的 JWKS 公钥。无生产 TLS、真实 IdP、多 issuer、DPoP、introspection 组合。
- 单实例、单库、loopback;无多实例部署、连接池生产容量、网络分区、OS 崩溃/断电、容量压测。
- 只使用合成用户 t1/t2、合成 SKU 与金额;测试结束两个专用库(
study_u79_*与study_u80_*)均已删除,MySQL 服务保持运行。
已知未运行
死信/重试主题/调度表恢复、多 broker 副本与 ISR 复制、网络分区/断电、kill -9 进程强杀、Micrometer 指标导出、OpenTelemetry 追踪、负载与队列容量、镜像/代理/停机/迁移/备份回滚、MySQL 崩溃恢复/复制延迟、MyBatis 缓存专项、服务器侧锁等待超时、生产 TLS/IdP、多实例并发与幂等租约,均未在本实验运行;正文按各票归属标注为待验证或源码/配置推演。
U80:异步通知与故障恢复(阶段 4)
| 组 | 真实运行入口 | 实际观察 |
|---|---|---|
| 正常链 | 真实 Servlet + 本机 MySQL + spring-kafka-test 单节点 KRaft broker | 下单事务内写入 outbox(PENDING)→ relay 领取(CLAIMED,令牌 version/claim_id)→ 事务外 KafkaTemplate.send().get(10s) → CAS 标记 SENT → 消费者 Inbox 与 notification 同事务;重复投递被 Inbox 唯一键拒绝 |
| 发送边界 | 发送成功后不标记 SENT,租约经 SQL 改为过期 | B 接管得到 version+1,重发同一 event_id;A 的旧令牌 CAS 影响 0 行;投递 2 次、业务效果 1 次 |
| ack 边界 | 业务效果与 Inbox 提交但不 commitSync | 同组新实例从已提交位点(无)重放同一事件;Inbox 唯一键拒绝,效果仍 1 次 |
| 提交边界 | 事务最后一步 outbox event_id 唯一约束冲突 | 库存/订单/幂等/意图全部回滚,无半执行状态 |
| 不变量 | 持久事实对账 | 与真实订单关联的 SENT=3、notification=3、Inbox=3、linkedOrders=3、orphanSent=0 |
边界:Kafka 为单节点 broker(副本 1),acks=all 退化为 leader 本地追加,复制确认归 U57 的三 broker 实验;relay 崩溃是受控窗口(不标记 + 租约改期),不是进程强杀;死信/重试主题/调度表恢复未运行(协议见重试与死信页)。