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)
}
}
@@ -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'
}
@@ -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)
}
}
@@ -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()))
}
}
@@ -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)
}
}
@@ -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'
}
@@ -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
}
@@ -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,
)
@@ -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)),
)
}
}
@@ -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()
}
@@ -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 一个 databaseMySQL 里 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
@@ -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) }
}
}
@@ -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 '定时任务分布式锁';
+9
View File
@@ -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'
}
@@ -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
}
}
@@ -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, "没有权限执行该操作"),
)
}
}
@@ -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)
}
}
@@ -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 字节,当前配置不满足"
}
}
}
@@ -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)
}
}
}
@@ -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)
}
}
@@ -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("未绑定门店的请求不应到达这里")
}
@@ -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-tripclaims 和 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) }
}
}
@@ -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()
}
}
@@ -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,
)
+9
View File
@@ -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'
}
@@ -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())
}
}
@@ -0,0 +1,20 @@
package com.continental.retailapp.platform.web
/**
* 审计上下文的最小契约。
*
* 对文档的有意偏离 #103-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?
}
@@ -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)
@@ -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
}
@@ -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, "系统繁忙,请稍后重试"))
}
}
@@ -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"
@@ -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
}
}
@@ -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
}
@@ -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?
}