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
@@ -0,0 +1,10 @@
dependencies {
api project(':platform:platform-web')
api 'org.springframework.boot:spring-boot-starter-web'
api 'org.apache.httpcomponents.client5:httpclient5'
// Boot 4 / Spring Framework 7 对应的 Resilience4j starter 是 -spring-boot4
api 'io.github.resilience4j:resilience4j-spring-boot4:2.4.0'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-aspectj'
}
@@ -0,0 +1,34 @@
package com.continental.retailapp.platform.integration
import jakarta.validation.Valid
import jakarta.validation.constraints.Min
import jakarta.validation.constraints.NotBlank
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.validation.annotation.Validated
/**
* 外部依赖的连接参数,见 05-integration-layer.md。
* 全部走 `@ConfigurationProperties` + `@Validated`,不用 `@Value`——配置错了要在启动时就炸,
* 而不是等第一次调用外部系统时才发现(见 07-config-governance.md)。
*/
@ConfigurationProperties(prefix = "integration")
@Validated
data class IntegrationClientProperties(
val clients: Map<String, @Valid ClientConfig> = emptyMap(),
) {
data class ClientConfig(
@field:NotBlank val baseUrl: String,
@field:Min(100) val connectTimeoutMs: Long = 1000,
@field:Min(100) val readTimeoutMs: Long = 2000,
/**
* 最容易漏配的一项:从连接池拿连接的等待超时。
* 不配的话池满时线程会一直排队,connect/read 超时全都管不到它。
*/
@field:Min(50) val connectionRequestTimeoutMs: Long = 500,
@field:Min(1) val maxConnections: Int = 60,
@field:Min(1) val maxConnectionsPerRoute: Int = 30,
)
fun require(name: String): ClientConfig =
clients[name] ?: error("缺少 integration.clients.$name 配置")
}
@@ -0,0 +1,17 @@
package com.continental.retailapp.platform.integration
import com.continental.retailapp.platform.web.BusinessException
import org.springframework.http.HttpStatus
/**
* 外部依赖失败的统一父类,见 05-integration-layer.md。
* 默认 502:调用方(APP)需要知道"不是你的请求有问题,是我依赖的系统挂了"。
*
* 注意 message 是**给用户看的文案**,绝不能把下游的原始报错透出去
* (可能含内网地址、SQL、堆栈)——原始信息只进日志。
*/
open class IntegrationException(
code: Int,
message: String,
httpStatus: HttpStatus = HttpStatus.BAD_GATEWAY,
) : BusinessException(code, message, httpStatus)
@@ -0,0 +1,68 @@
package com.continental.retailapp.platform.integration
import org.apache.hc.client5.http.config.ConnectionConfig
import org.apache.hc.client5.http.config.RequestConfig
import org.apache.hc.client5.http.impl.classic.HttpClients
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder
import org.apache.hc.core5.util.TimeValue
import org.apache.hc.core5.util.Timeout
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Configuration
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory
import org.springframework.web.client.RestClient
/**
* 统一构建 [RestClient]:超时、连接池、trace 透传都收口在这里,见 05-integration-layer.md。
*
* 对文档的有意偏离 #305 文档把 `f6RestClient` 的 `@Bean` 放在 platform-integration
* 但它的 statusHandler 抛的是 f6-adapter 里的 `F6ServerException`——平台层反过来依赖适配层,
* 方向是反的。这里把它拆成"平台提供工厂、各适配器自己声明 bean 并给自己的 statusHandler"。
*
* 用同步 `RestClient` + Apache HttpClient 5 而不是 WebClient:整条链路是同步阻塞的 Servlet 栈,
* 引入 Reactor 只会带来 `.block()` 和难排查的线程模型。
*/
@Configuration
@EnableConfigurationProperties(IntegrationClientProperties::class)
class RestClientFactory(
private val tracePropagationInterceptor: TracePropagationInterceptor,
) {
fun build(
config: IntegrationClientProperties.ClientConfig,
customizer: (RestClient.Builder) -> Unit = {},
): RestClient = RestClient.builder()
.baseUrl(config.baseUrl)
.requestFactory(requestFactory(config))
.requestInterceptor(tracePropagationInterceptor)
.apply(customizer)
.build()
private fun requestFactory(
config: IntegrationClientProperties.ClientConfig,
): HttpComponentsClientHttpRequestFactory {
val connectionManager = PoolingHttpClientConnectionManagerBuilder.create()
.setMaxConnTotal(config.maxConnections)
.setMaxConnPerRoute(config.maxConnectionsPerRoute)
.setDefaultConnectionConfig(
ConnectionConfig.custom()
.setConnectTimeout(Timeout.ofMilliseconds(config.connectTimeoutMs))
// 拿到连接后先探活,避免复用到对端已经关掉的半开连接
.setValidateAfterInactivity(TimeValue.ofSeconds(5))
.build(),
)
.build()
val httpClient = HttpClients.custom()
.setConnectionManager(connectionManager)
.setDefaultRequestConfig(
RequestConfig.custom()
.setConnectionRequestTimeout(Timeout.ofMilliseconds(config.connectionRequestTimeoutMs))
.setResponseTimeout(Timeout.ofMilliseconds(config.readTimeoutMs))
.build(),
)
.evictIdleConnections(TimeValue.ofSeconds(30))
.build()
return HttpComponentsClientHttpRequestFactory(httpClient)
}
}
@@ -0,0 +1,25 @@
package com.continental.retailapp.platform.integration
import org.slf4j.MDC
import org.springframework.http.HttpRequest
import org.springframework.http.client.ClientHttpRequestExecution
import org.springframework.http.client.ClientHttpRequestInterceptor
import org.springframework.http.client.ClientHttpResponse
import org.springframework.stereotype.Component
/**
* 把当前 traceId 透传给下游,见 05-integration-layer.md / 08-observability.md。
* 下游出问题时能用同一个 ID 把两边日志串起来。
*/
@Component
class TracePropagationInterceptor : ClientHttpRequestInterceptor {
override fun intercept(
request: HttpRequest,
body: ByteArray,
execution: ClientHttpRequestExecution,
): ClientHttpResponse {
MDC.get("traceId")?.let { request.headers.set("X-Trace-Id", it) }
return execution.execute(request, body)
}
}