This commit is contained in:
2026-09-02 16:17:41 +08:00
parent 12fbf2aa67
commit 03d04431f2
33 changed files with 1367 additions and 22 deletions
@@ -0,0 +1,87 @@
package com.baiyue.lastleg.financefee.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ActionRequest;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.AdjustRequest;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ReviewRequest;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.VoidRequest;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
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.RequestBody;
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.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/platform/v1/last-leg/finance-fee-sources")
public class PlatformOverseasFeeSourceController {
private final OverseasFeeSourceService service;
@GetMapping
@PreAuthorize("hasAuthority('platform:finance:customer-arap:list')")
public ApiResponse<List<OverseasFeeSource>> list(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@RequestParam(required = false) String businessType,
@RequestParam(required = false) String businessNo) {
requirePlatform(user); return ApiResponse.success(service.list(customerId, businessType, businessNo));
}
@PostMapping
@PreAuthorize("hasAuthority('platform:finance:fee-source:edit')")
public ApiResponse<OverseasFeeSource> create(@AuthenticationPrincipal AuthenticatedUser user,
@RequestHeader("Idempotency-Key") String key, @Valid @RequestBody SaveRequest request) {
requirePlatform(user); return ApiResponse.success(service.create(operator(user), key, request));
}
@PostMapping("/{sourceFeeId}/submit")
@PreAuthorize("hasAuthority('platform:finance:fee-source:edit')")
public ApiResponse<OverseasFeeSource> submit(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long sourceFeeId, @Valid @RequestBody ActionRequest request) {
requirePlatform(user); return ApiResponse.success(service.submit(sourceFeeId, operator(user), request.idempotencyKey()));
}
@PostMapping("/{sourceFeeId}/review")
@PreAuthorize("hasAuthority('platform:finance:fee-source:review')")
public ApiResponse<OverseasFeeSource> review(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long sourceFeeId, @Valid @RequestBody ReviewRequest request) {
requirePlatform(user); return ApiResponse.success(service.review(sourceFeeId, operator(user), request));
}
@PostMapping("/{sourceFeeId}/adjust")
@PreAuthorize("hasAuthority('platform:finance:fee-source:edit')")
public ApiResponse<OverseasFeeSource> adjust(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long sourceFeeId, @Valid @RequestBody AdjustRequest request) {
requirePlatform(user); return ApiResponse.success(service.adjust(sourceFeeId, operator(user), request));
}
@PostMapping("/{sourceFeeId}/void")
@PreAuthorize("hasAuthority('platform:finance:fee-source:review')")
public ApiResponse<OverseasFeeSource> voidSource(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long sourceFeeId, @Valid @RequestBody VoidRequest request) {
requirePlatform(user); return ApiResponse.success(service.voidSource(sourceFeeId, operator(user), request));
}
private static void requirePlatform(AuthenticatedUser user) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L)
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform identity is required");
}
private static String operator(AuthenticatedUser user) {
return user.userId() == null ? user.username() : String.valueOf(user.userId());
}
}
@@ -0,0 +1,58 @@
package com.baiyue.lastleg.financefee.dto;
import jakarta.validation.constraints.DecimalMin;
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;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
public final class OverseasFeeSourceDtos {
private OverseasFeeSourceDtos() { }
public record SaveRequest(
@NotBlank @Size(max = 128) String customerId,
@Size(max = 200) String customerName,
@NotBlank @Pattern(regexp = "OVERSEAS_INBOUND_FEE|OVERSEAS_PROCUREMENT_FEE|OVERSEAS_TRUCK_FEE|OVERSEAS_LOOSEBOARD_FEE|OVERSEAS_WAREHOUSE_FEE") String sourceType,
@NotBlank @Pattern(regexp = "OVERSEAS_INBOUND|OVERSEAS_PROCUREMENT|US_TRUCK|LOCAL_LOOSEBOARD|WAREHOUSE_SERVICE") String businessType,
@Min(1) Long businessId,
@NotBlank @Size(max = 128) String businessNo,
@Min(1) Long orderId,
@Min(1) Long inboundId,
@Size(max = 64) String inboundCode,
@Min(1) Long containerId,
@Min(1) Long containerBookingId,
@Size(max = 64) String containerNo,
@Min(1) Long warehouseId,
@NotBlank @Size(max = 64) String feeItemCode,
@NotBlank @Size(max = 200) String feeItemName,
@NotBlank @Pattern(regexp = "RECEIVABLE|PAYABLE") String direction,
@Min(1) Long supplierId,
@Size(max = 200) String supplierName,
@Size(max = 32) String supplierCategory,
@Pattern(regexp = "ON_ACCOUNT|DIRECT_PAID") String settlementMode,
LocalDateTime paidAt,
@Pattern(regexp = "ONLINE|BANK_TRANSFER|CASH|OTHER") String paymentMethod,
@Size(max = 128) String paymentReferenceNo,
@Size(max = 20) List<@NotBlank String> paymentVoucherFileIds,
@NotNull LocalDate businessDate,
@NotBlank @Pattern(regexp = "CNY") String currency,
@NotNull @DecimalMin(value = "0.00", inclusive = false) BigDecimal amount
) { }
public record ActionRequest(@NotBlank @Size(max = 128) String idempotencyKey) { }
public record ReviewRequest(@NotBlank @Pattern(regexp = "APPROVE|REJECT") String decision,
@Size(max = 500) String reason,
@NotBlank @Size(max = 128) String idempotencyKey) { }
public record AdjustRequest(@NotNull @Min(1) Integer expectedVersion,
@NotNull SaveRequest fee,
@NotBlank @Size(max = 128) String idempotencyKey) { }
public record VoidRequest(@NotNull @Min(1) Integer expectedVersion,
@NotBlank @Size(max = 500) String reason,
@NotBlank @Size(max = 128) String idempotencyKey) { }
}
@@ -0,0 +1,21 @@
package com.baiyue.lastleg.financefee.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_finance_fee_command")
public class OverseasFeeCommand {
@TableId(type = IdType.INPUT) private Long id;
private String operatorId;
private String action;
private String idempotencyKey;
private String requestHash;
private Long sourceFeeId;
private Integer sourceVersion;
private LocalDateTime createdAt;
}
@@ -0,0 +1,59 @@
package com.baiyue.lastleg.financefee.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
@Data
@TableName(value = "ll_finance_fee_source", autoResultMap = true)
public class OverseasFeeSource {
@TableId(type = IdType.INPUT) private Long id;
private Long sourceFeeId;
private Integer sourceVersion;
private String customerId;
private String customerName;
private String sourceType;
private String sourceNo;
private String businessType;
private Long businessId;
private String businessNo;
private Long orderId;
private Long inboundId;
private String inboundCode;
private Long containerId;
private Long containerBookingId;
private String containerNo;
private Long warehouseId;
private String feeItemCode;
private String feeItemName;
private String direction;
private Long supplierId;
private String supplierName;
private String supplierCategory;
private String settlementMode;
private LocalDateTime paidAt;
private String paymentMethod;
private String paymentReferenceNo;
@TableField(typeHandler = JacksonTypeHandler.class) private List<String> paymentVoucherFileIds;
private LocalDate businessDate;
private String currency;
private BigDecimal amount;
private String reviewStatus;
private String publicationStatus;
private String sourceStatus;
private String reviewReason;
private String reviewerId;
private LocalDateTime reviewedAt;
private String createdBy;
private String updatedBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,14 @@
package com.baiyue.lastleg.financefee.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.financefee.entity.OverseasFeeCommand;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface OverseasFeeCommandMapper extends BaseMapper<OverseasFeeCommand> {
@Select("SELECT * FROM ll_finance_fee_command WHERE operator_id=#{operatorId} AND action=#{action} AND idempotency_key=#{key} LIMIT 1")
OverseasFeeCommand find(@Param("operatorId") String operatorId, @Param("action") String action,
@Param("key") String key);
}
@@ -0,0 +1,13 @@
package com.baiyue.lastleg.financefee.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface OverseasFeeSourceMapper extends BaseMapper<OverseasFeeSource> {
@Select("SELECT * FROM ll_finance_fee_source WHERE source_fee_id=#{sourceFeeId} ORDER BY source_version DESC LIMIT 1 FOR UPDATE")
OverseasFeeSource lockCurrent(@Param("sourceFeeId") long sourceFeeId);
}
@@ -0,0 +1,56 @@
package com.baiyue.lastleg.financefee.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baiyue.common.event.EventTypes;
import com.baiyue.lastleg.entity.LastlegEventOutbox;
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
@Repository
@RequiredArgsConstructor
public class OverseasFeeOutboxRepository {
private final LastlegEventOutboxMapper mapper;
public List<LastlegEventOutbox> pending(int limit, LocalDateTime now, LocalDateTime stale) {
return mapper.selectList(new LambdaQueryWrapper<LastlegEventOutbox>()
.eq(LastlegEventOutbox::getEventType, EventTypes.BUSINESS_FEE_SOURCE_CHANGED)
.and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING")
.and(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt).or().le(LastlegEventOutbox::getNextRetryAt, now))
.or(processing -> processing.eq(LastlegEventOutbox::getStatus, "PROCESSING").le(LastlegEventOutbox::getLockedAt, stale)))
.orderByAsc(LastlegEventOutbox::getCreatedAt).last("LIMIT " + limit));
}
public boolean claim(long id, String worker, LocalDateTime now, LocalDateTime stale) {
return mapper.update(null, new LambdaUpdateWrapper<LastlegEventOutbox>()
.eq(LastlegEventOutbox::getId, id).eq(LastlegEventOutbox::getEventType, EventTypes.BUSINESS_FEE_SOURCE_CHANGED)
.and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING").or(processing ->
processing.eq(LastlegEventOutbox::getStatus, "PROCESSING").le(LastlegEventOutbox::getLockedAt, stale)))
.set(LastlegEventOutbox::getStatus, "PROCESSING").set(LastlegEventOutbox::getLockedBy, worker)
.set(LastlegEventOutbox::getLockedAt, now)) == 1;
}
public LastlegEventOutbox claimed(long id, String worker) {
return mapper.selectOne(new LambdaQueryWrapper<LastlegEventOutbox>().eq(LastlegEventOutbox::getId, id)
.eq(LastlegEventOutbox::getStatus, "PROCESSING").eq(LastlegEventOutbox::getLockedBy, worker));
}
public void published(long id, String worker, LocalDateTime now) {
mapper.update(null, claimed(id, worker, "PUBLISHED").set(LastlegEventOutbox::getPublishedAt, now)
.set(LastlegEventOutbox::getNextRetryAt, null).set(LastlegEventOutbox::getLastError, null));
}
public void retry(long id, String worker, int retryCount, LocalDateTime next, String error) {
mapper.update(null, claimed(id, worker, "PENDING").set(LastlegEventOutbox::getRetryCount, retryCount)
.set(LastlegEventOutbox::getNextRetryAt, next).set(LastlegEventOutbox::getLastError, truncate(error)));
}
private LambdaUpdateWrapper<LastlegEventOutbox> claimed(long id, String worker, String status) {
return new LambdaUpdateWrapper<LastlegEventOutbox>().eq(LastlegEventOutbox::getId, id)
.eq(LastlegEventOutbox::getStatus, "PROCESSING").eq(LastlegEventOutbox::getLockedBy, worker)
.set(LastlegEventOutbox::getStatus, status).set(LastlegEventOutbox::getLockedBy, null)
.set(LastlegEventOutbox::getLockedAt, null);
}
private static String truncate(String value) {
String safe = value == null ? "unknown" : value; return safe.length() <= 1000 ? safe : safe.substring(0, 1000);
}
}
@@ -0,0 +1,46 @@
package com.baiyue.lastleg.financefee.service;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
@Component
@RequiredArgsConstructor
public class OverseasFeeBusinessGuard {
private final JdbcTemplate jdbc;
public void requireOwned(SaveRequest fee) {
if (fee.businessId() == null) {
if (!"WAREHOUSE_SERVICE".equals(fee.businessType()))
throw bad("业务记录ID不能为空");
if (fee.warehouseId() == null || (fee.inboundId() == null && blank(fee.inboundCode())))
throw bad("海外仓服务费必须关联仓库以及入仓记录或入仓码");
return;
}
Table table = switch (fee.businessType()) {
case "OVERSEAS_INBOUND" -> new Table("ll_inbound_plan", "plan_no");
case "OVERSEAS_PROCUREMENT" -> new Table("ll_overseas_procurement_order", "order_no");
case "US_TRUCK" -> new Table("ll_us_truck_order", "order_no");
case "LOCAL_LOOSEBOARD" -> new Table("ll_local_looseboard_order", "order_no");
default -> throw bad("业务类型与业务记录ID不匹配");
};
Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM " + table.name
+ " WHERE id=? AND customer_id=? AND " + table.number + "=?",
Integer.class, fee.businessId(), fee.customerId().trim(), fee.businessNo().trim());
if (count == null || count != 1) throw bad("费用关联业务不存在或不属于该客户");
if ("OVERSEAS_INBOUND".equals(fee.businessType()) && !blank(fee.inboundCode())) {
Integer inbound = jdbc.queryForObject("SELECT COUNT(*) FROM ll_inbound_plan_line WHERE plan_id=? AND customer_id=? AND inbound_code=?",
Integer.class, fee.businessId(), fee.customerId().trim(), fee.inboundCode().trim());
if (inbound == null || inbound < 1) throw bad("入仓码不属于该客户和海外入仓计划");
}
}
private static boolean blank(String value) { return value == null || value.isBlank(); }
private static ResponseStatusException bad(String message) {
return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
}
private record Table(String name, String number) { }
}
@@ -0,0 +1,46 @@
package com.baiyue.lastleg.financefee.service;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.lastleg.client.EventBridgeClient;
import com.baiyue.lastleg.entity.LastlegEventOutbox;
import com.baiyue.lastleg.financefee.repository.OverseasFeeOutboxRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.lang.management.ManagementFactory;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.UUID;
@Component
public class OverseasFeeOutboxRelay {
@Resource private OverseasFeeOutboxRepository repository;
@Resource private EventBridgeClient client;
@Resource private ObjectMapper objectMapper;
@Value("${baiyue.security.internal-service-token:}") private String token;
private final String worker = ManagementFactory.getRuntimeMXBean().getName() + ':' + UUID.randomUUID();
@Scheduled(fixedDelayString = "${baiyue.finance-fee-relay.fixed-delay:3000}")
public void relay() {
LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
for (LastlegEventOutbox candidate : repository.pending(50, now, now.minusSeconds(30))) {
if (!repository.claim(candidate.getId(), worker, now, now.minusSeconds(30))) continue;
LastlegEventOutbox row = repository.claimed(candidate.getId(), worker);
if (row == null) continue;
try {
EventEnvelope envelope = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class).requireValid();
var response = client.receive(token, envelope);
if (response == null || !"200".equals(response.code()) || response.data() == null
|| !envelope.eventId().equals(response.data().eventId()))
throw new IllegalStateException("Event Bridge未确认海外费用事件");
repository.published(row.getId(), worker, LocalDateTime.now(ZoneOffset.UTC));
} catch (Exception exception) {
int retries = row.getRetryCount() == null ? 1 : row.getRetryCount() + 1;
repository.retry(row.getId(), worker, retries, now.plusSeconds(Math.min(900, 5L << Math.min(retries, 7))), exception.getMessage());
}
}
}
}
@@ -0,0 +1,79 @@
package com.baiyue.lastleg.financefee.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.event.BusinessFeeSourceChangedV1;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventIdentifiers;
import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.common.event.EventTypes;
import com.baiyue.common.event.RegionCode;
import com.baiyue.lastleg.entity.LastlegEventOutbox;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.List;
@Service
public class OverseasFeeOutboxService {
@Resource private LastlegEventOutboxMapper mapper;
@Resource private ObjectMapper objectMapper;
public void publish(OverseasFeeSource source, String action) {
BusinessFeeSourceChangedV1 unsigned = payload(source, action, null);
String sourceHash = EventPayloadHash.sha256(
EventPayloadCanonicalizer.canonicalize(objectMapper.valueToTree(unsigned)));
BusinessFeeSourceChangedV1 payload = payload(source, action, sourceHash);
JsonNode payloadNode = objectMapper.valueToTree(payload);
String eventKey = EventTypes.BUSINESS_FEE_SOURCE_CHANGED + ":LASTLEG:"
+ source.getSourceType() + ':' + source.getSourceFeeId() + ':' + source.getSourceVersion() + ':' + action;
if (mapper.findByEventKey(eventKey) != null) return;
EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(),
EventTypes.BUSINESS_FEE_SOURCE_CHANGED, 1, Instant.now(), RegionCode.OVERSEAS, RegionCode.CN,
source.getCustomerId(), source.getSourceType(), String.valueOf(source.getSourceFeeId()),
source.getSourceVersion(), null, EventIdentifiers.newUuid(), eventKey, null, payloadNode,
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode))).requireValid();
LastlegEventOutbox row = new LastlegEventOutbox();
row.setId(IdWorker.getId());
row.setCustomerId(source.getCustomerId());
row.setAggregateType(envelope.aggregateType());
row.setAggregateId(envelope.aggregateId());
row.setEventType(envelope.eventType());
row.setEventVersion(envelope.eventVersion());
row.setEventKey(eventKey);
row.setPayloadJson(json(envelope));
row.setStatus("PENDING");
row.setRetryCount(0);
row.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC));
mapper.insert(row);
}
private BusinessFeeSourceChangedV1 payload(OverseasFeeSource value, String action, String hash) {
return new BusinessFeeSourceChangedV1(action, "LASTLEG", value.getSourceType(),
String.valueOf(value.getSourceFeeId()), value.getSourceVersion(), value.getCustomerId(),
value.getCustomerName(), value.getSourceNo(), value.getBusinessType(), text(value.getBusinessId()),
value.getBusinessNo(), text(value.getOrderId()), text(value.getInboundId()), value.getInboundCode(),
text(value.getContainerId()), text(value.getContainerBookingId()), value.getContainerNo(),
text(value.getWarehouseId()), value.getFeeItemCode(), value.getFeeItemName(), value.getDirection(),
text(value.getSupplierId()), value.getSupplierName(), value.getSupplierCategory(),
value.getSettlementMode(), instant(value.getPaidAt()), value.getPaymentMethod(),
value.getPaymentReferenceNo(), value.getPaymentVoucherFileIds() == null ? List.of() : value.getPaymentVoucherFileIds(),
value.getBusinessDate(), value.getCurrency(), value.getAmount(), value.getReviewStatus(),
value.getPublicationStatus(), value.getSourceStatus(), hash);
}
private static String text(Long value) { return value == null ? null : String.valueOf(value); }
private static Instant instant(LocalDateTime value) { return value == null ? null : value.toInstant(ZoneOffset.UTC); }
private String json(Object value) {
try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException exception) { throw new IllegalStateException("海外费用事件序列化失败", exception); }
}
}
@@ -0,0 +1,195 @@
package com.baiyue.lastleg.financefee.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.AdjustRequest;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.ReviewRequest;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest;
import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.VoidRequest;
import com.baiyue.lastleg.financefee.entity.OverseasFeeCommand;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.financefee.mapper.OverseasFeeCommandMapper;
import com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.math.RoundingMode;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.HexFormat;
import java.util.List;
import java.util.Set;
@Service
@RequiredArgsConstructor
public class OverseasFeeSourceService {
private final OverseasFeeSourceMapper mapper;
private final OverseasFeeCommandMapper commands;
private final OverseasFeeBusinessGuard businessGuard;
private final OverseasFeeOutboxService outbox;
private final ObjectMapper objectMapper;
public List<OverseasFeeSource> list(String customerId, String businessType, String businessNo) {
return mapper.selectList(new LambdaQueryWrapper<OverseasFeeSource>()
.eq(text(customerId) != null, OverseasFeeSource::getCustomerId, text(customerId))
.eq(text(businessType) != null, OverseasFeeSource::getBusinessType, text(businessType))
.like(text(businessNo) != null, OverseasFeeSource::getBusinessNo, text(businessNo))
.inSql(OverseasFeeSource::getId, "SELECT MAX(v.id) FROM ll_finance_fee_source v GROUP BY v.source_fee_id")
.orderByDesc(OverseasFeeSource::getCreatedAt).orderByDesc(OverseasFeeSource::getId));
}
@Transactional
public OverseasFeeSource create(String operatorId, String key, SaveRequest request) {
OverseasFeeCommand replay = replay(operatorId, "CREATE", key, request);
if (replay != null) return current(replay.getSourceFeeId());
validate(request);
businessGuard.requireOwned(request);
long sourceFeeId = IdWorker.getId();
OverseasFeeSource value = build(IdWorker.getId(), sourceFeeId, 1, operatorId, request);
mapper.insert(value);
command(operatorId, "CREATE", key, request, sourceFeeId, 1);
return value;
}
@Transactional
public OverseasFeeSource submit(long sourceFeeId, String operatorId, String key) {
OverseasFeeCommand replay = replay(operatorId, "SUBMIT", key, sourceFeeId);
if (replay != null) return current(replay.getSourceFeeId());
OverseasFeeSource value = lock(sourceFeeId);
if (!"DRAFT".equals(value.getReviewStatus()) && !"REJECTED".equals(value.getReviewStatus()))
throw conflict("只有草稿或已驳回费用可以提交");
value.setReviewStatus("PENDING"); value.setReviewReason(null);
value.setUpdatedBy(operatorId); value.setUpdatedAt(now()); mapper.updateById(value);
command(operatorId, "SUBMIT", key, sourceFeeId, sourceFeeId, value.getSourceVersion());
return value;
}
@Transactional
public OverseasFeeSource review(long sourceFeeId, String operatorId, ReviewRequest request) {
OverseasFeeCommand replay = replay(operatorId, "REVIEW", request.idempotencyKey(), request);
if (replay != null) return current(replay.getSourceFeeId());
OverseasFeeSource value = lock(sourceFeeId);
if (!"PENDING".equals(value.getReviewStatus()) || !"ACTIVE".equals(value.getSourceStatus()))
throw conflict("只有待审核的有效费用可以审核");
boolean approved = "APPROVE".equals(request.decision());
value.setReviewStatus(approved ? "APPROVED" : "REJECTED");
value.setPublicationStatus(approved ? "PUBLISHED" : "UNPUBLISHED");
value.setReviewReason(text(request.reason())); value.setReviewerId(operatorId); value.setReviewedAt(now());
value.setUpdatedBy(operatorId); value.setUpdatedAt(now()); mapper.updateById(value);
if (approved) outbox.publish(value, value.getSourceVersion() == 1 ? "PUBLISHED" : "ADJUSTED");
command(operatorId, "REVIEW", request.idempotencyKey(), request, sourceFeeId, value.getSourceVersion());
return value;
}
@Transactional
public OverseasFeeSource adjust(long sourceFeeId, String operatorId, AdjustRequest request) {
OverseasFeeCommand replay = replay(operatorId, "ADJUST", request.idempotencyKey(), request);
if (replay != null) return current(replay.getSourceFeeId());
OverseasFeeSource old = lock(sourceFeeId);
if (!"APPROVED".equals(old.getReviewStatus()) || !"ACTIVE".equals(old.getSourceStatus())
|| old.getSourceVersion() != request.expectedVersion()) throw conflict("只有当前已发布版本可以调整");
validate(request.fee()); businessGuard.requireOwned(request.fee());
if (!old.getCustomerId().equals(request.fee().customerId().trim()))
throw conflict("调整不得改变费用所属客户");
old.setSourceStatus("SUPERSEDED"); old.setUpdatedBy(operatorId); old.setUpdatedAt(now()); mapper.updateById(old);
OverseasFeeSource next = build(IdWorker.getId(), sourceFeeId, old.getSourceVersion() + 1, operatorId, request.fee());
next.setReviewStatus("PENDING");
mapper.insert(next);
command(operatorId, "ADJUST", request.idempotencyKey(), request, sourceFeeId, next.getSourceVersion());
return next;
}
@Transactional
public OverseasFeeSource voidSource(long sourceFeeId, String operatorId, VoidRequest request) {
OverseasFeeCommand replay = replay(operatorId, "VOID", request.idempotencyKey(), request);
if (replay != null) return current(replay.getSourceFeeId());
OverseasFeeSource value = lock(sourceFeeId);
if (!"APPROVED".equals(value.getReviewStatus()) || !"ACTIVE".equals(value.getSourceStatus())
|| value.getSourceVersion() != request.expectedVersion()) throw conflict("只有当前已发布版本可以作废");
value.setSourceStatus("VOIDED"); value.setReviewReason(request.reason().trim());
value.setUpdatedBy(operatorId); value.setUpdatedAt(now()); mapper.updateById(value);
outbox.publish(value, "VOIDED");
command(operatorId, "VOID", request.idempotencyKey(), request, sourceFeeId, value.getSourceVersion());
return value;
}
private OverseasFeeSource build(long id, long sourceFeeId, int version, String operatorId, SaveRequest request) {
OverseasFeeSource value = new OverseasFeeSource();
value.setId(id); value.setSourceFeeId(sourceFeeId); value.setSourceVersion(version);
value.setCustomerId(request.customerId().trim()); value.setCustomerName(text(request.customerName()));
value.setSourceType(request.sourceType()); value.setSourceNo(text(request.inboundCode()) == null ? request.businessNo().trim() : request.inboundCode().trim());
value.setBusinessType(request.businessType()); value.setBusinessId(request.businessId()); value.setBusinessNo(request.businessNo().trim());
value.setOrderId(request.orderId()); value.setInboundId(request.inboundId()); value.setInboundCode(text(request.inboundCode()));
value.setContainerId(request.containerId()); value.setContainerBookingId(request.containerBookingId()); value.setContainerNo(text(request.containerNo()));
value.setWarehouseId(request.warehouseId()); value.setFeeItemCode(request.feeItemCode().trim()); value.setFeeItemName(request.feeItemName().trim());
value.setDirection(request.direction()); value.setSupplierId(request.supplierId()); value.setSupplierName(text(request.supplierName()));
value.setSupplierCategory(text(request.supplierCategory())); value.setSettlementMode(text(request.settlementMode()));
value.setPaidAt(request.paidAt()); value.setPaymentMethod(text(request.paymentMethod())); value.setPaymentReferenceNo(text(request.paymentReferenceNo()));
value.setPaymentVoucherFileIds(request.paymentVoucherFileIds()); value.setBusinessDate(request.businessDate()); value.setCurrency(request.currency());
value.setAmount(request.amount().setScale(2, RoundingMode.HALF_UP)); value.setReviewStatus("DRAFT");
value.setPublicationStatus("UNPUBLISHED"); value.setSourceStatus("ACTIVE"); value.setCreatedBy(operatorId); value.setUpdatedBy(operatorId);
value.setCreatedAt(now()); value.setUpdatedAt(now());
return value;
}
private void validate(SaveRequest value) {
String expected = switch (value.businessType()) {
case "OVERSEAS_INBOUND" -> "OVERSEAS_INBOUND_FEE";
case "OVERSEAS_PROCUREMENT" -> "OVERSEAS_PROCUREMENT_FEE";
case "US_TRUCK" -> "OVERSEAS_TRUCK_FEE";
case "LOCAL_LOOSEBOARD" -> "OVERSEAS_LOOSEBOARD_FEE";
case "WAREHOUSE_SERVICE" -> "OVERSEAS_WAREHOUSE_FEE";
default -> "";
};
if (!expected.equals(value.sourceType())) throw bad("来源类型与业务类型不匹配");
boolean payable = "PAYABLE".equals(value.direction());
boolean files = value.paymentVoucherFileIds() != null && !value.paymentVoucherFileIds().isEmpty();
if (!payable) {
if (value.supplierId() != null || text(value.supplierName()) != null || text(value.settlementMode()) != null
|| value.paidAt() != null || text(value.paymentMethod()) != null || text(value.paymentReferenceNo()) != null || files)
throw bad("应收费用不得携带供应商或付款信息");
return;
}
if (value.supplierId() == null || text(value.supplierName()) == null || !Set.of("ON_ACCOUNT", "DIRECT_PAID").contains(value.settlementMode()))
throw bad("应付费用必须携带供应商和结算方式");
if ("ON_ACCOUNT".equals(value.settlementMode())) {
if (value.paidAt() != null || text(value.paymentMethod()) != null || text(value.paymentReferenceNo()) != null || files)
throw bad("账期应付不得携带已付信息");
} else if (value.paidAt() == null || !Set.of("ONLINE", "BANK_TRANSFER", "CASH", "OTHER").contains(value.paymentMethod())
|| (text(value.paymentReferenceNo()) == null && !files)) throw bad("直接已付信息不完整");
}
private OverseasFeeSource current(long sourceFeeId) {
OverseasFeeSource value = mapper.selectOne(new LambdaQueryWrapper<OverseasFeeSource>()
.eq(OverseasFeeSource::getSourceFeeId, sourceFeeId).orderByDesc(OverseasFeeSource::getSourceVersion).last("LIMIT 1"));
if (value == null) throw notFound(); return value;
}
private OverseasFeeSource lock(long sourceFeeId) { OverseasFeeSource value = mapper.lockCurrent(sourceFeeId); if (value == null) throw notFound(); return value; }
private OverseasFeeCommand replay(String operator, String action, String key, Object request) {
OverseasFeeCommand existing = commands.find(operator, action, key.trim());
if (existing != null && !existing.getRequestHash().equals(hash(request))) throw conflict("幂等键已被不同请求使用");
return existing;
}
private void command(String operator, String action, String key, Object request, long sourceFeeId, int version) {
OverseasFeeCommand value = new OverseasFeeCommand(); value.setId(IdWorker.getId()); value.setOperatorId(operator);
value.setAction(action); value.setIdempotencyKey(key.trim()); value.setRequestHash(hash(request)); value.setSourceFeeId(sourceFeeId);
value.setSourceVersion(version); value.setCreatedAt(now()); commands.insert(value);
}
private String hash(Object value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(objectMapper.writeValueAsBytes(value))); }
catch (NoSuchAlgorithmException | JsonProcessingException exception) { throw new IllegalStateException(exception); }
}
private static LocalDateTime now() { return LocalDateTime.now(ZoneOffset.UTC); }
private static String text(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
private static ResponseStatusException notFound() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "海外费用来源不存在"); }
}