Merge branch 'test' into dev

This commit is contained in:
2026-08-20 09:50:48 +08:00
25 changed files with 836 additions and 43 deletions
@@ -4,7 +4,11 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.FinanceFeeSourceSnapshot;
import com.baiyue.firstleg.dto.FinanceFeeSourceCommandDtos.CommandResult;
import com.baiyue.firstleg.dto.FinanceFeeSourceCommandDtos.VoidRequest;
import com.baiyue.firstleg.service.FinanceFeeSourceCommandService;
import com.baiyue.firstleg.service.FinanceFeeSourceSnapshotService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
@@ -12,6 +16,8 @@ import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets;
@@ -22,6 +28,7 @@ import java.security.MessageDigest;
@RequestMapping("/internal/firstleg/v1")
public class InternalFinanceFeeSourceController {
private final FinanceFeeSourceSnapshotService service;
private final FinanceFeeSourceCommandService commands;
@Value("${baiyue.security.internal-service-token}")
private String expectedToken;
@@ -43,6 +50,16 @@ public class InternalFinanceFeeSourceController {
return ApiResponse.success(service.container(containerId, feeId, sourceVersion));
}
@PostMapping("/fee-sources/commands/void")
public ApiResponse<CommandResult> voidSource(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@RequestHeader("X-Request-Hash") String requestHash,
@Valid @RequestBody VoidRequest request) {
authenticate(token);
return ApiResponse.success(commands.voidSource(idempotencyKey, requestHash, request));
}
private void authenticate(String token) {
if (token == null || expectedToken == null || expectedToken.isBlank()
|| !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8),
@@ -0,0 +1,32 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
/** Finance 发起、Firstleg 执行的业务费用命令契约。 */
public final class FinanceFeeSourceCommandDtos {
private FinanceFeeSourceCommandDtos() {}
public record VoidRequest(
@NotBlank @Pattern(regexp = "INBOUND_FEE|CONTAINER_FEE") String sourceType,
@Min(1) long sourceFeeId,
@Min(1) int sourceVersion,
@Min(1) long customerId,
Long inboundId,
Long containerId,
@Min(1) long operatorId,
@NotBlank @Size(max = 64) String operatorName,
@NotBlank @Size(max = 500) String reason
) {}
public record CommandResult(
@NotNull String sourceType,
long sourceFeeId,
int sourceVersion,
@NotNull String status,
boolean replayed
) {}
}
@@ -0,0 +1,33 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("fl_finance_fee_source_command")
public class FinanceFeeSourceCommandEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long customerId;
private Long operatorId;
private String operatorName;
private String action;
private String idempotencyKey;
private String requestHash;
private String sourceType;
private Long sourceFeeId;
private Integer sourceVersion;
private Long inboundId;
private Long containerId;
private String reason;
private String status;
private String resultStatus;
private LocalDateTime createdAt;
private LocalDateTime completedAt;
}
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.FinanceFeeSourceCommandEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface FinanceFeeSourceCommandMapper extends BaseMapper<FinanceFeeSourceCommandEntity> {
}
@@ -258,6 +258,26 @@ public class FbaPricingRepository {
.set(InboundFeeEntity::getSentAt, now));
}
public int voidSentFee(long tenantId, long inboundId, long feeId, int feeVersion,
LocalDateTime now) {
return fees.update(null, new LambdaUpdateWrapper<InboundFeeEntity>()
.eq(InboundFeeEntity::getTenantId, tenantId)
.eq(InboundFeeEntity::getInboundId, inboundId)
.eq(InboundFeeEntity::getId, feeId)
.eq(InboundFeeEntity::getFeeVersion, feeVersion)
.eq(InboundFeeEntity::getFeeStatus, "SENT")
.set(InboundFeeEntity::getFeeStatus, "VOIDED")
.set(InboundFeeEntity::getUpdatedAt, now));
}
public int markInboundFeeVoided(long tenantId, long inboundId) {
return inbounds.update(null, new LambdaUpdateWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId)
.eq(InboundPricingEntity::getId, inboundId)
.eq(InboundPricingEntity::getFeeStatus, "SENT")
.set(InboundPricingEntity::getFeeStatus, "VOIDED"));
}
public int markInboundFeeSent(long tenantId, long inboundId, String processStatus) {
LambdaUpdateWrapper<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId)
@@ -0,0 +1,38 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baiyue.firstleg.entity.FinanceFeeSourceCommandEntity;
import com.baiyue.firstleg.mapper.FinanceFeeSourceCommandMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
@Repository
@RequiredArgsConstructor
public class FinanceFeeSourceCommandRepository {
private final FinanceFeeSourceCommandMapper mapper;
public void insert(FinanceFeeSourceCommandEntity value) {
mapper.insert(value);
}
public FinanceFeeSourceCommandEntity find(long customerId, long operatorId,
String action, String key) {
return mapper.selectOne(new LambdaQueryWrapper<FinanceFeeSourceCommandEntity>()
.eq(FinanceFeeSourceCommandEntity::getCustomerId, customerId)
.eq(FinanceFeeSourceCommandEntity::getOperatorId, operatorId)
.eq(FinanceFeeSourceCommandEntity::getAction, action)
.eq(FinanceFeeSourceCommandEntity::getIdempotencyKey, key));
}
public int complete(long id, String resultStatus, LocalDateTime completedAt) {
return mapper.update(null, new LambdaUpdateWrapper<FinanceFeeSourceCommandEntity>()
.eq(FinanceFeeSourceCommandEntity::getId, id)
.eq(FinanceFeeSourceCommandEntity::getStatus, "PROCESSING")
.set(FinanceFeeSourceCommandEntity::getStatus, "SUCCEEDED")
.set(FinanceFeeSourceCommandEntity::getResultStatus, resultStatus)
.set(FinanceFeeSourceCommandEntity::getCompletedAt, completedAt));
}
}
@@ -0,0 +1,192 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.FinanceFeeSourceCommandDtos.CommandResult;
import com.baiyue.firstleg.dto.FinanceFeeSourceCommandDtos.VoidRequest;
import com.baiyue.firstleg.entity.ContainerFeeDetailEntity;
import com.baiyue.firstleg.entity.FinanceFeeSourceCommandEntity;
import com.baiyue.firstleg.entity.InboundFeeDetailEntity;
import com.baiyue.firstleg.repository.ContainerFeeDetailRepository;
import com.baiyue.firstleg.repository.FbaPricingRepository;
import com.baiyue.firstleg.repository.FeeItemRepository;
import com.baiyue.firstleg.repository.FinanceFeeSourceCommandRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.HexFormat;
/** Finance 仅发起命令;业务状态与费用事件始终由 Firstleg 原子产生。 */
@Service
@RequiredArgsConstructor
public class FinanceFeeSourceCommandService {
private static final String ACTION_VOID = "VOID";
private final FinanceFeeSourceCommandRepository commands;
private final FeeItemRepository inboundFees;
private final FbaPricingRepository pricing;
private final ContainerFeeDetailRepository containerFees;
private final ContainerFeeDetailService containerFeeService;
private final FinanceFeeSourceOutboxService financeOutbox;
private final ObjectMapper objectMapper;
@Transactional
public CommandResult voidSource(String idempotencyKey, String clientHash, VoidRequest request) {
validateKey(idempotencyKey);
validateIdentity(request);
String requestHash = hash(request);
if (!StringUtils.hasText(clientHash) || !requestHash.equalsIgnoreCase(clientHash.trim())) {
throw bad("X-Request-Hash与费用作废请求不一致");
}
FinanceFeeSourceCommandEntity command = claim(idempotencyKey.trim(), requestHash, request);
if ("SUCCEEDED".equals(command.getStatus())) {
return result(request, command.getResultStatus(), true);
}
if ("INBOUND_FEE".equals(request.sourceType())) voidInbound(request);
else voidContainer(request);
if (commands.complete(command.getId(), "VOIDED", LocalDateTime.now()) != 1) {
throw conflict("费用作废幂等记录状态已变化");
}
return result(request, "VOIDED", false);
}
private FinanceFeeSourceCommandEntity claim(String key, String requestHash, VoidRequest request) {
FinanceFeeSourceCommandEntity value = new FinanceFeeSourceCommandEntity();
value.setCustomerId(request.customerId());
value.setOperatorId(request.operatorId());
value.setOperatorName(request.operatorName().trim());
value.setAction(ACTION_VOID);
value.setIdempotencyKey(key);
value.setRequestHash(requestHash);
value.setSourceType(request.sourceType());
value.setSourceFeeId(request.sourceFeeId());
value.setSourceVersion(request.sourceVersion());
value.setInboundId(request.inboundId());
value.setContainerId(request.containerId());
value.setReason(request.reason().trim());
value.setStatus("PROCESSING");
value.setCreatedAt(LocalDateTime.now());
try {
commands.insert(value);
return value;
} catch (DuplicateKeyException duplicate) {
FinanceFeeSourceCommandEntity existing = commands.find(request.customerId(),
request.operatorId(), ACTION_VOID, key);
if (existing == null || !requestHash.equals(existing.getRequestHash())) {
throw conflict("Idempotency-Key已用于其他费用作废请求");
}
if (!"SUCCEEDED".equals(existing.getStatus())) {
throw conflict("相同费用作废请求正在处理中");
}
return existing;
}
}
private void voidInbound(VoidRequest request) {
long inboundId = positive(request.inboundId(), "入库单ID");
if (request.containerId() != null) throw bad("入库费用不能携带柜次ID");
InboundFeeDetailEntity detail = inboundFees.findSourceVersion(inboundId,
request.sourceFeeId(), request.sourceVersion());
if (detail == null || !Long.valueOf(request.customerId()).equals(detail.getTenantId())) {
throw new BizException(ResultCode.NOT_FOUND, "指定客户的入库费用来源版本不存在");
}
if ("VOIDED".equals(detail.getPublicationStatus())) return;
if (!"APPROVED".equals(detail.getReviewStatus())
|| !"PUBLISHED".equals(detail.getPublicationStatus())) {
throw conflict("只有当前已审核发布的入库费用来源可以作废");
}
LocalDateTime now = LocalDateTime.now();
if ("FBA_PRICING".equals(detail.getSourceType())) {
if (pricing.voidSentFee(request.customerId(), inboundId, request.sourceFeeId(),
request.sourceVersion(), now) != 1
|| pricing.markInboundFeeVoided(request.customerId(), inboundId) != 1) {
throw conflict("入库计价费用状态或版本已变化");
}
} else if (!"MANUAL".equals(detail.getSourceType())) {
throw conflict("该入库费用来源类型不支持作废");
}
if (inboundFees.voidApproved(request.customerId(), inboundId, detail.getId(),
request.sourceVersion(), request.operatorId()) != 1) {
throw conflict("入库费用状态或版本已变化");
}
financeOutbox.publishInbound(FinanceFeeSourceOutboxService.VOIDED,
request.customerId(), inboundId, request.sourceFeeId(), request.sourceVersion(), now);
}
private void voidContainer(VoidRequest request) {
long containerId = positive(request.containerId(), "柜次ID");
if (request.inboundId() != null) throw bad("柜次费用不能携带入库单ID");
ContainerFeeDetailEntity detail = containerFees.find(containerId, request.sourceFeeId());
if (detail == null || !Long.valueOf(request.customerId()).equals(detail.getTenantId())) {
throw new BizException(ResultCode.NOT_FOUND, "指定客户的柜次费用来源版本不存在");
}
if (!Integer.valueOf(request.sourceVersion()).equals(detail.getSourceVersion())) {
throw conflict("柜次费用来源版本已变化");
}
if ("VOIDED".equals(detail.getSourceStatus())) return;
containerFeeService.voidApproved(request.customerId(), containerId,
request.sourceFeeId(), request.sourceVersion(), request.operatorId());
}
private String hash(VoidRequest request) {
try {
return sha256(objectMapper.writeValueAsString(request));
} catch (JsonProcessingException exception) {
throw new BizException(ResultCode.INTERNAL_ERROR, "费用作废请求序列化失败");
}
}
private static void validateIdentity(VoidRequest request) {
if (request == null || request.sourceFeeId() <= 0 || request.sourceVersion() <= 0
|| request.customerId() <= 0 || request.operatorId() <= 0
|| !StringUtils.hasText(request.operatorName()) || !StringUtils.hasText(request.reason())) {
throw bad("费用作废请求无效");
}
if (!"INBOUND_FEE".equals(request.sourceType())
&& !"CONTAINER_FEE".equals(request.sourceType())) {
throw bad("费用来源类型无效");
}
}
private static void validateKey(String key) {
if (!StringUtils.hasText(key) || key.trim().length() > 128) {
throw bad("Idempotency-Key不能为空且不能超过128个字符");
}
}
private static long positive(Long value, String label) {
if (value == null || value <= 0) throw bad(label + "无效");
return value;
}
private static CommandResult result(VoidRequest request, String status, boolean replayed) {
return new CommandResult(request.sourceType(), request.sourceFeeId(),
request.sourceVersion(), status, replayed);
}
private static String sha256(String value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 unavailable", exception);
}
}
private static BizException bad(String message) {
return new BizException(ResultCode.BAD_REQUEST, message);
}
private static BizException conflict(String message) {
return new BizException(ResultCode.CONFLICT, message);
}
}