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,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>