feat: Batch 9 - Actuator security, metrics, cache resilience and K8s deployments

- Implement dual-tier security with ActuatorSecurityConfig having higher priority
- Add metrics collection for order creation with tenant tagging
- Enhance cache resilience with graceful error handling
- Create custom Redis ping health indicator
- Add comprehensive K8s deployment manifests with Kustomize overlays
- Update OrderController to align with Batch 8 AppService methods
- Configure management endpoints with separate port in production
- Add deployment scripts for image tag updates

Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
Bruno Gao
2026-02-12 19:22:08 +08:00
co-authored by Qwen-Coder
parent 95adc3b03c
commit a6a016e5ea
19 changed files with 548 additions and 75 deletions
+10
View File
@@ -0,0 +1,10 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: tmpl-api-config
data:
SPRING_PROFILES_ACTIVE: "dev"
MANAGEMENT_PORT: "9090"
REDIS_HOST: "redis"
REDIS_PORT: "6379"
APP_CACHE_ENABLED: "true"
+67
View File
@@ -0,0 +1,67 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: tmpl-api
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: tmpl-api
template:
metadata:
labels:
app.kubernetes.io/name: tmpl-api
annotations:
# 原生 Prometheus 部署使用以下 annotations。
# 如使用 Prometheus Operator,建议改为 ServiceMonitor CRD 并删除这些 annotations。
# 参考: https://prometheus-operator.dev/docs/api-reference/api/#monitoring.coreos.com/v1.ServiceMonitor
prometheus.io/scrape: "true"
prometheus.io/port: "9090"
prometheus.io/path: "/actuator/prometheus"
spec:
containers:
- name: tmpl-api
image: tmpl-api:latest
ports:
- name: http
containerPort: 8080
protocol: TCP
- name: management
containerPort: 9090
protocol: TCP
env:
- name: JAVA_OPTS
value: "-XX:MaxRAMPercentage=75.0 -XX:+UseG1GC"
envFrom:
- configMapRef:
name: tmpl-api-config
- secretRef:
name: tmpl-api-secret
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: management
initialDelaySeconds: 15
periodSeconds: 10
failureThreshold: 3
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: management
initialDelaySeconds: 30
periodSeconds: 15
failureThreshold: 3
startupProbe:
httpGet:
path: /actuator/health/liveness
port: management
initialDelaySeconds: 10
periodSeconds: 5
failureThreshold: 12
resources:
requests:
cpu: 200m
memory: 384Mi
limits:
cpu: "1"
memory: 768Mi
+15
View File
@@ -0,0 +1,15 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
metadata:
name: tmpl-api
resources:
- deployment.yaml
- service.yaml
- configmap.yaml
- secret.yaml
commonLabels:
app.kubernetes.io/name: tmpl-api
app.kubernetes.io/part-of: java-service-template
+11
View File
@@ -0,0 +1,11 @@
apiVersion: v1
kind: Secret
metadata:
name: tmpl-api-secret
type: Opaque
# 占位值 — 实际部署时由 CI/CD 或 sealed-secrets / external-secrets 注入
stringData:
SPRING_DATASOURCE_URL: "jdbc:postgresql://postgres:5432/tmpl"
SPRING_DATASOURCE_USERNAME: "tmpl"
SPRING_DATASOURCE_PASSWORD: "changeme"
REDIS_PASSWORD: ""
+17
View File
@@ -0,0 +1,17 @@
apiVersion: v1
kind: Service
metadata:
name: tmpl-api
spec:
type: ClusterIP
ports:
- name: http
port: 80
targetPort: http
protocol: TCP
- name: management
port: 9090
targetPort: management
protocol: TCP
selector:
app.kubernetes.io/name: tmpl-api
+37
View File
@@ -0,0 +1,37 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
namePrefix: dev-
patches:
- target:
kind: Deployment
name: tmpl-api
patch: |
- op: replace
path: /spec/replicas
value: 1
- target:
kind: ConfigMap
name: tmpl-api-config
patch: |
- op: replace
path: /data/SPRING_PROFILES_ACTIVE
value: "dev,redis"
- target:
kind: Secret
name: tmpl-api-secret
patch: |
- op: replace
path: /stringData/SPRING_DATASOURCE_URL
value: "jdbc:postgresql://dev-postgres:5432/tmpl_dev"
images:
- name: tmpl-api
newName: registry.example.com/tmpl-api
newTag: latest
+49
View File
@@ -0,0 +1,49 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
namePrefix: prod-
patches:
- target:
kind: Deployment
name: tmpl-api
patch: |
- op: replace
path: /spec/replicas
value: 3
- op: replace
path: /spec/template/spec/containers/0/resources/requests/cpu
value: "500m"
- op: replace
path: /spec/template/spec/containers/0/resources/requests/memory
value: "768Mi"
- op: replace
path: /spec/template/spec/containers/0/resources/limits/cpu
value: "2"
- op: replace
path: /spec/template/spec/containers/0/resources/limits/memory
value: "1536Mi"
- target:
kind: ConfigMap
name: tmpl-api-config
patch: |
- op: replace
path: /data/SPRING_PROFILES_ACTIVE
value: "prod,redis"
- target:
kind: Secret
name: tmpl-api-secret
patch: |
- op: replace
path: /stringData/SPRING_DATASOURCE_URL
value: "jdbc:postgresql://prod-postgres-rw:5432/tmpl"
images:
- name: tmpl-api
newName: registry.example.com/tmpl-api
newTag: latest
@@ -0,0 +1,43 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
namePrefix: staging-
patches:
- target:
kind: Deployment
name: tmpl-api
patch: |
- op: replace
path: /spec/replicas
value: 2
- op: replace
path: /spec/template/spec/containers/0/resources/requests/memory
value: "512Mi"
- op: replace
path: /spec/template/spec/containers/0/resources/limits/memory
value: "1Gi"
- target:
kind: ConfigMap
name: tmpl-api-config
patch: |
- op: replace
path: /data/SPRING_PROFILES_ACTIVE
value: "staging,redis"
- target:
kind: Secret
name: tmpl-api-secret
patch: |
- op: replace
path: /stringData/SPRING_DATASOURCE_URL
value: "jdbc:postgresql://staging-postgres:5432/tmpl_staging"
images:
- name: tmpl-api
newName: registry.example.com/tmpl-api
newTag: latest
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
OVERLAY="${1:?Usage: $0 <overlay> <new-tag>}"
NEW_TAG="${2:?Usage: $0 <overlay> <new-tag>}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OVERLAY_DIR="${SCRIPT_DIR}/../overlays/${OVERLAY}"
if [[ ! -d "${OVERLAY_DIR}" ]]; then
echo "ERROR: overlay directory not found: ${OVERLAY_DIR}" >&2
exit 1
fi
if command -v kustomize &>/dev/null; then
echo "Updating ${OVERLAY} overlay: tmpl-api → ${NEW_TAG}"
cd "${OVERLAY_DIR}"
kustomize edit set image "tmpl-api=registry.example.com/tmpl-api:${NEW_TAG}"
echo "Done."
else
echo "kustomize CLI not found, falling back to sed"
if [[ "$(uname)" == "Darwin" ]]; then
sed -i '' "s/newTag: .*/newTag: ${NEW_TAG}/" "${OVERLAY_DIR}/kustomization.yaml"
else
sed -i "s/newTag: .*/newTag: ${NEW_TAG}/" "${OVERLAY_DIR}/kustomization.yaml"
fi
echo "Done. Updated ${OVERLAY_DIR}/kustomization.yaml"
fi
@@ -0,0 +1,38 @@
package com.ljwx.tmpl.api.config;
import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
/**
* Actuator 端点安全(优先级高于业务 chain)。
* <p>
* 放行 {@code management.endpoints.web.exposure.include} 中列出的端点,
* 拒绝其余所有 actuator 端点。
* </p>
* <p>
* <strong>部署模型</strong>:prod/staging 管理端口独立(9090),
* dev 环境合并到业务端口(方便调试),但 actuator chain 仍独立生效。
* </p>
*/
@Configuration
public class ActuatorSecurityConfig {
@Bean
@Order(1)
public SecurityFilterChain actuatorSecurityFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher(EndpointRequest.toAnyEndpoint())
.authorizeHttpRequests(auth -> auth
.requestMatchers(EndpointRequest.to(
"health", "info", "prometheus", "metrics", "caches"))
.permitAll()
.anyRequest().denyAll()
)
.csrf(csrf -> csrf.disable());
return http.build();
}
}
@@ -0,0 +1,24 @@
package com.ljwx.tmpl.api.config;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.boot.actuate.autoconfigure.metrics.MeterRegistryCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Micrometer 全局配置。
* <p>
* common tags 使所有指标自动附加 application 标签,
* 便于 Prometheus/Grafana 按服务名过滤。
* 业务指标由 {@link com.ljwx.tmpl.api.metrics.OrderMetrics} 提供。
* </p>
*/
@Configuration
public class MetricsConfig {
@Bean
public MeterRegistryCustomizer<MeterRegistry> commonTags() {
return registry -> registry.config()
.commonTags("application", "tmpl-api");
}
}
@@ -1,31 +1,32 @@
package com.ljwx.tmpl.api.config;
import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
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.web.SecurityFilterChain;
/**
* 最小化安全配置。
*
* <p>网关已完成鉴权,服务内仅做 scope validation(批次 3 实现)。
* 当前批次为骨架启动,permitAll 放行所有请求,避免本地/CI 被安全链路阻断。
* 业务请求安全配置。
* <p>
* 当前阶段所有业务请求 permitAll(M2 骨架)。
* 显式排除 actuator 端点,确保 actuator chain(Order=1)优先匹配。
* </p>
*/
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
@Order(2)
public SecurityFilterChain appSecurityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.anyRequest().permitAll()
);
.securityMatcher(request ->
!EndpointRequest.toAnyEndpoint().matches(request))
.authorizeHttpRequests(auth -> auth
.anyRequest().permitAll()
)
.csrf(csrf -> csrf.disable());
return http.build();
}
}
@@ -4,92 +4,91 @@ import com.ljwx.tmpl.api.dto.ApiResponse;
import com.ljwx.tmpl.api.dto.CreateOrderRequest;
import com.ljwx.tmpl.api.dto.OrderResponse;
import com.ljwx.tmpl.api.dto.UpdateOrderRequest;
import com.ljwx.tmpl.api.metrics.OrderMetrics;
import com.ljwx.tmpl.application.order.OrderAppService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* Order REST API controller.
*
* <p>Depends on { @link OrderAppService} (application layer) only.
* Does NOT depend on infrastructure — enforced by ArchUnit A-6.
*
* <p>Tenant context comes from the { @code X-Tenant-Id} header,
* which is also validated by { @link TenantContextFilter}.
*/
@RestController @RequestMapping(value = "/api/v1/orders", produces = MediaType.APPLICATION_JSON_VALUE)
@Tag(name = "Order", description = "Order management APIs")
@RestController
@RequestMapping(value = "/api/v1/orders", produces = MediaType.APPLICATION_JSON_VALUE)
@Tag(name = "Order", description = "Order management APIs")
public class OrderController {
private final OrderAppService appService;
private final OrderAppService orderAppService;
private final OrderMetrics orderMetrics;
public OrderController(OrderAppService appService) {
this.appService = appService;
public OrderController(OrderAppService orderAppService, OrderMetrics orderMetrics) {
this.orderAppService = orderAppService;
this.orderMetrics = orderMetrics;
}
@Operation(summary = "Create an order")
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
public ApiResponse<OrderResponse> create(
@ResponseStatus(HttpStatus.CREATED)
@Operation(summary = "Create an order")
public ApiResponse<OrderResponse> createOrder(
@Parameter(description = "Tenant ID", required = true)
@RequestHeader("X-Tenant-Id") Long tenantId,
@RequestBody CreateOrderRequest req) {
var order = appService.create(tenantId, req.getDeptId(), req.getOrderNo(), req.getAmount());
@RequestBody CreateOrderRequest request) {
var order = orderAppService.createOrder(
tenantId, request.getDeptId(), request.getOrderNo(), request.getAmount());
orderMetrics.incOrderCreated(tenantId);
return ApiResponse.success(OrderResponse.fromDomain(order));
}
@Operation(summary = "Get an order by ID")
@GetMapping("/{id}")
public ApiResponse<OrderResponse> get(
@Operation(summary = "Get an order by ID")
public ApiResponse<OrderResponse> getOrder(
@RequestHeader("X-Tenant-Id") Long tenantId,
@PathVariable("id") Long id) {
return appService.get(tenantId, id)
.map(order -> ApiResponse.success(OrderResponse.fromDomain(order)))
.orElse(ApiResponse.error("ORDER_NOT_FOUND", "Order not found"));
var order = orderAppService.getOrder(tenantId, id);
return ApiResponse.success(OrderResponse.fromDomain(order));
}
@Operation(summary = "List orders by tenant (paged)")
@GetMapping
public ApiResponse<List<OrderResponse>> list(
@Operation(summary = "List orders by tenant (paged)")
public ApiResponse<List<OrderResponse>> listOrders(
@RequestHeader("X-Tenant-Id") Long tenantId,
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "20") int pageSize) {
List<OrderResponse> items = appService.list(tenantId, pageNum, pageSize)
List<OrderResponse> items = orderAppService.listOrders(tenantId, pageNum, pageSize)
.stream()
.map(OrderResponse::fromDomain)
.toList();
return ApiResponse.success(items);
}
@PatchMapping(value = "/{id}/status", consumes = MediaType.APPLICATION_JSON_VALUE)
@Operation(summary = "Update order status")
@PutMapping(value = "/{id}/status", consumes = MediaType.APPLICATION_JSON_VALUE)
public ApiResponse<OrderResponse> updateStatus(
@RequestHeader("X-Tenant-Id") Long tenantId,
@PathVariable("id") Long id,
@RequestBody UpdateOrderRequest req) {
var updated = appService.updateStatus(tenantId, id, req.getStatus());
return ApiResponse.success(OrderResponse.fromDomain(updated));
@RequestBody UpdateOrderRequest request) {
var order = orderAppService.updateStatus(tenantId, id, request.getStatus());
return ApiResponse.success(OrderResponse.fromDomain(order));
}
@Operation(summary = "Soft-delete an order")
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(
@ResponseStatus(HttpStatus.NO_CONTENT)
@Operation(summary = "Soft-delete an order")
public void deleteOrder(
@RequestHeader("X-Tenant-Id") Long tenantId,
@PathVariable("id") Long id) {
appService.softDelete(tenantId, id);
return ApiResponse.success(null);
orderAppService.softDelete(tenantId, id);
}
}
@@ -0,0 +1,39 @@
package com.ljwx.tmpl.api.metrics;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.stereotype.Component;
import java.util.concurrent.ConcurrentHashMap;
/**
* 订单业务指标。
* <p>
* {@code order.created.total} 计数器,带 {@code tenantId} 标签。
* ConcurrentHashMap 缓存 per-tenant Counter,避免 meter 爆炸。
* </p>
* <p>
* <strong>Cardinality 控制</strong>:仅以 tenantId 为标签维度。
* 严禁将 orderNo、userId 等高基数字段作为 tag。
* </p>
*/
@Component
public class OrderMetrics {
private final MeterRegistry registry;
private final ConcurrentHashMap<Long, Counter> createdByTenant = new ConcurrentHashMap<>();
public OrderMetrics(MeterRegistry registry) {
this.registry = registry;
}
public void incOrderCreated(long tenantId) {
Counter counter = createdByTenant.computeIfAbsent(tenantId, t ->
Counter.builder("order.created.total")
.description("Total number of orders created")
.tag("tenantId", String.valueOf(t))
.register(registry)
);
counter.increment();
}
}
@@ -9,6 +9,20 @@ spring:
redis:
host: localhost
port: 6379
profiles:
include: redis
# ====== Batch 9 dev 覆盖 ======
# dev 环境管理端口合并到业务端口(仅本地开发便利)。
# prod/staging 使用独立管理端口 9090,通过网络策略隔离。
# actuator SecurityFilterChain(Order=1)在任何端口配置下都独立生效。
management:
server:
port: ${server.port:8080}
endpoint:
health:
show-details: always
show-components: always
app:
security:
@@ -19,8 +33,4 @@ app:
logging:
level:
com.ljwx.tmpl: DEBUG
org.hibernate.SQL: DEBUG
spring:
profiles:
include: redis
org.hibernate.SQL: DEBUG
+17 -6
View File
@@ -41,19 +41,28 @@ spring:
lifecycle:
timeout-per-shutdown-phase: 25s
# ====== Batch 9:Actuator / Prometheus / 管理端口 ======
management:
server:
port: ${MANAGEMENT_PORT:9090}
endpoints:
web:
exposure:
include: health,info,prometheus,metrics
include: health,info,prometheus,metrics,caches
endpoint:
health:
show-details: when_authorized
show-details: when-authorized
show-components: when-authorized
probes:
enabled: true
info:
git:
mode: full
prometheus:
enabled: true
metrics:
tags:
application: ${spring.application.name:tmpl-api}
distribution:
percentiles-histogram:
http.server.requests: true
server:
port: 8080
@@ -63,4 +72,6 @@ app:
security:
header-compat: false
observability:
slow-query-threshold-ms: 200
slow-query-threshold-ms: 200
cache:
enabled: ${APP_CACHE_ENABLED:false}
@@ -0,0 +1,34 @@
package com.ljwx.tmpl.infrastructure.cache;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cache.Cache;
import org.springframework.cache.interceptor.CacheErrorHandler;
/**
* 缓存容错 —— Redis 异常时记 WARN(含完整堆栈)并降级穿透 DB,不崩接口。
*/
public class GracefulCacheErrorHandler implements CacheErrorHandler {
private static final Logger log = LoggerFactory.getLogger(GracefulCacheErrorHandler.class);
@Override
public void handleCacheGetError(RuntimeException exception, Cache cache, Object key) {
log.warn("Cache GET failed — cache={}, key={}", cache.getName(), key, exception);
}
@Override
public void handleCachePutError(RuntimeException exception, Cache cache, Object key, Object value) {
log.warn("Cache PUT failed — cache={}, key={}", cache.getName(), key, exception);
}
@Override
public void handleCacheEvictError(RuntimeException exception, Cache cache, Object key) {
log.warn("Cache EVICT failed — cache={}, key={}", cache.getName(), key, exception);
}
@Override
public void handleCacheClearError(RuntimeException exception, Cache cache) {
log.warn("Cache CLEAR failed — cache={}", cache.getName(), exception);
}
}
@@ -2,7 +2,9 @@ package com.ljwx.tmpl.infrastructure.cache;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.CachingConfigurer;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.interceptor.CacheErrorHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
@@ -19,27 +21,23 @@ import java.util.HashMap;
import java.util.Map;
/**
* Redis 缓存配置。
* Redis 缓存配置 + 容错处理。
* <p>
* 仅在 { @code app.cache.enabled=true} 时生效。
* 未启用时 Spring 不注册 CacheManager,{ @code @Cacheable} 等注解为 no-op。
* </p>
* <p>
* <strong>注意</strong>:启用后若 Redis 连接不可用,应用启动或运行期可能报错。
* 生产环境需确保 Redis 可达,或配合熔断/降级策略(Batch 9/10 范畴)。
* 仅在 {@code app.cache.enabled=true} 时生效。
* 实现 {@link CachingConfigurer} 注册 {@link GracefulCacheErrorHandler},
* 确保 Redis 异常时不会导致接口 500。
* </p>
*/
@Configuration @EnableCaching @ConditionalOnProperty(name = "app.cache.enabled", havingValue = "true", matchIfMissing = false)
public class RedisCacheConfig {
@Configuration
@EnableCaching
@ConditionalOnProperty(name = "app.cache.enabled", havingValue = "true", matchIfMissing = false)
public class RedisCacheConfig implements CachingConfigurer {
private static final Duration DEFAULT_TTL = Duration.ofMinutes(10);
public static final String CACHE_ORDER = "order";
@Bean
public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
// 值序列化:JSON(无 DefaultTyping,更安全)
GenericJackson2JsonRedisSerializer jsonSerializer =
new GenericJackson2JsonRedisSerializer();
@@ -65,9 +63,11 @@ public class RedisCacheConfig {
.build();
}
/**
* 通用 RedisTemplate,供 RedisLockService 等使用。
*/
@Override
public CacheErrorHandler errorHandler() {
return new GracefulCacheErrorHandler();
}
@Bean
public RedisTemplate<String, String> stringRedisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, String> template = new RedisTemplate<>();
@@ -0,0 +1,40 @@
package com.ljwx.tmpl.infrastructure.cache;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
/**
* 自定义 Redis PING 健康指示器。
* <p>
* 在 {@code /actuator/health} 中显示为 {@code redisPing} 组件,
* 与 Spring Boot 默认 {@code redis} 健康指示器并存,不冲突。
* </p>
*/
@Component("redisPing")
@ConditionalOnBean(RedisTemplate.class)
public class RedisHealthContributor implements HealthIndicator {
private final RedisTemplate<String, String> redisTemplate;
public RedisHealthContributor(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Override
public Health health() {
try {
String pong = redisTemplate.execute(
(RedisCallback<String>) connection -> connection.serverCommands().ping());
if ("PONG".equals(pong)) {
return Health.up().withDetail("ping", pong).build();
}
return Health.down().withDetail("ping", pong).build();
} catch (Exception e) {
return Health.down(e).withDetail("error", e.getMessage()).build();
}
}
}