From 06f7f9d2f2a07cba2c6af004f5354f946e2ef1ee Mon Sep 17 00:00:00 2001
From: liuyihe <2513762445@qq.com>
Date: Wed, 5 Aug 2026 14:20:04 +0800
Subject: [PATCH] =?UTF-8?q?=E6=97=A5=E5=BF=97=E8=AE=B0=E5=BD=95=E6=8F=90?=
=?UTF-8?q?=E4=BA=A4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
audit-service/pom.xml | 9 +
.../baiyue/audit/BaiyueAuditApplication.java | 2 +
.../controller/AuditIngestController.java | 33 +++-
.../PlatformAuditLogController.java | 65 +++++++
.../baiyue/audit/dto/AuditIngestRequest.java | 2 +
.../com/baiyue/audit/dto/AuditLogQuery.java | 19 ++
.../baiyue/audit/dto/AuditLogResponses.java | 66 +++++++
.../baiyue/audit/entity/AuditLogEntity.java | 1 +
.../audit/mq/AuditDeadLetterConsumer.java | 40 ++++
.../audit/mq/AuditRocketMqConsumer.java | 34 ++++
.../repository/AuditDeadLetterRepository.java | 73 ++++++++
.../repository/AuditEventRepository.java | 44 ++++-
.../service/AuditDeadLetterCompensator.java | 47 +++++
.../audit/service/AuditIngestService.java | 8 +-
.../audit/service/AuditLogQueryService.java | 48 +++++
.../service/AuditPlatformScopeGuard.java | 20 ++
.../src/main/resources/application.yml | 13 ++
audit-service/src/main/resources/db/audit.sql | 19 ++
.../resources/db/reliable-audit-migration.sql | 39 ++++
.../controller/AuditIngestControllerTest.java | 74 ++++++++
...latformAuditLogControllerContractTest.java | 68 +++++++
.../audit/mq/AuditMqReliabilityTest.java | 107 +++++++++++
.../service/AuditIngestIdempotencyTest.java | 39 ++++
.../service/AuditLogQueryServiceTest.java | 90 +++++++++
auth/pom.xml | 1 +
auth/src/main/resources/db/audit-outbox.sql | 25 +++
common-audit/pom.xml | 11 ++
.../com/baiyue/common/audit/AuditEvent.java | 2 +-
.../common/audit/AuditOutboxPublisher.java | 62 ++++++
.../common/audit/AuditOutboxRepository.java | 94 ++++++++++
.../AuditOutboxRocketMqAutoConfiguration.java | 33 ++++
.../baiyue/common/audit/AuditProperties.java | 42 +++++
.../audit/BaiyueAuditAutoConfiguration.java | 31 ++-
.../common/audit/BusinessAuditAspect.java | 5 +-
.../baiyue/common/audit/HttpAuditSink.java | 17 +-
.../baiyue/common/audit/OutboxAuditSink.java | 33 ++++
...ot.autoconfigure.AutoConfiguration.imports | 1 +
.../common/audit/HttpAuditSinkTest.java | 40 ++++
.../audit/ReliableAuditDeliveryTest.java | 113 +++++++++++
config/nacos/cn/baiyue-common.yml | 16 +-
config/nacos/local/baiyue-common.yml | 16 +-
config/nacos/local/firstleg-service.yml | 2 +-
deploy/docker-compose.audit.yml | 43 +++++
deploy/docker-compose.rocketmq.yml | 10 +-
deploy/docker-compose.yml | 5 +
deploy/env/.env.infra-test.example | 5 +
.../sql/2026-08-05-可靠审计Outbox-MySQL8.sql | 61 ++++++
deploy/sql/头程数据库统一增量-MySQL8.sql | 176 ++++++++++++++++++
docs/头程项目目录结构与服务说明.md | 4 +-
firstleg/pom.xml | 2 +-
.../controller/PlatformFeeItemController.java | 105 +++++++++++
.../PlatformInboundFeeDetailController.java | 107 +++++++++++
.../com/baiyue/firstleg/dto/FeeItemDtos.java | 30 +++
.../firstleg/dto/InboundFeeDetailDtos.java | 30 +++
.../baiyue/firstleg/entity/FeeItemEntity.java | 30 +++
.../entity/InboundFeeDetailEntity.java | 49 +++++
.../baiyue/firstleg/mapper/FeeItemMapper.java | 9 +
.../mapper/InboundFeeDetailMapper.java | 9 +
.../repository/FeeItemRepository.java | 123 ++++++++++++
.../AutomaticInboundCompletionService.java | 3 +
.../firstleg/service/FeeItemService.java | 100 ++++++++++
.../service/InboundFeeDetailService.java | 149 +++++++++++++++
.../src/main/resources/db/audit-outbox.sql | 15 ++
.../db/phase2-fee-item-and-inbound-detail.sql | 72 +++++++
.../controller/FeeManagementContractTest.java | 44 +++++
.../InboundMeasurementReviewContractTest.java | 13 ++
.../service/FeeManagementServiceTest.java | 148 +++++++++++++++
gateway/src/main/resources/application.yml | 6 +-
.../GatewayFileRouteConfigurationTest.java | 31 +++
iam/pom.xml | 1 +
.../com/baiyue/iam/service/MemberService.java | 3 +-
.../service/PlatformInternalUserService.java | 3 +-
.../PlatformUserManagementService.java | 3 +-
iam/src/main/resources/db/audit-outbox.sql | 15 ++
.../db/phase2-fee-item-permissions.sql | 33 ++++
.../service/BusinessAuditCoverageTest.java | 70 +++++++
order/pom.xml | 1 +
order/src/main/resources/db/audit-outbox.sql | 15 ++
...本地真实API全链路回归-20260805120659.json | 75 ++++++++
...期本地真实API回归-20260805115520-续跑.json | 101 ++++++++++
.../二期本地真实API回归-20260805115520.json | 61 ++++++
warehouse/pom.xml | 4 +
.../src/main/resources/db/audit-outbox.sql | 15 ++
83 files changed, 3256 insertions(+), 27 deletions(-)
create mode 100644 audit-service/src/main/java/com/baiyue/audit/controller/PlatformAuditLogController.java
create mode 100644 audit-service/src/main/java/com/baiyue/audit/dto/AuditLogQuery.java
create mode 100644 audit-service/src/main/java/com/baiyue/audit/dto/AuditLogResponses.java
create mode 100644 audit-service/src/main/java/com/baiyue/audit/mq/AuditDeadLetterConsumer.java
create mode 100644 audit-service/src/main/java/com/baiyue/audit/mq/AuditRocketMqConsumer.java
create mode 100644 audit-service/src/main/java/com/baiyue/audit/repository/AuditDeadLetterRepository.java
create mode 100644 audit-service/src/main/java/com/baiyue/audit/service/AuditDeadLetterCompensator.java
create mode 100644 audit-service/src/main/java/com/baiyue/audit/service/AuditLogQueryService.java
create mode 100644 audit-service/src/main/java/com/baiyue/audit/service/AuditPlatformScopeGuard.java
create mode 100644 audit-service/src/main/resources/db/reliable-audit-migration.sql
create mode 100644 audit-service/src/test/java/com/baiyue/audit/controller/AuditIngestControllerTest.java
create mode 100644 audit-service/src/test/java/com/baiyue/audit/controller/PlatformAuditLogControllerContractTest.java
create mode 100644 audit-service/src/test/java/com/baiyue/audit/mq/AuditMqReliabilityTest.java
create mode 100644 audit-service/src/test/java/com/baiyue/audit/service/AuditIngestIdempotencyTest.java
create mode 100644 audit-service/src/test/java/com/baiyue/audit/service/AuditLogQueryServiceTest.java
create mode 100644 auth/src/main/resources/db/audit-outbox.sql
create mode 100644 common-audit/src/main/java/com/baiyue/common/audit/AuditOutboxPublisher.java
create mode 100644 common-audit/src/main/java/com/baiyue/common/audit/AuditOutboxRepository.java
create mode 100644 common-audit/src/main/java/com/baiyue/common/audit/AuditOutboxRocketMqAutoConfiguration.java
create mode 100644 common-audit/src/main/java/com/baiyue/common/audit/OutboxAuditSink.java
create mode 100644 common-audit/src/test/java/com/baiyue/common/audit/HttpAuditSinkTest.java
create mode 100644 common-audit/src/test/java/com/baiyue/common/audit/ReliableAuditDeliveryTest.java
create mode 100644 deploy/docker-compose.audit.yml
create mode 100644 deploy/sql/2026-08-05-可靠审计Outbox-MySQL8.sql
create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFeeItemController.java
create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundFeeDetailController.java
create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/dto/FeeItemDtos.java
create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/dto/InboundFeeDetailDtos.java
create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/entity/FeeItemEntity.java
create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/entity/InboundFeeDetailEntity.java
create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/mapper/FeeItemMapper.java
create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundFeeDetailMapper.java
create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/repository/FeeItemRepository.java
create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/service/FeeItemService.java
create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java
create mode 100644 firstleg/src/main/resources/db/audit-outbox.sql
create mode 100644 firstleg/src/main/resources/db/phase2-fee-item-and-inbound-detail.sql
create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/controller/FeeManagementContractTest.java
create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java
create mode 100644 iam/src/main/resources/db/audit-outbox.sql
create mode 100644 iam/src/main/resources/db/phase2-fee-item-permissions.sql
create mode 100644 iam/src/test/java/com/baiyue/iam/service/BusinessAuditCoverageTest.java
create mode 100644 order/src/main/resources/db/audit-outbox.sql
create mode 100644 reports/二期本地真实API全链路回归-20260805120659.json
create mode 100644 reports/二期本地真实API回归-20260805115520-续跑.json
create mode 100644 reports/二期本地真实API回归-20260805115520.json
create mode 100644 warehouse/src/main/resources/db/audit-outbox.sql
diff --git a/audit-service/pom.xml b/audit-service/pom.xml
index 87867c917..aa0b1626c 100644
--- a/audit-service/pom.xml
+++ b/audit-service/pom.xml
@@ -27,6 +27,11 @@
baiyue-common-openapi
${project.version}
+
+ com.baiyue.shared
+ baiyue-common-security
+ ${project.version}
+
com.baomidou
mybatis-plus-spring-boot3-starter
@@ -36,6 +41,10 @@
mysql-connector-j
runtime
+
+ org.apache.rocketmq
+ rocketmq-spring-boot-starter
+
com.alibaba.cloud
spring-cloud-starter-alibaba-nacos-discovery
diff --git a/audit-service/src/main/java/com/baiyue/audit/BaiyueAuditApplication.java b/audit-service/src/main/java/com/baiyue/audit/BaiyueAuditApplication.java
index 932f4b6e3..a981e9876 100644
--- a/audit-service/src/main/java/com/baiyue/audit/BaiyueAuditApplication.java
+++ b/audit-service/src/main/java/com/baiyue/audit/BaiyueAuditApplication.java
@@ -3,11 +3,13 @@ package com.baiyue.audit;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 审计微服务启动入口,初始化 Spring Boot 应用并扫描审计日志 Mapper。
*/
@MapperScan("com.baiyue.audit.mapper")
+@EnableScheduling
@SpringBootApplication
public class BaiyueAuditApplication {
diff --git a/audit-service/src/main/java/com/baiyue/audit/controller/AuditIngestController.java b/audit-service/src/main/java/com/baiyue/audit/controller/AuditIngestController.java
index 2ecd0dd5c..ec7166ce4 100644
--- a/audit-service/src/main/java/com/baiyue/audit/controller/AuditIngestController.java
+++ b/audit-service/src/main/java/com/baiyue/audit/controller/AuditIngestController.java
@@ -8,11 +8,15 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
/**
@@ -25,8 +29,13 @@ import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/internal/audit/events")
public class AuditIngestController {
+ private static final String INTERNAL_SERVICE_TOKEN_HEADER = "X-Internal-Service-Token";
+
@Resource
private AuditIngestService service;
+
+ @Value("${baiyue.security.internal-service-token:}")
+ private String internalServiceToken;
@Operation(
summary = "Ingest business audit event / 写入业务审计事件",
description = "Validates and stores one sanitized event in baiyue_audit.sys_audit_log. / 校验并将一条脱敏事件写入 baiyue_audit.sys_audit_log。"
@@ -35,9 +44,25 @@ public class AuditIngestController {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "202", description = "Event accepted and stored / 事件已接收并存储"),
})
@PostMapping
- @ResponseStatus(HttpStatus.ACCEPTED)
- public ApiResponse ingest(@Valid @RequestBody AuditIngestRequest event) {
+ public ResponseEntity> ingest(
+ @RequestHeader(value = INTERNAL_SERVICE_TOKEN_HEADER, required = false) String providedToken,
+ @Valid @RequestBody AuditIngestRequest event) {
+ if (!tokenMatches(providedToken)) {
+ return ResponseEntity.status(HttpStatus.FORBIDDEN)
+ .body(ApiResponse.failure(com.baiyue.common.core.api.ResultCode.FORBIDDEN,
+ "Internal service token is invalid", null, null));
+ }
service.ingest(event);
- return ApiResponse.success(null);
+ return ResponseEntity.status(HttpStatus.ACCEPTED).body(ApiResponse.success(null));
+ }
+
+ private boolean tokenMatches(String providedToken) {
+ if (providedToken == null || providedToken.isBlank()
+ || internalServiceToken == null || internalServiceToken.isBlank()) {
+ return false;
+ }
+ return MessageDigest.isEqual(
+ internalServiceToken.getBytes(StandardCharsets.UTF_8),
+ providedToken.getBytes(StandardCharsets.UTF_8));
}
}
diff --git a/audit-service/src/main/java/com/baiyue/audit/controller/PlatformAuditLogController.java b/audit-service/src/main/java/com/baiyue/audit/controller/PlatformAuditLogController.java
new file mode 100644
index 000000000..e25aaf610
--- /dev/null
+++ b/audit-service/src/main/java/com/baiyue/audit/controller/PlatformAuditLogController.java
@@ -0,0 +1,65 @@
+package com.baiyue.audit.controller;
+
+import com.baiyue.audit.dto.AuditLogQuery;
+import com.baiyue.audit.dto.AuditLogResponses;
+import com.baiyue.audit.service.AuditLogQueryService;
+import com.baiyue.audit.service.AuditPlatformScopeGuard;
+import com.baiyue.common.core.api.ApiResponse;
+import com.baiyue.common.core.api.PageResult;
+import com.baiyue.common.security.AuthenticatedUser;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.annotation.Resource;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import org.springframework.format.annotation.DateTimeFormat;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.time.Instant;
+
+/** 平台端统一操作日志查询入口。 */
+@RestController
+@Validated
+@RequestMapping("/api/platform/v1/audit/logs")
+@Tag(name = "Platform audit logs / 平台操作日志")
+public class PlatformAuditLogController {
+ @Resource
+ private AuditLogQueryService service;
+
+ @GetMapping
+ @PreAuthorize("hasAuthority('platform:audit:list')")
+ @Operation(summary = "分页查询操作日志", description = "仅平台端可查询,列表不返回变更前后快照。默认按发生时间、日志 ID 倒序。")
+ public ApiResponse> page(
+ @AuthenticationPrincipal AuthenticatedUser user,
+ @RequestParam(defaultValue = "1") @Min(1) long page,
+ @RequestParam(defaultValue = "20") @Min(1) @Max(100) long size,
+ @RequestParam(required = false) Long accountId,
+ @RequestParam(required = false) Long operatorUserId,
+ @RequestParam(required = false) String eventType,
+ @RequestParam(required = false) String resourceType,
+ @RequestParam(required = false) String resourceId,
+ @RequestParam(required = false) Boolean success,
+ @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant occurredFrom,
+ @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant occurredTo,
+ @RequestParam(required = false) String keyword) {
+ AuditPlatformScopeGuard.requirePlatform(user);
+ return ApiResponse.success(service.page(new AuditLogQuery(accountId, operatorUserId, eventType,
+ resourceType, resourceId, success, occurredFrom, occurredTo, keyword), page, size));
+ }
+
+ @GetMapping("/{id}")
+ @PreAuthorize("hasAuthority('platform:audit:list')")
+ @Operation(summary = "查看操作日志详情", description = "仅平台端可查询,详情返回已脱敏的变更前后快照。")
+ public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user,
+ @PathVariable @Min(1) Long id) {
+ AuditPlatformScopeGuard.requirePlatform(user);
+ return ApiResponse.success(service.detail(id));
+ }
+}
diff --git a/audit-service/src/main/java/com/baiyue/audit/dto/AuditIngestRequest.java b/audit-service/src/main/java/com/baiyue/audit/dto/AuditIngestRequest.java
index 7948394c3..6ecb297be 100644
--- a/audit-service/src/main/java/com/baiyue/audit/dto/AuditIngestRequest.java
+++ b/audit-service/src/main/java/com/baiyue/audit/dto/AuditIngestRequest.java
@@ -13,6 +13,8 @@ import java.time.Instant;
*/
@Schema(name = "AuditIngestRequest", description = "Internal business audit event / 内部业务审计事件")
public record AuditIngestRequest(
+ @Schema(description = "Global idempotency event ID / 全局幂等事件ID", example = "b150c69d-8813-46b7-9a7c-81bfa5b91021")
+ @Size(max = 64) String eventId,
@Schema(description = "Event code / 事件编码", example = "WAREHOUSE_UPDATE", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank @Size(max = 64) String eventType,
@Schema(description = "Resource type / 资源类型", example = "WAREHOUSE", requiredMode = Schema.RequiredMode.REQUIRED)
diff --git a/audit-service/src/main/java/com/baiyue/audit/dto/AuditLogQuery.java b/audit-service/src/main/java/com/baiyue/audit/dto/AuditLogQuery.java
new file mode 100644
index 000000000..71f54462e
--- /dev/null
+++ b/audit-service/src/main/java/com/baiyue/audit/dto/AuditLogQuery.java
@@ -0,0 +1,19 @@
+package com.baiyue.audit.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+import java.time.Instant;
+
+/** 平台操作日志筛选条件。accountId 对应审计库中的 tenant_id。 */
+@Schema(name = "AuditLogQuery", description = "平台操作日志分页筛选条件")
+public record AuditLogQuery(
+ Long accountId,
+ Long operatorUserId,
+ String eventType,
+ String resourceType,
+ String resourceId,
+ Boolean success,
+ Instant occurredFrom,
+ Instant occurredTo,
+ String keyword) {
+}
diff --git a/audit-service/src/main/java/com/baiyue/audit/dto/AuditLogResponses.java b/audit-service/src/main/java/com/baiyue/audit/dto/AuditLogResponses.java
new file mode 100644
index 000000000..def500edf
--- /dev/null
+++ b/audit-service/src/main/java/com/baiyue/audit/dto/AuditLogResponses.java
@@ -0,0 +1,66 @@
+package com.baiyue.audit.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+import java.time.Instant;
+import java.time.LocalDateTime;
+
+/** 平台审计日志查询响应。列表刻意不包含前后快照,详情才返回完整证据。 */
+public final class AuditLogResponses {
+ private AuditLogResponses() {
+ }
+
+ @Schema(name = "AuditLogListItem", description = "操作日志列表项,不包含变更前后快照")
+ public record ListItem(
+ Long id,
+ String eventId,
+ String eventType,
+ String resourceType,
+ String resourceId,
+ String description,
+ Long accountId,
+ Long operatorTenantId,
+ Long userId,
+ String username,
+ String appCode,
+ String clientType,
+ String requestId,
+ String traceId,
+ String serviceName,
+ Boolean success,
+ String errorCode,
+ String errorMessage,
+ Instant occurredAt,
+ LocalDateTime createdAt) {
+ }
+
+ @Schema(name = "AuditLogDetail", description = "操作日志详情,包含已脱敏的变更前后快照")
+ public record Detail(
+ Long id,
+ String eventId,
+ String eventType,
+ String resourceType,
+ String resourceId,
+ String description,
+ String requestId,
+ String traceId,
+ Long accountId,
+ Long operatorTenantId,
+ Long userId,
+ String username,
+ String appCode,
+ String clientType,
+ String httpMethod,
+ String requestUri,
+ String clientIp,
+ Long durationMs,
+ Boolean success,
+ String errorCode,
+ String errorMessage,
+ String beforeData,
+ String afterData,
+ String serviceName,
+ Instant occurredAt,
+ LocalDateTime createdAt) {
+ }
+}
diff --git a/audit-service/src/main/java/com/baiyue/audit/entity/AuditLogEntity.java b/audit-service/src/main/java/com/baiyue/audit/entity/AuditLogEntity.java
index ba85a9170..237689526 100644
--- a/audit-service/src/main/java/com/baiyue/audit/entity/AuditLogEntity.java
+++ b/audit-service/src/main/java/com/baiyue/audit/entity/AuditLogEntity.java
@@ -23,6 +23,7 @@ public class AuditLogEntity {
@Schema(description = "Audit log ID / 审计日志 ID", example = "190000000000000201")
@TableId(type = IdType.AUTO)
private Long id;
+ private String eventId;
private String eventType;
private String resourceType;
private String resourceId;
diff --git a/audit-service/src/main/java/com/baiyue/audit/mq/AuditDeadLetterConsumer.java b/audit-service/src/main/java/com/baiyue/audit/mq/AuditDeadLetterConsumer.java
new file mode 100644
index 000000000..4972299a2
--- /dev/null
+++ b/audit-service/src/main/java/com/baiyue/audit/mq/AuditDeadLetterConsumer.java
@@ -0,0 +1,40 @@
+package com.baiyue.audit.mq;
+
+import com.baiyue.audit.repository.AuditDeadLetterRepository;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.annotation.Resource;
+import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
+import org.apache.rocketmq.spring.core.RocketMQListener;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Component;
+
+import java.nio.charset.StandardCharsets;
+import java.util.UUID;
+
+/** 将 RocketMQ 最终死信转存数据库,供补偿任务恢复。 */
+@Component
+@ConditionalOnProperty(prefix = "baiyue.audit.rocketmq", name = "dead-letter-consumer-enabled", havingValue = "true")
+@RocketMQMessageListener(
+ topic = "${baiyue.audit.rocketmq.dead-letter-topic}",
+ consumerGroup = "${baiyue.audit.rocketmq.dead-letter-group}")
+public class AuditDeadLetterConsumer implements RocketMQListener {
+ @Resource
+ private ObjectMapper json;
+ @Resource
+ private AuditDeadLetterRepository repository;
+
+ @Override
+ public void onMessage(String payload) {
+ repository.save(eventId(payload), payload);
+ }
+
+ private String eventId(String payload) {
+ try {
+ String value = json.readTree(payload).path("eventId").asText();
+ if (!value.isBlank()) return value;
+ } catch (Exception ignored) {
+ // 无法解析的毒消息也必须使用确定性 ID 收入死信表。
+ }
+ return "invalid-" + UUID.nameUUIDFromBytes(payload.getBytes(StandardCharsets.UTF_8));
+ }
+}
diff --git a/audit-service/src/main/java/com/baiyue/audit/mq/AuditRocketMqConsumer.java b/audit-service/src/main/java/com/baiyue/audit/mq/AuditRocketMqConsumer.java
new file mode 100644
index 000000000..190c8414b
--- /dev/null
+++ b/audit-service/src/main/java/com/baiyue/audit/mq/AuditRocketMqConsumer.java
@@ -0,0 +1,34 @@
+package com.baiyue.audit.mq;
+
+import com.baiyue.audit.dto.AuditIngestRequest;
+import com.baiyue.audit.service.AuditIngestService;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.annotation.Resource;
+import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
+import org.apache.rocketmq.spring.core.RocketMQListener;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Component;
+
+/** RocketMQ 审计事件消费者;数据库 event_id 唯一键保证重复消息安全。 */
+@Component
+@ConditionalOnProperty(prefix = "baiyue.audit.rocketmq", name = "consumer-enabled", havingValue = "true")
+@RocketMQMessageListener(
+ topic = "${baiyue.audit.rocketmq.topic}",
+ selectorExpression = "AUDIT",
+ consumerGroup = "${baiyue.audit.rocketmq.consumer-group}")
+public class AuditRocketMqConsumer implements RocketMQListener {
+ @Resource
+ private ObjectMapper json;
+ @Resource
+ private AuditIngestService service;
+
+ @Override
+ public void onMessage(String payload) {
+ try {
+ service.ingest(json.readValue(payload, AuditIngestRequest.class));
+ } catch (JsonProcessingException exception) {
+ throw new IllegalArgumentException("审计消息格式无效", exception);
+ }
+ }
+}
diff --git a/audit-service/src/main/java/com/baiyue/audit/repository/AuditDeadLetterRepository.java b/audit-service/src/main/java/com/baiyue/audit/repository/AuditDeadLetterRepository.java
new file mode 100644
index 000000000..aa3dd9620
--- /dev/null
+++ b/audit-service/src/main/java/com/baiyue/audit/repository/AuditDeadLetterRepository.java
@@ -0,0 +1,73 @@
+package com.baiyue.audit.repository;
+
+import jakarta.annotation.Resource;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+
+/** 审计 RocketMQ 死信及补偿状态仓储。 */
+@Repository
+public class AuditDeadLetterRepository {
+ @Resource
+ private JdbcTemplate jdbc;
+
+ public void save(String eventId, String payload) {
+ jdbc.update("""
+ INSERT INTO sys_audit_dead_letter(event_id,payload_text,status,retry_count,next_retry_at,created_at)
+ VALUES (?,?,'PENDING',0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3))
+ ON DUPLICATE KEY UPDATE payload_text=VALUES(payload_text)
+ """, eventId, payload);
+ }
+
+ public List pendingIds(int limit) {
+ return jdbc.queryForList("""
+ SELECT id FROM sys_audit_dead_letter
+ WHERE status='PENDING' AND next_retry_at<=CURRENT_TIMESTAMP(3)
+ ORDER BY next_retry_at,id LIMIT ?
+ """, Long.class, limit);
+ }
+
+ public boolean claim(long id, String owner, int leaseSeconds) {
+ return jdbc.update("""
+ UPDATE sys_audit_dead_letter SET status='PROCESSING',lease_owner=?,
+ lease_until=DATE_ADD(CURRENT_TIMESTAMP(3),INTERVAL ? SECOND),retry_count=retry_count+1
+ WHERE id=? AND status='PENDING' AND next_retry_at<=CURRENT_TIMESTAMP(3)
+ """, owner, leaseSeconds, id) == 1;
+ }
+
+ public DeadRow find(long id) {
+ List rows = jdbc.query(
+ "SELECT id,event_id,payload_text,retry_count FROM sys_audit_dead_letter WHERE id=?",
+ (rs, rowNum) -> new DeadRow(rs.getLong(1), rs.getString(2), rs.getString(3), rs.getInt(4)), id);
+ return rows.isEmpty() ? null : rows.get(0);
+ }
+
+ public void processed(long id, String owner) {
+ jdbc.update("""
+ UPDATE sys_audit_dead_letter SET status='PROCESSED',processed_at=CURRENT_TIMESTAMP(3),
+ lease_owner=NULL,lease_until=NULL,last_error=NULL
+ WHERE id=? AND status='PROCESSING' AND lease_owner=?
+ """, id, owner);
+ }
+
+ public void failed(long id, String owner, int maxAttempts, String error) {
+ jdbc.update("""
+ UPDATE sys_audit_dead_letter
+ SET status=IF(retry_count>=?,'DEAD','PENDING'),
+ next_retry_at=DATE_ADD(CURRENT_TIMESTAMP(3),INTERVAL 60 SECOND),
+ lease_owner=NULL,lease_until=NULL,last_error=?
+ WHERE id=? AND status='PROCESSING' AND lease_owner=?
+ """, maxAttempts, error, id, owner);
+ }
+
+ public int releaseExpired() {
+ return jdbc.update("""
+ UPDATE sys_audit_dead_letter SET status='PENDING',next_retry_at=CURRENT_TIMESTAMP(3),
+ lease_owner=NULL,lease_until=NULL
+ WHERE status='PROCESSING' AND lease_until<=CURRENT_TIMESTAMP(3) LIMIT 100
+ """);
+ }
+
+ public record DeadRow(long id, String eventId, String payload, int retryCount) { }
+}
diff --git a/audit-service/src/main/java/com/baiyue/audit/repository/AuditEventRepository.java b/audit-service/src/main/java/com/baiyue/audit/repository/AuditEventRepository.java
index 07bcb9bfd..43a023bbe 100644
--- a/audit-service/src/main/java/com/baiyue/audit/repository/AuditEventRepository.java
+++ b/audit-service/src/main/java/com/baiyue/audit/repository/AuditEventRepository.java
@@ -5,7 +5,13 @@ import jakarta.annotation.Resource;
import com.baiyue.audit.dto.AuditIngestRequest;
import com.baiyue.audit.entity.AuditLogEntity;
import com.baiyue.audit.mapper.AuditLogMapper;
+import com.baiyue.audit.dto.AuditLogQuery;
+import com.baiyue.common.core.api.PageResult;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.springframework.stereotype.Repository;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.util.StringUtils;
/**
* 审计事件持久化仓储,负责将内部审计请求映射并追加写入审计日志表。
@@ -15,8 +21,9 @@ public class AuditEventRepository {
@Resource
private AuditLogMapper mapper;
- public void save(AuditIngestRequest event) {
+ public boolean save(AuditIngestRequest event, String eventId) {
AuditLogEntity entity = new AuditLogEntity();
+ entity.setEventId(eventId);
entity.setEventType(event.eventType());
entity.setResourceType(event.resourceType());
entity.setResourceId(event.resourceId());
@@ -40,7 +47,40 @@ public class AuditEventRepository {
entity.setAfterData(jsonOrNull(event.afterData()));
entity.setOccurredAt(event.occurredAt());
entity.setServiceName(event.serviceName());
- mapper.insert(entity);
+ try {
+ mapper.insert(entity);
+ return true;
+ } catch (DuplicateKeyException ignored) {
+ return false;
+ }
+ }
+
+ public PageResult page(AuditLogQuery query, long page, long size) {
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper()
+ .eq(query.accountId() != null, AuditLogEntity::getTenantId, query.accountId())
+ .eq(query.operatorUserId() != null, AuditLogEntity::getUserId, query.operatorUserId())
+ .eq(StringUtils.hasText(query.eventType()), AuditLogEntity::getEventType, query.eventType())
+ .eq(StringUtils.hasText(query.resourceType()), AuditLogEntity::getResourceType, query.resourceType())
+ .eq(StringUtils.hasText(query.resourceId()), AuditLogEntity::getResourceId, query.resourceId())
+ .eq(query.success() != null, AuditLogEntity::getSuccess, query.success())
+ .ge(query.occurredFrom() != null, AuditLogEntity::getOccurredAt, query.occurredFrom())
+ .le(query.occurredTo() != null, AuditLogEntity::getOccurredAt, query.occurredTo())
+ .orderByDesc(AuditLogEntity::getOccurredAt)
+ .orderByDesc(AuditLogEntity::getId);
+ if (StringUtils.hasText(query.keyword())) {
+ String keyword = query.keyword().trim();
+ wrapper.and(condition -> condition
+ .like(AuditLogEntity::getDescription, keyword)
+ .or().like(AuditLogEntity::getUsername, keyword)
+ .or().like(AuditLogEntity::getResourceId, keyword)
+ .or().like(AuditLogEntity::getRequestId, keyword)
+ .or().like(AuditLogEntity::getTraceId, keyword));
+ }
+ return PageResult.from(mapper.selectPage(new Page<>(page, size), wrapper));
+ }
+
+ public AuditLogEntity findById(Long id) {
+ return mapper.selectById(id);
}
private static String jsonOrNull(String value) {
diff --git a/audit-service/src/main/java/com/baiyue/audit/service/AuditDeadLetterCompensator.java b/audit-service/src/main/java/com/baiyue/audit/service/AuditDeadLetterCompensator.java
new file mode 100644
index 000000000..04ace66ea
--- /dev/null
+++ b/audit-service/src/main/java/com/baiyue/audit/service/AuditDeadLetterCompensator.java
@@ -0,0 +1,47 @@
+package com.baiyue.audit.service;
+
+import com.baiyue.audit.dto.AuditIngestRequest;
+import com.baiyue.audit.repository.AuditDeadLetterRepository;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.annotation.Resource;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+/** 定时重放已持久化的审计死信,达到上限后保留为 DEAD 供人工处理。 */
+@Component
+@ConditionalOnProperty(prefix = "baiyue.audit.rocketmq", name = "dead-letter-compensation-enabled", havingValue = "true")
+public class AuditDeadLetterCompensator {
+ @Resource
+ private AuditDeadLetterRepository repository;
+ @Resource
+ private AuditIngestService service;
+ @Resource
+ private ObjectMapper json;
+ @Value("${spring.application.name:audit-service}-${random.uuid}")
+ private String owner;
+ @Value("${baiyue.audit.rocketmq.dead-letter-max-attempts:5}")
+ private int maxAttempts;
+
+ @Scheduled(fixedDelayString = "${baiyue.audit.rocketmq.dead-letter-poll-ms:10000}")
+ public void compensate() {
+ repository.releaseExpired();
+ for (Long id : repository.pendingIds(50)) {
+ if (!repository.claim(id, owner, 60)) continue;
+ AuditDeadLetterRepository.DeadRow row = repository.find(id);
+ if (row == null) continue;
+ try {
+ service.ingest(json.readValue(row.payload(), AuditIngestRequest.class));
+ repository.processed(id, owner);
+ } catch (Exception exception) {
+ repository.failed(id, owner, maxAttempts, safe(exception.getMessage()));
+ }
+ }
+ }
+
+ private static String safe(String value) {
+ String message = value == null ? "死信补偿失败" : value;
+ return message.length() <= 1000 ? message : message.substring(0, 1000);
+ }
+}
diff --git a/audit-service/src/main/java/com/baiyue/audit/service/AuditIngestService.java b/audit-service/src/main/java/com/baiyue/audit/service/AuditIngestService.java
index a75e4177b..bcbbcc62a 100644
--- a/audit-service/src/main/java/com/baiyue/audit/service/AuditIngestService.java
+++ b/audit-service/src/main/java/com/baiyue/audit/service/AuditIngestService.java
@@ -5,6 +5,9 @@ import com.baiyue.audit.dto.AuditIngestRequest;
import com.baiyue.audit.repository.AuditEventRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+import java.util.UUID;
/**
* 审计事件写入应用服务,在事务内接收并持久化不可变业务审计记录。
@@ -16,7 +19,8 @@ public class AuditIngestService {
@Transactional
- public void ingest(AuditIngestRequest event) {
- repository.save(event);
+ public boolean ingest(AuditIngestRequest event) {
+ String eventId = StringUtils.hasText(event.eventId()) ? event.eventId() : UUID.randomUUID().toString();
+ return repository.save(event, eventId);
}
}
diff --git a/audit-service/src/main/java/com/baiyue/audit/service/AuditLogQueryService.java b/audit-service/src/main/java/com/baiyue/audit/service/AuditLogQueryService.java
new file mode 100644
index 000000000..b16fec9e1
--- /dev/null
+++ b/audit-service/src/main/java/com/baiyue/audit/service/AuditLogQueryService.java
@@ -0,0 +1,48 @@
+package com.baiyue.audit.service;
+
+import com.baiyue.audit.dto.AuditLogQuery;
+import com.baiyue.audit.dto.AuditLogResponses;
+import com.baiyue.audit.entity.AuditLogEntity;
+import com.baiyue.audit.repository.AuditEventRepository;
+import com.baiyue.common.core.api.PageResult;
+import com.baiyue.common.core.api.ResultCode;
+import com.baiyue.common.core.exception.BizException;
+import jakarta.annotation.Resource;
+import org.springframework.stereotype.Service;
+
+/** 平台统一操作日志查询服务。 */
+@Service
+public class AuditLogQueryService {
+ @Resource
+ private AuditEventRepository repository;
+
+ public PageResult page(AuditLogQuery query, long page, long size) {
+ PageResult source = repository.page(query, page, size);
+ return new PageResult<>(source.records().stream().map(this::toListItem).toList(),
+ source.page(), source.size(), source.total(), source.pages());
+ }
+
+ public AuditLogResponses.Detail detail(Long id) {
+ AuditLogEntity entity = repository.findById(id);
+ if (entity == null) {
+ throw new BizException(ResultCode.NOT_FOUND, "操作日志不存在");
+ }
+ return new AuditLogResponses.Detail(
+ entity.getId(), entity.getEventId(), entity.getEventType(), entity.getResourceType(), entity.getResourceId(),
+ entity.getDescription(), entity.getRequestId(), entity.getTraceId(), entity.getTenantId(),
+ entity.getOperatorTenantId(), entity.getUserId(), entity.getUsername(), entity.getAppCode(),
+ entity.getClientType(), entity.getHttpMethod(), entity.getRequestUri(), entity.getClientIp(),
+ entity.getDurationMs(), entity.getSuccess(), entity.getErrorCode(), entity.getErrorMessage(),
+ entity.getBeforeData(), entity.getAfterData(), entity.getServiceName(), entity.getOccurredAt(),
+ entity.getCreatedAt());
+ }
+
+ private AuditLogResponses.ListItem toListItem(AuditLogEntity entity) {
+ return new AuditLogResponses.ListItem(
+ entity.getId(), entity.getEventId(), entity.getEventType(), entity.getResourceType(), entity.getResourceId(),
+ entity.getDescription(), entity.getTenantId(), entity.getOperatorTenantId(), entity.getUserId(),
+ entity.getUsername(), entity.getAppCode(), entity.getClientType(), entity.getRequestId(),
+ entity.getTraceId(), entity.getServiceName(), entity.getSuccess(), entity.getErrorCode(),
+ entity.getErrorMessage(), entity.getOccurredAt(), entity.getCreatedAt());
+ }
+}
diff --git a/audit-service/src/main/java/com/baiyue/audit/service/AuditPlatformScopeGuard.java b/audit-service/src/main/java/com/baiyue/audit/service/AuditPlatformScopeGuard.java
new file mode 100644
index 000000000..57eaad8cd
--- /dev/null
+++ b/audit-service/src/main/java/com/baiyue/audit/service/AuditPlatformScopeGuard.java
@@ -0,0 +1,20 @@
+package com.baiyue.audit.service;
+
+import com.baiyue.common.core.api.ResultCode;
+import com.baiyue.common.core.exception.BizException;
+import com.baiyue.common.security.AuthenticatedUser;
+
+/** 审计日志只允许平台端账号读取,避免商户端通过通用日志接口获取跨账号记录。 */
+public final class AuditPlatformScopeGuard {
+ private AuditPlatformScopeGuard() {
+ }
+
+ public static void requirePlatform(AuthenticatedUser user) {
+ if (user == null || user.userId() == null) {
+ throw new BizException(ResultCode.UNAUTHORIZED, "请先登录");
+ }
+ if (!"PLATFORM".equals(user.appCode())) {
+ throw new BizException(ResultCode.FORBIDDEN, "仅平台端可查看操作日志");
+ }
+ }
+}
diff --git a/audit-service/src/main/resources/application.yml b/audit-service/src/main/resources/application.yml
index db601f1db..dfca83c9d 100644
--- a/audit-service/src/main/resources/application.yml
+++ b/audit-service/src/main/resources/application.yml
@@ -26,6 +26,19 @@ spring:
namespace: ${NACOS_NAMESPACE:public}
baiyue.audit.enabled: false
+baiyue:
+ audit:
+ rocketmq:
+ consumer-enabled: ${AUDIT_MQ_CONSUMER_ENABLED:false}
+ topic: ${MQ_TOPIC_PREFIX:test-}baiyue-audit-event-v1
+ consumer-group: ${MQ_TOPIC_PREFIX:test-}baiyue-audit-consumer-v1
+ dead-letter-consumer-enabled: ${AUDIT_DLQ_CONSUMER_ENABLED:false}
+ dead-letter-topic: "%DLQ%${MQ_TOPIC_PREFIX:test-}baiyue-audit-consumer-v1"
+ dead-letter-group: ${MQ_TOPIC_PREFIX:test-}baiyue-audit-dlq-handler-v1
+ dead-letter-compensation-enabled: ${AUDIT_DLQ_COMPENSATION_ENABLED:false}
+ dead-letter-max-attempts: ${AUDIT_DLQ_MAX_ATTEMPTS:5}
+rocketmq:
+ name-server: ${ROCKETMQ_NAME_SERVER:127.0.0.1:9876}
management.endpoints.web.exposure.include: health,info,prometheus
logging:
diff --git a/audit-service/src/main/resources/db/audit.sql b/audit-service/src/main/resources/db/audit.sql
index 985b89b32..efa939fa2 100644
--- a/audit-service/src/main/resources/db/audit.sql
+++ b/audit-service/src/main/resources/db/audit.sql
@@ -4,6 +4,7 @@ USE baiyue_audit;
CREATE TABLE IF NOT EXISTS sys_audit_log (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '审计日志自增ID',
+ event_id VARCHAR(64) DEFAULT NULL COMMENT '审计事件全局唯一ID',
event_type VARCHAR(64) NOT NULL COMMENT '审计事件编码',
resource_type VARCHAR(64) NOT NULL COMMENT '被操作资源类型',
resource_id VARCHAR(128) DEFAULT NULL COMMENT '被操作资源ID',
@@ -29,8 +30,26 @@ CREATE TABLE IF NOT EXISTS sys_audit_log (
occurred_at DATETIME(3) NOT NULL COMMENT '业务操作发生时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '审计服务落库时间',
PRIMARY KEY (id),
+ UNIQUE KEY uk_audit_event_id (event_id),
KEY idx_audit_tenant_time (tenant_id, occurred_at),
KEY idx_audit_event_time (event_type, occurred_at),
KEY idx_audit_user_time (user_id, occurred_at),
KEY idx_audit_request (request_id)
) ENGINE=InnoDB COMMENT='统一业务审计日志表:追加记录敏感操作的主体、租户、资源、结果及前后快照;不同于消息Outbox和消费幂等表';
+
+CREATE TABLE IF NOT EXISTS sys_audit_dead_letter (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ event_id VARCHAR(64) NOT NULL COMMENT '审计事件全局唯一ID',
+ payload_text LONGTEXT NOT NULL COMMENT 'RocketMQ死信原始载荷',
+ status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、PROCESSING、PROCESSED、DEAD',
+ retry_count INT NOT NULL DEFAULT 0,
+ next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ lease_owner VARCHAR(128) NULL,
+ lease_until DATETIME(3) NULL,
+ last_error VARCHAR(1000) NULL,
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ processed_at DATETIME(3) NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_audit_dead_event (event_id),
+ KEY idx_audit_dead_retry (status, next_retry_at, lease_until)
+) ENGINE=InnoDB COMMENT='审计消息死信补偿表';
diff --git a/audit-service/src/main/resources/db/reliable-audit-migration.sql b/audit-service/src/main/resources/db/reliable-audit-migration.sql
new file mode 100644
index 000000000..eab91d8e5
--- /dev/null
+++ b/audit-service/src/main/resources/db/reliable-audit-migration.sql
@@ -0,0 +1,39 @@
+USE baiyue_audit;
+
+DROP PROCEDURE IF EXISTS add_audit_event_id;
+DELIMITER $$
+CREATE PROCEDURE add_audit_event_id()
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA='baiyue_audit' AND TABLE_NAME='sys_audit_log' AND COLUMN_NAME='event_id'
+ ) THEN
+ ALTER TABLE sys_audit_log ADD COLUMN event_id VARCHAR(64) NULL COMMENT '审计事件全局唯一ID' AFTER id;
+ END IF;
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.STATISTICS
+ WHERE TABLE_SCHEMA='baiyue_audit' AND TABLE_NAME='sys_audit_log' AND INDEX_NAME='uk_audit_event_id'
+ ) THEN
+ ALTER TABLE sys_audit_log ADD UNIQUE KEY uk_audit_event_id (event_id);
+ END IF;
+END$$
+DELIMITER ;
+CALL add_audit_event_id();
+DROP PROCEDURE IF EXISTS add_audit_event_id;
+
+CREATE TABLE IF NOT EXISTS sys_audit_dead_letter (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ event_id VARCHAR(64) NOT NULL COMMENT '审计事件全局唯一ID',
+ payload_text LONGTEXT NOT NULL COMMENT 'RocketMQ死信原始载荷',
+ status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、PROCESSING、PROCESSED、DEAD',
+ retry_count INT NOT NULL DEFAULT 0,
+ next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ lease_owner VARCHAR(128) NULL,
+ lease_until DATETIME(3) NULL,
+ last_error VARCHAR(1000) NULL,
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ processed_at DATETIME(3) NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_audit_dead_event (event_id),
+ KEY idx_audit_dead_retry (status, next_retry_at, lease_until)
+) ENGINE=InnoDB COMMENT='审计消息死信补偿表';
diff --git a/audit-service/src/test/java/com/baiyue/audit/controller/AuditIngestControllerTest.java b/audit-service/src/test/java/com/baiyue/audit/controller/AuditIngestControllerTest.java
new file mode 100644
index 000000000..06f5563d5
--- /dev/null
+++ b/audit-service/src/test/java/com/baiyue/audit/controller/AuditIngestControllerTest.java
@@ -0,0 +1,74 @@
+package com.baiyue.audit.controller;
+
+import com.baiyue.audit.service.AuditIngestService;
+import jakarta.annotation.Resource;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@SpringJUnitConfig(AuditIngestController.class)
+@TestPropertySource(properties = "baiyue.security.internal-service-token=0123456789abcdef0123456789abcdef")
+class AuditIngestControllerTest {
+ private static final String TOKEN = "0123456789abcdef0123456789abcdef";
+
+ @MockBean
+ private AuditIngestService service;
+
+ @Resource
+ private AuditIngestController controller;
+
+ private MockMvc mockMvc;
+
+ @BeforeEach
+ void setUp() {
+ mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
+ }
+
+ @Test
+ void rejectsInvalidInternalToken() throws Exception {
+ mockMvc.perform(post("/internal/audit/events")
+ .header("X-Internal-Service-Token", "wrong-token")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(validRequest()))
+ .andExpect(status().isForbidden())
+ .andExpect(jsonPath("$.code").value("A0403"));
+
+ verify(service, never()).ingest(any());
+ }
+
+ @Test
+ void acceptsEventWithInternalToken() throws Exception {
+ mockMvc.perform(post("/internal/audit/events")
+ .header("X-Internal-Service-Token", TOKEN)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(validRequest()))
+ .andExpect(status().isAccepted())
+ .andExpect(jsonPath("$.code").value("200"));
+
+ verify(service).ingest(any());
+ }
+
+ private String validRequest() {
+ return """
+ {
+ "eventType":"ROLE_UPDATE",
+ "resourceType":"ROLE",
+ "durationMs":0,
+ "success":true,
+ "occurredAt":"2026-08-05T00:00:00Z"
+ }
+ """;
+ }
+}
diff --git a/audit-service/src/test/java/com/baiyue/audit/controller/PlatformAuditLogControllerContractTest.java b/audit-service/src/test/java/com/baiyue/audit/controller/PlatformAuditLogControllerContractTest.java
new file mode 100644
index 000000000..37d91da8b
--- /dev/null
+++ b/audit-service/src/test/java/com/baiyue/audit/controller/PlatformAuditLogControllerContractTest.java
@@ -0,0 +1,68 @@
+package com.baiyue.audit.controller;
+
+import com.baiyue.audit.dto.AuditLogQuery;
+import com.baiyue.audit.dto.AuditLogResponses;
+import com.baiyue.audit.service.AuditLogQueryService;
+import com.baiyue.common.core.api.PageResult;
+import com.baiyue.common.core.exception.BizException;
+import com.baiyue.common.security.AuthenticatedUser;
+import jakarta.annotation.Resource;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
+
+import java.util.List;
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+@SpringJUnitConfig(PlatformAuditLogController.class)
+class PlatformAuditLogControllerContractTest {
+ @MockBean
+ private AuditLogQueryService service;
+
+ @Resource
+ private PlatformAuditLogController controller;
+
+ @Test
+ void platformCanQueryLogList() {
+ when(service.page(any(AuditLogQuery.class), org.mockito.ArgumentMatchers.eq(1L),
+ org.mockito.ArgumentMatchers.eq(20L)))
+ .thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0));
+
+ var response = controller.page(user("PLATFORM"), 1, 20, 10001L, 7L, "ROLE_UPDATE", "ROLE", "9",
+ true, null, null, "管理员");
+
+ assertThat(response.successful()).isTrue();
+ verify(service).page(new AuditLogQuery(10001L, 7L, "ROLE_UPDATE", "ROLE", "9", true,
+ null, null, "管理员"), 1, 20);
+ }
+
+ @Test
+ void merchantCannotReadPlatformLogs() {
+ assertThatThrownBy(() -> controller.page(user("MERCHANT"), 1, 20, null, null, null, null,
+ null, null, null, null, null)).isInstanceOf(BizException.class)
+ .hasMessage("仅平台端可查看操作日志");
+ verifyNoInteractions(service);
+ }
+
+ @Test
+ void platformCanReadLogDetail() {
+ AuditLogResponses.Detail detail = new AuditLogResponses.Detail(8L, "audit-1", "ROLE_UPDATE", "ROLE", "9", null,
+ null, null, null, null, 7L, "管理员", "PLATFORM", "WEB", null, null, null, 1L,
+ true, null, null, "{}", "{}", "iam-service", null, null);
+ when(service.detail(8L)).thenReturn(detail);
+
+ assertThat(controller.detail(user("PLATFORM"), 8L).data()).isEqualTo(detail);
+ verify(service).detail(8L);
+ }
+
+ private static AuthenticatedUser user(String appCode) {
+ return new AuthenticatedUser(7L, 0L, 0L, 1L, "管理员", appCode, "WEB", Set.of("platform:audit:list"));
+ }
+}
diff --git a/audit-service/src/test/java/com/baiyue/audit/mq/AuditMqReliabilityTest.java b/audit-service/src/test/java/com/baiyue/audit/mq/AuditMqReliabilityTest.java
new file mode 100644
index 000000000..66b714017
--- /dev/null
+++ b/audit-service/src/test/java/com/baiyue/audit/mq/AuditMqReliabilityTest.java
@@ -0,0 +1,107 @@
+package com.baiyue.audit.mq;
+
+import com.baiyue.audit.repository.AuditDeadLetterRepository;
+import com.baiyue.audit.service.AuditDeadLetterCompensator;
+import com.baiyue.audit.service.AuditIngestService;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.annotation.Resource;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
+
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@SpringJUnitConfig(classes = {
+ AuditMqReliabilityTest.Config.class,
+ AuditRocketMqConsumer.class,
+ AuditDeadLetterConsumer.class,
+ AuditDeadLetterCompensator.class
+})
+@TestPropertySource(properties = {
+ "baiyue.audit.rocketmq.consumer-enabled=true",
+ "baiyue.audit.rocketmq.dead-letter-consumer-enabled=true",
+ "baiyue.audit.rocketmq.dead-letter-compensation-enabled=true",
+ "baiyue.audit.rocketmq.topic=test-audit",
+ "baiyue.audit.rocketmq.consumer-group=test-audit-consumer",
+ "baiyue.audit.rocketmq.dead-letter-topic=%DLQ%test-audit-consumer",
+ "baiyue.audit.rocketmq.dead-letter-group=test-audit-dlq",
+ "spring.application.name=audit-service-test"
+})
+class AuditMqReliabilityTest {
+ private static final String PAYLOAD = """
+ {"eventId":"event-1","eventType":"ROLE_UPDATE","resourceType":"ROLE",
+ "durationMs":10,"success":true,"occurredAt":"2026-08-05T00:00:00Z"}
+ """;
+
+ @MockBean
+ private AuditIngestService service;
+ @MockBean
+ private AuditDeadLetterRepository repository;
+ @Resource
+ private AuditRocketMqConsumer consumer;
+ @Resource
+ private AuditDeadLetterConsumer deadLetterConsumer;
+ @Resource
+ private AuditDeadLetterCompensator compensator;
+
+ @Test
+ void consumesAuditEvent() {
+ consumer.onMessage(PAYLOAD);
+
+ verify(service).ingest(any());
+ }
+
+ @Test
+ void rejectsInvalidPayload() {
+ assertThatThrownBy(() -> consumer.onMessage("not-json"))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void storesDeadLetter() {
+ deadLetterConsumer.onMessage(PAYLOAD);
+
+ verify(repository).save("event-1", PAYLOAD);
+ }
+
+ @Test
+ void compensatesDeadLetter() {
+ when(repository.pendingIds(50)).thenReturn(java.util.List.of(7L));
+ when(repository.claim(eq(7L), anyString(), eq(60))).thenReturn(true);
+ when(repository.find(7L)).thenReturn(
+ new AuditDeadLetterRepository.DeadRow(7L, "event-1", PAYLOAD, 1));
+
+ compensator.compensate();
+
+ verify(service).ingest(any());
+ verify(repository).processed(eq(7L), anyString());
+ }
+
+ @Test
+ void retriesFailedCompensation() {
+ when(repository.pendingIds(50)).thenReturn(java.util.List.of(8L));
+ when(repository.claim(eq(8L), anyString(), eq(60))).thenReturn(true);
+ when(repository.find(8L)).thenReturn(
+ new AuditDeadLetterRepository.DeadRow(8L, "event-2", "not-json", 1));
+
+ compensator.compensate();
+
+ verify(repository).failed(eq(8L), anyString(), eq(5), anyString());
+ }
+
+ @Configuration
+ static class Config {
+ @Bean
+ ObjectMapper objectMapper() {
+ return new ObjectMapper().findAndRegisterModules();
+ }
+ }
+}
diff --git a/audit-service/src/test/java/com/baiyue/audit/service/AuditIngestIdempotencyTest.java b/audit-service/src/test/java/com/baiyue/audit/service/AuditIngestIdempotencyTest.java
new file mode 100644
index 000000000..8de45c19c
--- /dev/null
+++ b/audit-service/src/test/java/com/baiyue/audit/service/AuditIngestIdempotencyTest.java
@@ -0,0 +1,39 @@
+package com.baiyue.audit.service;
+
+import com.baiyue.audit.dto.AuditIngestRequest;
+import com.baiyue.audit.repository.AuditEventRepository;
+import jakarta.annotation.Resource;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
+
+import java.time.Instant;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@SpringJUnitConfig(AuditIngestService.class)
+class AuditIngestIdempotencyTest {
+ @MockBean
+ private AuditEventRepository repository;
+ @Resource
+ private AuditIngestService service;
+
+ @Test
+ void keepsStableEventIdForMqRedelivery() {
+ AuditIngestRequest request = request("audit-1");
+ when(repository.save(request, "audit-1")).thenReturn(false);
+
+ assertThat(service.ingest(request)).isFalse();
+
+ verify(repository).save(request, "audit-1");
+ }
+
+ private static AuditIngestRequest request(String eventId) {
+ return new AuditIngestRequest(eventId, "ROLE_UPDATE", "ROLE", "1", "修改角色",
+ "request-1", "trace-1", 1L, 1L, 1L, "admin", "PLATFORM", "WEB", "PUT",
+ "/roles/1", "127.0.0.1", 10, true, null, null, null, null,
+ Instant.parse("2026-08-05T00:00:00Z"), "iam-service");
+ }
+}
diff --git a/audit-service/src/test/java/com/baiyue/audit/service/AuditLogQueryServiceTest.java b/audit-service/src/test/java/com/baiyue/audit/service/AuditLogQueryServiceTest.java
new file mode 100644
index 000000000..96cae3c54
--- /dev/null
+++ b/audit-service/src/test/java/com/baiyue/audit/service/AuditLogQueryServiceTest.java
@@ -0,0 +1,90 @@
+package com.baiyue.audit.service;
+
+import com.baiyue.audit.dto.AuditLogQuery;
+import com.baiyue.audit.dto.AuditLogResponses;
+import com.baiyue.audit.entity.AuditLogEntity;
+import com.baiyue.audit.repository.AuditEventRepository;
+import com.baiyue.common.core.api.PageResult;
+import com.baiyue.common.core.exception.BizException;
+import jakarta.annotation.Resource;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
+
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.when;
+
+@SpringJUnitConfig(AuditLogQueryService.class)
+class AuditLogQueryServiceTest {
+ @MockBean
+ private AuditEventRepository repository;
+
+ @Resource
+ private AuditLogQueryService service;
+
+ @Test
+ void pageDoesNotExposeSnapshots() {
+ AuditLogQuery query = new AuditLogQuery(10001L, 7L, "ROLE_UPDATE", "ROLE", "9", true,
+ null, null, "管理员");
+ when(repository.page(query, 1, 20)).thenReturn(new PageResult<>(List.of(log(8L)), 1, 20, 1, 1));
+
+ PageResult result = service.page(query, 1, 20);
+
+ assertThat(result.records()).singleElement().satisfies(item -> {
+ assertThat(item.id()).isEqualTo(8L);
+ assertThat(item.accountId()).isEqualTo(10001L);
+ assertThat(item.eventType()).isEqualTo("ROLE_UPDATE");
+ });
+ assertThat(AuditLogResponses.ListItem.class.getRecordComponents())
+ .extracting(component -> component.getName())
+ .doesNotContain("beforeData", "afterData");
+ }
+
+ @Test
+ void detailReturnsSanitizedSnapshots() {
+ when(repository.findById(8L)).thenReturn(log(8L));
+
+ AuditLogResponses.Detail detail = service.detail(8L);
+
+ assertThat(detail.beforeData()).isEqualTo("{\"name\":\"旧角色\"}");
+ assertThat(detail.afterData()).isEqualTo("{\"name\":\"新角色\"}");
+ }
+
+ @Test
+ void missingLogIsReportedAsNotFound() {
+ when(repository.findById(8L)).thenReturn(null);
+
+ assertThatThrownBy(() -> service.detail(8L)).isInstanceOf(BizException.class)
+ .hasMessage("操作日志不存在");
+ }
+
+ private static AuditLogEntity log(Long id) {
+ AuditLogEntity entity = new AuditLogEntity();
+ entity.setId(id);
+ entity.setEventId("audit-8");
+ entity.setEventType("ROLE_UPDATE");
+ entity.setResourceType("ROLE");
+ entity.setResourceId("9");
+ entity.setDescription("管理员修改角色权限");
+ entity.setTenantId(10001L);
+ entity.setOperatorTenantId(0L);
+ entity.setUserId(7L);
+ entity.setUsername("管理员");
+ entity.setAppCode("PLATFORM");
+ entity.setClientType("WEB");
+ entity.setRequestId("request-8");
+ entity.setTraceId("trace-8");
+ entity.setSuccess(true);
+ entity.setBeforeData("{\"name\":\"旧角色\"}");
+ entity.setAfterData("{\"name\":\"新角色\"}");
+ entity.setServiceName("iam-service");
+ entity.setOccurredAt(Instant.parse("2026-08-05T10:00:00Z"));
+ entity.setCreatedAt(LocalDateTime.of(2026, 8, 5, 18, 0));
+ return entity;
+ }
+}
diff --git a/auth/pom.xml b/auth/pom.xml
index d8d25d7f3..8d1a1d8cb 100644
--- a/auth/pom.xml
+++ b/auth/pom.xml
@@ -6,6 +6,7 @@
auth-service
com.baiyue.sharedbaiyue-common-audit${project.version}
+ org.apache.rocketmqrocketmq-spring-boot-starter
com.baiyue.sharedbaiyue-common-openapi${project.version}
com.baiyue.sharedbaiyue-common-web${project.version}
com.baiyue.sharedbaiyue-common-security${project.version}
diff --git a/auth/src/main/resources/db/audit-outbox.sql b/auth/src/main/resources/db/audit-outbox.sql
new file mode 100644
index 000000000..fc7fe485f
--- /dev/null
+++ b/auth/src/main/resources/db/audit-outbox.sql
@@ -0,0 +1,25 @@
+USE baiyue_auth;
+
+-- 可靠审计本地 Outbox;与认证业务表位于同一数据库。
+CREATE TABLE IF NOT EXISTS sys_audit_outbox (
+ event_id VARCHAR(64) NOT NULL COMMENT '审计事件全局唯一ID',
+ event_type VARCHAR(64) NOT NULL COMMENT '审计事件类型',
+ resource_type VARCHAR(64) NOT NULL COMMENT '资源类型',
+ resource_id VARCHAR(128) NULL COMMENT '资源ID',
+ service_name VARCHAR(64) NULL COMMENT '来源服务',
+ payload_json JSON NOT NULL COMMENT '脱敏后的审计事件',
+ status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、SENDING、SENT、DEAD',
+ attempt_count INT NOT NULL DEFAULT 0 COMMENT '当前周期发送次数',
+ compensation_count INT NOT NULL DEFAULT 0 COMMENT '自动补偿次数',
+ next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ lease_owner VARCHAR(128) NULL,
+ lease_until DATETIME(3) NULL,
+ last_error VARCHAR(1000) NULL,
+ dead_at DATETIME(3) NULL,
+ sent_at DATETIME(3) NULL,
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
+ PRIMARY KEY (event_id),
+ KEY idx_audit_outbox_publish (status, next_retry_at, lease_until, created_at),
+ KEY idx_audit_outbox_retention (status, sent_at)
+) ENGINE=InnoDB COMMENT='可靠审计事件本地发送箱';
diff --git a/common-audit/pom.xml b/common-audit/pom.xml
index 7b8224ae1..16946b96f 100644
--- a/common-audit/pom.xml
+++ b/common-audit/pom.xml
@@ -11,5 +11,16 @@
com.baiyue.sharedbaiyue-common-security${project.version}
org.springframework.bootspring-boot-starter-aop
org.springframework.bootspring-boot-starter-web
+ org.springframeworkspring-jdbc
+
+ org.apache.rocketmq
+ rocketmq-spring-boot-starter
+ true
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
diff --git a/common-audit/src/main/java/com/baiyue/common/audit/AuditEvent.java b/common-audit/src/main/java/com/baiyue/common/audit/AuditEvent.java
index 75a59edb2..9f0e7da97 100644
--- a/common-audit/src/main/java/com/baiyue/common/audit/AuditEvent.java
+++ b/common-audit/src/main/java/com/baiyue/common/audit/AuditEvent.java
@@ -6,7 +6,7 @@ import java.time.Instant;
* 跨服务业务审计事件契约,记录操作对象、操作者、租户、请求上下文、执行结果和变更快照。
*/
public record AuditEvent(
- String eventType, String resourceType, String resourceId, String description,
+ String eventId, String eventType, String resourceType, String resourceId, String description,
String requestId, String traceId, Long tenantId, Long operatorTenantId,
Long userId, String username, String appCode, String clientType,
String httpMethod, String requestUri, String clientIp, long durationMs,
diff --git a/common-audit/src/main/java/com/baiyue/common/audit/AuditOutboxPublisher.java b/common-audit/src/main/java/com/baiyue/common/audit/AuditOutboxPublisher.java
new file mode 100644
index 000000000..2548a2262
--- /dev/null
+++ b/common-audit/src/main/java/com/baiyue/common/audit/AuditOutboxPublisher.java
@@ -0,0 +1,62 @@
+package com.baiyue.common.audit;
+
+import org.apache.rocketmq.spring.core.RocketMQTemplate;
+import org.apache.rocketmq.spring.support.RocketMQHeaders;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.messaging.support.MessageBuilder;
+import org.springframework.scheduling.annotation.Scheduled;
+
+/** 带租约、退避、死信和自动补偿的审计 Outbox 发布器。 */
+public class AuditOutboxPublisher {
+ private static final Logger log = LoggerFactory.getLogger(AuditOutboxPublisher.class);
+ private static final int[] BACKOFF_SECONDS = {5, 15, 60, 300, 900};
+ private final AuditOutboxRepository repository;
+ private final RocketMQTemplate rocketMQ;
+ private final AuditProperties properties;
+ private final String owner;
+
+ public AuditOutboxPublisher(AuditOutboxRepository repository, RocketMQTemplate rocketMQ,
+ AuditProperties properties, String owner) {
+ this.repository = repository;
+ this.rocketMQ = rocketMQ;
+ this.properties = properties;
+ this.owner = owner;
+ }
+
+ @Scheduled(fixedDelayString = "${baiyue.audit.outbox.poll-interval-ms:5000}")
+ public void publish() {
+ AuditProperties.Outbox config = properties.getOutbox();
+ repository.releaseExpiredClaims();
+ repository.compensateDead(config.getCompensationDelayMinutes(), config.getMaxCompensations());
+ repository.deleteSent(config.getRetentionDays());
+ for (String eventId : repository.publishableIds(config.getBatchSize())) {
+ if (!repository.claim(eventId, owner, config.getLeaseSeconds())) {
+ continue;
+ }
+ AuditOutboxRepository.OutboxRow row = repository.find(eventId);
+ if (row == null) {
+ continue;
+ }
+ try {
+ var message = MessageBuilder.withPayload(row.payloadJson())
+ .setHeader(RocketMQHeaders.KEYS, eventId)
+ .setHeader("eventId", eventId)
+ .build();
+ rocketMQ.syncSend(properties.getRocketmq().getTopic() + ":AUDIT", message,
+ properties.getRocketmq().getSendTimeoutMs());
+ repository.sent(eventId, owner);
+ } catch (RuntimeException exception) {
+ int index = Math.min(Math.max(row.attemptCount(), 1) - 1, BACKOFF_SECONDS.length - 1);
+ repository.failed(eventId, owner, config.getMaxAttempts(), BACKOFF_SECONDS[index],
+ safe(exception.getMessage()));
+ log.warn("审计事件投递失败 eventId={} attempt={}", eventId, row.attemptCount(), exception);
+ }
+ }
+ }
+
+ private static String safe(String value) {
+ String message = value == null ? "RocketMQ 发送失败" : value;
+ return message.length() <= 1000 ? message : message.substring(0, 1000);
+ }
+}
diff --git a/common-audit/src/main/java/com/baiyue/common/audit/AuditOutboxRepository.java b/common-audit/src/main/java/com/baiyue/common/audit/AuditOutboxRepository.java
new file mode 100644
index 000000000..9083d5368
--- /dev/null
+++ b/common-audit/src/main/java/com/baiyue/common/audit/AuditOutboxRepository.java
@@ -0,0 +1,94 @@
+package com.baiyue.common.audit;
+
+import jakarta.annotation.Resource;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+import java.util.List;
+
+/** 业务库本地审计 Outbox 仓储。 */
+public class AuditOutboxRepository {
+ @Resource
+ private JdbcTemplate jdbc;
+
+ public void append(AuditEvent event, String payload) {
+ jdbc.update("""
+ INSERT INTO sys_audit_outbox
+ (event_id,event_type,resource_type,resource_id,service_name,payload_json,status,
+ attempt_count,compensation_count,next_retry_at,created_at,updated_at)
+ VALUES (?,?,?,?,?,CAST(? AS JSON),'PENDING',0,0,CURRENT_TIMESTAMP(3),
+ CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3))
+ """, event.eventId(), event.eventType(), event.resourceType(), event.resourceId(),
+ event.serviceName(), payload);
+ }
+
+ public List publishableIds(int limit) {
+ return jdbc.queryForList("""
+ SELECT event_id FROM sys_audit_outbox
+ WHERE status='PENDING' AND next_retry_at<=CURRENT_TIMESTAMP(3)
+ ORDER BY next_retry_at,created_at LIMIT ?
+ """, String.class, limit);
+ }
+
+ public boolean claim(String eventId, String owner, int leaseSeconds) {
+ return jdbc.update("""
+ UPDATE sys_audit_outbox SET status='SENDING',lease_owner=?,
+ lease_until=DATE_ADD(CURRENT_TIMESTAMP(3),INTERVAL ? SECOND),
+ attempt_count=attempt_count+1,updated_at=CURRENT_TIMESTAMP(3)
+ WHERE event_id=? AND status='PENDING' AND next_retry_at<=CURRENT_TIMESTAMP(3)
+ """, owner, leaseSeconds, eventId) == 1;
+ }
+
+ public OutboxRow find(String eventId) {
+ List rows = jdbc.query(
+ "SELECT event_id,payload_json,attempt_count FROM sys_audit_outbox WHERE event_id=?",
+ (rs, rowNum) -> new OutboxRow(rs.getString(1), rs.getString(2), rs.getInt(3)), eventId);
+ return rows.isEmpty() ? null : rows.get(0);
+ }
+
+ public boolean sent(String eventId, String owner) {
+ return jdbc.update("""
+ UPDATE sys_audit_outbox SET status='SENT',sent_at=CURRENT_TIMESTAMP(3),
+ lease_owner=NULL,lease_until=NULL,last_error=NULL,updated_at=CURRENT_TIMESTAMP(3)
+ WHERE event_id=? AND status='SENDING' AND lease_owner=?
+ """, eventId, owner) == 1;
+ }
+
+ public boolean failed(String eventId, String owner, int maxAttempts, int backoffSeconds, String error) {
+ return jdbc.update("""
+ UPDATE sys_audit_outbox
+ SET status=IF(attempt_count>=?,'DEAD','PENDING'),
+ next_retry_at=IF(attempt_count>=?,next_retry_at,
+ DATE_ADD(CURRENT_TIMESTAMP(3),INTERVAL ? SECOND)),
+ dead_at=IF(attempt_count>=?,CURRENT_TIMESTAMP(3),dead_at),
+ lease_owner=NULL,lease_until=NULL,last_error=?,updated_at=CURRENT_TIMESTAMP(3)
+ WHERE event_id=? AND status='SENDING' AND lease_owner=?
+ """, maxAttempts, maxAttempts, backoffSeconds, maxAttempts, error, eventId, owner) == 1;
+ }
+
+ public int releaseExpiredClaims() {
+ return jdbc.update("""
+ UPDATE sys_audit_outbox SET status='PENDING',next_retry_at=CURRENT_TIMESTAMP(3),
+ lease_owner=NULL,lease_until=NULL,updated_at=CURRENT_TIMESTAMP(3)
+ WHERE status='SENDING' AND lease_until<=CURRENT_TIMESTAMP(3) LIMIT 100
+ """);
+ }
+
+ public int compensateDead(int delayMinutes, int maxCompensations) {
+ return jdbc.update("""
+ UPDATE sys_audit_outbox SET status='PENDING',attempt_count=0,
+ compensation_count=compensation_count+1,next_retry_at=CURRENT_TIMESTAMP(3),
+ dead_at=NULL,last_error=NULL,updated_at=CURRENT_TIMESTAMP(3)
+ WHERE status='DEAD' AND compensation_count
+ AND dead_at<=DATE_SUB(CURRENT_TIMESTAMP(3),INTERVAL ? MINUTE) LIMIT 50
+ """, maxCompensations, delayMinutes);
+ }
+
+ public int deleteSent(int retentionDays) {
+ return jdbc.update("""
+ DELETE FROM sys_audit_outbox WHERE status='SENT'
+ AND sent_at outbox,
+ ObjectProvider transactionManagers,
+ @Value("${baiyue.security.internal-service-token:}") String internalServiceToken) {
+ AuditOutboxRepository repository = outbox.getIfAvailable();
+ PlatformTransactionManager transactionManager = transactionManagers.getIfAvailable();
+ if (p.getOutbox().isEnabled() && p.getOutbox().isPublisherEnabled()
+ && repository != null && transactionManager != null) {
+ TransactionTemplate transactions = new TransactionTemplate(transactionManager);
+ transactions.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
+ return new OutboxAuditSink(repository, mapper, transactions);
+ }
+ return p.getEndpoint() == null || p.getEndpoint().isBlank()
+ ? new LoggingAuditSink()
+ : new HttpAuditSink(builder, p.getEndpoint(), internalServiceToken);
}
+ @Bean
+ @ConditionalOnBean(JdbcTemplate.class)
+ @ConditionalOnProperty(prefix = "baiyue.audit.outbox", name = "enabled", havingValue = "true")
+ AuditOutboxRepository auditOutboxRepository() { return new AuditOutboxRepository(); }
+
@Bean SensitiveDataSanitizer sensitiveDataSanitizer(ObjectMapper mapper, AuditProperties p) { return new SensitiveDataSanitizer(mapper, p.getMaxPayloadLength()); }
@Bean BusinessAuditAspect businessAuditAspect() { return new BusinessAuditAspect(); }
}
diff --git a/common-audit/src/main/java/com/baiyue/common/audit/BusinessAuditAspect.java b/common-audit/src/main/java/com/baiyue/common/audit/BusinessAuditAspect.java
index dd1d190f5..305522764 100644
--- a/common-audit/src/main/java/com/baiyue/common/audit/BusinessAuditAspect.java
+++ b/common-audit/src/main/java/com/baiyue/common/audit/BusinessAuditAspect.java
@@ -23,6 +23,7 @@ import org.springframework.web.context.request.ServletRequestAttributes;
import java.time.Instant;
import java.util.Arrays;
+import java.util.UUID;
/**
* 业务审计切面,拦截审计注解方法并采集操作者、租户、请求、耗时、结果及脱敏前后快照。
@@ -34,7 +35,7 @@ public class BusinessAuditAspect {
private AuditSink sink;
@Resource
private SensitiveDataSanitizer sanitizer;
- @Value("${baiyue.audit.service-name:unknown-service}")
+ @Value("${baiyue.audit.service-name:${spring.application.name:unknown-service}}")
private String serviceName;
private final ExpressionParser parser = new SpelExpressionParser();
@@ -56,7 +57,7 @@ public class BusinessAuditAspect {
HttpServletRequest req = request();
AuthenticatedUser user = currentUser();
String resourceId = evaluateResourceId(pjp, a.resourceId());
- sink.publish(new AuditEvent(a.event(), a.resourceType(), resourceId, a.description(),
+ sink.publish(new AuditEvent(UUID.randomUUID().toString(), a.event(), a.resourceType(), resourceId, a.description(),
req == null ? null : CorrelationContext.requestId(req), req == null ? null : CorrelationContext.traceId(req),
user == null ? headerLong(req, "X-Tenant-Id") : user.tenantId(),
user == null ? headerLong(req, "X-Operator-Tenant-Id") : user.operatorTenantId(),
diff --git a/common-audit/src/main/java/com/baiyue/common/audit/HttpAuditSink.java b/common-audit/src/main/java/com/baiyue/common/audit/HttpAuditSink.java
index 0a8aa2a19..a623e1875 100644
--- a/common-audit/src/main/java/com/baiyue/common/audit/HttpAuditSink.java
+++ b/common-audit/src/main/java/com/baiyue/common/audit/HttpAuditSink.java
@@ -8,17 +8,28 @@ import org.springframework.web.client.RestClient;
* HTTP 审计输出实现,将结构化业务审计事件发送到独立审计服务。
*/
public class HttpAuditSink implements AuditSink {
+ public static final String INTERNAL_SERVICE_TOKEN_HEADER = "X-Internal-Service-Token";
private static final Logger auditLog = LoggerFactory.getLogger("BAIYUE_AUDIT");
private final RestClient client;
private final String endpoint;
+ private final String internalServiceToken;
- public HttpAuditSink(RestClient.Builder builder, String endpoint) {
+ public HttpAuditSink(RestClient.Builder builder, String endpoint, String internalServiceToken) {
this.client = builder.build();
this.endpoint = endpoint;
+ this.internalServiceToken = internalServiceToken;
}
- @Override public void publish(AuditEvent event) {
- try { client.post().uri(endpoint).body(event).retrieve().toBodilessEntity(); }
+ @Override
+ public void publish(AuditEvent event) {
+ try {
+ client.post()
+ .uri(endpoint)
+ .header(INTERNAL_SERVICE_TOKEN_HEADER, internalServiceToken)
+ .body(event)
+ .retrieve()
+ .toBodilessEntity();
+ }
catch (RuntimeException ex) {
auditLog.warn("deliveryFailed errorType={} errorMessage={} audit={}",
ex.getClass().getSimpleName(), ex.getMessage(), event);
diff --git a/common-audit/src/main/java/com/baiyue/common/audit/OutboxAuditSink.java b/common-audit/src/main/java/com/baiyue/common/audit/OutboxAuditSink.java
new file mode 100644
index 000000000..3a887797a
--- /dev/null
+++ b/common-audit/src/main/java/com/baiyue/common/audit/OutboxAuditSink.java
@@ -0,0 +1,33 @@
+package com.baiyue.common.audit;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.transaction.support.TransactionOperations;
+
+/** 将审计事件先持久化到业务库 Outbox,核心业务不等待 MQ。 */
+public class OutboxAuditSink implements AuditSink {
+ private static final Logger log = LoggerFactory.getLogger(OutboxAuditSink.class);
+ private final AuditOutboxRepository repository;
+ private final ObjectMapper json;
+ private final TransactionOperations transactions;
+
+ public OutboxAuditSink(AuditOutboxRepository repository, ObjectMapper json,
+ TransactionOperations transactions) {
+ this.repository = repository;
+ this.json = json;
+ this.transactions = transactions;
+ }
+
+ @Override
+ public void publish(AuditEvent event) {
+ try {
+ String payload = json.writeValueAsString(event);
+ transactions.executeWithoutResult(status -> repository.append(event, payload));
+ } catch (JsonProcessingException | RuntimeException exception) {
+ log.error("审计事件写入本地 Outbox 失败 eventId={} eventType={}",
+ event.eventId(), event.eventType(), exception);
+ }
+ }
+}
diff --git a/common-audit/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/common-audit/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
index 279053f24..7ad45d4f2 100644
--- a/common-audit/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
+++ b/common-audit/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
@@ -1 +1,2 @@
com.baiyue.common.audit.BaiyueAuditAutoConfiguration
+com.baiyue.common.audit.AuditOutboxRocketMqAutoConfiguration
diff --git a/common-audit/src/test/java/com/baiyue/common/audit/HttpAuditSinkTest.java b/common-audit/src/test/java/com/baiyue/common/audit/HttpAuditSinkTest.java
new file mode 100644
index 000000000..6dddeb2cd
--- /dev/null
+++ b/common-audit/src/test/java/com/baiyue/common/audit/HttpAuditSinkTest.java
@@ -0,0 +1,40 @@
+package com.baiyue.common.audit;
+
+import java.time.Instant;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.client.MockRestServiceServer;
+import org.springframework.web.client.RestClient;
+
+import static org.springframework.test.web.client.ExpectedCount.once;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
+import static org.springframework.test.web.client.response.MockRestResponseCreators.withAccepted;
+
+class HttpAuditSinkTest {
+ private static final String ENDPOINT = "http://audit-service/internal/audit/events";
+ private static final String TOKEN = "0123456789abcdef0123456789abcdef";
+
+ @Test
+ void sendsInternalServiceToken() {
+ RestClient.Builder builder = RestClient.builder();
+ MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
+ server.expect(once(), requestTo(ENDPOINT))
+ .andExpect(method(HttpMethod.POST))
+ .andExpect(header(HttpAuditSink.INTERNAL_SERVICE_TOKEN_HEADER, TOKEN))
+ .andRespond(withAccepted().contentType(MediaType.APPLICATION_JSON));
+
+ new HttpAuditSink(builder, ENDPOINT, TOKEN).publish(sampleEvent());
+
+ server.verify();
+ }
+
+ private AuditEvent sampleEvent() {
+ return new AuditEvent("audit-1", "ROLE_UPDATE", "ROLE", "1", "update role",
+ "request-1", "trace-1", 1L, 1L, 1L, "admin", "PLATFORM", "WEB",
+ "PUT", "/api/platform/v1/roles/1", "127.0.0.1", 10L, true,
+ null, null, null, null, Instant.parse("2026-08-05T00:00:00Z"), "iam");
+ }
+}
diff --git a/common-audit/src/test/java/com/baiyue/common/audit/ReliableAuditDeliveryTest.java b/common-audit/src/test/java/com/baiyue/common/audit/ReliableAuditDeliveryTest.java
new file mode 100644
index 000000000..9c7f8780d
--- /dev/null
+++ b/common-audit/src/test/java/com/baiyue/common/audit/ReliableAuditDeliveryTest.java
@@ -0,0 +1,113 @@
+package com.baiyue.common.audit;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.annotation.Resource;
+import org.apache.rocketmq.spring.core.RocketMQTemplate;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.messaging.Message;
+import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
+import org.springframework.transaction.support.TransactionOperations;
+
+import java.time.Instant;
+import java.util.List;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@SpringJUnitConfig(ReliableAuditDeliveryTest.Config.class)
+class ReliableAuditDeliveryTest {
+ @MockBean
+ private AuditOutboxRepository repository;
+ @MockBean
+ private RocketMQTemplate rocketMQ;
+ @MockBean
+ private TransactionOperations transactions;
+ @Resource
+ private OutboxAuditSink sink;
+ @Resource
+ private AuditOutboxPublisher publisher;
+
+ @Test
+ void storesEventBeforeDelivery() {
+ AuditEvent event = event("audit-1");
+ org.mockito.Mockito.doAnswer(invocation -> {
+ java.util.function.Consumer> callback = invocation.getArgument(0);
+ ((java.util.function.Consumer
+
+ org.apache.rocketmq
+ rocketmq-spring-boot-starter
+
com.baomidou
mybatis-plus-spring-boot3-starter
diff --git a/warehouse/src/main/resources/db/audit-outbox.sql b/warehouse/src/main/resources/db/audit-outbox.sql
new file mode 100644
index 000000000..e678802a7
--- /dev/null
+++ b/warehouse/src/main/resources/db/audit-outbox.sql
@@ -0,0 +1,15 @@
+USE baiyue_warehouse;
+
+-- 可靠审计本地 Outbox;与仓储业务表位于同一数据库。
+CREATE TABLE IF NOT EXISTS sys_audit_outbox (
+ event_id VARCHAR(64) NOT NULL, event_type VARCHAR(64) NOT NULL, resource_type VARCHAR(64) NOT NULL,
+ resource_id VARCHAR(128) NULL, service_name VARCHAR(64) NULL, payload_json JSON NOT NULL,
+ status VARCHAR(16) NOT NULL DEFAULT 'PENDING', attempt_count INT NOT NULL DEFAULT 0,
+ compensation_count INT NOT NULL DEFAULT 0,
+ next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), lease_owner VARCHAR(128) NULL,
+ lease_until DATETIME(3) NULL, last_error VARCHAR(1000) NULL, dead_at DATETIME(3) NULL,
+ sent_at DATETIME(3) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
+ PRIMARY KEY (event_id), KEY idx_audit_outbox_publish (status, next_retry_at, lease_until, created_at),
+ KEY idx_audit_outbox_retention (status, sent_at)
+) ENGINE=InnoDB COMMENT='可靠审计事件本地发送箱';