日志记录提交
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, "仅平台端可查看操作日志");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user