# 05. 集成层设计(F6 / Mini 域) ## 决策 供应商(F6)和历史 Mini 域的调用统一收口在 `f6-integration` / `mini-clients` 模块,业务 domain 不直接持有 `WebClient` 或 HTTP 客户端;用 Resilience4j 统一管理超时、重试、熔断。 ## 结构约定 ``` platform-integration/ WebClientConfig # 统一封装 WebClient(连接池、超时基线配置) Resilience4jDefaults # 超时/重试/熔断的公共默认配置 domains/f6-integration/ 负责:换票、供应商访问上下文准备、超时/重试/熔断策略、异常转换为内部标准错误码 domains/mini-clients/ 对 O2O/Warranty/Retail Store/ROOS 的只读客户端封装,供 workbench / bff-orchestration 调用 ``` ## `WebClient` + Resilience4j 配置示例 ```yaml # application.yml resilience4j: timelimiter: instances: f6-api: timeout-duration: 2s mini-o2o: timeout-duration: 1s retry: instances: f6-api: max-attempts: 2 wait-duration: 200ms retry-exceptions: - java.net.SocketTimeoutException - org.springframework.web.reactive.function.client.WebClientRequestException circuitbreaker: instances: f6-api: sliding-window-size: 20 failure-rate-threshold: 50 wait-duration-in-open-state: 10s permitted-number-of-calls-in-half-open-state: 5 ``` ```kotlin // domains/f6-integration/.../F6ApiClient.kt @Component class F6ApiClient( private val webClient: WebClient, // 来自 platform-integration 的统一封装 ) { @CircuitBreaker(name = "f6-api", fallbackMethod = "fallbackProcurementList") @Retry(name = "f6-api") @TimeLimiter(name = "f6-api") fun fetchProcurementList(storeId: Long): Mono = webClient.get() .uri("/f6/procurement/list?storeId={storeId}", storeId) .retrieve() .onStatus({ it.isError }) { resp -> resp.bodyToMono(String::class.java) .map { body -> F6IntegrationException("F6 采购列表调用失败: ${resp.statusCode()} $body") } } .bodyToMono(ProcurementListResponse::class.java) // Resilience4j 约定:fallback 方法签名 = 原方法参数 + Throwable,返回类型一致 fun fallbackProcurementList(storeId: Long, ex: Throwable): Mono = Mono.just(ProcurementListResponse.degraded()) } ``` ```kotlin // 统一异常转换:F6IntegrationException -> 内部标准错误码,业务层不感知供应商原始协议 class F6IntegrationException(message: String) : RuntimeException(message) @RestControllerAdvice class F6ExceptionHandler { @ExceptionHandler(F6IntegrationException::class) fun handle(ex: F6IntegrationException): ResponseEntity> = ResponseEntity.status(HttpStatus.BAD_GATEWAY) .body(ApiResult.error(code = "F6_UNAVAILABLE", message = "供应商服务暂不可用,请稍后重试")) } ``` ## Mini 域客户端示例(内部系统,策略更宽松) ```kotlin // domains/mini-clients/.../O2OClient.kt @Component class O2OClient(private val webClient: WebClient) { @TimeLimiter(name = "mini-o2o") // 只兜底超时,不需要熔断(内部系统,稳定性相对可控) fun fetchOrderSummary(storeId: Long): Mono = webClient.get() .uri("/o2o/orders/summary?storeId={storeId}", storeId) .retrieve() .bodyToMono(OrderSummary::class.java) .onErrorResume { Mono.just(OrderSummary.empty()) } // 局部降级,见 workbench 聚合规则 } ``` ## 关键规则 - **F6 是外部供应商域**,稳定性不可控,必须配置超时 + 重试 + 熔断,且熔断后要有降级返回(`fallbackXxx` 方法),不能让异常直接穿透到 APP。 - **异常统一转换**:F6 / Mini 域返回的异常或非标准错误,在 `f6-integration` / `mini-clients` 内部转换成内部标准错误码(如 `F6_UNAVAILABLE`),业务 domain 和最终 API 响应都不暴露供应商侧的原始协议细节。 - **Mini 域调用相对可控**(内部系统),熔断策略可以比 F6 宽松(示例里只加超时兜底),但仍需要超时兜底,避免慢查询拖垮 `workbench` 聚合——对应架构图 Flow 3 "首页失败按 tile 降级"的要求。 - 业务 domain(如 `workbench`)只依赖 `mini-clients` / `f6-integration` 暴露的接口,不自己 `new WebClient` 发请求。 ## 附录:超时、重试、熔断分别解决什么问题 三者经常被一起提,但作用点不同,配置的时候容易搞混: - **超时(Timeout)**:解决"对方一直不回应,我方请求线程/连接被一直占着"的问题。没有超时,一个慢下游能拖垮整个调用方的线程池。这是三者里最基础、必须有的一道防线。 - **重试(Retry)**:解决"这次失败大概率是偶发的(网络抖动、瞬时过载)"的问题。重试的前提是**幂等**——`fetchProcurementList` 这种 GET 查询可以放心重试,但如果是"扣库存""创建订单"这类有副作用的调用,重试前要先确认接口本身幂等(比如带幂等 key),否则重试可能造成重复下单这类更严重的问题。 - **熔断(Circuit Breaker)**:解决"对方已经持续故障,继续重试只是在浪费资源、拖慢自己"的问题。熔断器统计一个滑动窗口内的失败率,超过阈值后直接短路请求(进入 `OPEN` 状态,一段时间内不再真的发请求,直接走 fallback),过一段时间放几个探测请求(`HALF_OPEN`)判断对方是否恢复。 三者组合的顺序也有讲究:一次调用先看熔断器状态(`OPEN` 直接 fallback,不发请求)→ 没熔断就真的发请求 → 超时控制这次请求最多等多久 → 超时或失败了再看要不要重试。上面 Resilience4j 的注解顺序(`@CircuitBreaker` 在最外层,`@Retry`、`@TimeLimiter`在内层)就是按这个语义叠加的。 ## 待补充 - 具体超时/重试参数需要结合 F6 实际 SLA 压测后调整,示例里的数值是起点,不是最终值。 - 熔断后降级返回的数据结构约定(`degraded()` 具体字段)。 - F6 换票具体协议细节(对接 [webview-ticket](./04-security-auth.md) 的会话失效联动)。 ## 参考链接 - [Resilience4j 官方文档](https://resilience4j.readme.io/docs) - [Spring WebFlux WebClient](https://docs.spring.io/spring-framework/reference/web/webflux-webclient.html) - [Martin Fowler: CircuitBreaker](https://martinfowler.com/bliki/CircuitBreaker.html)