日志记录提交
This commit is contained in:
@@ -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 {
|
||||
|
||||
|
||||
@@ -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<Void> ingest(@Valid @RequestBody AuditIngestRequest event) {
|
||||
public ResponseEntity<ApiResponse<Void>> 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));
|
||||
}
|
||||
}
|
||||
|
||||
+65
@@ -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<PageResult<AuditLogResponses.ListItem>> 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<AuditLogResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Min(1) Long id) {
|
||||
AuditPlatformScopeGuard.requirePlatform(user);
|
||||
return ApiResponse.success(service.detail(id));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String> {
|
||||
@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));
|
||||
}
|
||||
}
|
||||
@@ -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<String> {
|
||||
@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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+73
@@ -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<Long> 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<DeadRow> 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) { }
|
||||
}
|
||||
@@ -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<AuditLogEntity> page(AuditLogQuery query, long page, long size) {
|
||||
LambdaQueryWrapper<AuditLogEntity> wrapper = new LambdaQueryWrapper<AuditLogEntity>()
|
||||
.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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AuditLogResponses.ListItem> page(AuditLogQuery query, long page, long size) {
|
||||
PageResult<AuditLogEntity> 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());
|
||||
}
|
||||
}
|
||||
@@ -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, "仅平台端可查看操作日志");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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='审计消息死信补偿表';
|
||||
|
||||
@@ -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='审计消息死信补偿表';
|
||||
+74
@@ -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"
|
||||
}
|
||||
""";
|
||||
}
|
||||
}
|
||||
+68
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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<AuditLogResponses.ListItem> 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user