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:
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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: ""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Executable
+28
@@ -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
|
||||
@@ -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}
|
||||
Vendored
+34
@@ -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);
|
||||
}
|
||||
}
|
||||
+15
-15
@@ -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<>();
|
||||
|
||||
Vendored
+40
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user