backend scaffold
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user