backend scaffold
This commit is contained in:
@@ -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'
|
||||
}
|
||||
+34
@@ -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 配置")
|
||||
}
|
||||
+17
@@ -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)
|
||||
+68
@@ -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。
|
||||
*
|
||||
* 对文档的有意偏离 #3:05 文档把 `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)
|
||||
}
|
||||
}
|
||||
+25
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
dependencies {
|
||||
api project(':platform:platform-web')
|
||||
|
||||
api 'org.springframework.boot:spring-boot-starter-actuator'
|
||||
api 'io.micrometer:micrometer-core'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-aspectj'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||
implementation 'io.micrometer:micrometer-registry-prometheus'
|
||||
implementation 'io.micrometer:micrometer-tracing-bridge-otel'
|
||||
implementation 'net.logstash.logback:logstash-logback-encoder:9.0'
|
||||
implementation 'io.github.resilience4j:resilience4j-micrometer:2.4.0'
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package com.continental.retailapp.platform.observability
|
||||
|
||||
import jakarta.servlet.FilterChain
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import jakarta.servlet.http.HttpServletResponse
|
||||
import jakarta.servlet.http.HttpServletRequestWrapper
|
||||
import org.springframework.core.Ordered
|
||||
import org.springframework.core.annotation.Order
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.filter.OncePerRequestFilter
|
||||
import java.security.SecureRandom
|
||||
import java.util.Enumeration
|
||||
import java.util.Collections
|
||||
|
||||
/**
|
||||
* 把客户端传来的 `X-Trace-Id` 桥接成 W3C `traceparent`,让 Micrometer Tracing 直接续上,
|
||||
* 见 08-observability.md。必须跑在所有 filter 最前面,否则 tracing 已经自己生成了 traceId。
|
||||
*
|
||||
* 安全边界:`X-Trace-Id` 是**外部输入**,会进日志。不校验就等于给了日志注入的口子,
|
||||
* 所以只接受 32 位小写十六进制、且拒绝全零(W3C 规定的无效值)。
|
||||
*/
|
||||
@Component
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
class ClientTraceIdBridgeFilter : OncePerRequestFilter() {
|
||||
|
||||
companion object {
|
||||
private val TRACE_ID = Regex("^[0-9a-f]{32}$")
|
||||
private const val INVALID = "00000000000000000000000000000000"
|
||||
private val RANDOM = SecureRandom()
|
||||
}
|
||||
|
||||
override fun doFilterInternal(
|
||||
request: HttpServletRequest,
|
||||
response: HttpServletResponse,
|
||||
chain: FilterChain,
|
||||
) {
|
||||
val clientTraceId = request.getHeader("X-Trace-Id")
|
||||
if (clientTraceId == null || !TRACE_ID.matches(clientTraceId) || clientTraceId == INVALID) {
|
||||
chain.doFilter(request, response)
|
||||
return
|
||||
}
|
||||
val traceparent = "00-$clientTraceId-${randomSpanId()}-01"
|
||||
chain.doFilter(TraceparentRequestWrapper(request, traceparent), response)
|
||||
}
|
||||
|
||||
private fun randomSpanId(): String {
|
||||
val bytes = ByteArray(8)
|
||||
RANDOM.nextBytes(bytes)
|
||||
return bytes.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** 只覆盖 `traceparent` 这一个请求头,其余原样透传。 */
|
||||
class TraceparentRequestWrapper(
|
||||
request: HttpServletRequest,
|
||||
private val traceparent: String,
|
||||
) : HttpServletRequestWrapper(request) {
|
||||
|
||||
override fun getHeader(name: String): String? =
|
||||
if (name.equals("traceparent", ignoreCase = true)) traceparent else super.getHeader(name)
|
||||
|
||||
override fun getHeaders(name: String): Enumeration<String> =
|
||||
if (name.equals("traceparent", ignoreCase = true)) {
|
||||
Collections.enumeration(listOf(traceparent))
|
||||
} else {
|
||||
super.getHeaders(name)
|
||||
}
|
||||
|
||||
override fun getHeaderNames(): Enumeration<String> {
|
||||
val names = super.getHeaderNames().toList().toMutableList()
|
||||
if (names.none { it.equals("traceparent", ignoreCase = true) }) {
|
||||
names.add("traceparent")
|
||||
}
|
||||
return Collections.enumeration(names)
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.continental.retailapp.platform.observability
|
||||
|
||||
import io.micrometer.core.instrument.MeterRegistry
|
||||
import io.micrometer.core.instrument.config.MeterFilter
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.boot.micrometer.metrics.autoconfigure.MeterRegistryCustomizer
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
|
||||
/**
|
||||
* 指标公共标签,见 08-observability.md。
|
||||
*
|
||||
* `maximumAllowableTags` 是防基数爆炸的闸:URI 里如果混进了 storeId/userId 这类高基数值,
|
||||
* Prometheus 的时间序列会失控。这里给 http 请求指标压一个上限,超了直接丢,不拖垮监控。
|
||||
*
|
||||
* 注:Boot 4 把 `MeterRegistryCustomizer` 从 `org.springframework.boot.actuate.autoconfigure.metrics`
|
||||
* 挪到了 `org.springframework.boot.micrometer.metrics.autoconfigure`。
|
||||
*/
|
||||
@Configuration
|
||||
class MetricsConfig {
|
||||
|
||||
@Bean
|
||||
fun commonTags(
|
||||
@Value("\${spring.application.name:conti-backend}") application: String,
|
||||
): MeterRegistryCustomizer<MeterRegistry> = MeterRegistryCustomizer { registry ->
|
||||
registry.config()
|
||||
.commonTags("application", application)
|
||||
.meterFilter(MeterFilter.maximumAllowableTags("http.server.requests", "uri", 200, MeterFilter.deny()))
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.continental.retailapp.platform.observability
|
||||
|
||||
import jakarta.servlet.FilterChain
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import jakarta.servlet.http.HttpServletResponse
|
||||
import org.slf4j.MDC
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.filter.OncePerRequestFilter
|
||||
|
||||
/**
|
||||
* 把服务端最终使用的 traceId 回写到响应头,见 08-observability.md。
|
||||
* 客户端拿到它就能在报障时直接给出一个可检索的 ID。
|
||||
*/
|
||||
@Component
|
||||
class TraceResponseFilter : OncePerRequestFilter() {
|
||||
|
||||
override fun doFilterInternal(
|
||||
request: HttpServletRequest,
|
||||
response: HttpServletResponse,
|
||||
chain: FilterChain,
|
||||
) {
|
||||
MDC.get("traceId")?.let { response.setHeader("X-Trace-Id", it) }
|
||||
chain.doFilter(request, response)
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package com.continental.retailapp.platform.observability.audit
|
||||
|
||||
import com.continental.retailapp.platform.web.AuditContext
|
||||
import com.continental.retailapp.platform.web.currentTraceId
|
||||
import org.aspectj.lang.ProceedingJoinPoint
|
||||
import org.aspectj.lang.annotation.Around
|
||||
import org.aspectj.lang.annotation.Aspect
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.ObjectProvider
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
/**
|
||||
* 审计日志,见 08-observability.md。加在敏感操作(登录、切店、改配置)的 application 方法上。
|
||||
*/
|
||||
@Target(AnnotationTarget.FUNCTION)
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
annotation class Audited(val action: String)
|
||||
|
||||
/**
|
||||
* 只记"谁在哪个门店做了什么、成没成功",**不记参数、不记返回值**——
|
||||
* 这两处最容易把密码、令牌、手机号带进日志(见 08-observability.md 的脱敏红线)。
|
||||
*
|
||||
* 注入 [AuditContext] 而不是 platform-security 的 StoreContextHolder:对文档的有意偏离 #1。
|
||||
*/
|
||||
@Aspect
|
||||
@Component
|
||||
class AuditLogAspect(private val auditContext: ObjectProvider<AuditContext>) {
|
||||
|
||||
private val auditLog = LoggerFactory.getLogger("AUDIT")
|
||||
|
||||
@Around("@annotation(audited)")
|
||||
fun logAudit(joinPoint: ProceedingJoinPoint, audited: Audited): Any? {
|
||||
val result = runCatching { joinPoint.proceed() }
|
||||
// 定时任务等非 HTTP 场景取不到 @RequestScope bean,兜住,别让审计把业务打挂
|
||||
val ctx = runCatching { auditContext.getObject() }.getOrNull()
|
||||
auditLog.info(
|
||||
"action={} userId={} storeId={} traceId={} success={}",
|
||||
audited.action,
|
||||
ctx?.userId,
|
||||
ctx?.storeId,
|
||||
currentTraceId(),
|
||||
result.isSuccess,
|
||||
)
|
||||
return result.getOrThrow()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
JSON 结构化日志,见 08-observability.md。
|
||||
只输出到 stdout,绝不写文件 —— 容器里的日志由采集侧统一处理,写文件只会把只读根文件系统撑爆。
|
||||
-->
|
||||
<configuration>
|
||||
|
||||
<springProperty scope="context" name="appName" source="spring.application.name" defaultValue="conti-backend"/>
|
||||
|
||||
<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
|
||||
<includeMdcKeyName>traceId</includeMdcKeyName>
|
||||
<includeMdcKeyName>spanId</includeMdcKeyName>
|
||||
<customFields>{"app":"${appName}","env":"${SPRING_PROFILES_ACTIVE:-local}"}</customFields>
|
||||
<!--
|
||||
兜底脱敏。第一道防线永远是"不要把这些东西传进日志",
|
||||
这里只是防手滑,不要依赖它来保证合规。
|
||||
-->
|
||||
<jsonGeneratorDecorator class="net.logstash.logback.decorate.MaskingJsonGeneratorDecorator">
|
||||
<path>password</path>
|
||||
<path>accessToken</path>
|
||||
<path>refreshToken</path>
|
||||
<path>authorization</path>
|
||||
</jsonGeneratorDecorator>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<!-- 审计日志走独立 logger,方便采集侧单独路由和长期留存 -->
|
||||
<logger name="AUDIT" level="INFO" additivity="false">
|
||||
<appender-ref ref="JSON"/>
|
||||
</logger>
|
||||
|
||||
<root level="INFO">
|
||||
<appender-ref ref="JSON"/>
|
||||
</root>
|
||||
|
||||
</configuration>
|
||||
@@ -0,0 +1,13 @@
|
||||
dependencies {
|
||||
// 只依赖 platform-web 里的 AuditContext(见根 README 的"对文档的偏离 #1")
|
||||
api project(':platform:platform-web')
|
||||
|
||||
api 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||
api 'net.javacrumbs.shedlock:shedlock-spring:6.9.2'
|
||||
implementation 'net.javacrumbs.shedlock:shedlock-provider-jdbc-template:6.9.2'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-cache'
|
||||
implementation 'com.github.ben-manes.caffeine:caffeine'
|
||||
implementation 'org.flywaydb:flyway-core'
|
||||
runtimeOnly 'org.flywaydb:flyway-mysql'
|
||||
runtimeOnly 'com.mysql:mysql-connector-j'
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package com.continental.retailapp.platform.persistence
|
||||
|
||||
import jakarta.persistence.Column
|
||||
import jakarta.persistence.EntityListeners
|
||||
import jakarta.persistence.MappedSuperclass
|
||||
import jakarta.persistence.Version
|
||||
import org.springframework.data.annotation.CreatedBy
|
||||
import org.springframework.data.annotation.CreatedDate
|
||||
import org.springframework.data.annotation.LastModifiedBy
|
||||
import org.springframework.data.annotation.LastModifiedDate
|
||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* 审计字段基类,见 03-persistence.md。
|
||||
* 时间统一 UTC 的 [Instant],落库 `datetime(6)`——不要用 LocalDateTime,它不带时区信息。
|
||||
*/
|
||||
@MappedSuperclass
|
||||
@EntityListeners(AuditingEntityListener::class)
|
||||
abstract class BaseEntity {
|
||||
@CreatedDate
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
var createdAt: Instant = Instant.EPOCH
|
||||
|
||||
@LastModifiedDate
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
var updatedAt: Instant = Instant.EPOCH
|
||||
|
||||
@CreatedBy
|
||||
@Column(name = "created_by", updatable = false, length = 64)
|
||||
var createdBy: String? = null
|
||||
|
||||
@LastModifiedBy
|
||||
@Column(name = "updated_by", length = 64)
|
||||
var updatedBy: String? = null
|
||||
}
|
||||
|
||||
/** 需要乐观锁的表继承它,见 12-concurrency-and-scheduling.md。 */
|
||||
@MappedSuperclass
|
||||
abstract class VersionedEntity : BaseEntity() {
|
||||
@Version
|
||||
@Column(name = "version", nullable = false)
|
||||
var version: Long = 0
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package com.continental.retailapp.platform.persistence
|
||||
|
||||
/**
|
||||
* 统一分页响应,见 03-persistence.md / 06-api-design.md。
|
||||
* `pageNum` 从 1 开始,与客户端约定一致。
|
||||
*/
|
||||
data class PageResult<T>(
|
||||
val list: List<T>,
|
||||
val pageNum: Int,
|
||||
val pageSize: Int,
|
||||
val total: Long,
|
||||
val hasMore: Boolean,
|
||||
)
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.continental.retailapp.platform.persistence.cache
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Caffeine
|
||||
import org.springframework.cache.CacheManager
|
||||
import org.springframework.cache.annotation.EnableCaching
|
||||
import org.springframework.cache.caffeine.CaffeineCacheManager
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import java.time.Duration
|
||||
|
||||
/**
|
||||
* 本地缓存,见 12-concurrency-and-scheduling.md。只用 Caffeine,暂不引 Redis。
|
||||
*
|
||||
* 代价要如实认识:多副本下各自一份缓存,失效时机不一致。所以
|
||||
* **用户→门店的访问权限、WebView 票据状态一律不缓存**(必须能立即撤销)。
|
||||
*/
|
||||
@Configuration
|
||||
@EnableCaching
|
||||
class CacheConfig {
|
||||
|
||||
@Bean
|
||||
fun cacheManager(): CacheManager = CaffeineCacheManager().apply {
|
||||
setCaffeine(
|
||||
Caffeine.newBuilder()
|
||||
.maximumSize(1_000)
|
||||
.expireAfterWrite(Duration.ofMinutes(10)),
|
||||
)
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.continental.retailapp.platform.persistence.config
|
||||
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import java.time.Clock
|
||||
|
||||
/**
|
||||
* 全局 [Clock],见 10-testing.md。
|
||||
* 所有涉及时间的业务代码一律构造注入它,不要写 `Instant.now()`——
|
||||
* 否则"过期判断"这类逻辑只能靠 `Thread.sleep` 测,那是慢测试和随机失败的主要来源。
|
||||
*/
|
||||
@Configuration
|
||||
class ClockConfig {
|
||||
@Bean
|
||||
fun clock(): Clock = Clock.systemUTC()
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package com.continental.retailapp.platform.persistence.config
|
||||
|
||||
import org.flywaydb.core.Flyway
|
||||
import org.springframework.boot.jpa.autoconfigure.EntityManagerFactoryDependsOnPostProcessor
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import javax.sql.DataSource
|
||||
|
||||
/**
|
||||
* 一个 domain 一个 database(MySQL 里 schema ≡ database),所以要跑多个 Flyway 实例,
|
||||
* 见 03-persistence.md。Boot 自带的单实例 Flyway 用 `spring.flyway.enabled=false` 关掉。
|
||||
*
|
||||
* `EntityManagerFactoryDependsOnPostProcessor` 保证迁移在 Hibernate 做 `ddl-auto: validate` 之前跑完,
|
||||
* 否则新表还没建,校验直接失败。
|
||||
*
|
||||
* 注:Boot 4 把这个类从 `org.springframework.boot.autoconfigure.orm.jpa` 挪到了
|
||||
* `org.springframework.boot.jpa.autoconfigure`(autoconfigure 模块拆分的结果)。
|
||||
*
|
||||
* `platform` 库不在 03 文档的清单里,是按 12-concurrency-and-scheduling.md 补上的
|
||||
* (`idempotency_record` 和 `shedlock` 两张公共表)。
|
||||
*/
|
||||
@Configuration
|
||||
class DomainFlywayConfig {
|
||||
|
||||
companion object {
|
||||
val DOMAIN_SCHEMAS = listOf("platform", "identity_store", "workbench", "webview_ticket")
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun domainFlywayMigrations(dataSource: DataSource): DomainFlywayMigrations {
|
||||
DOMAIN_SCHEMAS.forEach { schema ->
|
||||
Flyway.configure()
|
||||
.dataSource(dataSource)
|
||||
.schemas(schema)
|
||||
.defaultSchema(schema)
|
||||
.table("flyway_schema_history")
|
||||
.locations("classpath:db/migration/$schema")
|
||||
.load()
|
||||
.migrate()
|
||||
}
|
||||
return DomainFlywayMigrations
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun flywayEntityManagerFactoryDependsOn(): EntityManagerFactoryDependsOnPostProcessor =
|
||||
object : EntityManagerFactoryDependsOnPostProcessor("domainFlywayMigrations") {}
|
||||
}
|
||||
|
||||
/** 只是一个可被依赖的标记 bean,让 EntityManagerFactory 有东西可以 depends-on。 */
|
||||
object DomainFlywayMigrations
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.continental.retailapp.platform.persistence.config
|
||||
|
||||
import com.continental.retailapp.platform.web.AuditContext
|
||||
import org.springframework.beans.factory.ObjectProvider
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.data.domain.AuditorAware
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaAuditing
|
||||
import java.util.Optional
|
||||
|
||||
/**
|
||||
* JPA 审计,见 03-persistence.md。
|
||||
*
|
||||
* 注入的是 platform-web 的 [AuditContext] 接口而不是 platform-security 的 StoreContextHolder
|
||||
* (对文档的有意偏离 #1,理由见 AuditContext 的注释)。
|
||||
*
|
||||
* `runCatching` 是必要的:定时任务、启动期的种子数据这些场景没有 HTTP 请求,
|
||||
* `@RequestScope` 的 bean 取不到,不兜住会直接把写操作打挂。
|
||||
*/
|
||||
@Configuration
|
||||
@EnableJpaAuditing(auditorAwareRef = "auditorAware")
|
||||
class JpaAuditingConfig(private val auditContext: ObjectProvider<AuditContext>) {
|
||||
|
||||
@Bean
|
||||
fun auditorAware(): AuditorAware<String> = AuditorAware {
|
||||
runCatching { auditContext.getObject().userId?.toString() }
|
||||
.getOrNull()
|
||||
.let { Optional.ofNullable(it) }
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.continental.retailapp.platform.persistence.scheduling
|
||||
|
||||
import net.javacrumbs.shedlock.core.LockProvider
|
||||
import net.javacrumbs.shedlock.provider.jdbctemplate.JdbcTemplateLockProvider
|
||||
import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.jdbc.core.JdbcTemplate
|
||||
import org.springframework.scheduling.annotation.EnableScheduling
|
||||
import javax.sql.DataSource
|
||||
|
||||
/**
|
||||
* 定时任务 + 分布式锁,见 12-concurrency-and-scheduling.md。
|
||||
*
|
||||
* 多副本下 `@Scheduled` 会在每个 Pod 上各跑一次,ShedLock 保证同一时刻只有一个副本真正执行。
|
||||
* `usingDbTime()` 是关键:用数据库时间而不是各 Pod 的本地时间判断锁,避免时钟漂移导致锁形同虚设。
|
||||
*/
|
||||
@Configuration
|
||||
@EnableScheduling
|
||||
@EnableSchedulerLock(defaultLockAtMostFor = "PT10M")
|
||||
class SchedulingConfig {
|
||||
|
||||
@Bean
|
||||
fun lockProvider(dataSource: DataSource): LockProvider = JdbcTemplateLockProvider(
|
||||
JdbcTemplateLockProvider.Configuration.builder()
|
||||
.withJdbcTemplate(JdbcTemplate(dataSource))
|
||||
.withTableName("platform.shedlock")
|
||||
.usingDbTime()
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
-- platform 公共库:幂等记录与 ShedLock 锁表(见 12-concurrency-and-scheduling.md)
|
||||
-- 这两张表不属于任何业务域,所以单独放一个 database。
|
||||
|
||||
create table idempotency_record
|
||||
(
|
||||
id bigint not null auto_increment,
|
||||
idem_key varchar(64) not null comment '客户端传入的幂等键',
|
||||
user_id bigint not null,
|
||||
response text not null comment '首次执行的响应快照,重放时原样返回',
|
||||
created_at datetime(6) not null,
|
||||
primary key (id),
|
||||
unique key uk_idem_key_user (idem_key, user_id),
|
||||
key idx_idempotency_record_created_at (created_at)
|
||||
) engine = InnoDB
|
||||
default charset = utf8mb4
|
||||
collate = utf8mb4_0900_ai_ci comment '幂等记录';
|
||||
|
||||
create table shedlock
|
||||
(
|
||||
name varchar(64) not null,
|
||||
lock_until datetime(6) not null,
|
||||
locked_at datetime(6) not null,
|
||||
locked_by varchar(255) not null,
|
||||
primary key (name)
|
||||
) engine = InnoDB
|
||||
default charset = utf8mb4
|
||||
collate = utf8mb4_0900_ai_ci comment '定时任务分布式锁';
|
||||
@@ -0,0 +1,9 @@
|
||||
dependencies {
|
||||
// 01-project-structure.md 认可的唯一 platform-* 之间的依赖:platform-security -> platform-web
|
||||
api project(':platform:platform-web')
|
||||
|
||||
api 'org.springframework.boot:spring-boot-starter-security'
|
||||
api 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.continental.retailapp.platform.security
|
||||
|
||||
import org.springframework.security.oauth2.jose.jws.MacAlgorithm
|
||||
import org.springframework.security.oauth2.jwt.JwsHeader
|
||||
import org.springframework.security.oauth2.jwt.JwtClaimsSet
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoder
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoderParameters
|
||||
import org.springframework.stereotype.Component
|
||||
import java.time.Clock
|
||||
import java.time.temporal.ChronoUnit
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* 签发 access token,见 04-security-auth.md。
|
||||
* 门店维度写进 `storeId` claim——**请求头 X-Store-Id 不构成身份,只有签名过的 claim 算数**。
|
||||
*/
|
||||
@Component
|
||||
class AccessTokenIssuer(
|
||||
private val jwtEncoder: JwtEncoder,
|
||||
private val props: JwtProperties,
|
||||
private val clock: Clock,
|
||||
) {
|
||||
fun issue(userId: Long, storeId: Long, roles: List<String>): String {
|
||||
val now = clock.instant()
|
||||
val claims = JwtClaimsSet.builder()
|
||||
.subject(userId.toString())
|
||||
.claim("storeId", storeId)
|
||||
.claim("roles", roles)
|
||||
.id(UUID.randomUUID().toString())
|
||||
.issuedAt(now)
|
||||
.expiresAt(now.plus(props.accessTokenTtlMinutes, ChronoUnit.MINUTES))
|
||||
.build()
|
||||
val header = JwsHeader.with(MacAlgorithm.HS256).keyId(props.activeKeyId).build()
|
||||
return jwtEncoder.encode(JwtEncoderParameters.from(header, claims)).tokenValue
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package com.continental.retailapp.platform.security
|
||||
|
||||
import com.continental.retailapp.platform.web.ApiResult
|
||||
import com.continental.retailapp.platform.web.ErrorCode
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import jakarta.servlet.http.HttpServletResponse
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.security.access.AccessDeniedException
|
||||
import org.springframework.security.core.AuthenticationException
|
||||
import org.springframework.security.web.AuthenticationEntryPoint
|
||||
import org.springframework.security.web.access.AccessDeniedHandler
|
||||
import org.springframework.stereotype.Component
|
||||
import tools.jackson.databind.ObjectMapper
|
||||
|
||||
/**
|
||||
* 认证失败(401)也要返回统一的 [ApiResult] 结构,见 04-security-auth.md。
|
||||
* 不接管的话 Spring Security 会直接吐一个空 body 的 401,客户端的统一解析会崩。
|
||||
*/
|
||||
@Component
|
||||
class ApiResultAuthenticationEntryPoint(private val objectMapper: ObjectMapper) : AuthenticationEntryPoint {
|
||||
override fun commence(
|
||||
req: HttpServletRequest,
|
||||
resp: HttpServletResponse,
|
||||
ex: AuthenticationException,
|
||||
) {
|
||||
resp.status = HttpStatus.UNAUTHORIZED.value()
|
||||
resp.contentType = MediaType.APPLICATION_JSON_VALUE
|
||||
resp.characterEncoding = Charsets.UTF_8.name()
|
||||
objectMapper.writeValue(
|
||||
resp.outputStream,
|
||||
ApiResult.error(ErrorCode.UNAUTHORIZED, "登录状态已失效,请重新登录"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 鉴权失败(403)同上。 */
|
||||
@Component
|
||||
class ApiResultAccessDeniedHandler(private val objectMapper: ObjectMapper) : AccessDeniedHandler {
|
||||
override fun handle(
|
||||
req: HttpServletRequest,
|
||||
resp: HttpServletResponse,
|
||||
ex: AccessDeniedException,
|
||||
) {
|
||||
resp.status = HttpStatus.FORBIDDEN.value()
|
||||
resp.contentType = MediaType.APPLICATION_JSON_VALUE
|
||||
resp.characterEncoding = Charsets.UTF_8.name()
|
||||
objectMapper.writeValue(
|
||||
resp.outputStream,
|
||||
ApiResult.error(ErrorCode.FORBIDDEN, "没有权限执行该操作"),
|
||||
)
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package com.continental.retailapp.platform.security
|
||||
|
||||
import com.nimbusds.jose.JWSAlgorithm
|
||||
import com.nimbusds.jose.jwk.JWKSet
|
||||
import com.nimbusds.jose.jwk.OctetSequenceKey
|
||||
import com.nimbusds.jose.jwk.source.ImmutableJWKSet
|
||||
import com.nimbusds.jose.proc.JWSVerificationKeySelector
|
||||
import com.nimbusds.jose.proc.SecurityContext
|
||||
import com.nimbusds.jwt.proc.DefaultJWTProcessor
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoder
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder
|
||||
import java.util.Base64
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
/**
|
||||
* JWT 编解码器,见 04-security-auth.md。用 Spring Security 自带的 Nimbus 实现而不是 jjwt——
|
||||
* jjwt 的 Jackson 集成还在 Jackson 2,与 Boot 4 的 Jackson 3 会打架。
|
||||
*
|
||||
* 对文档的有意偏离 #2:文档正文要求"轮换期间新旧 token 都能验过",但示例里的
|
||||
* `NimbusJwtDecoder.withSecretKey(secretKey(activeKeyId))` 只装了一把密钥,做不到这件事。
|
||||
* 这里改成把 `keys` 里所有密钥装进 JWKSet,由 `JWSVerificationKeySelector` 按 header 的 kid 选,
|
||||
* 这样才对得上文档写的轮换三步走(先加新密钥 → 再切 activeKeyId → 最后删旧密钥)。
|
||||
*/
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(JwtProperties::class)
|
||||
class JwtEncoderConfig(private val props: JwtProperties) {
|
||||
|
||||
private fun secretKey(keyId: String): SecretKey =
|
||||
SecretKeySpec(Base64.getDecoder().decode(props.keys.getValue(keyId)), "HmacSHA256")
|
||||
|
||||
private fun jwkSource(): ImmutableJWKSet<SecurityContext> {
|
||||
val jwkSet = JWKSet(
|
||||
props.keys.keys.map { kid ->
|
||||
OctetSequenceKey.Builder(secretKey(kid))
|
||||
.keyID(kid)
|
||||
.algorithm(JWSAlgorithm.HS256)
|
||||
.build()
|
||||
},
|
||||
)
|
||||
return ImmutableJWKSet(jwkSet)
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun jwtEncoder(): JwtEncoder = NimbusJwtEncoder(jwkSource())
|
||||
|
||||
@Bean
|
||||
fun jwtDecoder(): JwtDecoder {
|
||||
val processor = DefaultJWTProcessor<SecurityContext>().apply {
|
||||
jwsKeySelector = JWSVerificationKeySelector(JWSAlgorithm.HS256, jwkSource())
|
||||
// 只签发/校验自家 token,不需要 Nimbus 默认的 claims 校验器之外的东西
|
||||
}
|
||||
return NimbusJwtDecoder(processor)
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.continental.retailapp.platform.security
|
||||
|
||||
import jakarta.annotation.PostConstruct
|
||||
import jakarta.validation.constraints.Min
|
||||
import jakarta.validation.constraints.NotBlank
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties
|
||||
import org.springframework.validation.annotation.Validated
|
||||
import java.util.Base64
|
||||
|
||||
/**
|
||||
* JWT 配置,见 04-security-auth.md。
|
||||
*
|
||||
* `keys` 是 kid -> base64(密钥) 的映射,支持多把密钥同时在用(轮换期新旧 token 都要能验过)。
|
||||
* 真实密钥来自 K8s Secret(见 07-config-governance.md),仓库和镜像里只有 local profile 的假密钥。
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "security.jwt")
|
||||
@Validated
|
||||
data class JwtProperties(
|
||||
@field:NotBlank val activeKeyId: String,
|
||||
val keys: Map<String, String> = emptyMap(),
|
||||
@field:Min(5) val accessTokenTtlMinutes: Long = 30,
|
||||
@field:Min(1) val refreshTokenTtlDays: Long = 30,
|
||||
) {
|
||||
/** 启动即失败:弱密钥或缺 activeKeyId 不允许把服务拉起来。 */
|
||||
@PostConstruct
|
||||
fun validate() {
|
||||
val key = keys[activeKeyId] ?: error("security.jwt.keys 里没有 activeKeyId=$activeKeyId 对应的密钥")
|
||||
require(Base64.getDecoder().decode(key).size >= 32) {
|
||||
"HS256 密钥长度必须 ≥ 32 字节,当前配置不满足"
|
||||
}
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package com.continental.retailapp.platform.security
|
||||
|
||||
import org.springframework.beans.factory.ObjectFactory
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.core.env.Environment
|
||||
import org.springframework.core.env.Profiles
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
|
||||
import org.springframework.security.config.http.SessionCreationPolicy
|
||||
import org.springframework.security.crypto.factory.PasswordEncoderFactories
|
||||
import org.springframework.security.crypto.password.PasswordEncoder
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter
|
||||
import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter
|
||||
import org.springframework.security.web.SecurityFilterChain
|
||||
|
||||
/**
|
||||
* 安全配置,见 04-security-auth.md。
|
||||
*
|
||||
* 无状态资源服务器:没有 session、没有 CSRF token,认证完全靠 Bearer JWT。
|
||||
* swagger 只在非 prod 放开——生产环境暴露接口文档等于把攻击面清单送出去。
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@EnableMethodSecurity
|
||||
class SecurityConfig(
|
||||
private val storeContextHolder: ObjectFactory<StoreContextHolder>,
|
||||
private val entryPoint: ApiResultAuthenticationEntryPoint,
|
||||
private val accessDeniedHandler: ApiResultAccessDeniedHandler,
|
||||
private val environment: Environment,
|
||||
) {
|
||||
|
||||
@Bean
|
||||
fun filterChain(http: HttpSecurity): SecurityFilterChain {
|
||||
val isProd = environment.acceptsProfiles(Profiles.of("prod"))
|
||||
|
||||
http
|
||||
.csrf { it.disable() }
|
||||
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
|
||||
.authorizeHttpRequests { auth ->
|
||||
auth.requestMatchers("/actuator/health/**").permitAll()
|
||||
auth.requestMatchers("/api/v1/auth/login", "/api/v1/auth/refresh").permitAll()
|
||||
if (!isProd) {
|
||||
auth.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
|
||||
}
|
||||
auth.anyRequest().authenticated()
|
||||
}
|
||||
.oauth2ResourceServer { oauth2 ->
|
||||
oauth2.jwt { jwt -> jwt.jwtAuthenticationConverter(rolesConverter()) }
|
||||
oauth2.authenticationEntryPoint(entryPoint)
|
||||
}
|
||||
.exceptionHandling {
|
||||
it.authenticationEntryPoint(entryPoint)
|
||||
it.accessDeniedHandler(accessDeniedHandler)
|
||||
}
|
||||
.addFilterAfter(
|
||||
StoreContextFilter(storeContextHolder),
|
||||
BearerTokenAuthenticationFilter::class.java,
|
||||
)
|
||||
|
||||
return http.build()
|
||||
}
|
||||
|
||||
/** BCrypt strength 12,见 04-security-auth.md;用 Delegating 是为了将来换算法时老哈希还能验。 */
|
||||
@Bean
|
||||
fun passwordEncoder(): PasswordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder()
|
||||
|
||||
private fun rolesConverter(): JwtAuthenticationConverter {
|
||||
val authorities = JwtGrantedAuthoritiesConverter().apply {
|
||||
setAuthoritiesClaimName("roles")
|
||||
setAuthorityPrefix("ROLE_")
|
||||
}
|
||||
return JwtAuthenticationConverter().apply {
|
||||
setJwtGrantedAuthoritiesConverter(authorities)
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package com.continental.retailapp.platform.security
|
||||
|
||||
import jakarta.servlet.FilterChain
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import jakarta.servlet.http.HttpServletResponse
|
||||
import org.springframework.beans.factory.ObjectFactory
|
||||
import org.springframework.security.core.context.SecurityContextHolder
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken
|
||||
import org.springframework.web.filter.OncePerRequestFilter
|
||||
|
||||
/**
|
||||
* 把 JWT 里的身份/门店信息搬进 [StoreContextHolder],见 04-security-auth.md。
|
||||
* 注册位置在 `BearerTokenAuthenticationFilter` 之后(见 SecurityConfig),保证此时认证已经完成。
|
||||
*/
|
||||
class StoreContextFilter(
|
||||
private val storeContextHolder: ObjectFactory<StoreContextHolder>,
|
||||
) : OncePerRequestFilter() {
|
||||
|
||||
override fun doFilterInternal(
|
||||
request: HttpServletRequest,
|
||||
response: HttpServletResponse,
|
||||
chain: FilterChain,
|
||||
) {
|
||||
val jwt = (SecurityContextHolder.getContext().authentication as? JwtAuthenticationToken)?.token
|
||||
if (jwt != null) {
|
||||
val ctx = storeContextHolder.`object`
|
||||
ctx.userId = jwt.subject?.toLongOrNull()
|
||||
ctx.storeId = jwt.getClaim<Number>("storeId")?.toLong()
|
||||
ctx.roles = jwt.getClaimAsStringList("roles") ?: emptyList()
|
||||
// X-Store-Id 只用于对账排查,**永远以 token 里的 storeId 为准**
|
||||
request.getHeader("X-Store-Id")?.toLongOrNull()?.let { headerStoreId ->
|
||||
if (headerStoreId != ctx.storeId) {
|
||||
logger.warn("X-Store-Id($headerStoreId) 与 token storeId(${ctx.storeId}) 不一致,以 token 为准")
|
||||
}
|
||||
}
|
||||
}
|
||||
chain.doFilter(request, response)
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.continental.retailapp.platform.security
|
||||
|
||||
import com.continental.retailapp.platform.web.AuditContext
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.context.annotation.RequestScope
|
||||
|
||||
/**
|
||||
* 当前请求的门店上下文,见 04-security-auth.md。
|
||||
*
|
||||
* 用 `@RequestScope` bean 而不是 ThreadLocal:容器负责在请求结束时清理,
|
||||
* 不会出现忘记 remove 导致的线程池串味。并行聚合的子线程靠
|
||||
* `ContextPropagatingTaskDecorator` 传播(见 11-cross-domain-collaboration.md)。
|
||||
*
|
||||
* 实现 [AuditContext] 是为了让 platform-persistence / platform-observability 不用反向依赖本模块。
|
||||
*/
|
||||
@Component
|
||||
@RequestScope
|
||||
class StoreContextHolder : AuditContext {
|
||||
override var userId: Long? = null
|
||||
override var storeId: Long? = null
|
||||
var roles: List<String> = emptyList()
|
||||
|
||||
fun currentUserId(): Long = userId ?: throw IllegalStateException("未认证请求不应到达这里")
|
||||
|
||||
fun currentStoreId(): Long = storeId ?: throw IllegalStateException("未绑定门店的请求不应到达这里")
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package com.continental.retailapp.platform.security
|
||||
|
||||
import com.continental.retailapp.platform.security.fixtures.TEST_JWT_SECRET_RAW
|
||||
import com.continental.retailapp.platform.security.fixtures.TEST_JWT_SECRET_RAW_V2
|
||||
import com.continental.retailapp.platform.security.fixtures.aJwtProperties
|
||||
import com.continental.retailapp.platform.security.fixtures.base64Of
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.security.oauth2.jwt.JwtException
|
||||
import java.time.Clock
|
||||
import java.time.ZoneOffset
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
/**
|
||||
* 签发 → 解码 round-trip:claims 和 kid 都要对得上(04-security-auth.md)。
|
||||
*
|
||||
* 直接 new 出 [JwtEncoderConfig],不起 Spring 容器——这一层没有任何需要容器才能装配的东西。
|
||||
*/
|
||||
class AccessTokenIssuerTest {
|
||||
|
||||
// 签发端的时钟可以固定,**解码端的不行**:`exp` 校验发生在 Nimbus 的
|
||||
// DefaultJWTProcessor 里,用的是真实系统时间,注不进去。
|
||||
// 所以基准点取真实当下(截断到秒,因为 JWT 的 iat/exp 只有秒精度),
|
||||
// 再由各测试用例围绕它做加减——单个用例内部仍然是确定的。
|
||||
private val now = Clock.systemUTC().instant().truncatedTo(ChronoUnit.SECONDS)
|
||||
private val clock = Clock.fixed(now, ZoneOffset.UTC)
|
||||
|
||||
private fun issuerFor(props: JwtProperties): Pair<AccessTokenIssuer, JwtEncoderConfig> {
|
||||
val config = JwtEncoderConfig(props)
|
||||
return AccessTokenIssuer(config.jwtEncoder(), props, clock) to config
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `签发的 token 能被解回原样的 claims`() {
|
||||
val props = aJwtProperties()
|
||||
val (issuer, config) = issuerFor(props)
|
||||
|
||||
val token = issuer.issue(userId = 42L, storeId = 100L, roles = listOf("STORE_MANAGER", "STAFF"))
|
||||
val decoded = config.jwtDecoder().decode(token)
|
||||
|
||||
assertEquals("42", decoded.subject)
|
||||
assertEquals(100L, decoded.getClaim<Long>("storeId"))
|
||||
assertEquals(listOf("STORE_MANAGER", "STAFF"), decoded.getClaimAsStringList("roles"))
|
||||
assertEquals(now, decoded.issuedAt)
|
||||
assertEquals(now.plusSeconds(30 * 60), decoded.expiresAt)
|
||||
assertNotNull(decoded.id) // jti,审计日志里用来串联同一次会话
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `token 头里带 activeKeyId 作为 kid`() {
|
||||
val props = aJwtProperties(
|
||||
activeKeyId = "v2",
|
||||
keys = mapOf("v1" to base64Of(TEST_JWT_SECRET_RAW), "v2" to base64Of(TEST_JWT_SECRET_RAW_V2)),
|
||||
)
|
||||
val (issuer, config) = issuerFor(props)
|
||||
|
||||
val decoded = config.jwtDecoder().decode(issuer.issue(1L, 100L, listOf("STAFF")))
|
||||
|
||||
assertEquals("v2", decoded.headers["kid"])
|
||||
assertEquals("HS256", decoded.headers["alg"].toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `轮换期间旧 kid 签发的 token 仍然能验过`() {
|
||||
// 轮换第一步:只加密钥,activeKeyId 还指向 v1,此时签出来的是 v1 的 token
|
||||
val beforeSwitch = aJwtProperties(
|
||||
activeKeyId = "v1",
|
||||
keys = mapOf("v1" to base64Of(TEST_JWT_SECRET_RAW), "v2" to base64Of(TEST_JWT_SECRET_RAW_V2)),
|
||||
)
|
||||
val (oldIssuer, _) = issuerFor(beforeSwitch)
|
||||
val oldToken = oldIssuer.issue(1L, 100L, listOf("STAFF"))
|
||||
|
||||
// 轮换第二步:activeKeyId 切到 v2,两把密钥都还在
|
||||
val afterSwitch = aJwtProperties(
|
||||
activeKeyId = "v2",
|
||||
keys = mapOf("v1" to base64Of(TEST_JWT_SECRET_RAW), "v2" to base64Of(TEST_JWT_SECRET_RAW_V2)),
|
||||
)
|
||||
val decoder = JwtEncoderConfig(afterSwitch).jwtDecoder()
|
||||
|
||||
// 这条就是对文档偏离 #2 的理由:单密钥的 withSecretKey 解码器在这里会直接失败,
|
||||
// 已经发出去的 access token 会全体作废
|
||||
assertEquals("1", decoder.decode(oldToken).subject)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `轮换第三步移除旧密钥后,旧 token 不再被接受`() {
|
||||
val beforeSwitch = aJwtProperties(
|
||||
activeKeyId = "v1",
|
||||
keys = mapOf("v1" to base64Of(TEST_JWT_SECRET_RAW), "v2" to base64Of(TEST_JWT_SECRET_RAW_V2)),
|
||||
)
|
||||
val (oldIssuer, _) = issuerFor(beforeSwitch)
|
||||
val oldToken = oldIssuer.issue(1L, 100L, listOf("STAFF"))
|
||||
|
||||
val onlyV2 = aJwtProperties(activeKeyId = "v2", keys = mapOf("v2" to base64Of(TEST_JWT_SECRET_RAW_V2)))
|
||||
val decoder = JwtEncoderConfig(onlyV2).jwtDecoder()
|
||||
|
||||
assertThrows(JwtException::class.java) { decoder.decode(oldToken) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `被篡改的 token 验签失败`() {
|
||||
val props = aJwtProperties()
|
||||
val (issuer, config) = issuerFor(props)
|
||||
val token = issuer.issue(userId = 42L, storeId = 100L, roles = listOf("STAFF"))
|
||||
|
||||
// 只动 payload 不动签名:storeId 造假是这套鉴权最直接的攻击面
|
||||
val parts = token.split(".")
|
||||
val tampered = "${parts[0]}.${parts[1].dropLast(2)}XX.${parts[2]}"
|
||||
|
||||
assertThrows(JwtException::class.java) { config.jwtDecoder().decode(tampered) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `过期的 token 解码失败`() {
|
||||
val props = aJwtProperties(accessTokenTtlMinutes = 5)
|
||||
val expiredIssuer = AccessTokenIssuer(
|
||||
JwtEncoderConfig(props).jwtEncoder(),
|
||||
props,
|
||||
Clock.fixed(now.minusSeconds(3600), ZoneOffset.UTC),
|
||||
)
|
||||
val token = expiredIssuer.issue(1L, 100L, listOf("STAFF"))
|
||||
|
||||
assertThrows(JwtException::class.java) { JwtEncoderConfig(props).jwtDecoder().decode(token) }
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package com.continental.retailapp.platform.security
|
||||
|
||||
import com.continental.retailapp.platform.security.fixtures.TEST_JWT_SECRET_RAW
|
||||
import com.continental.retailapp.platform.security.fixtures.base64Of
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner
|
||||
import org.springframework.boot.validation.autoconfigure.ValidationAutoConfiguration
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoder
|
||||
|
||||
/**
|
||||
* 密钥强度是**启动期**的事,不是运行期的事:配错了就不该让服务活着接流量。
|
||||
* 所以这里用 [ApplicationContextRunner] 起一个真的上下文,验证 `@PostConstruct` 确实把它拦下来了,
|
||||
* 而不是直接调 `validate()` ——后者只能证明方法本身好使,证明不了它会被触发。
|
||||
*/
|
||||
class JwtPropertiesTest {
|
||||
|
||||
private val runner = ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(ValidationAutoConfiguration::class.java))
|
||||
.withUserConfiguration(JwtEncoderConfig::class.java)
|
||||
|
||||
@Test
|
||||
fun `密钥合规时正常启动并装配好编解码器`() {
|
||||
runner
|
||||
.withPropertyValues(
|
||||
"security.jwt.active-key-id=v1",
|
||||
"security.jwt.keys.v1=${base64Of(TEST_JWT_SECRET_RAW)}",
|
||||
)
|
||||
.run { context ->
|
||||
assertNull(context.startupFailure) { "期望启动成功,实际:${context.startupFailure}" }
|
||||
assertNotNull(context.getBean(JwtEncoder::class.java))
|
||||
assertNotNull(context.getBean(JwtDecoder::class.java))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `密钥不足 32 字节时启动失败`() {
|
||||
runner
|
||||
.withPropertyValues(
|
||||
"security.jwt.active-key-id=v1",
|
||||
"security.jwt.keys.v1=${base64Of("too-short-16byte")}",
|
||||
)
|
||||
.run { context ->
|
||||
val failure = context.startupFailure
|
||||
assertNotNull(failure) { "弱密钥必须让启动失败,不能带病上线" }
|
||||
assertTrue(rootCauseMessageOf(failure!!).contains("32")) {
|
||||
"错误信息里要写清楚是长度问题:${rootCauseMessageOf(failure)}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `activeKeyId 在 keys 里找不到对应密钥时启动失败`() {
|
||||
runner
|
||||
.withPropertyValues(
|
||||
"security.jwt.active-key-id=v9",
|
||||
"security.jwt.keys.v1=${base64Of(TEST_JWT_SECRET_RAW)}",
|
||||
)
|
||||
.run { context ->
|
||||
val failure = context.startupFailure
|
||||
assertNotNull(failure) { "activeKeyId 指向不存在的密钥必须启动失败" }
|
||||
assertTrue(rootCauseMessageOf(failure!!).contains("activeKeyId")) {
|
||||
rootCauseMessageOf(failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `activeKeyId 为空时校验不通过`() {
|
||||
runner
|
||||
.withPropertyValues("security.jwt.active-key-id=", "security.jwt.keys.v1=${base64Of(TEST_JWT_SECRET_RAW)}")
|
||||
.run { context ->
|
||||
assertNotNull(context.startupFailure) { "空 activeKeyId 必须启动失败" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun rootCauseMessageOf(throwable: Throwable): String {
|
||||
var current = throwable
|
||||
while (current.cause != null && current.cause !== current) {
|
||||
current = current.cause!!
|
||||
}
|
||||
return current.message.orEmpty()
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.continental.retailapp.platform.security.fixtures
|
||||
|
||||
import com.continental.retailapp.platform.security.JwtProperties
|
||||
import java.util.Base64
|
||||
|
||||
/** 32 字节,刚好过 [JwtProperties.validate] 的下限。 */
|
||||
const val TEST_JWT_SECRET_RAW = "conti-test-secret-key-32bytes!!!"
|
||||
|
||||
/** 第二把密钥,用来验证轮换期间新旧 kid 都能验过。 */
|
||||
const val TEST_JWT_SECRET_RAW_V2 = "conti-test-secret-key-32bytes#v2"
|
||||
|
||||
fun base64Of(raw: String): String = Base64.getEncoder().encodeToString(raw.toByteArray())
|
||||
|
||||
fun aJwtProperties(
|
||||
activeKeyId: String = "v1",
|
||||
keys: Map<String, String> = mapOf("v1" to base64Of(TEST_JWT_SECRET_RAW)),
|
||||
accessTokenTtlMinutes: Long = 30,
|
||||
refreshTokenTtlDays: Long = 30,
|
||||
) = JwtProperties(
|
||||
activeKeyId = activeKeyId,
|
||||
keys = keys,
|
||||
accessTokenTtlMinutes = accessTokenTtlMinutes,
|
||||
refreshTokenTtlDays = refreshTokenTtlDays,
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
dependencies {
|
||||
api 'org.springframework.boot:spring-boot-starter-web'
|
||||
api 'org.springframework.boot:spring-boot-starter-validation'
|
||||
// 幂等记录表(见 12-concurrency-and-scheduling.md)落在 platform 库里,所以这里需要 JPA
|
||||
api 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||
// 只要注解,不要 springdoc 的 UI —— Controller 上的 @Operation/@Tag 各 domain 都要用,
|
||||
// 收口在这里比每个 domain 各写一遍好。UI 只在 bootstrap 里装(见 06-api-design.md)。
|
||||
api 'io.swagger.core.v3:swagger-annotations-jakarta:2.2.30'
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.continental.retailapp.platform.web
|
||||
|
||||
/**
|
||||
* 统一响应结构,见 06-api-design.md。
|
||||
* 所有 Controller 端点方法的返回类型都必须是它(ArchUnit 有规则盯着,见 10-testing.md)。
|
||||
*/
|
||||
data class ApiResult<T>(
|
||||
val code: Int,
|
||||
val message: String,
|
||||
val data: T?,
|
||||
val traceId: String,
|
||||
) {
|
||||
companion object {
|
||||
fun <T> ok(data: T): ApiResult<T> = ApiResult(ErrorCode.OK, "success", data, currentTraceId())
|
||||
|
||||
fun ok(): ApiResult<Unit> = ApiResult(ErrorCode.OK, "success", Unit, currentTraceId())
|
||||
|
||||
fun error(code: Int, message: String): ApiResult<Nothing> =
|
||||
ApiResult(code, message, null, currentTraceId())
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.continental.retailapp.platform.web
|
||||
|
||||
/**
|
||||
* 审计上下文的最小契约。
|
||||
*
|
||||
* 对文档的有意偏离 #1:03-persistence.md 的 `JpaAuditingConfig` 和 08-observability.md 的
|
||||
* `AuditLogAspect` 都直接注入了 platform-security 的 `StoreContextHolder`,那会造成
|
||||
* `platform-persistence -> platform-security`、`platform-observability -> platform-security`
|
||||
* 两条依赖,与 01-project-structure.md「platform-* 之间尽量不互相依赖」冲突。
|
||||
*
|
||||
* 折中:把"当前是谁、在哪个门店"这一点点信息抽成本接口放在 platform-web(公共基座,
|
||||
* `platform-security -> platform-web` 本来就是文档认可的例外),`StoreContextHolder` 实现它,
|
||||
* persistence / observability 只注入 `ObjectProvider<AuditContext>`。
|
||||
*
|
||||
* 收敛后的规则:**platform-web 是公共基座,其他 platform-* 只能依赖它,彼此之间不再有依赖。**
|
||||
*/
|
||||
interface AuditContext {
|
||||
val userId: Long?
|
||||
val storeId: Long?
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package com.continental.retailapp.platform.web
|
||||
|
||||
import org.springframework.http.HttpStatus
|
||||
|
||||
/**
|
||||
* 业务异常基类,见 06-api-design.md。
|
||||
* 子类只负责携带错误码和给用户看的文案,HTTP 状态码由 [GlobalExceptionHandler] 统一落地。
|
||||
*/
|
||||
open class BusinessException(
|
||||
val code: Int,
|
||||
override val message: String,
|
||||
val httpStatus: HttpStatus = HttpStatus.BAD_REQUEST,
|
||||
) : RuntimeException(message)
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.continental.retailapp.platform.web
|
||||
|
||||
/**
|
||||
* 业务错误码,见 06-api-design.md。
|
||||
* 分段约定:1xxxx 通用、11xxx 门店/权限、3xxxx 外部系统。
|
||||
* 各 domain 自己的错误码段在骨架阶段尚未分配(交付说明里已列为留白项)。
|
||||
*/
|
||||
object ErrorCode {
|
||||
const val OK = 0
|
||||
|
||||
const val INVALID_PARAM = 10001
|
||||
const val UNAUTHORIZED = 10401
|
||||
const val FORBIDDEN = 10403
|
||||
const val NOT_FOUND = 10404
|
||||
const val CONFLICT = 10409
|
||||
const val INTERNAL_ERROR = 10500
|
||||
|
||||
const val STORE_NOT_ACCESSIBLE = 11001
|
||||
const val NO_STORE_PERMISSION = 11002
|
||||
|
||||
const val F6_UNAVAILABLE = 30001
|
||||
const val F6_BUSINESS_ERROR = 30002
|
||||
const val MINI_UNAVAILABLE = 31001
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package com.continental.retailapp.platform.web
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.orm.ObjectOptimisticLockingFailureException
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice
|
||||
|
||||
/**
|
||||
* 全局异常处理,见 06-api-design.md。
|
||||
* 所有异常最终都以 [ApiResult] 的结构返回,客户端只需要解析一种响应体。
|
||||
*/
|
||||
@RestControllerAdvice
|
||||
class GlobalExceptionHandler {
|
||||
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException::class)
|
||||
fun handleValidation(ex: MethodArgumentNotValidException): ResponseEntity<ApiResult<Nothing>> {
|
||||
val message = ex.bindingResult.fieldErrors.joinToString("; ") { "${it.field}: ${it.defaultMessage}" }
|
||||
return ResponseEntity.badRequest().body(ApiResult.error(ErrorCode.INVALID_PARAM, message))
|
||||
}
|
||||
|
||||
@ExceptionHandler(BusinessException::class)
|
||||
fun handleBusiness(ex: BusinessException): ResponseEntity<ApiResult<Nothing>> =
|
||||
ResponseEntity.status(ex.httpStatus).body(ApiResult.error(ex.code, ex.message))
|
||||
|
||||
@ExceptionHandler(ObjectOptimisticLockingFailureException::class)
|
||||
fun handleConcurrentUpdate(ex: ObjectOptimisticLockingFailureException): ResponseEntity<ApiResult<Nothing>> {
|
||||
log.warn("乐观锁冲突", ex)
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(ApiResult.error(ErrorCode.CONFLICT, "数据已被他人修改,请刷新后重试"))
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception::class)
|
||||
fun handleUnexpected(ex: Exception): ResponseEntity<ApiResult<Nothing>> {
|
||||
// 只记异常本身,绝不打印请求体(可能含密码/令牌,见 08-observability.md 的脱敏要求)
|
||||
log.error("未处理异常", ex)
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(ApiResult.error(ErrorCode.INTERNAL_ERROR, "系统繁忙,请稍后重试"))
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.continental.retailapp.platform.web
|
||||
|
||||
import org.slf4j.MDC
|
||||
|
||||
/**
|
||||
* 从 MDC 里取当前 traceId,见 08-observability.md。
|
||||
* traceId 由 Micrometer Tracing 写进 MDC;请求头 `X-Trace-Id` 的桥接在 platform-observability 里做。
|
||||
*/
|
||||
fun currentTraceId(): String = MDC.get("traceId") ?: "unknown"
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.continental.retailapp.platform.web.idempotency
|
||||
|
||||
import com.continental.retailapp.platform.web.BusinessException
|
||||
import com.continental.retailapp.platform.web.ErrorCode
|
||||
import org.springframework.dao.DataIntegrityViolationException
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import tools.jackson.databind.ObjectMapper
|
||||
import java.time.Clock
|
||||
|
||||
/**
|
||||
* 幂等守卫,见 12-concurrency-and-scheduling.md。
|
||||
*
|
||||
* 真正的兜底是 `uk_idem_key_user` 这个唯一索引——"先查再写"之间存在竞态窗口,
|
||||
* 两个并发请求可能都查不到记录然后都执行 block,所以必须捕获唯一键冲突。
|
||||
*
|
||||
* 与文档示例的一点补充:反序列化需要目标类型,所以签名上多了一个 `responseType`。
|
||||
*/
|
||||
@Service
|
||||
class IdempotencyGuard(
|
||||
private val recordRepository: IdempotencyRecordRepository,
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val clock: Clock,
|
||||
) {
|
||||
|
||||
@Transactional
|
||||
fun <T : Any> execute(key: String, userId: Long, responseType: Class<T>, block: () -> T): T {
|
||||
recordRepository.findByIdemKeyAndUserId(key, userId)?.let {
|
||||
return objectMapper.readValue(it.response, responseType)
|
||||
}
|
||||
val result = block()
|
||||
try {
|
||||
recordRepository.saveAndFlush(
|
||||
IdempotencyRecordEntity(
|
||||
idemKey = key,
|
||||
userId = userId,
|
||||
response = objectMapper.writeValueAsString(result),
|
||||
createdAt = clock.instant(),
|
||||
),
|
||||
)
|
||||
} catch (ex: DataIntegrityViolationException) {
|
||||
// 并发窗口内另一个请求先落库了:这次请求的副作用可能已经重复执行,
|
||||
// 明确报冲突让客户端重试,比静默返回一个可能不一致的结果安全
|
||||
throw BusinessException(ErrorCode.CONFLICT, "请求正在处理中,请稍后重试", HttpStatus.CONFLICT)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.continental.retailapp.platform.web.idempotency
|
||||
|
||||
import jakarta.persistence.Column
|
||||
import jakarta.persistence.Entity
|
||||
import jakarta.persistence.GeneratedValue
|
||||
import jakarta.persistence.GenerationType
|
||||
import jakarta.persistence.Id
|
||||
import jakarta.persistence.Table
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* 幂等记录,见 12-concurrency-and-scheduling.md。
|
||||
* 表落在 platform 共用库里,DDL 见 platform-persistence 的 `db/migration/platform/V1__init.sql`。
|
||||
*
|
||||
* 注意它不继承 platform-persistence 的 `BaseEntity`:platform-web 是公共基座,不反过来依赖
|
||||
* platform-persistence(见 AuditContext 里对偏离 #1 的说明),所以这里自带 createdAt。
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "idempotency_record", schema = "platform")
|
||||
class IdempotencyRecordEntity(
|
||||
@Column(name = "idem_key", nullable = false, updatable = false, length = 64)
|
||||
var idemKey: String,
|
||||
|
||||
@Column(name = "user_id", nullable = false, updatable = false)
|
||||
var userId: Long,
|
||||
|
||||
@Column(name = "response", nullable = false, columnDefinition = "text")
|
||||
var response: String,
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
var createdAt: Instant = Instant.EPOCH,
|
||||
) {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
var id: Long? = null
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.continental.retailapp.platform.web.idempotency
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface IdempotencyRecordRepository : JpaRepository<IdempotencyRecordEntity, Long> {
|
||||
fun findByIdemKeyAndUserId(idemKey: String, userId: Long): IdempotencyRecordEntity?
|
||||
}
|
||||
Reference in New Issue
Block a user