日志记录提交

This commit is contained in:
2026-08-05 14:20:04 +08:00
parent 264458920b
commit 06f7f9d2f2
83 changed files with 3256 additions and 27 deletions
+9
View File
@@ -27,6 +27,11 @@
<artifactId>baiyue-common-openapi</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.baiyue.shared</groupId>
<artifactId>baiyue-common-security</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
@@ -36,6 +41,10 @@
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
@@ -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));
}
}
@@ -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);
}
}
}
@@ -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());
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='审计消息死信补偿表';
@@ -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"
}
""";
}
}
@@ -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;
}
}
+1
View File
@@ -6,6 +6,7 @@
<artifactId>auth-service</artifactId>
<dependencies>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-audit</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>org.apache.rocketmq</groupId><artifactId>rocketmq-spring-boot-starter</artifactId></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-openapi</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-web</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-security</artifactId><version>${project.version}</version></dependency>
@@ -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='可靠审计事件本地发送箱';
+11
View File
@@ -11,5 +11,16 @@
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-security</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-aop</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
<dependency><groupId>org.springframework</groupId><artifactId>spring-jdbc</artifactId></dependency>
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-spring-boot-starter</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -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,
@@ -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);
}
}
@@ -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<String> 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<OutboxRow> 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<DATE_SUB(CURRENT_TIMESTAMP(3),INTERVAL ? DAY) LIMIT 500
""", retentionDays);
}
public record OutboxRow(String eventId, String payloadJson, int attemptCount) { }
}
@@ -0,0 +1,33 @@
package com.baiyue.common.audit;
import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 审计 Outbox 的 RocketMQ 发布配置。
* 该配置与基础审计配置隔离:未引入 RocketMQ 的服务仍可使用 HTTP 审计投递。
*/
@AutoConfiguration(after = BaiyueAuditAutoConfiguration.class)
@EnableScheduling
@ConditionalOnClass(name = "org.apache.rocketmq.spring.core.RocketMQTemplate")
@ConditionalOnBean(type = {
"com.baiyue.common.audit.AuditOutboxRepository",
"org.apache.rocketmq.spring.core.RocketMQTemplate"
})
@ConditionalOnProperty(prefix = "baiyue.audit.outbox", name = "publisher-enabled", havingValue = "true")
public class AuditOutboxRocketMqAutoConfiguration {
@Bean
AuditOutboxPublisher auditOutboxPublisher(AuditOutboxRepository repository,
RocketMQTemplate rocketMQ,
AuditProperties properties,
@Value("${spring.application.name:unknown}-${random.uuid}") String owner) {
return new AuditOutboxPublisher(repository, rocketMQ, properties, owner);
}
}
@@ -11,6 +11,8 @@ public class AuditProperties {
private String endpoint;
private int maxPayloadLength = 16000;
private String serviceName = "unknown-service";
private final Outbox outbox = new Outbox();
private final Rocketmq rocketmq = new Rocketmq();
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
@@ -20,4 +22,44 @@ public class AuditProperties {
public void setMaxPayloadLength(int maxPayloadLength) { this.maxPayloadLength = maxPayloadLength; }
public String getServiceName() { return serviceName; }
public void setServiceName(String serviceName) { this.serviceName = serviceName; }
public Outbox getOutbox() { return outbox; }
public Rocketmq getRocketmq() { return rocketmq; }
public static class Outbox {
private boolean enabled;
private boolean publisherEnabled;
private int batchSize = 50;
private int leaseSeconds = 60;
private int maxAttempts = 5;
private int maxCompensations = 2;
private int compensationDelayMinutes = 30;
private int retentionDays = 30;
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public boolean isPublisherEnabled() { return publisherEnabled; }
public void setPublisherEnabled(boolean publisherEnabled) { this.publisherEnabled = publisherEnabled; }
public int getBatchSize() { return batchSize; }
public void setBatchSize(int batchSize) { this.batchSize = batchSize; }
public int getLeaseSeconds() { return leaseSeconds; }
public void setLeaseSeconds(int leaseSeconds) { this.leaseSeconds = leaseSeconds; }
public int getMaxAttempts() { return maxAttempts; }
public void setMaxAttempts(int maxAttempts) { this.maxAttempts = maxAttempts; }
public int getMaxCompensations() { return maxCompensations; }
public void setMaxCompensations(int maxCompensations) { this.maxCompensations = maxCompensations; }
public int getCompensationDelayMinutes() { return compensationDelayMinutes; }
public void setCompensationDelayMinutes(int compensationDelayMinutes) { this.compensationDelayMinutes = compensationDelayMinutes; }
public int getRetentionDays() { return retentionDays; }
public void setRetentionDays(int retentionDays) { this.retentionDays = retentionDays; }
}
public static class Rocketmq {
private String topic = "test-baiyue-audit-event-v1";
private int sendTimeoutMs = 3000;
public String getTopic() { return topic; }
public void setTopic(String topic) { this.topic = topic; }
public int getSendTimeoutMs() { return sendTimeoutMs; }
public void setSendTimeoutMs(int sendTimeoutMs) { this.sendTimeoutMs = sendTimeoutMs; }
}
}
@@ -3,10 +3,17 @@ package com.baiyue.common.audit;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.web.client.RestClient;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
/**
* 业务审计自动配置,按配置创建脱敏器、审计输出端和审计切面。
@@ -15,9 +22,29 @@ import org.springframework.web.client.RestClient;
@EnableConfigurationProperties(AuditProperties.class)
@ConditionalOnProperty(prefix = "baiyue.audit", name = "enabled", havingValue = "true", matchIfMissing = true)
public class BaiyueAuditAutoConfiguration {
@Bean @ConditionalOnMissingBean AuditSink auditSink(RestClient.Builder builder, AuditProperties p) {
return p.getEndpoint() == null || p.getEndpoint().isBlank() ? new LoggingAuditSink() : new HttpAuditSink(builder, p.getEndpoint());
@Bean
@ConditionalOnMissingBean
AuditSink auditSink(RestClient.Builder builder, AuditProperties p, ObjectMapper mapper,
ObjectProvider<AuditOutboxRepository> outbox,
ObjectProvider<PlatformTransactionManager> 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(); }
}
@@ -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(),
@@ -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);
@@ -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);
}
}
}
@@ -1 +1,2 @@
com.baiyue.common.audit.BaiyueAuditAutoConfiguration
com.baiyue.common.audit.AuditOutboxRocketMqAutoConfiguration
@@ -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");
}
}
@@ -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<Object>) callback).accept(null);
return null;
}).when(transactions).executeWithoutResult(any());
sink.publish(event);
verify(repository).append(eq(event), anyString());
}
@Test
void marksDeliveredMessageSent() {
when(repository.publishableIds(50)).thenReturn(List.of("audit-1"));
when(repository.claim("audit-1", "test-owner", 60)).thenReturn(true);
when(repository.find("audit-1"))
.thenReturn(new AuditOutboxRepository.OutboxRow("audit-1", "{}", 1));
publisher.publish();
verify(rocketMQ).syncSend(anyString(), any(Message.class), anyLong());
verify(repository).sent("audit-1", "test-owner");
}
@Test
void schedulesRetryAfterMqFailure() {
when(repository.publishableIds(50)).thenReturn(List.of("audit-2"));
when(repository.claim("audit-2", "test-owner", 60)).thenReturn(true);
when(repository.find("audit-2"))
.thenReturn(new AuditOutboxRepository.OutboxRow("audit-2", "{}", 1));
doThrow(new IllegalStateException("broker unavailable"))
.when(rocketMQ).syncSend(anyString(), any(Message.class), anyLong());
publisher.publish();
verify(repository).failed("audit-2", "test-owner", 5, 5, "broker unavailable");
}
private static AuditEvent event(String id) {
return new AuditEvent(id, "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");
}
@Configuration
static class Config {
@Bean
ObjectMapper objectMapper() { return new ObjectMapper().findAndRegisterModules(); }
@Bean
AuditProperties properties() {
AuditProperties value = new AuditProperties();
value.getOutbox().setBatchSize(50);
value.getOutbox().setLeaseSeconds(60);
value.getOutbox().setMaxAttempts(5);
value.getRocketmq().setTopic("test-baiyue-audit-event-v1");
return value;
}
@Bean
OutboxAuditSink sink(AuditOutboxRepository repository, ObjectMapper json,
TransactionOperations transactions) {
return new OutboxAuditSink(repository, json, transactions);
}
@Bean
AuditOutboxPublisher publisher(AuditOutboxRepository repository, RocketMQTemplate rocketMQ,
AuditProperties properties) {
return new AuditOutboxPublisher(repository, rocketMQ, properties, "test-owner");
}
}
}
+15 -1
View File
@@ -11,7 +11,21 @@ baiyue:
warehouse:
public-tenant-id: ${DEFAULT_TENANT_ID:10001}
openapi.enabled: ${SWAGGER_ENABLED:false}
audit.enabled: true
audit:
enabled: true
endpoint: ${AUDIT_SERVICE_URL:http://audit-service:9700}/internal/audit/events
outbox:
enabled: ${AUDIT_OUTBOX_ENABLED:false}
publisher-enabled: ${AUDIT_OUTBOX_PUBLISHER_ENABLED:false}
batch-size: ${AUDIT_OUTBOX_BATCH_SIZE:50}
lease-seconds: ${AUDIT_OUTBOX_LEASE_SECONDS:60}
max-attempts: ${AUDIT_OUTBOX_MAX_ATTEMPTS:5}
max-compensations: ${AUDIT_OUTBOX_MAX_COMPENSATIONS:2}
compensation-delay-minutes: ${AUDIT_OUTBOX_COMPENSATION_DELAY_MINUTES:30}
retention-days: ${AUDIT_OUTBOX_RETENTION_DAYS:30}
rocketmq:
topic: ${MQ_TOPIC_PREFIX:}baiyue-audit-event-v1
send-timeout-ms: ${AUDIT_MQ_SEND_TIMEOUT_MS:3000}
spring:
jackson:
default-property-inclusion: non_null
+15 -1
View File
@@ -11,7 +11,21 @@ baiyue:
warehouse:
public-tenant-id: ${DEFAULT_TENANT_ID:10001}
openapi.enabled: ${SWAGGER_ENABLED:true}
audit.enabled: true
audit:
enabled: true
endpoint: ${AUDIT_SERVICE_URL:http://127.0.0.1:9700}/internal/audit/events
outbox:
enabled: ${AUDIT_OUTBOX_ENABLED:false}
publisher-enabled: ${AUDIT_OUTBOX_PUBLISHER_ENABLED:false}
batch-size: ${AUDIT_OUTBOX_BATCH_SIZE:50}
lease-seconds: ${AUDIT_OUTBOX_LEASE_SECONDS:60}
max-attempts: ${AUDIT_OUTBOX_MAX_ATTEMPTS:5}
max-compensations: ${AUDIT_OUTBOX_MAX_COMPENSATIONS:2}
compensation-delay-minutes: ${AUDIT_OUTBOX_COMPENSATION_DELAY_MINUTES:30}
retention-days: ${AUDIT_OUTBOX_RETENTION_DAYS:30}
rocketmq:
topic: ${MQ_TOPIC_PREFIX:test-}baiyue-audit-event-v1
send-timeout-ms: ${AUDIT_MQ_SEND_TIMEOUT_MS:3000}
spring:
jackson:
default-property-inclusion: non_null
+1 -1
View File
@@ -11,7 +11,7 @@ firstleg:
allow-rejected-measurement: false
default-pallet-capacity-cbm: ${DEFAULT_PALLET_CAPACITY_CBM:1.200000}
reservation-ttl: ${PALLET_RESERVATION_TTL:30m}
warehouse-resource-tenant-id: ${WAREHOUSE_RESOURCE_TENANT_ID:0}
warehouse-resource-tenant-id: ${WAREHOUSE_RESOURCE_TENANT_ID:10001}
measurement-openapi:
enabled: ${MEASUREMENT_OPENAPI_ENABLED:false}
max-clock-skew: ${MEASUREMENT_OPENAPI_MAX_CLOCK_SKEW:5m}
+43
View File
@@ -0,0 +1,43 @@
services:
auth:
environment:
AUDIT_OUTBOX_ENABLED: "true"
AUDIT_OUTBOX_PUBLISHER_ENABLED: "true"
ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER:?可靠审计启用时必须配置ROCKETMQ_NAME_SERVER}
ROCKETMQ_PRODUCER_GROUP: ${MQ_TOPIC_PREFIX-}auth-service-audit-publisher-v1
MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX-}
iam:
environment:
AUDIT_OUTBOX_ENABLED: "true"
AUDIT_OUTBOX_PUBLISHER_ENABLED: "true"
ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER:?可靠审计启用时必须配置ROCKETMQ_NAME_SERVER}
ROCKETMQ_PRODUCER_GROUP: ${MQ_TOPIC_PREFIX-}iam-service-audit-publisher-v1
MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX-}
order:
environment:
AUDIT_OUTBOX_ENABLED: "true"
AUDIT_OUTBOX_PUBLISHER_ENABLED: "true"
ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER:?可靠审计启用时必须配置ROCKETMQ_NAME_SERVER}
ROCKETMQ_PRODUCER_GROUP: ${MQ_TOPIC_PREFIX-}order-service-audit-publisher-v1
MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX-}
firstleg:
environment:
AUDIT_OUTBOX_ENABLED: "true"
AUDIT_OUTBOX_PUBLISHER_ENABLED: "true"
ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER:?可靠审计启用时必须配置ROCKETMQ_NAME_SERVER}
ROCKETMQ_PRODUCER_GROUP: ${MQ_TOPIC_PREFIX-}firstleg-service-audit-publisher-v1
MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX-}
warehouse:
environment:
AUDIT_OUTBOX_ENABLED: "true"
AUDIT_OUTBOX_PUBLISHER_ENABLED: "true"
ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER:?可靠审计启用时必须配置ROCKETMQ_NAME_SERVER}
ROCKETMQ_PRODUCER_GROUP: ${MQ_TOPIC_PREFIX-}warehouse-service-audit-publisher-v1
MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX-}
audit-service:
environment:
ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER:?可靠审计启用时必须配置ROCKETMQ_NAME_SERVER}
MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX-}
AUDIT_MQ_CONSUMER_ENABLED: "true"
AUDIT_DLQ_CONSUMER_ENABLED: "true"
AUDIT_DLQ_COMPENSATION_ENABLED: "true"
+9 -1
View File
@@ -35,7 +35,15 @@ services:
sh mqadmin updateSubGroup -n rocketmq-namesrv:9876 -c BaiyueCluster
-g "$${MQ_TOPIC_PREFIX}baiyue-file-export-dlq-handler-v1";
sh mqadmin updateTopic -n rocketmq-namesrv:9876 -c BaiyueCluster
-t "%DLQ%$${MQ_TOPIC_PREFIX}baiyue-file-export-worker-v1" -r 1 -w 1 -p 6'
-t "%DLQ%$${MQ_TOPIC_PREFIX}baiyue-file-export-worker-v1" -r 1 -w 1 -p 6;
sh mqadmin updateTopic -n rocketmq-namesrv:9876 -c BaiyueCluster
-t "$${MQ_TOPIC_PREFIX}baiyue-audit-event-v1" -r 4 -w 4;
sh mqadmin updateSubGroup -n rocketmq-namesrv:9876 -c BaiyueCluster
-g "$${MQ_TOPIC_PREFIX}baiyue-audit-consumer-v1";
sh mqadmin updateSubGroup -n rocketmq-namesrv:9876 -c BaiyueCluster
-g "$${MQ_TOPIC_PREFIX}baiyue-audit-dlq-handler-v1";
sh mqadmin updateTopic -n rocketmq-namesrv:9876 -c BaiyueCluster
-t "%DLQ%$${MQ_TOPIC_PREFIX}baiyue-audit-consumer-v1" -r 1 -w 1 -p 6'
# Broker 注册较慢或任一初始化命令失败时整组重试;所有命令均幂等。
restart: on-failure
networks: [infra-network]
+5
View File
@@ -19,6 +19,8 @@ x-common: &common
REDIS_DATABASE: ${REDIS_DATABASE:-0}
JWT_SECRET: ${JWT_SECRET}
INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN}
AUDIT_OUTBOX_ENABLED: ${AUDIT_OUTBOX_ENABLED:-false}
AUDIT_OUTBOX_PUBLISHER_ENABLED: ${AUDIT_OUTBOX_PUBLISHER_ENABLED:-false}
REGION_CODE: ${REGION_CODE:-CN}
DEPLOYMENT_CELL: ${DEPLOYMENT_CELL:-test-cn}
DATA_RESIDENCY: ${DATA_RESIDENCY:-CN}
@@ -189,6 +191,9 @@ services:
AUDIT_DB_URL: ${AUDIT_DB_URL}
AUDIT_DB_USERNAME: ${AUDIT_DB_USERNAME}
AUDIT_DB_PASSWORD: ${AUDIT_DB_PASSWORD}
AUDIT_MQ_CONSUMER_ENABLED: ${AUDIT_MQ_CONSUMER_ENABLED:-false}
AUDIT_DLQ_CONSUMER_ENABLED: ${AUDIT_DLQ_CONSUMER_ENABLED:-false}
AUDIT_DLQ_COMPENSATION_ENABLED: ${AUDIT_DLQ_COMPENSATION_ENABLED:-false}
networks:
firstleg-network:
+5
View File
@@ -9,4 +9,9 @@ REDIS_PASSWORD=CHANGE_ME_TEST_REDIS_PASSWORD
ROCKETMQ_BROKER_IP=rocketmq-broker
# 测试环境必须显式使用 test-;生产环境在对应凭据文件中显式设置为空字符串。
MQ_TOPIC_PREFIX=test-
AUDIT_OUTBOX_ENABLED=false
AUDIT_OUTBOX_PUBLISHER_ENABLED=false
AUDIT_MQ_CONSUMER_ENABLED=false
AUDIT_DLQ_CONSUMER_ENABLED=false
AUDIT_DLQ_COMPENSATION_ENABLED=false
ROCKETMQ_DATA_DIR=/data/firstleg/rocketmq/store
@@ -0,0 +1,61 @@
-- 可靠审计 Outbox、消费幂等和死信补偿增量(MySQL 8)
SET NAMES utf8mb4;
USE baiyue_audit;
DROP PROCEDURE IF EXISTS create_reliable_audit_outbox;
DELIMITER $$
CREATE PROCEDURE create_reliable_audit_outbox(IN schema_name VARCHAR(64))
BEGIN
SET @ddl = CONCAT(
'CREATE TABLE IF NOT EXISTS \`', schema_name, '\`.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=''可靠审计事件本地发送箱'''
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END$$
DELIMITER ;
CALL create_reliable_audit_outbox('baiyue_auth');
CALL create_reliable_audit_outbox('baiyue_iam');
CALL create_reliable_audit_outbox('baiyue_order');
CALL create_reliable_audit_outbox('baiyue_firstleg');
CALL create_reliable_audit_outbox('baiyue_warehouse');
DROP PROCEDURE IF EXISTS create_reliable_audit_outbox;
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,payload_text LONGTEXT NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PENDING',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='审计消息死信补偿表';
SELECT 'PASS' AS migration_status,'可靠审计Outbox、幂等和死信补偿表已就绪' AS message;
@@ -3462,3 +3462,179 @@ DROP PROCEDURE IF EXISTS unified_assert_overseas_storage_fee;
SELECT 'PASS' AS migration_status,
'统一增量执行完成,海外仓阶梯费用表、索引和权限校验通过' AS message;
-- ============================================================
-- 二期:费用项目主数据与入库单应收应付费用明细
-- ============================================================
USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_fee_item (
id BIGINT UNSIGNED NOT NULL COMMENT '费用项目ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '账号ID,沿用tenant_id字段名',
category VARCHAR(64) NOT NULL COMMENT '费用分类',
trade_scope VARCHAR(20) NOT NULL COMMENT 'DOMESTIC国内、INTERNATIONAL国际',
fee_type_name VARCHAR(128) NOT NULL COMMENT '费用类型名称',
pickup_payable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '提柜应付是否参与',
pickup_receivable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '提柜应收是否参与',
warehouse_payable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '仓储应付是否参与',
warehouse_receivable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '仓储应收是否参与',
allocation_fee TINYINT(1) NOT NULL DEFAULT 0 COMMENT '平摊费用是否参与',
description VARCHAR(500) NULL COMMENT '说明',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT 'ACTIVE或DISABLED',
created_by BIGINT NULL,
created_by_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_by BIGINT NULL,
updated_by_name VARCHAR(64) NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
deleted TINYINT(1) NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 0,
PRIMARY KEY (id),
KEY idx_fee_item_query (tenant_id, trade_scope, category, status, deleted),
KEY idx_fee_item_name (tenant_id, fee_type_name, deleted)
) ENGINE=InnoDB COMMENT='费用项目主数据';
CREATE TABLE IF NOT EXISTS fl_inbound_fee_detail (
id BIGINT UNSIGNED NOT NULL COMMENT '入库费用明细ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '账号ID,沿用tenant_id字段名',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
fee_item_id BIGINT UNSIGNED NOT NULL COMMENT '绑定费用项目ID',
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
direction VARCHAR(16) NOT NULL COMMENT 'RECEIVABLE应收、PAYABLE应付',
business_date DATE NOT NULL COMMENT '业务发生日',
fee_category_snapshot VARCHAR(64) NOT NULL COMMENT '费用分类快照',
trade_scope_snapshot VARCHAR(20) NOT NULL COMMENT '国内国际快照',
fee_type_name_snapshot VARCHAR(128) NOT NULL COMMENT '费用类型名称快照',
channel_code VARCHAR(64) NULL COMMENT '渠道编码快照',
channel_name VARCHAR(128) NULL COMMENT '渠道名称快照',
destination_code VARCHAR(32) NULL COMMENT '目的仓编码快照',
destination_name VARCHAR(128) NULL COMMENT '目的仓名称快照',
package_count INT NULL COMMENT '件数快照',
actual_gross_weight_kg DECIMAL(18,4) NULL COMMENT '仓库实测毛重',
actual_volume_cbm DECIMAL(18,6) NULL COMMENT '体积',
volume_weight_kg DECIMAL(18,4) NULL COMMENT '材积重',
chargeable_weight_kg DECIMAL(18,4) NULL COMMENT '计费重量',
chargeable_volume_cbm DECIMAL(18,6) NULL COMMENT '计费体积',
unit_price DECIMAL(18,4) NOT NULL COMMENT '单价',
currency CHAR(3) NOT NULL COMMENT '币种',
amount DECIMAL(18,2) NOT NULL COMMENT '金额',
remark VARCHAR(500) NULL COMMENT '备注',
review_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、APPROVED、REJECTED',
reviewer_id BIGINT NULL COMMENT '审核人ID',
reviewer_name VARCHAR(64) NULL COMMENT '审核人名称快照',
reviewed_at DATETIME(3) NULL COMMENT '审核时间',
review_reason VARCHAR(500) NULL COMMENT '审核驳回原因',
created_by BIGINT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_by BIGINT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
deleted TINYINT(1) NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 0,
PRIMARY KEY (id),
KEY idx_inbound_fee_detail (tenant_id, inbound_id, business_date, deleted),
KEY idx_inbound_fee_review (tenant_id, review_status, business_date, deleted),
KEY idx_inbound_fee_item (tenant_id, fee_item_id, deleted),
CONSTRAINT fk_inbound_fee_detail_order FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id),
CONSTRAINT fk_inbound_fee_detail_item FOREIGN KEY (fee_item_id) REFERENCES fl_fee_item (id)
) ENGINE=InnoDB COMMENT='入库单应收应付费用明细';
USE baiyue_iam;
INSERT INTO iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code,
permission_name, route_path, http_method, sort_no, status, built_in, deleted)
VALUES
(5102070, 0, 'PLATFORM', 'MENU', 5103000, 'firstleg:fee-item:view',
'费用项目', '/master-data/fee-items', NULL, 40, 'ACTIVE', 1, 0),
(5102071, 0, 'PLATFORM', 'BUTTON', 5102070, 'firstleg:fee-item:create',
'新增费用项目', NULL, NULL, 10, 'ACTIVE', 1, 0),
(5102072, 0, 'PLATFORM', 'BUTTON', 5102070, 'firstleg:fee-item:edit',
'编辑或停用费用项目', NULL, NULL, 20, 'ACTIVE', 1, 0),
(5102073, 0, 'PLATFORM', 'BUTTON', 5102070, 'firstleg:fee-item:delete',
'删除费用项目', NULL, NULL, 30, 'ACTIVE', 1, 0),
(5102081, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:inbound:fee-detail:view',
'查看入库费用明细', NULL, NULL, 100, 'ACTIVE', 1, 0),
(5102082, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:inbound:fee-detail:edit',
'新增编辑入库费用明细', NULL, NULL, 110, 'ACTIVE', 1, 0),
(5102083, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:inbound:fee-detail:delete',
'删除入库费用明细', NULL, NULL, 120, 'ACTIVE', 1, 0),
(5102084, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:inbound:fee-detail:review',
'审核入库费用明细', NULL, NULL, 130, 'ACTIVE', 1, 0)
ON DUPLICATE KEY UPDATE
permission_type = VALUES(permission_type), parent_id = VALUES(parent_id),
permission_name = VALUES(permission_name), route_path = VALUES(route_path),
sort_no = VALUES(sort_no), status = 'ACTIVE', built_in = 1, deleted = 0;
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000001, id, 0
FROM iam_permission
WHERE id IN (5102070, 5102071, 5102072, 5102073, 5102081, 5102082, 5102083, 5102084)
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
SELECT 'PASS' AS migration_status,
'费用项目、入库费用明细及按钮权限增量已执行' AS message;
-- ============================================================
-- 可靠审计:本地 Outbox、RocketMQ 消费幂等和死信补偿
-- ============================================================
-- 可靠审计 Outbox、消费幂等和死信补偿增量(MySQL 8)
SET NAMES utf8mb4;
USE baiyue_audit;
DROP PROCEDURE IF EXISTS create_reliable_audit_outbox;
DELIMITER $$
CREATE PROCEDURE create_reliable_audit_outbox(IN schema_name VARCHAR(64))
BEGIN
SET @ddl = CONCAT(
'CREATE TABLE IF NOT EXISTS \`', schema_name, '\`.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=''可靠审计事件本地发送箱'''
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END$$
DELIMITER ;
CALL create_reliable_audit_outbox('baiyue_auth');
CALL create_reliable_audit_outbox('baiyue_iam');
CALL create_reliable_audit_outbox('baiyue_order');
CALL create_reliable_audit_outbox('baiyue_firstleg');
CALL create_reliable_audit_outbox('baiyue_warehouse');
DROP PROCEDURE IF EXISTS create_reliable_audit_outbox;
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,payload_text LONGTEXT NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PENDING',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='审计消息死信补偿表';
SELECT 'PASS' AS migration_status,'可靠审计Outbox、幂等和死信补偿表已就绪' AS message;
@@ -95,7 +95,7 @@ baiyue-firstleg/
| `print` | `print-service` | 9207 | 无 | 无状态 PDF 渲染服务;调用方传入完整标签数据,服务在内存中生成一次性多页 PDF |
| `integration-service` | `integration-service` | 9210 | 无 | 百度 OCR、Amazon SP-API 等第三方适配器和启停状态;默认关闭外部调用 |
| `event-bridge-service` | `event-bridge-service` | 9300 | `baiyue_event_bridge` | 国内外事件接收、签名/内部令牌保护、幂等 Inbox 和接收结果 |
| `audit-service` | `audit-service` | 9700 | `baiyue_audit` | 接收公共审计事件并持久化;当前配置默认关闭业务审计发送,可按环境开启 |
| `audit-service` | `audit-service` | 9700 | `baiyue_audit` | 接收公共审计事件并持久化;平台端可按权限查询已脱敏日志,业务审计发送默认开启 |
MySQL、Redis、Nacos 是外部基础设施,不属于上述 11 个业务服务,也不应打包进业务镜像。
@@ -145,7 +145,7 @@ Print 是无数据库的无状态 PDF 渲染服务。业务归属服务完成权
### 5.9 `audit-service/`:审计日志中心
公共审计模块负责采集、脱敏和发送,Audit Service 负责集中接收并写入 `baiyue_audit`,后续查询和留存策略也归本服务。审计链路不得记录密码、Token、第三方密钥或未经脱敏的客户敏感数据。
公共审计模块负责采集、脱敏和发送,Audit Service 负责集中接收并写入 `baiyue_audit`。平台端使用 `GET /api/platform/v1/audit/logs` 和 `GET /api/platform/v1/audit/logs/{id}` 查询,需具备 `platform:audit:list` 权限;列表不返回变更快照,详情仅返回已脱敏快照。审计链路不得记录密码、Token、第三方密钥或未经脱敏的客户敏感数据。
### 5.10 `integration-service/`:第三方集成中心
+1 -1
View File
@@ -7,6 +7,7 @@
<dependencies>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-core</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-audit</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>org.apache.rocketmq</groupId><artifactId>rocketmq-spring-boot-starter</artifactId></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-openapi</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-spring-boot3-starter</artifactId></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-web</artifactId><version>${project.version}</version></dependency>
@@ -23,4 +24,3 @@
</dependencies>
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>
@@ -0,0 +1,105 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.FeeItemDtos.SaveRequest;
import com.baiyue.firstleg.dto.FeeItemDtos.StatusRequest;
import com.baiyue.firstleg.entity.FeeItemEntity;
import com.baiyue.firstleg.service.FeeItemService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/** 平台端费用项目维护接口。 */
@Tag(name = "费用项目")
@RestController
@RequestMapping("/api/platform/v1/fee-items")
public class PlatformFeeItemController {
@Resource
private FeeItemService service;
@GetMapping
@PreAuthorize("hasAuthority('firstleg:fee-item:view')")
@Operation(summary = "查询费用项目")
public ApiResponse<PageResult<FeeItemEntity>> list(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long accountId,
@RequestParam(required = false) String category,
@RequestParam(required = false) String tradeScope,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "1") @Min(1) long page,
@RequestParam(defaultValue = "20") @Min(1) @Max(200) long size) {
return ApiResponse.success(service.list(FirstlegScopeGuard.platform(user, accountId),
category, tradeScope, keyword, status, page, size));
}
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('firstleg:fee-item:view')")
public ApiResponse<FeeItemEntity> detail(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long accountId,
@PathVariable long id) {
return ApiResponse.success(service.detail(FirstlegScopeGuard.platform(user, accountId), id));
}
@PostMapping
@BusinessAudit(event = "FEE_ITEM_CREATE", resourceType = "FEE_ITEM", description = "新增费用项目", captureBefore = false)
@PreAuthorize("hasAuthority('firstleg:fee-item:create')")
public ApiResponse<FeeItemEntity> create(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long accountId,
@Valid @RequestBody SaveRequest request) {
long scope = FirstlegScopeGuard.platform(user, accountId);
return ApiResponse.success(service.create(scope, user.userId(), user.username(), request));
}
@PutMapping("/{id}")
@BusinessAudit(event = "FEE_ITEM_UPDATE", resourceType = "FEE_ITEM", resourceId = "#id", description = "编辑费用项目")
@PreAuthorize("hasAuthority('firstleg:fee-item:edit')")
public ApiResponse<FeeItemEntity> update(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long accountId,
@PathVariable long id,
@Valid @RequestBody SaveRequest request) {
long scope = FirstlegScopeGuard.platform(user, accountId);
return ApiResponse.success(service.update(scope, id, user.userId(), user.username(), request));
}
@PatchMapping("/{id}/status")
@BusinessAudit(event = "FEE_ITEM_STATUS_CHANGE", resourceType = "FEE_ITEM", resourceId = "#id", description = "启停费用项目")
@PreAuthorize("hasAuthority('firstleg:fee-item:edit')")
public ApiResponse<Void> changeStatus(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long accountId,
@PathVariable long id,
@Valid @RequestBody StatusRequest request) {
service.changeStatus(FirstlegScopeGuard.platform(user, accountId), id,
user.userId(), user.username(), request.status());
return ApiResponse.success(null);
}
@DeleteMapping("/{id}")
@BusinessAudit(event = "FEE_ITEM_DELETE", resourceType = "FEE_ITEM", resourceId = "#id", description = "删除费用项目")
@PreAuthorize("hasAuthority('firstleg:fee-item:delete')")
public ApiResponse<Void> delete(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long accountId,
@PathVariable long id) {
service.delete(FirstlegScopeGuard.platform(user, accountId), id);
return ApiResponse.success(null);
}
}
@@ -0,0 +1,107 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.InboundFeeDetailDtos.RejectRequest;
import com.baiyue.firstleg.dto.InboundFeeDetailDtos.SaveRequest;
import com.baiyue.firstleg.entity.InboundFeeDetailEntity;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.service.InboundFeeDetailService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/** 平台端入库单费用明细接口。 */
@Tag(name = "入库单费用明细")
@RestController
@RequestMapping("/api/platform/v1/inbounds/{inboundId}/fee-details")
public class PlatformInboundFeeDetailController {
@Resource
private InboundFeeDetailService service;
@GetMapping
@PreAuthorize("hasAuthority('firstleg:inbound:fee-detail:view')")
@Operation(summary = "查询入库单费用明细")
public ApiResponse<List<InboundFeeDetailEntity>> list(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long accountId,
@PathVariable long inboundId) {
return ApiResponse.success(service.list(FirstlegScopeGuard.platform(user, accountId), inboundId));
}
@PostMapping
@BusinessAudit(event = "INBOUND_FEE_DETAIL_CREATE", resourceType = "INBOUND", resourceId = "#inboundId", description = "新增入库费用明细", captureBefore = false)
@PreAuthorize("hasAuthority('firstleg:inbound:fee-detail:edit')")
public ApiResponse<InboundFeeDetailEntity> create(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long accountId,
@PathVariable long inboundId,
@Valid @RequestBody SaveRequest request) {
long scope = FirstlegScopeGuard.platform(user, accountId);
return ApiResponse.success(service.create(scope, inboundId, user.userId(), request));
}
@PutMapping("/{detailId}")
@BusinessAudit(event = "INBOUND_FEE_DETAIL_UPDATE", resourceType = "INBOUND_FEE_DETAIL", resourceId = "#detailId", description = "编辑入库费用明细")
@PreAuthorize("hasAuthority('firstleg:inbound:fee-detail:edit')")
public ApiResponse<InboundFeeDetailEntity> update(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long accountId,
@PathVariable long inboundId,
@PathVariable long detailId,
@Valid @RequestBody SaveRequest request) {
long scope = FirstlegScopeGuard.platform(user, accountId);
return ApiResponse.success(service.update(scope, inboundId, detailId, user.userId(), request));
}
@DeleteMapping("/{detailId}")
@BusinessAudit(event = "INBOUND_FEE_DETAIL_DELETE", resourceType = "INBOUND_FEE_DETAIL", resourceId = "#detailId", description = "删除入库费用明细")
@PreAuthorize("hasAuthority('firstleg:inbound:fee-detail:delete')")
public ApiResponse<Void> delete(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long accountId,
@PathVariable long inboundId,
@PathVariable long detailId) {
service.delete(FirstlegScopeGuard.platform(user, accountId), inboundId, detailId);
return ApiResponse.success(null);
}
@PostMapping("/{detailId}/approve")
@BusinessAudit(event = "INBOUND_FEE_DETAIL_APPROVE", resourceType = "INBOUND_FEE_DETAIL", resourceId = "#detailId", description = "审核通过入库费用明细")
@PreAuthorize("hasAuthority('firstleg:inbound:fee-detail:review')")
public ApiResponse<Void> approve(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long accountId,
@PathVariable long inboundId,
@PathVariable long detailId) {
service.approve(FirstlegScopeGuard.platform(user, accountId), inboundId, detailId,
user.userId(), user.username());
return ApiResponse.success(null);
}
@PostMapping("/{detailId}/reject")
@BusinessAudit(event = "INBOUND_FEE_DETAIL_REJECT", resourceType = "INBOUND_FEE_DETAIL", resourceId = "#detailId", description = "驳回入库费用明细")
@PreAuthorize("hasAuthority('firstleg:inbound:fee-detail:review')")
public ApiResponse<Void> reject(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long accountId,
@PathVariable long inboundId,
@PathVariable long detailId,
@Valid @RequestBody RejectRequest request) {
service.reject(FirstlegScopeGuard.platform(user, accountId), inboundId, detailId,
user.userId(), user.username(), request.reason());
return ApiResponse.success(null);
}
}
@@ -0,0 +1,30 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
/** 费用项目请求模型。 */
public final class FeeItemDtos {
private FeeItemDtos() {
}
public record SaveRequest(
@NotBlank @Size(max = 64) String category,
@NotBlank @Pattern(regexp = "DOMESTIC|INTERNATIONAL") String tradeScope,
@NotBlank @Size(max = 128) String feeTypeName,
@NotNull Boolean pickupPayable,
@NotNull Boolean pickupReceivable,
@NotNull Boolean warehousePayable,
@NotNull Boolean warehouseReceivable,
@NotNull Boolean allocationFee,
@Size(max = 500) String description
) {
}
public record StatusRequest(
@NotBlank @Pattern(regexp = "ACTIVE|DISABLED") String status
) {
}
}
@@ -0,0 +1,30 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDate;
/** 入库单费用明细请求模型。 */
public final class InboundFeeDetailDtos {
private InboundFeeDetailDtos() {
}
public record SaveRequest(
@NotNull Long feeItemId,
@NotBlank @Pattern(regexp = "RECEIVABLE|PAYABLE") String direction,
@NotNull LocalDate businessDate,
@NotNull @DecimalMin("0.00") BigDecimal unitPrice,
@NotBlank @Pattern(regexp = "[A-Z]{3}") String currency,
@NotNull @DecimalMin("0.00") BigDecimal amount,
@Size(max = 500) String remark
) {
}
public record RejectRequest(@NotBlank @Size(max = 500) String reason) {
}
}
@@ -0,0 +1,30 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baiyue.common.core.model.BaseEntity;
import lombok.Getter;
import lombok.Setter;
/** 费用项目主数据。 */
@Getter
@Setter
@TableName("fl_fee_item")
public class FeeItemEntity extends BaseEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private String category;
private String tradeScope;
private String feeTypeName;
private Boolean pickupPayable;
private Boolean pickupReceivable;
private Boolean warehousePayable;
private Boolean warehouseReceivable;
private Boolean allocationFee;
private String description;
private String status;
private String createdByName;
private String updatedByName;
}
@@ -0,0 +1,49 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baiyue.common.core.model.BaseEntity;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/** 入库单人工费用明细及业务快照。 */
@Getter
@Setter
@TableName("fl_inbound_fee_detail")
public class InboundFeeDetailEntity extends BaseEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long inboundId;
private Long feeItemId;
private String inboundCode;
private String direction;
private LocalDate businessDate;
private String feeCategorySnapshot;
private String tradeScopeSnapshot;
private String feeTypeNameSnapshot;
private String channelCode;
private String channelName;
private String destinationCode;
private String destinationName;
private Integer packageCount;
private BigDecimal actualGrossWeightKg;
private BigDecimal actualVolumeCbm;
private BigDecimal volumeWeightKg;
private BigDecimal chargeableWeightKg;
private BigDecimal chargeableVolumeCbm;
private BigDecimal unitPrice;
private String currency;
private BigDecimal amount;
private String remark;
private String reviewStatus;
private Long reviewerId;
private String reviewerName;
private LocalDateTime reviewedAt;
private String reviewReason;
}
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.FeeItemEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface FeeItemMapper extends BaseMapper<FeeItemEntity> {
}
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.InboundFeeDetailEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface InboundFeeDetailMapper extends BaseMapper<InboundFeeDetailEntity> {
}
@@ -0,0 +1,123 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.firstleg.entity.FeeItemEntity;
import com.baiyue.firstleg.entity.InboundFeeDetailEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.mapper.FeeItemMapper;
import com.baiyue.firstleg.mapper.InboundFeeDetailMapper;
import com.baiyue.firstleg.mapper.InboundOrderMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.List;
/** 费用项目与入库费用明细仓储。 */
@Repository
public class FeeItemRepository {
@Resource
private FeeItemMapper feeItems;
@Resource
private InboundFeeDetailMapper feeDetails;
@Resource
private InboundOrderMapper inboundOrders;
public PageResult<FeeItemEntity> listItems(long tenantId, String category, String tradeScope,
String keyword, String status, long page, long size) {
return PageResult.from(feeItems.selectPage(new Page<>(page, size),
new LambdaQueryWrapper<FeeItemEntity>()
.eq(FeeItemEntity::getTenantId, tenantId)
.eq(StringUtils.hasText(category), FeeItemEntity::getCategory, category)
.eq(StringUtils.hasText(tradeScope), FeeItemEntity::getTradeScope, tradeScope)
.like(StringUtils.hasText(keyword), FeeItemEntity::getFeeTypeName, keyword)
.eq(StringUtils.hasText(status), FeeItemEntity::getStatus, status)
.orderByAsc(FeeItemEntity::getCategory)
.orderByAsc(FeeItemEntity::getFeeTypeName)
.orderByDesc(FeeItemEntity::getId)));
}
public FeeItemEntity findItem(long tenantId, long id) {
return feeItems.selectOne(new LambdaQueryWrapper<FeeItemEntity>()
.eq(FeeItemEntity::getTenantId, tenantId)
.eq(FeeItemEntity::getId, id));
}
public boolean itemNameExists(long tenantId, String tradeScope, String feeTypeName, Long excludedId) {
return feeItems.selectCount(new LambdaQueryWrapper<FeeItemEntity>()
.eq(FeeItemEntity::getTenantId, tenantId)
.eq(FeeItemEntity::getTradeScope, tradeScope)
.eq(FeeItemEntity::getFeeTypeName, feeTypeName)
.ne(excludedId != null, FeeItemEntity::getId, excludedId)) > 0;
}
public void insertItem(FeeItemEntity item) {
feeItems.insert(item);
}
public int updateItem(FeeItemEntity item) {
return feeItems.updateById(item);
}
public int deleteItem(long id) {
return feeItems.deleteById(id);
}
public long countBindings(long tenantId, long feeItemId) {
return feeDetails.selectCount(new LambdaQueryWrapper<InboundFeeDetailEntity>()
.eq(InboundFeeDetailEntity::getTenantId, tenantId)
.eq(InboundFeeDetailEntity::getFeeItemId, feeItemId));
}
public InboundOrderEntity findInbound(long tenantId, long inboundId) {
return inboundOrders.selectOne(new LambdaQueryWrapper<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, tenantId)
.eq(InboundOrderEntity::getId, inboundId));
}
public List<InboundFeeDetailEntity> listDetails(long tenantId, long inboundId) {
return feeDetails.selectList(new LambdaQueryWrapper<InboundFeeDetailEntity>()
.eq(InboundFeeDetailEntity::getTenantId, tenantId)
.eq(InboundFeeDetailEntity::getInboundId, inboundId)
.orderByDesc(InboundFeeDetailEntity::getBusinessDate)
.orderByDesc(InboundFeeDetailEntity::getId));
}
public InboundFeeDetailEntity findDetail(long tenantId, long inboundId, long detailId) {
return feeDetails.selectOne(new LambdaQueryWrapper<InboundFeeDetailEntity>()
.eq(InboundFeeDetailEntity::getTenantId, tenantId)
.eq(InboundFeeDetailEntity::getInboundId, inboundId)
.eq(InboundFeeDetailEntity::getId, detailId));
}
public void insertDetail(InboundFeeDetailEntity detail) {
feeDetails.insert(detail);
}
public int updateDetail(InboundFeeDetailEntity detail) {
return feeDetails.updateById(detail);
}
public int deleteDetail(long detailId) {
return feeDetails.deleteById(detailId);
}
public int review(long tenantId, long inboundId, long detailId, String targetStatus,
long reviewerId, String reviewerName, String reason) {
return feeDetails.update(null, new LambdaUpdateWrapper<InboundFeeDetailEntity>()
.eq(InboundFeeDetailEntity::getTenantId, tenantId)
.eq(InboundFeeDetailEntity::getInboundId, inboundId)
.eq(InboundFeeDetailEntity::getId, detailId)
.eq(InboundFeeDetailEntity::getReviewStatus, "PENDING")
.set(InboundFeeDetailEntity::getReviewStatus, targetStatus)
.set(InboundFeeDetailEntity::getReviewerId, reviewerId)
.set(InboundFeeDetailEntity::getReviewerName, reviewerName)
.set(InboundFeeDetailEntity::getReviewedAt, LocalDateTime.now())
.set(InboundFeeDetailEntity::getReviewReason, reason)
.set(InboundFeeDetailEntity::getUpdatedBy, reviewerId));
}
}
@@ -2,6 +2,7 @@ package com.baiyue.firstleg.service;
import jakarta.annotation.Resource;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
@@ -18,6 +19,8 @@ public class AutomaticInboundCompletionService {
@Resource
private InboundCompletionOrchestrator completion;
@BusinessAudit(event = "INBOUND_AUTO_COMPLETE", resourceType = "INBOUND", resourceId = "#inboundId",
description = "测量审核通过后自动计费、发送费用并完成入库", captureBefore = false)
public FeeSnapshotResponse completeApproved(long tenantId, long inboundId, long operatorId,
String operatorName, long measurementId,
int reviewedOrderVersion) {
@@ -0,0 +1,100 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.FeeItemDtos.SaveRequest;
import com.baiyue.firstleg.entity.FeeItemEntity;
import com.baiyue.firstleg.repository.FeeItemRepository;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/** 费用项目主数据应用服务。 */
@Service
public class FeeItemService {
@Resource
private FeeItemRepository repository;
public PageResult<FeeItemEntity> list(long tenantId, String category, String tradeScope,
String keyword, String status, long page, long size) {
return repository.listItems(tenantId, category, tradeScope, keyword, status, page, size);
}
public FeeItemEntity detail(long tenantId, long id) {
return required(tenantId, id);
}
@Transactional
public FeeItemEntity create(long tenantId, long operatorId, String operatorName, SaveRequest request) {
ensureUnique(tenantId, request, null);
FeeItemEntity value = new FeeItemEntity();
value.setTenantId(tenantId);
copy(value, request);
value.setStatus("ACTIVE");
value.setCreatedBy(operatorId);
value.setUpdatedBy(operatorId);
value.setCreatedByName(operatorName);
value.setUpdatedByName(operatorName);
repository.insertItem(value);
return value;
}
@Transactional
public FeeItemEntity update(long tenantId, long id, long operatorId, String operatorName,
SaveRequest request) {
FeeItemEntity value = required(tenantId, id);
ensureUnique(tenantId, request, id);
copy(value, request);
value.setUpdatedBy(operatorId);
value.setUpdatedByName(operatorName);
if (repository.updateItem(value) != 1) throw conflict("费用项目已被其他人修改,请刷新后重试");
return required(tenantId, id);
}
@Transactional
public void changeStatus(long tenantId, long id, long operatorId, String operatorName, String status) {
FeeItemEntity value = required(tenantId, id);
value.setStatus(status);
value.setUpdatedBy(operatorId);
value.setUpdatedByName(operatorName);
if (repository.updateItem(value) != 1) throw conflict("费用项目状态更新冲突");
}
@Transactional
public void delete(long tenantId, long id) {
required(tenantId, id);
if (repository.countBindings(tenantId, id) > 0) {
throw conflict("费用项目已被入库费用明细使用,只能停用,不能删除");
}
if (repository.deleteItem(id) != 1) throw conflict("费用项目删除冲突");
}
private FeeItemEntity required(long tenantId, long id) {
FeeItemEntity value = repository.findItem(tenantId, id);
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "费用项目不存在");
return value;
}
private void ensureUnique(long tenantId, SaveRequest request, Long excludedId) {
if (repository.itemNameExists(tenantId, request.tradeScope(), request.feeTypeName(), excludedId)) {
throw conflict("相同国内国际范围下的费用类型名称已存在");
}
}
private static void copy(FeeItemEntity value, SaveRequest request) {
value.setCategory(request.category().trim());
value.setTradeScope(request.tradeScope());
value.setFeeTypeName(request.feeTypeName().trim());
value.setPickupPayable(request.pickupPayable());
value.setPickupReceivable(request.pickupReceivable());
value.setWarehousePayable(request.warehousePayable());
value.setWarehouseReceivable(request.warehouseReceivable());
value.setAllocationFee(request.allocationFee());
value.setDescription(request.description() == null ? null : request.description().trim());
}
private static BizException conflict(String message) {
return new BizException(ResultCode.CONFLICT, message);
}
}
@@ -0,0 +1,149 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.InboundFeeDetailDtos.SaveRequest;
import com.baiyue.firstleg.entity.FeeItemEntity;
import com.baiyue.firstleg.entity.InboundFeeDetailEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.repository.FeeItemRepository;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.RoundingMode;
import java.util.List;
/** 入库单人工费用明细应用服务。 */
@Service
public class InboundFeeDetailService {
@Resource
private FeeItemRepository repository;
public List<InboundFeeDetailEntity> list(long tenantId, long inboundId) {
requiredInbound(tenantId, inboundId);
return repository.listDetails(tenantId, inboundId);
}
@Transactional
public InboundFeeDetailEntity create(long tenantId, long inboundId, long operatorId,
SaveRequest request) {
InboundOrderEntity inbound = requiredInbound(tenantId, inboundId);
FeeItemEntity item = requiredActiveItem(tenantId, request.feeItemId());
InboundFeeDetailEntity value = new InboundFeeDetailEntity();
value.setTenantId(tenantId);
value.setInboundId(inboundId);
value.setCreatedBy(operatorId);
apply(value, inbound, item, request, operatorId);
value.setReviewStatus("PENDING");
repository.insertDetail(value);
return value;
}
@Transactional
public InboundFeeDetailEntity update(long tenantId, long inboundId, long detailId,
long operatorId, SaveRequest request) {
InboundFeeDetailEntity value = requiredEditable(tenantId, inboundId, detailId);
InboundOrderEntity inbound = requiredInbound(tenantId, inboundId);
FeeItemEntity item = requiredActiveItem(tenantId, request.feeItemId());
apply(value, inbound, item, request, operatorId);
value.setReviewStatus("PENDING");
value.setReviewerId(null);
value.setReviewerName(null);
value.setReviewedAt(null);
value.setReviewReason(null);
if (repository.updateDetail(value) != 1) throw conflict("费用明细已被其他人修改,请刷新后重试");
return required(tenantId, inboundId, detailId);
}
@Transactional
public void delete(long tenantId, long inboundId, long detailId) {
requiredEditable(tenantId, inboundId, detailId);
if (repository.deleteDetail(detailId) != 1) throw conflict("费用明细删除冲突");
}
@Transactional
public void approve(long tenantId, long inboundId, long detailId,
long reviewerId, String reviewerName) {
required(tenantId, inboundId, detailId);
if (repository.review(tenantId, inboundId, detailId, "APPROVED",
reviewerId, reviewerName, null) != 1) {
throw conflict("只有待审核费用明细可以审核通过");
}
}
@Transactional
public void reject(long tenantId, long inboundId, long detailId, long reviewerId,
String reviewerName, String reason) {
required(tenantId, inboundId, detailId);
if (repository.review(tenantId, inboundId, detailId, "REJECTED",
reviewerId, reviewerName, reason.trim()) != 1) {
throw conflict("只有待审核费用明细可以驳回");
}
}
private InboundOrderEntity requiredInbound(long tenantId, long inboundId) {
InboundOrderEntity value = repository.findInbound(tenantId, inboundId);
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
return value;
}
private FeeItemEntity requiredActiveItem(long tenantId, long itemId) {
FeeItemEntity value = repository.findItem(tenantId, itemId);
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "费用项目不存在");
if (!"ACTIVE".equals(value.getStatus())) throw conflict("费用项目已停用,不能绑定");
return value;
}
private static void ensureWarehouseApplicable(FeeItemEntity item, String direction) {
boolean applicable = "RECEIVABLE".equals(direction)
? Boolean.TRUE.equals(item.getWarehouseReceivable())
: Boolean.TRUE.equals(item.getWarehousePayable());
if (!applicable) {
throw conflict("所选费用项目未启用对应的仓储应收/应付,不能绑定入库单");
}
}
private InboundFeeDetailEntity required(long tenantId, long inboundId, long detailId) {
InboundFeeDetailEntity value = repository.findDetail(tenantId, inboundId, detailId);
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "入库费用明细不存在");
return value;
}
private InboundFeeDetailEntity requiredEditable(long tenantId, long inboundId, long detailId) {
InboundFeeDetailEntity value = required(tenantId, inboundId, detailId);
if ("APPROVED".equals(value.getReviewStatus())) throw conflict("审核通过的费用明细不能修改或删除");
return value;
}
private static void apply(InboundFeeDetailEntity value, InboundOrderEntity inbound,
FeeItemEntity item, SaveRequest request, long operatorId) {
ensureWarehouseApplicable(item, request.direction());
value.setFeeItemId(item.getId());
value.setInboundCode(inbound.getInboundCode());
value.setDirection(request.direction());
value.setBusinessDate(request.businessDate());
value.setFeeCategorySnapshot(item.getCategory());
value.setTradeScopeSnapshot(item.getTradeScope());
value.setFeeTypeNameSnapshot(item.getFeeTypeName());
value.setChannelCode(inbound.getChannelCode());
value.setChannelName(inbound.getChannelName());
value.setDestinationCode(inbound.getDestinationCode());
value.setDestinationName(inbound.getDestinationName());
value.setPackageCount(inbound.getTotalPackageCount());
value.setActualGrossWeightKg(inbound.getActualGrossWeightKg());
value.setActualVolumeCbm(inbound.getActualVolumeCbm());
value.setVolumeWeightKg(inbound.getVolumeWeightKg());
value.setChargeableWeightKg(inbound.getChargeableWeightKg());
value.setChargeableVolumeCbm(inbound.getChargeableVolumeCbm());
value.setUnitPrice(request.unitPrice().setScale(4, RoundingMode.HALF_UP));
value.setCurrency(request.currency());
value.setAmount(request.amount().setScale(2, RoundingMode.HALF_UP));
value.setRemark(request.remark() == null ? null : request.remark().trim());
value.setUpdatedBy(operatorId);
}
private static BizException conflict(String message) {
return new BizException(ResultCode.CONFLICT, message);
}
}
@@ -0,0 +1,15 @@
USE baiyue_firstleg;
-- 可靠审计本地 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='可靠审计事件本地发送箱';
@@ -0,0 +1,72 @@
USE baiyue_firstleg;
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS fl_fee_item (
id BIGINT UNSIGNED NOT NULL COMMENT '费用项目ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '账号ID,沿用tenant_id字段名',
category VARCHAR(64) NOT NULL COMMENT '费用分类',
trade_scope VARCHAR(20) NOT NULL COMMENT 'DOMESTIC国内、INTERNATIONAL国际',
fee_type_name VARCHAR(128) NOT NULL COMMENT '费用类型名称',
pickup_payable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '提柜应付是否参与',
pickup_receivable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '提柜应收是否参与',
warehouse_payable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '仓储应付是否参与',
warehouse_receivable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '仓储应收是否参与',
allocation_fee TINYINT(1) NOT NULL DEFAULT 0 COMMENT '平摊费用是否参与',
description VARCHAR(500) NULL COMMENT '说明',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT 'ACTIVE或DISABLED',
created_by BIGINT NULL,
created_by_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_by BIGINT NULL,
updated_by_name VARCHAR(64) NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
deleted TINYINT(1) NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 0,
PRIMARY KEY (id),
KEY idx_fee_item_query (tenant_id, trade_scope, category, status, deleted),
KEY idx_fee_item_name (tenant_id, fee_type_name, deleted)
) ENGINE=InnoDB COMMENT='费用项目主数据';
CREATE TABLE IF NOT EXISTS fl_inbound_fee_detail (
id BIGINT UNSIGNED NOT NULL COMMENT '入库费用明细ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '账号ID,沿用tenant_id字段名',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
fee_item_id BIGINT UNSIGNED NOT NULL COMMENT '绑定费用项目ID',
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
direction VARCHAR(16) NOT NULL COMMENT 'RECEIVABLE应收、PAYABLE应付',
business_date DATE NOT NULL COMMENT '业务发生日',
fee_category_snapshot VARCHAR(64) NOT NULL COMMENT '费用分类快照',
trade_scope_snapshot VARCHAR(20) NOT NULL COMMENT '国内国际快照',
fee_type_name_snapshot VARCHAR(128) NOT NULL COMMENT '费用类型名称快照',
channel_code VARCHAR(64) NULL COMMENT '渠道编码快照',
channel_name VARCHAR(128) NULL COMMENT '渠道名称快照',
destination_code VARCHAR(32) NULL COMMENT '目的仓编码快照',
destination_name VARCHAR(128) NULL COMMENT '目的仓名称快照',
package_count INT NULL COMMENT '件数快照',
actual_gross_weight_kg DECIMAL(18,4) NULL COMMENT '仓库实测毛重',
actual_volume_cbm DECIMAL(18,6) NULL COMMENT '体积',
volume_weight_kg DECIMAL(18,4) NULL COMMENT '材积重',
chargeable_weight_kg DECIMAL(18,4) NULL COMMENT '计费重量',
chargeable_volume_cbm DECIMAL(18,6) NULL COMMENT '计费体积',
unit_price DECIMAL(18,4) NOT NULL COMMENT '单价',
currency CHAR(3) NOT NULL COMMENT '币种',
amount DECIMAL(18,2) NOT NULL COMMENT '金额',
remark VARCHAR(500) NULL COMMENT '备注',
review_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、APPROVED、REJECTED',
reviewer_id BIGINT NULL COMMENT '审核人ID',
reviewer_name VARCHAR(64) NULL COMMENT '审核人名称快照',
reviewed_at DATETIME(3) NULL COMMENT '审核时间',
review_reason VARCHAR(500) NULL COMMENT '审核驳回原因',
created_by BIGINT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_by BIGINT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
deleted TINYINT(1) NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 0,
PRIMARY KEY (id),
KEY idx_inbound_fee_detail (tenant_id, inbound_id, business_date, deleted),
KEY idx_inbound_fee_review (tenant_id, review_status, business_date, deleted),
KEY idx_inbound_fee_item (tenant_id, fee_item_id, deleted),
CONSTRAINT fk_inbound_fee_detail_order FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id),
CONSTRAINT fk_inbound_fee_detail_item FOREIGN KEY (fee_item_id) REFERENCES fl_fee_item (id)
) ENGINE=InnoDB COMMENT='入库单应收应付费用明细';
@@ -0,0 +1,44 @@
package com.baiyue.firstleg.controller;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import java.lang.reflect.Method;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("费用管理接口权限契约")
class FeeManagementContractTest {
@Test
@DisplayName("通过和驳回共用审核按钮权限")
void protectsReviewActions() {
for (String methodName : new String[] {"approve", "reject"}) {
Method method = Arrays.stream(PlatformInboundFeeDetailController.class.getDeclaredMethods())
.filter(candidate -> candidate.getName().equals(methodName))
.findFirst().orElseThrow();
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('firstleg:inbound:fee-detail:review')");
}
}
@Test
@DisplayName("费用项目增删改查权限彼此独立")
void separatesCrudPermissions() {
assertPermission("list", "firstleg:fee-item:view");
assertPermission("detail", "firstleg:fee-item:view");
assertPermission("create", "firstleg:fee-item:create");
assertPermission("update", "firstleg:fee-item:edit");
assertPermission("changeStatus", "firstleg:fee-item:edit");
assertPermission("delete", "firstleg:fee-item:delete");
}
private static void assertPermission(String methodName, String permission) {
Method method = Arrays.stream(PlatformFeeItemController.class.getDeclaredMethods())
.filter(candidate -> candidate.getName().equals(methodName))
.findFirst().orElseThrow();
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('" + permission + "')");
}
}
@@ -1,8 +1,10 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.ExceptionReleaseRequest;
import com.baiyue.firstleg.dto.MeasurementReviewRequest;
import com.baiyue.firstleg.service.AutomaticInboundCompletionService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
@@ -43,6 +45,17 @@ class InboundMeasurementReviewContractTest {
.doesNotContain("待客户确认");
}
@Test
void shouldAuditAutomaticInboundCompletion() throws Exception {
Method method = AutomaticInboundCompletionService.class.getDeclaredMethod("completeApproved",
long.class, long.class, long.class, String.class, long.class, int.class);
BusinessAudit audit = method.getAnnotation(BusinessAudit.class);
assertThat(audit.event()).isEqualTo("INBOUND_AUTO_COMPLETE");
assertThat(audit.resourceType()).isEqualTo("INBOUND");
assertThat(audit.resourceId()).isEqualTo("#inboundId");
assertThat(audit.captureBefore()).isFalse();
}
private void assertAuthority(String name, String expression, Class<?>... types) throws Exception {
Method method = PlatformInboundMeasurementReviewController.class.getDeclaredMethod(name, types);
assertThat(method.getAnnotation(PreAuthorize.class).value()).isEqualTo(expression);
@@ -0,0 +1,148 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.FeeItemDtos;
import com.baiyue.firstleg.dto.InboundFeeDetailDtos;
import com.baiyue.firstleg.entity.FeeItemEntity;
import com.baiyue.firstleg.entity.InboundFeeDetailEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.repository.FeeItemRepository;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
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.doAnswer;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@DisplayName("费用项目和入库费用明细")
@SpringJUnitConfig({FeeItemService.class, InboundFeeDetailService.class})
class FeeManagementServiceTest {
@MockBean
private FeeItemRepository repository;
@Resource
private FeeItemService feeItems;
@Resource
private InboundFeeDetailService details;
@Test
@DisplayName("费用项目列表返回分页结果")
void pagesItems() {
PageResult<FeeItemEntity> expected = new PageResult<>(List.of(), 2, 20, 21, 2);
when(repository.listItems(10001L, "仓储成本", "INTERNATIONAL", "人工", "ACTIVE", 2, 20))
.thenReturn(expected);
assertThat(feeItems.list(10001L, "仓储成本", "INTERNATIONAL", "人工", "ACTIVE", 2, 20))
.isSameAs(expected);
}
@Test
@DisplayName("新增费用项目保存原型字段")
void createsItem() {
doAnswer(invocation -> {
((FeeItemEntity) invocation.getArgument(0)).setId(11L);
return null;
}).when(repository).insertItem(any());
FeeItemEntity created = feeItems.create(10001L, 9L, "admin", itemRequest());
assertThat(created.getId()).isEqualTo(11L);
assertThat(created.getAllocationFee()).isTrue();
assertThat(created.getWarehouseReceivable()).isTrue();
assertThat(created.getCreatedByName()).isEqualTo("admin");
}
@Test
@DisplayName("入库费用绑定项目并生成订单快照")
void bindsInboundFee() {
InboundOrderEntity inbound = inbound();
FeeItemEntity item = item(true, true);
when(repository.findInbound(10001L, 21L)).thenReturn(inbound);
when(repository.findItem(10001L, 11L)).thenReturn(item);
doAnswer(invocation -> {
((InboundFeeDetailEntity) invocation.getArgument(0)).setId(31L);
return null;
}).when(repository).insertDetail(any());
InboundFeeDetailEntity created = details.create(10001L, 21L, 9L,
detailRequest("RECEIVABLE"));
assertThat(created.getId()).isEqualTo(31L);
assertThat(created.getInboundCode()).isEqualTo("RK-20260805-001");
assertThat(created.getFeeTypeNameSnapshot()).isEqualTo("仓库租赁费");
assertThat(created.getActualGrossWeightKg()).isEqualByComparingTo("10.5000");
assertThat(created.getReviewStatus()).isEqualTo("PENDING");
}
@Test
@DisplayName("拒绝绑定未启用仓储应收的项目")
void rejectsInapplicableItem() {
when(repository.findInbound(10001L, 21L)).thenReturn(inbound());
when(repository.findItem(10001L, 11L)).thenReturn(item(false, true));
assertThatThrownBy(() -> details.create(10001L, 21L, 9L,
detailRequest("RECEIVABLE")))
.isInstanceOf(BizException.class)
.hasMessageContaining("仓储应收/应付");
}
@Test
@DisplayName("审核只更新待审核明细")
void approvesPendingDetail() {
when(repository.findDetail(10001L, 21L, 31L)).thenReturn(new InboundFeeDetailEntity());
when(repository.review(10001L, 21L, 31L, "APPROVED", 9L, "admin", null)).thenReturn(1);
details.approve(10001L, 21L, 31L, 9L, "admin");
verify(repository).review(10001L, 21L, 31L, "APPROVED", 9L, "admin", null);
}
private static FeeItemDtos.SaveRequest itemRequest() {
return new FeeItemDtos.SaveRequest("仓储成本", "INTERNATIONAL", "仓库租赁费",
false, false, true, true, true, "应付仓库租赁费");
}
private static InboundFeeDetailDtos.SaveRequest detailRequest(String direction) {
return new InboundFeeDetailDtos.SaveRequest(11L, direction, LocalDate.of(2026, 8, 5),
new BigDecimal("12.3456"), "CNY", new BigDecimal("123.46"), "联调费用");
}
private static FeeItemEntity item(boolean receivable, boolean payable) {
FeeItemEntity item = new FeeItemEntity();
item.setId(11L);
item.setCategory("仓储成本");
item.setTradeScope("INTERNATIONAL");
item.setFeeTypeName("仓库租赁费");
item.setWarehouseReceivable(receivable);
item.setWarehousePayable(payable);
item.setStatus("ACTIVE");
return item;
}
private static InboundOrderEntity inbound() {
InboundOrderEntity inbound = new InboundOrderEntity();
inbound.setId(21L);
inbound.setInboundCode("RK-20260805-001");
inbound.setChannelCode("SEA");
inbound.setChannelName("海运");
inbound.setDestinationCode("ONT8");
inbound.setDestinationName("ONT8");
inbound.setTotalPackageCount(10);
inbound.setActualGrossWeightKg(new BigDecimal("10.5000"));
inbound.setActualVolumeCbm(new BigDecimal("1.250000"));
inbound.setVolumeWeightKg(new BigDecimal("208.3333"));
inbound.setChargeableWeightKg(new BigDecimal("208.3333"));
inbound.setChargeableVolumeCbm(new BigDecimal("1.250000"));
return inbound;
}
}
+5 -1
View File
@@ -50,6 +50,10 @@ spring:
uri: lb://iam-service
predicates:
- Path=/api/iam/**,/api/platform/v1/iam/**,/api/merchant/v1/iam/**
- id: audit-service
uri: lb://audit-service
predicates:
- Path=/api/platform/v1/audit/**
- id: order-service
uri: lb://order-service
predicates:
@@ -69,7 +73,7 @@ spring:
- id: firstleg-service
uri: lb://firstleg-service
predicates:
- Path=/api/merchant/v1/first-leg/**,/api/platform/v1/first-leg/**,/api/platform/v1/fba-pricing/**,/api/platform/v1/inbounds/**,/api/platform/inbounds/**,/openapi/v1/inbound-measurements/**
- Path=/api/merchant/v1/first-leg/**,/api/platform/v1/first-leg/**,/api/platform/v1/fba-pricing/**,/api/platform/v1/inbounds/**,/api/platform/v1/fee-items/**,/api/platform/inbounds/**,/openapi/v1/inbound-measurements/**
# Swagger 文档聚合路由。
- id: auth-openapi
uri: lb://auth-service
@@ -52,4 +52,35 @@ class GatewayFileRouteConfigurationTest {
.contains("/api/platform/v1/overseas-storage-fees/**");
});
}
@Test
void feeItemsRouteToFirstlegService() {
var route = gatewayProperties.getRoutes().stream()
.filter(candidate -> "firstleg-service".equals(candidate.getId()))
.findFirst()
.orElseThrow();
assertThat(route.getUri().toString()).isEqualTo("lb://firstleg-service");
assertThat(route.getPredicates()).singleElement().satisfies(predicate -> {
assertThat(predicate.getName()).isEqualTo("Path");
assertThat(String.join(",", predicate.getArgs().values()))
.contains("/api/platform/v1/fee-items/**")
.contains("/api/platform/v1/inbounds/**");
});
}
@Test
void auditLogRequestsRouteToAuditService() {
var route = gatewayProperties.getRoutes().stream()
.filter(candidate -> "audit-service".equals(candidate.getId()))
.findFirst()
.orElseThrow();
assertThat(route.getUri().toString()).isEqualTo("lb://audit-service");
assertThat(route.getPredicates()).singleElement().satisfies(predicate -> {
assertThat(predicate.getName()).isEqualTo("Path");
assertThat(String.join(",", predicate.getArgs().values()))
.contains("/api/platform/v1/audit/**");
});
}
}
+1
View File
@@ -6,6 +6,7 @@
<artifactId>iam-service</artifactId>
<dependencies>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-audit</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>org.apache.rocketmq</groupId><artifactId>rocketmq-spring-boot-starter</artifactId></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-openapi</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-spring-boot3-starter</artifactId></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-web</artifactId><version>${project.version}</version></dependency>
@@ -136,7 +136,8 @@ public class MemberService {
return response(tenantId, membership);
}
@BusinessAudit(event = "MEMBER_PASSWORD_RESET", resourceType = "MEMBERSHIP", resourceId = "#membershipId", description = "管理员重置成员密码", captureAfter = false)
@BusinessAudit(event = "MEMBER_PASSWORD_RESET", resourceType = "MEMBERSHIP", resourceId = "#membershipId",
description = "管理员重置成员密码", captureBefore = false, captureAfter = false)
public void resetPassword(long tenantId, Long membershipId, ResetMemberPasswordRequest request) {
IamMembership membership = required(tenantId, membershipId);
if ("OWNER".equals(membership.getMemberType())) {
@@ -143,7 +143,8 @@ public class PlatformInternalUserService {
}
@BusinessAudit(event = "PLATFORM_INTERNAL_USER_PASSWORD_RESET", resourceType = "MEMBERSHIP",
resourceId = "#membershipId", description = "重置平台内部用户密码", captureAfter = false)
resourceId = "#membershipId", description = "重置平台内部用户密码",
captureBefore = false, captureAfter = false)
public void resetPassword(Long membershipId, ResetInternalUserPasswordRequest request) {
IamMembership membership = required(membershipId);
IamUser user = requiredUser(membership.getUserId());
@@ -189,7 +189,8 @@ public class PlatformUserManagementService {
}
@BusinessAudit(event = "PLATFORM_USER_PASSWORD_RESET", resourceType = "MEMBERSHIP",
resourceId = "#membershipId", description = "平台重置用户密码", captureAfter = false)
resourceId = "#membershipId", description = "平台重置用户密码",
captureBefore = false, captureAfter = false)
public void resetPassword(Long membershipId, ResetPasswordRequest request) {
IamUser user = requiredUser(requiredPlatformMembership(membershipId).getUserId());
call(() -> authClient.reset(internalToken, user.getAuthUserId(),
@@ -0,0 +1,15 @@
USE baiyue_iam;
-- 可靠审计本地 Outbox;与 IAM 业务表位于同一数据库。
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='可靠审计事件本地发送箱';
@@ -0,0 +1,33 @@
USE baiyue_iam;
SET NAMES utf8mb4;
INSERT INTO iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code,
permission_name, route_path, http_method, sort_no, status, built_in, deleted)
VALUES
(5102070, 0, 'PLATFORM', 'MENU', 5103000, 'firstleg:fee-item:view',
'费用项目', '/master-data/fee-items', NULL, 40, 'ACTIVE', 1, 0),
(5102071, 0, 'PLATFORM', 'BUTTON', 5102070, 'firstleg:fee-item:create',
'新增费用项目', NULL, NULL, 10, 'ACTIVE', 1, 0),
(5102072, 0, 'PLATFORM', 'BUTTON', 5102070, 'firstleg:fee-item:edit',
'编辑或停用费用项目', NULL, NULL, 20, 'ACTIVE', 1, 0),
(5102073, 0, 'PLATFORM', 'BUTTON', 5102070, 'firstleg:fee-item:delete',
'删除费用项目', NULL, NULL, 30, 'ACTIVE', 1, 0),
(5102081, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:inbound:fee-detail:view',
'查看入库费用明细', NULL, NULL, 100, 'ACTIVE', 1, 0),
(5102082, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:inbound:fee-detail:edit',
'新增编辑入库费用明细', NULL, NULL, 110, 'ACTIVE', 1, 0),
(5102083, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:inbound:fee-detail:delete',
'删除入库费用明细', NULL, NULL, 120, 'ACTIVE', 1, 0),
(5102084, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:inbound:fee-detail:review',
'审核入库费用明细', NULL, NULL, 130, 'ACTIVE', 1, 0)
ON DUPLICATE KEY UPDATE
permission_type = VALUES(permission_type), parent_id = VALUES(parent_id),
permission_name = VALUES(permission_name), route_path = VALUES(route_path),
sort_no = VALUES(sort_no), status = 'ACTIVE', built_in = 1, deleted = 0;
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000001, id, 0
FROM iam_permission
WHERE id IN (5102070, 5102071, 5102072, 5102073, 5102081, 5102082, 5102083, 5102084)
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
@@ -0,0 +1,70 @@
package com.baiyue.iam.service;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.iam.dto.MemberDtos.ResetMemberPasswordRequest;
import com.baiyue.iam.dto.PlatformInternalUserDtos.ResetInternalUserPasswordRequest;
import com.baiyue.iam.dto.PlatformUserManagementDtos.ResetPasswordRequest;
import com.baiyue.iam.dto.RbacDtos.AssignRolesRequest;
import com.baiyue.iam.dto.RbacDtos.CreateRoleRequest;
import com.baiyue.iam.dto.RbacDtos.GrantPermissionsRequest;
import com.baiyue.iam.dto.RbacDtos.UpdateRoleRequest;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
class BusinessAuditCoverageTest {
@Test
void shouldAuditRbacChanges() throws Exception {
assertAudit(RbacService.class, "createRole", "ROLE_CREATE", "ROLE", "#request.roleCode",
long.class, String.class, CreateRoleRequest.class);
assertAudit(RbacService.class, "updateRole", "ROLE_UPDATE", "ROLE", "#roleId",
long.class, String.class, Long.class, UpdateRoleRequest.class);
assertAudit(RbacService.class, "changeStatus", "ROLE_STATUS_CHANGE", "ROLE", "#roleId",
long.class, String.class, Long.class,
com.baiyue.iam.dto.RbacDtos.ChangeRoleStatusRequest.class);
assertAudit(RbacService.class, "deleteRole", "ROLE_DELETE", "ROLE", "#roleId",
long.class, String.class, Long.class);
assertAudit(RbacService.class, "assignRoles", "MEMBER_ROLE_ASSIGN", "MEMBERSHIP", "#request.membershipId",
long.class, String.class, AssignRolesRequest.class);
assertAudit(RbacService.class, "grantPermissions", "ROLE_PERMISSION_GRANT", "ROLE", "#request.roleId",
long.class, String.class, GrantPermissionsRequest.class);
}
@Test
void shouldNeverCapturePasswordSnapshots() throws Exception {
assertPasswordAudit(MemberService.class, "resetPassword", long.class, Long.class,
ResetMemberPasswordRequest.class);
assertPasswordAudit(PlatformUserManagementService.class, "resetPassword", Long.class,
ResetPasswordRequest.class);
assertPasswordAudit(PlatformInternalUserService.class, "resetPassword", Long.class,
ResetInternalUserPasswordRequest.class);
}
@Test
void shouldAuditCustomerReview() throws Exception {
assertAudit(TenantService.class, "review", "TENANT_REVIEW", "TENANT_REVIEW", "#reviewId",
long.class, long.class, boolean.class, String.class);
}
private void assertAudit(Class<?> type, String methodName, String event, String resourceType,
String resourceId, Class<?>... parameterTypes) throws Exception {
BusinessAudit audit = annotation(type, methodName, parameterTypes);
assertEquals(event, audit.event());
assertEquals(resourceType, audit.resourceType());
assertEquals(resourceId, audit.resourceId());
}
private void assertPasswordAudit(Class<?> type, String methodName, Class<?>... parameterTypes) throws Exception {
BusinessAudit audit = annotation(type, methodName, parameterTypes);
assertFalse(audit.captureBefore());
assertFalse(audit.captureAfter());
}
private BusinessAudit annotation(Class<?> type, String methodName, Class<?>... parameterTypes) throws Exception {
Method method = type.getDeclaredMethod(methodName, parameterTypes);
return method.getAnnotation(BusinessAudit.class);
}
}
+1
View File
@@ -6,6 +6,7 @@
<artifactId>order-service</artifactId>
<dependencies>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-audit</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>org.apache.rocketmq</groupId><artifactId>rocketmq-spring-boot-starter</artifactId></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-openapi</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-spring-boot3-starter</artifactId></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-web</artifactId><version>${project.version}</version></dependency>
@@ -0,0 +1,15 @@
USE baiyue_order;
-- 可靠审计本地 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='可靠审计事件本地发送箱';
@@ -0,0 +1,75 @@
{
"reportName": "二期本地真实API全链路回归",
"success": false,
"data": {
"stamp": "20260805120659",
"tenantId": 2084512243973591041,
"error": "创建头程订单 HTTP=200 code=A0404 message=仓库不存在或不属于当前企业",
"finishedAt": "2026-08-05T12:07:00.1514551+08:00"
},
"results": [
{
"name": "平台登录",
"passed": true,
"http": 200,
"code": "200",
"message": "success"
},
{
"name": "创建发货/收货往来单位",
"passed": true,
"http": 200,
"code": "200",
"message": "success"
},
{
"name": "创建起运仓",
"passed": true,
"http": 200,
"code": "200",
"message": "success"
},
{
"name": "创建FBA目的仓",
"passed": true,
"http": 200,
"code": "200",
"message": "success"
},
{
"name": "创建托盘",
"passed": true,
"http": 200,
"code": "200",
"message": "success"
},
{
"name": "创建库位",
"passed": true,
"http": 200,
"code": "200",
"message": "success"
},
{
"name": "创建客户计费规则",
"passed": true,
"http": 200,
"code": "200",
"message": "success"
},
{
"name": "创建船司FBA费率",
"passed": true,
"http": 200,
"code": "200",
"message": "success"
},
{
"name": "创建头程订单",
"passed": false,
"http": 200,
"code": "A0404",
"message": "仓库不存在或不属于当前企业"
}
]
}
@@ -0,0 +1,101 @@
{
"reportName": "二期本地真实API回归(续跑)",
"success": false,
"data": {
"stamp": "20260805115520",
"tenantId": 10001,
"partnerId": 2084850721181642753,
"warehouseId": 2084850721563324417,
"palletId": 2084850721823371266,
"locationId": 2084850722024697857,
"resumeFrom": "客户计费规则",
"error": "平台创建头程订单 HTTP=200 code=A0422 message=所选往来单位不具备发货单位类型",
"finishedAt": "2026-08-05T11:57:57.2970359+08:00"
},
"results": [
{
"name": "平台登录",
"passed": true,
"http": 200,
"code": "200",
"message": "success"
},
{
"name": "商户登录",
"passed": true,
"http": 200,
"code": "200",
"message": "success"
},
{
"name": "创建回归客户",
"passed": true,
"http": 200,
"code": "200",
"message": "success"
},
{
"name": "创建回归仓库",
"passed": true,
"http": 200,
"code": "200",
"message": "success"
},
{
"name": "创建回归托盘",
"passed": true,
"http": 200,
"code": "200",
"message": "success"
},
{
"name": "创建回归库位",
"passed": true,
"http": 200,
"code": "200",
"message": "success"
},
{
"name": "创建客户计费规则",
"passed": false,
"http": 200,
"code": "A0422",
"message": "Validation failed"
},
{
"name": "平台登录(续跑)",
"passed": true,
"http": 200,
"code": "200",
"message": "success"
},
{
"name": "商户登录(续跑)",
"passed": true,
"http": 200,
"code": "200",
"message": "success"
},
{
"name": "创建客户计费规则(FBA_SEA)",
"passed": true,
"http": 200,
"code": "200",
"message": "success"
},
{
"name": "创建船司FBA费率(FBA_SEA)",
"passed": true,
"http": 200,
"code": "200",
"message": "success"
},
{
"name": "平台创建头程订单",
"passed": false,
"http": 200,
"code": "A0422",
"message": "所选往来单位不具备发货单位类型"
}
]
}
@@ -0,0 +1,61 @@
{
"reportName": "二期本地真实API回归",
"success": false,
"data": {
"stamp": "20260805115520",
"tenantId": 10001,
"error": "创建客户计费规则 HTTP=200 code=A0422 message=Validation failed",
"finishedAt": "2026-08-05T11:55:21.7301796+08:00"
},
"results": [
{
"name": "平台登录",
"passed": true,
"http": 200,
"code": "200",
"message": "success"
},
{
"name": "商户登录",
"passed": true,
"http": 200,
"code": "200",
"message": "success"
},
{
"name": "创建回归客户",
"passed": true,
"http": 200,
"code": "200",
"message": "success"
},
{
"name": "创建回归仓库",
"passed": true,
"http": 200,
"code": "200",
"message": "success"
},
{
"name": "创建回归托盘",
"passed": true,
"http": 200,
"code": "200",
"message": "success"
},
{
"name": "创建回归库位",
"passed": true,
"http": 200,
"code": "200",
"message": "success"
},
{
"name": "创建客户计费规则",
"passed": false,
"http": 200,
"code": "A0422",
"message": "Validation failed"
}
]
}
+4
View File
@@ -27,6 +27,10 @@
<artifactId>baiyue-common-audit</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
@@ -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='可靠审计事件本地发送箱';