diff --git a/deploy/base/configmap.yaml b/deploy/base/configmap.yaml new file mode 100644 index 0000000..4d9bd37 --- /dev/null +++ b/deploy/base/configmap.yaml @@ -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" \ No newline at end of file diff --git a/deploy/base/deployment.yaml b/deploy/base/deployment.yaml new file mode 100644 index 0000000..3b65252 --- /dev/null +++ b/deploy/base/deployment.yaml @@ -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 \ No newline at end of file diff --git a/deploy/base/kustomization.yaml b/deploy/base/kustomization.yaml new file mode 100644 index 0000000..4cc01aa --- /dev/null +++ b/deploy/base/kustomization.yaml @@ -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 \ No newline at end of file diff --git a/deploy/base/secret.yaml b/deploy/base/secret.yaml new file mode 100644 index 0000000..4138663 --- /dev/null +++ b/deploy/base/secret.yaml @@ -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: "" \ No newline at end of file diff --git a/deploy/base/service.yaml b/deploy/base/service.yaml new file mode 100644 index 0000000..261f337 --- /dev/null +++ b/deploy/base/service.yaml @@ -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 \ No newline at end of file diff --git a/deploy/overlays/dev/kustomization.yaml b/deploy/overlays/dev/kustomization.yaml new file mode 100644 index 0000000..3807087 --- /dev/null +++ b/deploy/overlays/dev/kustomization.yaml @@ -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 \ No newline at end of file diff --git a/deploy/overlays/prod/kustomization.yaml b/deploy/overlays/prod/kustomization.yaml new file mode 100644 index 0000000..ef99c21 --- /dev/null +++ b/deploy/overlays/prod/kustomization.yaml @@ -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 \ No newline at end of file diff --git a/deploy/overlays/staging/kustomization.yaml b/deploy/overlays/staging/kustomization.yaml new file mode 100644 index 0000000..828126b --- /dev/null +++ b/deploy/overlays/staging/kustomization.yaml @@ -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 \ No newline at end of file diff --git a/deploy/scripts/update-image-tag.sh b/deploy/scripts/update-image-tag.sh new file mode 100755 index 0000000..719dfba --- /dev/null +++ b/deploy/scripts/update-image-tag.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +OVERLAY="${1:?Usage: $0 }" +NEW_TAG="${2:?Usage: $0 }" + +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 \ No newline at end of file diff --git a/tmpl-api/src/main/java/com/ljwx/tmpl/api/config/ActuatorSecurityConfig.java b/tmpl-api/src/main/java/com/ljwx/tmpl/api/config/ActuatorSecurityConfig.java new file mode 100644 index 0000000..ec1d235 --- /dev/null +++ b/tmpl-api/src/main/java/com/ljwx/tmpl/api/config/ActuatorSecurityConfig.java @@ -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)。 + *

+ * 放行 {@code management.endpoints.web.exposure.include} 中列出的端点, + * 拒绝其余所有 actuator 端点。 + *

+ *

+ * 部署模型:prod/staging 管理端口独立(9090), + * dev 环境合并到业务端口(方便调试),但 actuator chain 仍独立生效。 + *

+ */ +@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(); + } +} \ No newline at end of file diff --git a/tmpl-api/src/main/java/com/ljwx/tmpl/api/config/MetricsConfig.java b/tmpl-api/src/main/java/com/ljwx/tmpl/api/config/MetricsConfig.java new file mode 100644 index 0000000..b5de5ef --- /dev/null +++ b/tmpl-api/src/main/java/com/ljwx/tmpl/api/config/MetricsConfig.java @@ -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 全局配置。 + *

+ * common tags 使所有指标自动附加 application 标签, + * 便于 Prometheus/Grafana 按服务名过滤。 + * 业务指标由 {@link com.ljwx.tmpl.api.metrics.OrderMetrics} 提供。 + *

+ */ +@Configuration +public class MetricsConfig { + + @Bean + public MeterRegistryCustomizer commonTags() { + return registry -> registry.config() + .commonTags("application", "tmpl-api"); + } +} \ No newline at end of file diff --git a/tmpl-api/src/main/java/com/ljwx/tmpl/api/config/SecurityConfig.java b/tmpl-api/src/main/java/com/ljwx/tmpl/api/config/SecurityConfig.java index ceef425..59d054d 100644 --- a/tmpl-api/src/main/java/com/ljwx/tmpl/api/config/SecurityConfig.java +++ b/tmpl-api/src/main/java/com/ljwx/tmpl/api/config/SecurityConfig.java @@ -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; /** - * 最小化安全配置。 - * - *

网关已完成鉴权,服务内仅做 scope validation(批次 3 实现)。 - * 当前批次为骨架启动,permitAll 放行所有请求,避免本地/CI 被安全链路阻断。 + * 业务请求安全配置。 + *

+ * 当前阶段所有业务请求 permitAll(M2 骨架)。 + * 显式排除 actuator 端点,确保 actuator chain(Order=1)优先匹配。 + *

*/ @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(); } } \ No newline at end of file diff --git a/tmpl-api/src/main/java/com/ljwx/tmpl/api/controller/OrderController.java b/tmpl-api/src/main/java/com/ljwx/tmpl/api/controller/OrderController.java index af56d5e..fcbdf19 100644 --- a/tmpl-api/src/main/java/com/ljwx/tmpl/api/controller/OrderController.java +++ b/tmpl-api/src/main/java/com/ljwx/tmpl/api/controller/OrderController.java @@ -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. - * - *

