日志记录提交
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package com.baiyue.audit.controller;
|
||||
|
||||
import com.baiyue.audit.dto.AuditLogQuery;
|
||||
import com.baiyue.audit.dto.AuditLogResponses;
|
||||
import com.baiyue.audit.service.AuditLogQueryService;
|
||||
import com.baiyue.audit.service.AuditPlatformScopeGuard;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/** 平台端统一操作日志查询入口。 */
|
||||
@RestController
|
||||
@Validated
|
||||
@RequestMapping("/api/platform/v1/audit/logs")
|
||||
@Tag(name = "Platform audit logs / 平台操作日志")
|
||||
public class PlatformAuditLogController {
|
||||
@Resource
|
||||
private AuditLogQueryService service;
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('platform:audit:list')")
|
||||
@Operation(summary = "分页查询操作日志", description = "仅平台端可查询,列表不返回变更前后快照。默认按发生时间、日志 ID 倒序。")
|
||||
public ApiResponse<PageResult<AuditLogResponses.ListItem>> page(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(defaultValue = "1") @Min(1) long page,
|
||||
@RequestParam(defaultValue = "20") @Min(1) @Max(100) long size,
|
||||
@RequestParam(required = false) Long accountId,
|
||||
@RequestParam(required = false) Long operatorUserId,
|
||||
@RequestParam(required = false) String eventType,
|
||||
@RequestParam(required = false) String resourceType,
|
||||
@RequestParam(required = false) String resourceId,
|
||||
@RequestParam(required = false) Boolean success,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant occurredFrom,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant occurredTo,
|
||||
@RequestParam(required = false) String keyword) {
|
||||
AuditPlatformScopeGuard.requirePlatform(user);
|
||||
return ApiResponse.success(service.page(new AuditLogQuery(accountId, operatorUserId, eventType,
|
||||
resourceType, resourceId, success, occurredFrom, occurredTo, keyword), page, size));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@PreAuthorize("hasAuthority('platform:audit:list')")
|
||||
@Operation(summary = "查看操作日志详情", description = "仅平台端可查询,详情返回已脱敏的变更前后快照。")
|
||||
public ApiResponse<AuditLogResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Min(1) Long id) {
|
||||
AuditPlatformScopeGuard.requirePlatform(user);
|
||||
return ApiResponse.success(service.detail(id));
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import java.time.Instant;
|
||||
*/
|
||||
@Schema(name = "AuditIngestRequest", description = "Internal business audit event / 内部业务审计事件")
|
||||
public record AuditIngestRequest(
|
||||
@Schema(description = "Global idempotency event ID / 全局幂等事件ID", example = "b150c69d-8813-46b7-9a7c-81bfa5b91021")
|
||||
@Size(max = 64) String eventId,
|
||||
@Schema(description = "Event code / 事件编码", example = "WAREHOUSE_UPDATE", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank @Size(max = 64) String eventType,
|
||||
@Schema(description = "Resource type / 资源类型", example = "WAREHOUSE", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.baiyue.audit.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/** 平台操作日志筛选条件。accountId 对应审计库中的 tenant_id。 */
|
||||
@Schema(name = "AuditLogQuery", description = "平台操作日志分页筛选条件")
|
||||
public record AuditLogQuery(
|
||||
Long accountId,
|
||||
Long operatorUserId,
|
||||
String eventType,
|
||||
String resourceType,
|
||||
String resourceId,
|
||||
Boolean success,
|
||||
Instant occurredFrom,
|
||||
Instant occurredTo,
|
||||
String keyword) {
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.baiyue.audit.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 平台审计日志查询响应。列表刻意不包含前后快照,详情才返回完整证据。 */
|
||||
public final class AuditLogResponses {
|
||||
private AuditLogResponses() {
|
||||
}
|
||||
|
||||
@Schema(name = "AuditLogListItem", description = "操作日志列表项,不包含变更前后快照")
|
||||
public record ListItem(
|
||||
Long id,
|
||||
String eventId,
|
||||
String eventType,
|
||||
String resourceType,
|
||||
String resourceId,
|
||||
String description,
|
||||
Long accountId,
|
||||
Long operatorTenantId,
|
||||
Long userId,
|
||||
String username,
|
||||
String appCode,
|
||||
String clientType,
|
||||
String requestId,
|
||||
String traceId,
|
||||
String serviceName,
|
||||
Boolean success,
|
||||
String errorCode,
|
||||
String errorMessage,
|
||||
Instant occurredAt,
|
||||
LocalDateTime createdAt) {
|
||||
}
|
||||
|
||||
@Schema(name = "AuditLogDetail", description = "操作日志详情,包含已脱敏的变更前后快照")
|
||||
public record Detail(
|
||||
Long id,
|
||||
String eventId,
|
||||
String eventType,
|
||||
String resourceType,
|
||||
String resourceId,
|
||||
String description,
|
||||
String requestId,
|
||||
String traceId,
|
||||
Long accountId,
|
||||
Long operatorTenantId,
|
||||
Long userId,
|
||||
String username,
|
||||
String appCode,
|
||||
String clientType,
|
||||
String httpMethod,
|
||||
String requestUri,
|
||||
String clientIp,
|
||||
Long durationMs,
|
||||
Boolean success,
|
||||
String errorCode,
|
||||
String errorMessage,
|
||||
String beforeData,
|
||||
String afterData,
|
||||
String serviceName,
|
||||
Instant occurredAt,
|
||||
LocalDateTime createdAt) {
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ public class AuditLogEntity {
|
||||
@Schema(description = "Audit log ID / 审计日志 ID", example = "190000000000000201")
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private String eventId;
|
||||
private String eventType;
|
||||
private String resourceType;
|
||||
private String resourceId;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.baiyue.audit.mq;
|
||||
|
||||
import com.baiyue.audit.repository.AuditDeadLetterRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
|
||||
import org.apache.rocketmq.spring.core.RocketMQListener;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.UUID;
|
||||
|
||||
/** 将 RocketMQ 最终死信转存数据库,供补偿任务恢复。 */
|
||||
@Component
|
||||
@ConditionalOnProperty(prefix = "baiyue.audit.rocketmq", name = "dead-letter-consumer-enabled", havingValue = "true")
|
||||
@RocketMQMessageListener(
|
||||
topic = "${baiyue.audit.rocketmq.dead-letter-topic}",
|
||||
consumerGroup = "${baiyue.audit.rocketmq.dead-letter-group}")
|
||||
public class AuditDeadLetterConsumer implements RocketMQListener<String> {
|
||||
@Resource
|
||||
private ObjectMapper json;
|
||||
@Resource
|
||||
private AuditDeadLetterRepository repository;
|
||||
|
||||
@Override
|
||||
public void onMessage(String payload) {
|
||||
repository.save(eventId(payload), payload);
|
||||
}
|
||||
|
||||
private String eventId(String payload) {
|
||||
try {
|
||||
String value = json.readTree(payload).path("eventId").asText();
|
||||
if (!value.isBlank()) return value;
|
||||
} catch (Exception ignored) {
|
||||
// 无法解析的毒消息也必须使用确定性 ID 收入死信表。
|
||||
}
|
||||
return "invalid-" + UUID.nameUUIDFromBytes(payload.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.baiyue.audit.mq;
|
||||
|
||||
import com.baiyue.audit.dto.AuditIngestRequest;
|
||||
import com.baiyue.audit.service.AuditIngestService;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
|
||||
import org.apache.rocketmq.spring.core.RocketMQListener;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** RocketMQ 审计事件消费者;数据库 event_id 唯一键保证重复消息安全。 */
|
||||
@Component
|
||||
@ConditionalOnProperty(prefix = "baiyue.audit.rocketmq", name = "consumer-enabled", havingValue = "true")
|
||||
@RocketMQMessageListener(
|
||||
topic = "${baiyue.audit.rocketmq.topic}",
|
||||
selectorExpression = "AUDIT",
|
||||
consumerGroup = "${baiyue.audit.rocketmq.consumer-group}")
|
||||
public class AuditRocketMqConsumer implements RocketMQListener<String> {
|
||||
@Resource
|
||||
private ObjectMapper json;
|
||||
@Resource
|
||||
private AuditIngestService service;
|
||||
|
||||
@Override
|
||||
public void onMessage(String payload) {
|
||||
try {
|
||||
service.ingest(json.readValue(payload, AuditIngestRequest.class));
|
||||
} catch (JsonProcessingException exception) {
|
||||
throw new IllegalArgumentException("审计消息格式无效", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package com.baiyue.audit.repository;
|
||||
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 审计 RocketMQ 死信及补偿状态仓储。 */
|
||||
@Repository
|
||||
public class AuditDeadLetterRepository {
|
||||
@Resource
|
||||
private JdbcTemplate jdbc;
|
||||
|
||||
public void save(String eventId, String payload) {
|
||||
jdbc.update("""
|
||||
INSERT INTO sys_audit_dead_letter(event_id,payload_text,status,retry_count,next_retry_at,created_at)
|
||||
VALUES (?,?,'PENDING',0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3))
|
||||
ON DUPLICATE KEY UPDATE payload_text=VALUES(payload_text)
|
||||
""", eventId, payload);
|
||||
}
|
||||
|
||||
public List<Long> pendingIds(int limit) {
|
||||
return jdbc.queryForList("""
|
||||
SELECT id FROM sys_audit_dead_letter
|
||||
WHERE status='PENDING' AND next_retry_at<=CURRENT_TIMESTAMP(3)
|
||||
ORDER BY next_retry_at,id LIMIT ?
|
||||
""", Long.class, limit);
|
||||
}
|
||||
|
||||
public boolean claim(long id, String owner, int leaseSeconds) {
|
||||
return jdbc.update("""
|
||||
UPDATE sys_audit_dead_letter SET status='PROCESSING',lease_owner=?,
|
||||
lease_until=DATE_ADD(CURRENT_TIMESTAMP(3),INTERVAL ? SECOND),retry_count=retry_count+1
|
||||
WHERE id=? AND status='PENDING' AND next_retry_at<=CURRENT_TIMESTAMP(3)
|
||||
""", owner, leaseSeconds, id) == 1;
|
||||
}
|
||||
|
||||
public DeadRow find(long id) {
|
||||
List<DeadRow> rows = jdbc.query(
|
||||
"SELECT id,event_id,payload_text,retry_count FROM sys_audit_dead_letter WHERE id=?",
|
||||
(rs, rowNum) -> new DeadRow(rs.getLong(1), rs.getString(2), rs.getString(3), rs.getInt(4)), id);
|
||||
return rows.isEmpty() ? null : rows.get(0);
|
||||
}
|
||||
|
||||
public void processed(long id, String owner) {
|
||||
jdbc.update("""
|
||||
UPDATE sys_audit_dead_letter SET status='PROCESSED',processed_at=CURRENT_TIMESTAMP(3),
|
||||
lease_owner=NULL,lease_until=NULL,last_error=NULL
|
||||
WHERE id=? AND status='PROCESSING' AND lease_owner=?
|
||||
""", id, owner);
|
||||
}
|
||||
|
||||
public void failed(long id, String owner, int maxAttempts, String error) {
|
||||
jdbc.update("""
|
||||
UPDATE sys_audit_dead_letter
|
||||
SET status=IF(retry_count>=?,'DEAD','PENDING'),
|
||||
next_retry_at=DATE_ADD(CURRENT_TIMESTAMP(3),INTERVAL 60 SECOND),
|
||||
lease_owner=NULL,lease_until=NULL,last_error=?
|
||||
WHERE id=? AND status='PROCESSING' AND lease_owner=?
|
||||
""", maxAttempts, error, id, owner);
|
||||
}
|
||||
|
||||
public int releaseExpired() {
|
||||
return jdbc.update("""
|
||||
UPDATE sys_audit_dead_letter SET status='PENDING',next_retry_at=CURRENT_TIMESTAMP(3),
|
||||
lease_owner=NULL,lease_until=NULL
|
||||
WHERE status='PROCESSING' AND lease_until<=CURRENT_TIMESTAMP(3) LIMIT 100
|
||||
""");
|
||||
}
|
||||
|
||||
public record DeadRow(long id, String eventId, String payload, int retryCount) { }
|
||||
}
|
||||
@@ -5,7 +5,13 @@ import jakarta.annotation.Resource;
|
||||
import com.baiyue.audit.dto.AuditIngestRequest;
|
||||
import com.baiyue.audit.entity.AuditLogEntity;
|
||||
import com.baiyue.audit.mapper.AuditLogMapper;
|
||||
import com.baiyue.audit.dto.AuditLogQuery;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* 审计事件持久化仓储,负责将内部审计请求映射并追加写入审计日志表。
|
||||
@@ -15,8 +21,9 @@ public class AuditEventRepository {
|
||||
@Resource
|
||||
private AuditLogMapper mapper;
|
||||
|
||||
public void save(AuditIngestRequest event) {
|
||||
public boolean save(AuditIngestRequest event, String eventId) {
|
||||
AuditLogEntity entity = new AuditLogEntity();
|
||||
entity.setEventId(eventId);
|
||||
entity.setEventType(event.eventType());
|
||||
entity.setResourceType(event.resourceType());
|
||||
entity.setResourceId(event.resourceId());
|
||||
@@ -40,7 +47,40 @@ public class AuditEventRepository {
|
||||
entity.setAfterData(jsonOrNull(event.afterData()));
|
||||
entity.setOccurredAt(event.occurredAt());
|
||||
entity.setServiceName(event.serviceName());
|
||||
mapper.insert(entity);
|
||||
try {
|
||||
mapper.insert(entity);
|
||||
return true;
|
||||
} catch (DuplicateKeyException ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public PageResult<AuditLogEntity> page(AuditLogQuery query, long page, long size) {
|
||||
LambdaQueryWrapper<AuditLogEntity> wrapper = new LambdaQueryWrapper<AuditLogEntity>()
|
||||
.eq(query.accountId() != null, AuditLogEntity::getTenantId, query.accountId())
|
||||
.eq(query.operatorUserId() != null, AuditLogEntity::getUserId, query.operatorUserId())
|
||||
.eq(StringUtils.hasText(query.eventType()), AuditLogEntity::getEventType, query.eventType())
|
||||
.eq(StringUtils.hasText(query.resourceType()), AuditLogEntity::getResourceType, query.resourceType())
|
||||
.eq(StringUtils.hasText(query.resourceId()), AuditLogEntity::getResourceId, query.resourceId())
|
||||
.eq(query.success() != null, AuditLogEntity::getSuccess, query.success())
|
||||
.ge(query.occurredFrom() != null, AuditLogEntity::getOccurredAt, query.occurredFrom())
|
||||
.le(query.occurredTo() != null, AuditLogEntity::getOccurredAt, query.occurredTo())
|
||||
.orderByDesc(AuditLogEntity::getOccurredAt)
|
||||
.orderByDesc(AuditLogEntity::getId);
|
||||
if (StringUtils.hasText(query.keyword())) {
|
||||
String keyword = query.keyword().trim();
|
||||
wrapper.and(condition -> condition
|
||||
.like(AuditLogEntity::getDescription, keyword)
|
||||
.or().like(AuditLogEntity::getUsername, keyword)
|
||||
.or().like(AuditLogEntity::getResourceId, keyword)
|
||||
.or().like(AuditLogEntity::getRequestId, keyword)
|
||||
.or().like(AuditLogEntity::getTraceId, keyword));
|
||||
}
|
||||
return PageResult.from(mapper.selectPage(new Page<>(page, size), wrapper));
|
||||
}
|
||||
|
||||
public AuditLogEntity findById(Long id) {
|
||||
return mapper.selectById(id);
|
||||
}
|
||||
|
||||
private static String jsonOrNull(String value) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.baiyue.audit.service;
|
||||
|
||||
import com.baiyue.audit.dto.AuditIngestRequest;
|
||||
import com.baiyue.audit.repository.AuditDeadLetterRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** 定时重放已持久化的审计死信,达到上限后保留为 DEAD 供人工处理。 */
|
||||
@Component
|
||||
@ConditionalOnProperty(prefix = "baiyue.audit.rocketmq", name = "dead-letter-compensation-enabled", havingValue = "true")
|
||||
public class AuditDeadLetterCompensator {
|
||||
@Resource
|
||||
private AuditDeadLetterRepository repository;
|
||||
@Resource
|
||||
private AuditIngestService service;
|
||||
@Resource
|
||||
private ObjectMapper json;
|
||||
@Value("${spring.application.name:audit-service}-${random.uuid}")
|
||||
private String owner;
|
||||
@Value("${baiyue.audit.rocketmq.dead-letter-max-attempts:5}")
|
||||
private int maxAttempts;
|
||||
|
||||
@Scheduled(fixedDelayString = "${baiyue.audit.rocketmq.dead-letter-poll-ms:10000}")
|
||||
public void compensate() {
|
||||
repository.releaseExpired();
|
||||
for (Long id : repository.pendingIds(50)) {
|
||||
if (!repository.claim(id, owner, 60)) continue;
|
||||
AuditDeadLetterRepository.DeadRow row = repository.find(id);
|
||||
if (row == null) continue;
|
||||
try {
|
||||
service.ingest(json.readValue(row.payload(), AuditIngestRequest.class));
|
||||
repository.processed(id, owner);
|
||||
} catch (Exception exception) {
|
||||
repository.failed(id, owner, maxAttempts, safe(exception.getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String safe(String value) {
|
||||
String message = value == null ? "死信补偿失败" : value;
|
||||
return message.length() <= 1000 ? message : message.substring(0, 1000);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,9 @@ import com.baiyue.audit.dto.AuditIngestRequest;
|
||||
import com.baiyue.audit.repository.AuditEventRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 审计事件写入应用服务,在事务内接收并持久化不可变业务审计记录。
|
||||
@@ -16,7 +19,8 @@ public class AuditIngestService {
|
||||
|
||||
|
||||
@Transactional
|
||||
public void ingest(AuditIngestRequest event) {
|
||||
repository.save(event);
|
||||
public boolean ingest(AuditIngestRequest event) {
|
||||
String eventId = StringUtils.hasText(event.eventId()) ? event.eventId() : UUID.randomUUID().toString();
|
||||
return repository.save(event, eventId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.baiyue.audit.service;
|
||||
|
||||
import com.baiyue.audit.dto.AuditLogQuery;
|
||||
import com.baiyue.audit.dto.AuditLogResponses;
|
||||
import com.baiyue.audit.entity.AuditLogEntity;
|
||||
import com.baiyue.audit.repository.AuditEventRepository;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/** 平台统一操作日志查询服务。 */
|
||||
@Service
|
||||
public class AuditLogQueryService {
|
||||
@Resource
|
||||
private AuditEventRepository repository;
|
||||
|
||||
public PageResult<AuditLogResponses.ListItem> page(AuditLogQuery query, long page, long size) {
|
||||
PageResult<AuditLogEntity> source = repository.page(query, page, size);
|
||||
return new PageResult<>(source.records().stream().map(this::toListItem).toList(),
|
||||
source.page(), source.size(), source.total(), source.pages());
|
||||
}
|
||||
|
||||
public AuditLogResponses.Detail detail(Long id) {
|
||||
AuditLogEntity entity = repository.findById(id);
|
||||
if (entity == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "操作日志不存在");
|
||||
}
|
||||
return new AuditLogResponses.Detail(
|
||||
entity.getId(), entity.getEventId(), entity.getEventType(), entity.getResourceType(), entity.getResourceId(),
|
||||
entity.getDescription(), entity.getRequestId(), entity.getTraceId(), entity.getTenantId(),
|
||||
entity.getOperatorTenantId(), entity.getUserId(), entity.getUsername(), entity.getAppCode(),
|
||||
entity.getClientType(), entity.getHttpMethod(), entity.getRequestUri(), entity.getClientIp(),
|
||||
entity.getDurationMs(), entity.getSuccess(), entity.getErrorCode(), entity.getErrorMessage(),
|
||||
entity.getBeforeData(), entity.getAfterData(), entity.getServiceName(), entity.getOccurredAt(),
|
||||
entity.getCreatedAt());
|
||||
}
|
||||
|
||||
private AuditLogResponses.ListItem toListItem(AuditLogEntity entity) {
|
||||
return new AuditLogResponses.ListItem(
|
||||
entity.getId(), entity.getEventId(), entity.getEventType(), entity.getResourceType(), entity.getResourceId(),
|
||||
entity.getDescription(), entity.getTenantId(), entity.getOperatorTenantId(), entity.getUserId(),
|
||||
entity.getUsername(), entity.getAppCode(), entity.getClientType(), entity.getRequestId(),
|
||||
entity.getTraceId(), entity.getServiceName(), entity.getSuccess(), entity.getErrorCode(),
|
||||
entity.getErrorMessage(), entity.getOccurredAt(), entity.getCreatedAt());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.baiyue.audit.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
|
||||
/** 审计日志只允许平台端账号读取,避免商户端通过通用日志接口获取跨账号记录。 */
|
||||
public final class AuditPlatformScopeGuard {
|
||||
private AuditPlatformScopeGuard() {
|
||||
}
|
||||
|
||||
public static void requirePlatform(AuthenticatedUser user) {
|
||||
if (user == null || user.userId() == null) {
|
||||
throw new BizException(ResultCode.UNAUTHORIZED, "请先登录");
|
||||
}
|
||||
if (!"PLATFORM".equals(user.appCode())) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "仅平台端可查看操作日志");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,19 @@ spring:
|
||||
namespace: ${NACOS_NAMESPACE:public}
|
||||
|
||||
baiyue.audit.enabled: false
|
||||
baiyue:
|
||||
audit:
|
||||
rocketmq:
|
||||
consumer-enabled: ${AUDIT_MQ_CONSUMER_ENABLED:false}
|
||||
topic: ${MQ_TOPIC_PREFIX:test-}baiyue-audit-event-v1
|
||||
consumer-group: ${MQ_TOPIC_PREFIX:test-}baiyue-audit-consumer-v1
|
||||
dead-letter-consumer-enabled: ${AUDIT_DLQ_CONSUMER_ENABLED:false}
|
||||
dead-letter-topic: "%DLQ%${MQ_TOPIC_PREFIX:test-}baiyue-audit-consumer-v1"
|
||||
dead-letter-group: ${MQ_TOPIC_PREFIX:test-}baiyue-audit-dlq-handler-v1
|
||||
dead-letter-compensation-enabled: ${AUDIT_DLQ_COMPENSATION_ENABLED:false}
|
||||
dead-letter-max-attempts: ${AUDIT_DLQ_MAX_ATTEMPTS:5}
|
||||
rocketmq:
|
||||
name-server: ${ROCKETMQ_NAME_SERVER:127.0.0.1:9876}
|
||||
management.endpoints.web.exposure.include: health,info,prometheus
|
||||
|
||||
logging:
|
||||
|
||||
@@ -4,6 +4,7 @@ USE baiyue_audit;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sys_audit_log (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '审计日志自增ID',
|
||||
event_id VARCHAR(64) DEFAULT NULL COMMENT '审计事件全局唯一ID',
|
||||
event_type VARCHAR(64) NOT NULL COMMENT '审计事件编码',
|
||||
resource_type VARCHAR(64) NOT NULL COMMENT '被操作资源类型',
|
||||
resource_id VARCHAR(128) DEFAULT NULL COMMENT '被操作资源ID',
|
||||
@@ -29,8 +30,26 @@ CREATE TABLE IF NOT EXISTS sys_audit_log (
|
||||
occurred_at DATETIME(3) NOT NULL COMMENT '业务操作发生时间',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '审计服务落库时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_audit_event_id (event_id),
|
||||
KEY idx_audit_tenant_time (tenant_id, occurred_at),
|
||||
KEY idx_audit_event_time (event_type, occurred_at),
|
||||
KEY idx_audit_user_time (user_id, occurred_at),
|
||||
KEY idx_audit_request (request_id)
|
||||
) ENGINE=InnoDB COMMENT='统一业务审计日志表:追加记录敏感操作的主体、租户、资源、结果及前后快照;不同于消息Outbox和消费幂等表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sys_audit_dead_letter (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
event_id VARCHAR(64) NOT NULL COMMENT '审计事件全局唯一ID',
|
||||
payload_text LONGTEXT NOT NULL COMMENT 'RocketMQ死信原始载荷',
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、PROCESSING、PROCESSED、DEAD',
|
||||
retry_count INT NOT NULL DEFAULT 0,
|
||||
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
lease_owner VARCHAR(128) NULL,
|
||||
lease_until DATETIME(3) NULL,
|
||||
last_error VARCHAR(1000) NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
processed_at DATETIME(3) NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_audit_dead_event (event_id),
|
||||
KEY idx_audit_dead_retry (status, next_retry_at, lease_until)
|
||||
) ENGINE=InnoDB COMMENT='审计消息死信补偿表';
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
USE baiyue_audit;
|
||||
|
||||
DROP PROCEDURE IF EXISTS add_audit_event_id;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE add_audit_event_id()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA='baiyue_audit' AND TABLE_NAME='sys_audit_log' AND COLUMN_NAME='event_id'
|
||||
) THEN
|
||||
ALTER TABLE sys_audit_log ADD COLUMN event_id VARCHAR(64) NULL COMMENT '审计事件全局唯一ID' AFTER id;
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA='baiyue_audit' AND TABLE_NAME='sys_audit_log' AND INDEX_NAME='uk_audit_event_id'
|
||||
) THEN
|
||||
ALTER TABLE sys_audit_log ADD UNIQUE KEY uk_audit_event_id (event_id);
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL add_audit_event_id();
|
||||
DROP PROCEDURE IF EXISTS add_audit_event_id;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sys_audit_dead_letter (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
event_id VARCHAR(64) NOT NULL COMMENT '审计事件全局唯一ID',
|
||||
payload_text LONGTEXT NOT NULL COMMENT 'RocketMQ死信原始载荷',
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、PROCESSING、PROCESSED、DEAD',
|
||||
retry_count INT NOT NULL DEFAULT 0,
|
||||
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
lease_owner VARCHAR(128) NULL,
|
||||
lease_until DATETIME(3) NULL,
|
||||
last_error VARCHAR(1000) NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
processed_at DATETIME(3) NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_audit_dead_event (event_id),
|
||||
KEY idx_audit_dead_retry (status, next_retry_at, lease_until)
|
||||
) ENGINE=InnoDB COMMENT='审计消息死信补偿表';
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package com.baiyue.audit.controller;
|
||||
|
||||
import com.baiyue.audit.service.AuditIngestService;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@SpringJUnitConfig(AuditIngestController.class)
|
||||
@TestPropertySource(properties = "baiyue.security.internal-service-token=0123456789abcdef0123456789abcdef")
|
||||
class AuditIngestControllerTest {
|
||||
private static final String TOKEN = "0123456789abcdef0123456789abcdef";
|
||||
|
||||
@MockBean
|
||||
private AuditIngestService service;
|
||||
|
||||
@Resource
|
||||
private AuditIngestController controller;
|
||||
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidInternalToken() throws Exception {
|
||||
mockMvc.perform(post("/internal/audit/events")
|
||||
.header("X-Internal-Service-Token", "wrong-token")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(validRequest()))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.code").value("A0403"));
|
||||
|
||||
verify(service, never()).ingest(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsEventWithInternalToken() throws Exception {
|
||||
mockMvc.perform(post("/internal/audit/events")
|
||||
.header("X-Internal-Service-Token", TOKEN)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(validRequest()))
|
||||
.andExpect(status().isAccepted())
|
||||
.andExpect(jsonPath("$.code").value("200"));
|
||||
|
||||
verify(service).ingest(any());
|
||||
}
|
||||
|
||||
private String validRequest() {
|
||||
return """
|
||||
{
|
||||
"eventType":"ROLE_UPDATE",
|
||||
"resourceType":"ROLE",
|
||||
"durationMs":0,
|
||||
"success":true,
|
||||
"occurredAt":"2026-08-05T00:00:00Z"
|
||||
}
|
||||
""";
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package com.baiyue.audit.controller;
|
||||
|
||||
import com.baiyue.audit.dto.AuditLogQuery;
|
||||
import com.baiyue.audit.dto.AuditLogResponses;
|
||||
import com.baiyue.audit.service.AuditLogQueryService;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@SpringJUnitConfig(PlatformAuditLogController.class)
|
||||
class PlatformAuditLogControllerContractTest {
|
||||
@MockBean
|
||||
private AuditLogQueryService service;
|
||||
|
||||
@Resource
|
||||
private PlatformAuditLogController controller;
|
||||
|
||||
@Test
|
||||
void platformCanQueryLogList() {
|
||||
when(service.page(any(AuditLogQuery.class), org.mockito.ArgumentMatchers.eq(1L),
|
||||
org.mockito.ArgumentMatchers.eq(20L)))
|
||||
.thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0));
|
||||
|
||||
var response = controller.page(user("PLATFORM"), 1, 20, 10001L, 7L, "ROLE_UPDATE", "ROLE", "9",
|
||||
true, null, null, "管理员");
|
||||
|
||||
assertThat(response.successful()).isTrue();
|
||||
verify(service).page(new AuditLogQuery(10001L, 7L, "ROLE_UPDATE", "ROLE", "9", true,
|
||||
null, null, "管理员"), 1, 20);
|
||||
}
|
||||
|
||||
@Test
|
||||
void merchantCannotReadPlatformLogs() {
|
||||
assertThatThrownBy(() -> controller.page(user("MERCHANT"), 1, 20, null, null, null, null,
|
||||
null, null, null, null, null)).isInstanceOf(BizException.class)
|
||||
.hasMessage("仅平台端可查看操作日志");
|
||||
verifyNoInteractions(service);
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformCanReadLogDetail() {
|
||||
AuditLogResponses.Detail detail = new AuditLogResponses.Detail(8L, "audit-1", "ROLE_UPDATE", "ROLE", "9", null,
|
||||
null, null, null, null, 7L, "管理员", "PLATFORM", "WEB", null, null, null, 1L,
|
||||
true, null, null, "{}", "{}", "iam-service", null, null);
|
||||
when(service.detail(8L)).thenReturn(detail);
|
||||
|
||||
assertThat(controller.detail(user("PLATFORM"), 8L).data()).isEqualTo(detail);
|
||||
verify(service).detail(8L);
|
||||
}
|
||||
|
||||
private static AuthenticatedUser user(String appCode) {
|
||||
return new AuthenticatedUser(7L, 0L, 0L, 1L, "管理员", appCode, "WEB", Set.of("platform:audit:list"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.baiyue.audit.mq;
|
||||
|
||||
import com.baiyue.audit.repository.AuditDeadLetterRepository;
|
||||
import com.baiyue.audit.service.AuditDeadLetterCompensator;
|
||||
import com.baiyue.audit.service.AuditIngestService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@SpringJUnitConfig(classes = {
|
||||
AuditMqReliabilityTest.Config.class,
|
||||
AuditRocketMqConsumer.class,
|
||||
AuditDeadLetterConsumer.class,
|
||||
AuditDeadLetterCompensator.class
|
||||
})
|
||||
@TestPropertySource(properties = {
|
||||
"baiyue.audit.rocketmq.consumer-enabled=true",
|
||||
"baiyue.audit.rocketmq.dead-letter-consumer-enabled=true",
|
||||
"baiyue.audit.rocketmq.dead-letter-compensation-enabled=true",
|
||||
"baiyue.audit.rocketmq.topic=test-audit",
|
||||
"baiyue.audit.rocketmq.consumer-group=test-audit-consumer",
|
||||
"baiyue.audit.rocketmq.dead-letter-topic=%DLQ%test-audit-consumer",
|
||||
"baiyue.audit.rocketmq.dead-letter-group=test-audit-dlq",
|
||||
"spring.application.name=audit-service-test"
|
||||
})
|
||||
class AuditMqReliabilityTest {
|
||||
private static final String PAYLOAD = """
|
||||
{"eventId":"event-1","eventType":"ROLE_UPDATE","resourceType":"ROLE",
|
||||
"durationMs":10,"success":true,"occurredAt":"2026-08-05T00:00:00Z"}
|
||||
""";
|
||||
|
||||
@MockBean
|
||||
private AuditIngestService service;
|
||||
@MockBean
|
||||
private AuditDeadLetterRepository repository;
|
||||
@Resource
|
||||
private AuditRocketMqConsumer consumer;
|
||||
@Resource
|
||||
private AuditDeadLetterConsumer deadLetterConsumer;
|
||||
@Resource
|
||||
private AuditDeadLetterCompensator compensator;
|
||||
|
||||
@Test
|
||||
void consumesAuditEvent() {
|
||||
consumer.onMessage(PAYLOAD);
|
||||
|
||||
verify(service).ingest(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidPayload() {
|
||||
assertThatThrownBy(() -> consumer.onMessage("not-json"))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void storesDeadLetter() {
|
||||
deadLetterConsumer.onMessage(PAYLOAD);
|
||||
|
||||
verify(repository).save("event-1", PAYLOAD);
|
||||
}
|
||||
|
||||
@Test
|
||||
void compensatesDeadLetter() {
|
||||
when(repository.pendingIds(50)).thenReturn(java.util.List.of(7L));
|
||||
when(repository.claim(eq(7L), anyString(), eq(60))).thenReturn(true);
|
||||
when(repository.find(7L)).thenReturn(
|
||||
new AuditDeadLetterRepository.DeadRow(7L, "event-1", PAYLOAD, 1));
|
||||
|
||||
compensator.compensate();
|
||||
|
||||
verify(service).ingest(any());
|
||||
verify(repository).processed(eq(7L), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void retriesFailedCompensation() {
|
||||
when(repository.pendingIds(50)).thenReturn(java.util.List.of(8L));
|
||||
when(repository.claim(eq(8L), anyString(), eq(60))).thenReturn(true);
|
||||
when(repository.find(8L)).thenReturn(
|
||||
new AuditDeadLetterRepository.DeadRow(8L, "event-2", "not-json", 1));
|
||||
|
||||
compensator.compensate();
|
||||
|
||||
verify(repository).failed(eq(8L), anyString(), eq(5), anyString());
|
||||
}
|
||||
|
||||
@Configuration
|
||||
static class Config {
|
||||
@Bean
|
||||
ObjectMapper objectMapper() {
|
||||
return new ObjectMapper().findAndRegisterModules();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.baiyue.audit.service;
|
||||
|
||||
import com.baiyue.audit.dto.AuditIngestRequest;
|
||||
import com.baiyue.audit.repository.AuditEventRepository;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@SpringJUnitConfig(AuditIngestService.class)
|
||||
class AuditIngestIdempotencyTest {
|
||||
@MockBean
|
||||
private AuditEventRepository repository;
|
||||
@Resource
|
||||
private AuditIngestService service;
|
||||
|
||||
@Test
|
||||
void keepsStableEventIdForMqRedelivery() {
|
||||
AuditIngestRequest request = request("audit-1");
|
||||
when(repository.save(request, "audit-1")).thenReturn(false);
|
||||
|
||||
assertThat(service.ingest(request)).isFalse();
|
||||
|
||||
verify(repository).save(request, "audit-1");
|
||||
}
|
||||
|
||||
private static AuditIngestRequest request(String eventId) {
|
||||
return new AuditIngestRequest(eventId, "ROLE_UPDATE", "ROLE", "1", "修改角色",
|
||||
"request-1", "trace-1", 1L, 1L, 1L, "admin", "PLATFORM", "WEB", "PUT",
|
||||
"/roles/1", "127.0.0.1", 10, true, null, null, null, null,
|
||||
Instant.parse("2026-08-05T00:00:00Z"), "iam-service");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.baiyue.audit.service;
|
||||
|
||||
import com.baiyue.audit.dto.AuditLogQuery;
|
||||
import com.baiyue.audit.dto.AuditLogResponses;
|
||||
import com.baiyue.audit.entity.AuditLogEntity;
|
||||
import com.baiyue.audit.repository.AuditEventRepository;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@SpringJUnitConfig(AuditLogQueryService.class)
|
||||
class AuditLogQueryServiceTest {
|
||||
@MockBean
|
||||
private AuditEventRepository repository;
|
||||
|
||||
@Resource
|
||||
private AuditLogQueryService service;
|
||||
|
||||
@Test
|
||||
void pageDoesNotExposeSnapshots() {
|
||||
AuditLogQuery query = new AuditLogQuery(10001L, 7L, "ROLE_UPDATE", "ROLE", "9", true,
|
||||
null, null, "管理员");
|
||||
when(repository.page(query, 1, 20)).thenReturn(new PageResult<>(List.of(log(8L)), 1, 20, 1, 1));
|
||||
|
||||
PageResult<AuditLogResponses.ListItem> result = service.page(query, 1, 20);
|
||||
|
||||
assertThat(result.records()).singleElement().satisfies(item -> {
|
||||
assertThat(item.id()).isEqualTo(8L);
|
||||
assertThat(item.accountId()).isEqualTo(10001L);
|
||||
assertThat(item.eventType()).isEqualTo("ROLE_UPDATE");
|
||||
});
|
||||
assertThat(AuditLogResponses.ListItem.class.getRecordComponents())
|
||||
.extracting(component -> component.getName())
|
||||
.doesNotContain("beforeData", "afterData");
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailReturnsSanitizedSnapshots() {
|
||||
when(repository.findById(8L)).thenReturn(log(8L));
|
||||
|
||||
AuditLogResponses.Detail detail = service.detail(8L);
|
||||
|
||||
assertThat(detail.beforeData()).isEqualTo("{\"name\":\"旧角色\"}");
|
||||
assertThat(detail.afterData()).isEqualTo("{\"name\":\"新角色\"}");
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingLogIsReportedAsNotFound() {
|
||||
when(repository.findById(8L)).thenReturn(null);
|
||||
|
||||
assertThatThrownBy(() -> service.detail(8L)).isInstanceOf(BizException.class)
|
||||
.hasMessage("操作日志不存在");
|
||||
}
|
||||
|
||||
private static AuditLogEntity log(Long id) {
|
||||
AuditLogEntity entity = new AuditLogEntity();
|
||||
entity.setId(id);
|
||||
entity.setEventId("audit-8");
|
||||
entity.setEventType("ROLE_UPDATE");
|
||||
entity.setResourceType("ROLE");
|
||||
entity.setResourceId("9");
|
||||
entity.setDescription("管理员修改角色权限");
|
||||
entity.setTenantId(10001L);
|
||||
entity.setOperatorTenantId(0L);
|
||||
entity.setUserId(7L);
|
||||
entity.setUsername("管理员");
|
||||
entity.setAppCode("PLATFORM");
|
||||
entity.setClientType("WEB");
|
||||
entity.setRequestId("request-8");
|
||||
entity.setTraceId("trace-8");
|
||||
entity.setSuccess(true);
|
||||
entity.setBeforeData("{\"name\":\"旧角色\"}");
|
||||
entity.setAfterData("{\"name\":\"新角色\"}");
|
||||
entity.setServiceName("iam-service");
|
||||
entity.setOccurredAt(Instant.parse("2026-08-05T10:00:00Z"));
|
||||
entity.setCreatedAt(LocalDateTime.of(2026, 8, 5, 18, 0));
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
@@ -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,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) { }
|
||||
}
|
||||
+33
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
+29
-2
@@ -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 +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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
Vendored
+5
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
+107
@@ -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));
|
||||
}
|
||||
}
|
||||
+3
@@ -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 + "')");
|
||||
}
|
||||
}
|
||||
+13
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+31
@@ -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/**");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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='可靠审计事件本地发送箱';
|
||||
Reference in New Issue
Block a user