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