Depends on { @link OrderAppService} (application layer) only. - * Does NOT depend on infrastructure — enforced by ArchUnit A-6. - * - *

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 create( + @ResponseStatus(HttpStatus.CREATED) + @Operation(summary = "Create an order") + public ApiResponse 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 get( + @Operation(summary = "Get an order by ID") + public ApiResponse 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( + @Operation(summary = "List orders by tenant (paged)") + public ApiResponse> listOrders( @RequestHeader("X-Tenant-Id") Long tenantId, @RequestParam(defaultValue = "1") int pageNum, @RequestParam(defaultValue = "20") int pageSize) { - List items = appService.list(tenantId, pageNum, pageSize) + List 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 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 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); } } \ No newline at end of file diff --git a/tmpl-api/src/main/java/com/ljwx/tmpl/api/metrics/OrderMetrics.java b/tmpl-api/src/main/java/com/ljwx/tmpl/api/metrics/OrderMetrics.java new file mode 100644 index 0000000..5067367 --- /dev/null +++ b/tmpl-api/src/main/java/com/ljwx/tmpl/api/metrics/OrderMetrics.java @@ -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; + +/** + * 订单业务指标。 + *

+ * {@code order.created.total} 计数器,带 {@code tenantId} 标签。 + * ConcurrentHashMap 缓存 per-tenant Counter,避免 meter 爆炸。 + *

+ *

+ * Cardinality 控制:仅以 tenantId 为标签维度。 + * 严禁将 orderNo、userId 等高基数字段作为 tag。 + *

+ */ +@Component +public class OrderMetrics { + + private final MeterRegistry registry; + private final ConcurrentHashMap 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(); + } +} \ No newline at end of file diff --git a/tmpl-api/src/main/resources/application-dev.yml b/tmpl-api/src/main/resources/application-dev.yml index f852c28..4571d10 100644 --- a/tmpl-api/src/main/resources/application-dev.yml +++ b/tmpl-api/src/main/resources/application-dev.yml @@ -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 \ No newline at end of file + org.hibernate.SQL: DEBUG \ No newline at end of file diff --git a/tmpl-api/src/main/resources/application.yml b/tmpl-api/src/main/resources/application.yml index 00eaf60..f7de0b9 100644 --- a/tmpl-api/src/main/resources/application.yml +++ b/tmpl-api/src/main/resources/application.yml @@ -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 \ No newline at end of file + slow-query-threshold-ms: 200 + cache: + enabled: ${APP_CACHE_ENABLED:false} \ No newline at end of file diff --git a/tmpl-infrastructure/src/main/java/com/ljwx/tmpl/infrastructure/cache/GracefulCacheErrorHandler.java b/tmpl-infrastructure/src/main/java/com/ljwx/tmpl/infrastructure/cache/GracefulCacheErrorHandler.java new file mode 100644 index 0000000..5d61f58 --- /dev/null +++ b/tmpl-infrastructure/src/main/java/com/ljwx/tmpl/infrastructure/cache/GracefulCacheErrorHandler.java @@ -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); + } +} \ No newline at end of file diff --git a/tmpl-infrastructure/src/main/java/com/ljwx/tmpl/infrastructure/cache/RedisCacheConfig.java b/tmpl-infrastructure/src/main/java/com/ljwx/tmpl/infrastructure/cache/RedisCacheConfig.java index e3c0306..7c2c6b7 100644 --- a/tmpl-infrastructure/src/main/java/com/ljwx/tmpl/infrastructure/cache/RedisCacheConfig.java +++ b/tmpl-infrastructure/src/main/java/com/ljwx/tmpl/infrastructure/cache/RedisCacheConfig.java @@ -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 缓存配置 + 容错处理。 *

- * 仅在 { @code app.cache.enabled=true} 时生效。 - * 未启用时 Spring 不注册 CacheManager,{ @code @Cacheable} 等注解为 no-op。 - *

- *

- * 注意:启用后若 Redis 连接不可用,应用启动或运行期可能报错。 - * 生产环境需确保 Redis 可达,或配合熔断/降级策略(Batch 9/10 范畴)。 + * 仅在 {@code app.cache.enabled=true} 时生效。 + * 实现 {@link CachingConfigurer} 注册 {@link GracefulCacheErrorHandler}, + * 确保 Redis 异常时不会导致接口 500。 *

*/ - @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 stringRedisTemplate(RedisConnectionFactory connectionFactory) { RedisTemplate template = new RedisTemplate<>(); diff --git a/tmpl-infrastructure/src/main/java/com/ljwx/tmpl/infrastructure/cache/RedisHealthContributor.java b/tmpl-infrastructure/src/main/java/com/ljwx/tmpl/infrastructure/cache/RedisHealthContributor.java new file mode 100644 index 0000000..efdef42 --- /dev/null +++ b/tmpl-infrastructure/src/main/java/com/ljwx/tmpl/infrastructure/cache/RedisHealthContributor.java @@ -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 健康指示器。 + *

+ * 在 {@code /actuator/health} 中显示为 {@code redisPing} 组件, + * 与 Spring Boot 默认 {@code redis} 健康指示器并存,不冲突。 + *

+ */ +@Component("redisPing") +@ConditionalOnBean(RedisTemplate.class) +public class RedisHealthContributor implements HealthIndicator { + + private final RedisTemplate redisTemplate; + + public RedisHealthContributor(RedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + @Override + public Health health() { + try { + String pong = redisTemplate.execute( + (RedisCallback) 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(); + } + } +} \ No newline at end of file