backend scaffold

This commit is contained in:
Guangfei.Zhao
2026-08-17 15:31:27 +08:00
commit 84fc2c0677
159 changed files with 10542 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
dependencies {
api project(':platform:platform-integration')
implementation project(':platform:platform-web')
testImplementation 'org.wiremock:wiremock-standalone:3.9.1'
testImplementation 'com.ninja-squad:springmockk:5.0.1'
}
@@ -0,0 +1,42 @@
package com.continental.retailapp.integration.f6
import io.github.resilience4j.bulkhead.annotation.Bulkhead
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker
import io.github.resilience4j.retry.annotation.Retry
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component
import org.springframework.web.client.RestClient
/**
* F6 采购接口,见 05-integration-layer.md。
*
* 三个注解的叠加顺序由 `application.yml` 里的 `*-aspect-order` 决定,
* 配的是 bulkhead(1) < circuitbreaker(2) < retry(3),也就是
* **Retry 在最外层**:每次重试都会被熔断器单独统计,连续失败能更快打开熔断。
* 方向容易记反,所以有一条 `ResilienceAspectOrderTest` 实测它(见 10-testing.md)。
*
* 没有 `@TimeLimiter`:同步栈下超时由 HTTP 客户端本身控制(见 RestClientFactory),
* 加 TimeLimiter 反而会引入一个额外线程池。
*/
@Component
class F6ApiClient(private val f6RestClient: RestClient) {
private val log = LoggerFactory.getLogger(javaClass)
@Bulkhead(name = "f6-api")
@CircuitBreaker(name = "f6-api", fallbackMethod = "fallbackProcurementList")
@Retry(name = "f6-api")
fun fetchProcurementList(storeId: Long): ProcurementList =
f6RestClient.get()
.uri("/f6/procurement/list?storeId={storeId}", storeId)
.retrieve()
.body(ProcurementList::class.java)
?: ProcurementList.degraded(storeId)
/** fallback 的签名必须是"原方法参数 + Throwable",少一个参数 Resilience4j 就找不到它。 */
@Suppress("unused")
private fun fallbackProcurementList(storeId: Long, ex: Throwable): ProcurementList {
log.warn("F6 采购列表降级 storeId={} cause={}", storeId, ex.javaClass.simpleName)
return ProcurementList.degraded(storeId)
}
}
@@ -0,0 +1,36 @@
package com.continental.retailapp.integration.f6
import com.continental.retailapp.platform.integration.IntegrationException
import com.continental.retailapp.platform.web.ErrorCode
/**
* F6 侧的失败分两类,见 05-integration-layer.md——这个区分直接决定了要不要重试:
*
* - [F6ServerException]5xx / 网络层):对方的问题,**可以重试**,计入熔断统计;
* - [F6ClientException]4xx):我们请求本身有问题,重试一百次也是同样结果,
* 所以它在 `retry-exceptions` 之外、在 `ignore-exceptions` 之内。
*
* 构造参数里的 message 只进日志,对外文案是父类里那句用户能看懂的话。
*/
class F6ServerException(detail: String) :
IntegrationException(ErrorCode.F6_UNAVAILABLE, "供应商服务暂不可用,请稍后重试") {
init {
// detail 只在日志里出现,不进响应体
F6_LOG.warn("F6 服务端错误: {}", detail)
}
companion object {
private val F6_LOG = org.slf4j.LoggerFactory.getLogger(F6ServerException::class.java)
}
}
class F6ClientException(detail: String) :
IntegrationException(ErrorCode.F6_BUSINESS_ERROR, "供应商请求被拒绝") {
init {
F6_LOG.warn("F6 客户端错误: {}", detail)
}
companion object {
private val F6_LOG = org.slf4j.LoggerFactory.getLogger(F6ClientException::class.java)
}
}
@@ -0,0 +1,31 @@
package com.continental.retailapp.integration.f6
import com.continental.retailapp.platform.integration.IntegrationClientProperties
import com.continental.retailapp.platform.integration.RestClientFactory
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.client.RestClient
/**
* F6 的 [RestClient],见 05-integration-layer.md(以及那里对文档偏离 #3 的说明)。
*
* statusHandler 在这里而不是 platform-integration:只有 f6-adapter 知道 F6 的错误该映射成什么异常,
* 平台层不应该反过来 import 适配层的类型。
*/
@Configuration
class F6RestClientConfig {
@Bean
fun f6RestClient(
factory: RestClientFactory,
props: IntegrationClientProperties,
): RestClient = factory.build(props.require("f6")) { builder ->
builder
.defaultStatusHandler({ it.is5xxServerError }) { _, response ->
throw F6ServerException("status=${response.statusCode}")
}
.defaultStatusHandler({ it.is4xxClientError }) { _, response ->
throw F6ClientException("status=${response.statusCode}")
}
}
}
@@ -0,0 +1,15 @@
package com.continental.retailapp.integration.f6
/**
* F6 采购列表的传输模型。`degraded` 让调用方能区分"真的没数据"和"没拉到"
* 见 11-cross-domain-collaboration.md 的局部降级约定。
*/
data class ProcurementList(
val storeId: Long,
val pendingCount: Int,
val degraded: Boolean = false,
) {
companion object {
fun degraded(storeId: Long) = ProcurementList(storeId, pendingCount = 0, degraded = true)
}
}
@@ -0,0 +1,139 @@
package com.continental.retailapp.integration.f6
import com.github.tomakehurst.wiremock.WireMockServer
import com.github.tomakehurst.wiremock.client.WireMock.aResponse
import com.github.tomakehurst.wiremock.client.WireMock.get
import com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor
import com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo
import com.github.tomakehurst.wiremock.core.WireMockConfiguration
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.DynamicPropertyRegistry
import org.springframework.test.context.DynamicPropertySource
/**
* 降级路径的行为测试,见 10-testing.md:**外部依赖挂掉时本服务不能跟着挂**。
*
* 用 WireMock 而不是 mock 掉 `RestClient`:要验的是超时、状态码映射、fallback 这条真实链路,
* 把 RestClient mock 掉就等于把要测的东西测掉了。
*/
@Tag("integration")
@SpringBootTest(
classes = [F6TestApplication::class],
properties = [
"integration.clients.f6.connect-timeout-ms=300",
"integration.clients.f6.read-timeout-ms=500",
"integration.clients.f6.connection-request-timeout-ms=200",
"resilience4j.retry.retry-aspect-order=3",
"resilience4j.retry.instances.f6-api.max-attempts=2",
"resilience4j.retry.instances.f6-api.wait-duration=10ms",
"resilience4j.retry.instances.f6-api.retry-exceptions[0]=" +
"org.springframework.web.client.ResourceAccessException",
"resilience4j.retry.instances.f6-api.retry-exceptions[1]=" +
"com.continental.retailapp.integration.f6.F6ServerException",
"resilience4j.retry.instances.f6-api.ignore-exceptions[0]=" +
"com.continental.retailapp.integration.f6.F6ClientException",
"resilience4j.circuitbreaker.circuit-breaker-aspect-order=2",
// 样本门槛调高,避免熔断在测试中途打开,把"降级"和"熔断"两件事混在一起
"resilience4j.circuitbreaker.instances.f6-api.minimum-number-of-calls=100",
"resilience4j.circuitbreaker.instances.f6-api.record-exceptions[0]=" +
"com.continental.retailapp.integration.f6.F6ServerException",
"resilience4j.bulkhead.bulkhead-aspect-order=1",
"resilience4j.bulkhead.instances.f6-api.max-concurrent-calls=20",
"resilience4j.bulkhead.instances.f6-api.max-wait-duration=0",
],
)
class F6ApiClientResilienceTest {
@Autowired
private lateinit var client: F6ApiClient
@BeforeEach
fun resetStubs() {
wireMock.resetAll()
}
@Test
fun `F6 返回 5xx 时降级而不是抛给调用方`() {
wireMock.stubFor(
get(urlPathEqualTo("/f6/procurement/list"))
.willReturn(aResponse().withStatus(500)),
)
val result = client.fetchProcurementList(storeId = 100L)
assertTrue(result.degraded)
assertEquals(100L, result.storeId)
assertEquals(0, result.pendingCount)
}
@Test
fun `F6 响应超时时降级`() {
wireMock.stubFor(
get(urlPathEqualTo("/f6/procurement/list"))
.willReturn(aResponse().withStatus(200).withFixedDelay(2_000)),
)
val result = client.fetchProcurementList(storeId = 100L)
// read-timeout 500ms < 固定延迟 2000ms,超时由 HTTP 客户端本身兜住,
// 这也是不需要 @TimeLimiter 的原因(见 05-integration-layer.md
assertTrue(result.degraded)
}
@Test
fun `F6 正常返回时不降级`() {
wireMock.stubFor(
get(urlPathEqualTo("/f6/procurement/list"))
.willReturn(
aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("""{"storeId":100,"pendingCount":7}"""),
),
)
val result = client.fetchProcurementList(storeId = 100L)
assertFalse(result.degraded)
assertEquals(7, result.pendingCount)
}
@Test
fun `4xx 不重试`() {
wireMock.stubFor(
get(urlPathEqualTo("/f6/procurement/list"))
.willReturn(aResponse().withStatus(400)),
)
val result = client.fetchProcurementList(storeId = 100L)
assertTrue(result.degraded)
// 参数错了再打一次还是错,重试只会放大下游压力:F6ClientException 在 ignore-exceptions 里
wireMock.verify(1, getRequestedFor(urlPathEqualTo("/f6/procurement/list")))
}
companion object {
private val wireMock = WireMockServer(WireMockConfiguration.options().dynamicPort())
@JvmStatic
@DynamicPropertySource
fun registerBaseUrl(registry: DynamicPropertyRegistry) {
if (!wireMock.isRunning) wireMock.start()
registry.add("integration.clients.f6.base-url") { "http://localhost:${wireMock.port()}" }
}
@JvmStatic
@AfterAll
fun stopWireMock() {
if (wireMock.isRunning) wireMock.stop()
}
}
}
@@ -0,0 +1,18 @@
package com.continental.retailapp.integration.f6
import org.springframework.boot.autoconfigure.SpringBootApplication
/**
* 只给 f6-adapter 的集成测试用的最小上下文。
*
* 扫描范围刻意收窄到 f6-adapter + platform-integration 两个包:
* 真实的 `BootstrapApplication` 会把 JPA、Security、Flyway 全拉起来,
* 而这里要测的只是 RestClient 加三层韧性注解之后的行为。
*/
@SpringBootApplication(
scanBasePackages = [
"com.continental.retailapp.integration.f6",
"com.continental.retailapp.platform.integration",
],
)
class F6TestApplication
@@ -0,0 +1,106 @@
package com.continental.retailapp.integration.f6
import com.github.tomakehurst.wiremock.WireMockServer
import com.github.tomakehurst.wiremock.client.WireMock.aResponse
import com.github.tomakehurst.wiremock.client.WireMock.get
import com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor
import com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo
import com.github.tomakehurst.wiremock.core.WireMockConfiguration
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.DynamicPropertyRegistry
import org.springframework.test.context.DynamicPropertySource
/**
* 三个韧性注解的叠加顺序是**配出来的**,不是写注解的先后顺序决定的,
* 而且方向极容易记反——所以 10-testing.md 要求实测它。
*
* 当前配置是 bulkhead(1) < circuitbreaker(2) < retry(3),即 Retry 在最外层:
*
* ```
* Retry( CircuitBreaker( Bulkhead( 真实调用 )))
* ```
*
* 可观察的差别就是本测试断言的那一条:**每一次重试都会被熔断器单独记一笔失败**。
* 如果哪天有人把 order 改反了(Retry 在熔断器内层),一次业务调用只会给熔断器留下 1 笔失败,
* 连续故障时熔断打开会慢得多——这条测试会立刻变红。
*/
@Tag("integration")
@SpringBootTest(
classes = [F6TestApplication::class],
properties = [
"integration.clients.f6.connect-timeout-ms=300",
"integration.clients.f6.read-timeout-ms=500",
"integration.clients.f6.connection-request-timeout-ms=200",
"resilience4j.retry.retry-aspect-order=3",
"resilience4j.retry.instances.f6-api.max-attempts=2",
"resilience4j.retry.instances.f6-api.wait-duration=10ms",
"resilience4j.retry.instances.f6-api.retry-exceptions[0]=" +
"com.continental.retailapp.integration.f6.F6ServerException",
"resilience4j.circuitbreaker.circuit-breaker-aspect-order=2",
"resilience4j.circuitbreaker.instances.f6-api.sliding-window-type=COUNT_BASED",
"resilience4j.circuitbreaker.instances.f6-api.sliding-window-size=20",
// 门槛抬高到测试内不可能触发,否则熔断打开后调用不再落到 WireMock,计数就没意义了
"resilience4j.circuitbreaker.instances.f6-api.minimum-number-of-calls=100",
"resilience4j.circuitbreaker.instances.f6-api.record-exceptions[0]=" +
"com.continental.retailapp.integration.f6.F6ServerException",
"resilience4j.bulkhead.bulkhead-aspect-order=1",
"resilience4j.bulkhead.instances.f6-api.max-concurrent-calls=20",
"resilience4j.bulkhead.instances.f6-api.max-wait-duration=0",
],
)
class ResilienceAspectOrderTest {
@Autowired
private lateinit var client: F6ApiClient
@Autowired
private lateinit var circuitBreakerRegistry: CircuitBreakerRegistry
@BeforeEach
fun reset() {
wireMock.resetAll()
circuitBreakerRegistry.circuitBreaker("f6-api").reset()
}
@Test
fun `Retry 在熔断器外层,每次重试都被单独统计`() {
wireMock.stubFor(
get(urlPathEqualTo("/f6/procurement/list"))
.willReturn(aResponse().withStatus(500)),
)
client.fetchProcurementList(storeId = 100L)
// max-attempts=2 → 一次业务调用打了 2 个 HTTP 请求
wireMock.verify(2, getRequestedFor(urlPathEqualTo("/f6/procurement/list")))
// 关键断言:熔断器看到的是 2 笔失败而不是 1 笔。
// Retry 若在熔断器内层,这里会是 1。
val metrics = circuitBreakerRegistry.circuitBreaker("f6-api").metrics
assertEquals(2, metrics.numberOfFailedCalls)
}
companion object {
private val wireMock = WireMockServer(WireMockConfiguration.options().dynamicPort())
@JvmStatic
@DynamicPropertySource
fun registerBaseUrl(registry: DynamicPropertyRegistry) {
if (!wireMock.isRunning) wireMock.start()
registry.add("integration.clients.f6.base-url") { "http://localhost:${wireMock.port()}" }
}
@JvmStatic
@AfterAll
fun stopWireMock() {
if (wireMock.isRunning) wireMock.stop()
}
}
}
+4
View File
@@ -0,0 +1,4 @@
dependencies {
api project(':platform:platform-integration')
implementation project(':platform:platform-web')
}
@@ -0,0 +1,31 @@
package com.continental.retailapp.integration.mini
import com.continental.retailapp.platform.integration.IntegrationClientProperties
import com.continental.retailapp.platform.integration.IntegrationException
import com.continental.retailapp.platform.integration.RestClientFactory
import com.continental.retailapp.platform.web.ErrorCode
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.client.RestClient
/** Mini 侧(O2O 等自建服务)的 [RestClient],构建方式同 F6,见 05-integration-layer.md。 */
@Configuration
class MiniRestClientConfig {
@Bean
fun miniRestClient(
factory: RestClientFactory,
props: IntegrationClientProperties,
): RestClient = factory.build(props.require("mini")) { builder ->
builder.defaultStatusHandler({ it.isError }) { _, response ->
throw MiniUnavailableException("status=${response.statusCode}")
}
}
}
class MiniUnavailableException(detail: String) :
IntegrationException(ErrorCode.MINI_UNAVAILABLE, "服务暂不可用,请稍后重试") {
init {
org.slf4j.LoggerFactory.getLogger(javaClass).warn("Mini 服务错误: {}", detail)
}
}
@@ -0,0 +1,43 @@
package com.continental.retailapp.integration.mini
import io.github.resilience4j.bulkhead.annotation.Bulkhead
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component
import org.springframework.web.client.RestClient
/** 保修概要,`degraded` 的含义同 F6 侧。 */
data class WarrantySummary(
val storeId: Long,
val openCount: Int,
val degraded: Boolean = false,
) {
companion object {
fun degraded(storeId: Long) = WarrantySummary(storeId, openCount = 0, degraded = true)
}
}
/**
* O2O 客户端,见 05-integration-layer.md。
*
* 这里只用舱壁不用熔断:这是自建服务,抖动通常是短暂的,
* 熔断打开反而会在恢复后多压 10 秒不可用。真正要防的是它把 Tomcat 线程全占住,
* 那正是 `@Bulkhead` 的职责(`max-wait-duration: 0` —— 拿不到许可立刻失败,不排队)。
*/
@Component
class O2OClient(private val miniRestClient: RestClient) {
private val log = LoggerFactory.getLogger(javaClass)
@Bulkhead(name = "mini-o2o")
fun fetchWarrantySummary(storeId: Long): WarrantySummary =
try {
miniRestClient.get()
.uri("/o2o/warranty/summary?storeId={storeId}", storeId)
.retrieve()
.body(WarrantySummary::class.java)
?: WarrantySummary.degraded(storeId)
} catch (ex: Exception) {
log.warn("O2O 保修概要降级 storeId={} cause={}", storeId, ex.javaClass.simpleName)
WarrantySummary.degraded(storeId)
}
}