优化
This commit is contained in:
+87
@@ -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());
|
||||
}
|
||||
}
|
||||
+58
@@ -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) { }
|
||||
}
|
||||
+21
@@ -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;
|
||||
}
|
||||
+59
@@ -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;
|
||||
}
|
||||
+14
@@ -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);
|
||||
}
|
||||
+13
@@ -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);
|
||||
}
|
||||
+56
@@ -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);
|
||||
}
|
||||
}
|
||||
+46
@@ -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) { }
|
||||
}
|
||||
+46
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+79
@@ -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); }
|
||||
}
|
||||
}
|
||||
+195
@@ -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, "海外费用来源不存在"); }
|
||||
}
|
||||
Reference in New Issue
Block a user