Merge branch 'dev' into dev-lyhfive
# Conflicts: # deploy/sql/头程数据库统一增量-MySQL8.sql # deploy/sql/海外区域统一增量-MySQL8.sql # lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java # lastleg-service/src/main/resources/db/lastleg.sql # lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java
This commit is contained in:
+3
-2
@@ -44,8 +44,9 @@ public class InboundPlanController {
|
||||
|
||||
@PostMapping("/convert")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_CREATE + "')")
|
||||
/** 将入仓单转换为入仓计划。 */
|
||||
@Operation(summary = "入仓单转入仓计划", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_CREATE)
|
||||
/** 由平台人工将待分配尾程订单转换为入仓计划。 */
|
||||
@Operation(summary = "人工创建入仓计划", description = "头程装柜完成只生成待分配尾程订单;平台确认后调用本接口创建入仓计划。Required authority: "
|
||||
+ OverseasPermissions.INBOUND_ORDER_CREATE)
|
||||
public ApiResponse<InboundPlanResponse> convert(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody ConvertInboundPlanRequest request) {
|
||||
return ApiResponse.success(service.convert(scope(user, request.customerId()), request.orderId()));
|
||||
|
||||
+3
-1
@@ -3,6 +3,7 @@ package com.baiyue.lastleg.inboundplan.pda.dto;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
@@ -14,7 +15,8 @@ public final class ReceivingContainerRequests {
|
||||
public record Action(@NotBlank @Size(max = 255) String idempotencyKey) { }
|
||||
public record Sign(@NotNull @Size(min = 3, max = 3) List<@Valid SignedPhoto> photos,
|
||||
@NotBlank @Size(max = 255) String idempotencyKey) { }
|
||||
public record SignedPhoto(@NotNull ReceivingPhotoRole role, @Positive long fileId) { }
|
||||
public record SignedPhoto(@NotNull ReceivingPhotoRole role,
|
||||
@NotBlank @Pattern(regexp = "^[1-9][0-9]{0,18}$") String fileId) { }
|
||||
public record CartonResolve(@NotBlank String cartonMark) { }
|
||||
public record SkuResolve(@NotBlank @Size(max = 128) String skuCode) { }
|
||||
public record PalletResolve(@NotBlank String palletCode, @NotBlank String inboundCode) { }
|
||||
|
||||
+2
-2
@@ -17,7 +17,7 @@ public final class ReceivingContainerResponses {
|
||||
int totalPackages, int receivedPackages, int pendingPackages,
|
||||
List<Inbound> inbounds) { }
|
||||
|
||||
public record SignedPhoto(ReceivingPhotoRole role, long fileId) { }
|
||||
public record SignedPhoto(ReceivingPhotoRole role, String fileId) { }
|
||||
|
||||
public record Inbound(String inboundCode, String destinationType,
|
||||
String overseasWarehouseId, String overseasWarehouseName,
|
||||
@@ -48,5 +48,5 @@ public final class ReceivingContainerResponses {
|
||||
String destinationType, String palletCode, int acceptedPackages,
|
||||
int receivedPackages, int pendingPackages, String sessionStatus) { }
|
||||
|
||||
public record Photo(long fileId, String fileName, String contentType, long sizeBytes) { }
|
||||
public record Photo(String fileId, String fileName, String contentType, long sizeBytes) { }
|
||||
}
|
||||
|
||||
+14
-4
@@ -169,7 +169,7 @@ public class ReceivingContainerService {
|
||||
String customerId = session.getCustomerId();
|
||||
if (!"WAIT_SIGN".equals(session.getStatus())) throw conflict("Unload photos can only be uploaded before signing");
|
||||
UnloadPhotoFileGateway.UploadedFile uploaded = unloadPhotos.upload(customerId, session.getId(), operatorId, file);
|
||||
return new ReceivingContainerResponses.Photo(uploaded.fileId(), uploaded.fileName(), uploaded.contentType(), uploaded.sizeBytes());
|
||||
return new ReceivingContainerResponses.Photo(Long.toString(uploaded.fileId()), uploaded.fileName(), uploaded.contentType(), uploaded.sizeBytes());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -181,7 +181,7 @@ public class ReceivingContainerService {
|
||||
String customerId = session.getCustomerId();
|
||||
if (!"WAIT_SIGN".equals(session.getStatus())) throw conflict("Container has already been signed");
|
||||
List<ReceivingContainerResponses.SignedPhoto> photos = validateSignPhotos(request);
|
||||
List<Long> photoFileIds = photos.stream().map(ReceivingContainerResponses.SignedPhoto::fileId).toList();
|
||||
List<Long> photoFileIds = photos.stream().map(photo -> positiveFileId(photo.fileId())).toList();
|
||||
photoFileIds.forEach(fileId -> unloadPhotos.validate(customerId, session.getId(), fileId));
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
session.setSignPhotos(write(photos)); session.setStatus("SIGNED"); session.setSignedAt(now);
|
||||
@@ -513,9 +513,9 @@ public class ReceivingContainerService {
|
||||
Set<com.baiyue.lastleg.inboundplan.pda.dto.ReceivingPhotoRole> roles = request.photos().stream()
|
||||
.map(ReceivingContainerRequests.SignedPhoto::role).collect(Collectors.toSet());
|
||||
if (roles.size() != 3 || roles.contains(null)) throw conflict("Each unload photo role is required exactly once");
|
||||
Set<Long> fileIds = request.photos().stream().map(ReceivingContainerRequests.SignedPhoto::fileId)
|
||||
Set<Long> fileIds = request.photos().stream().map(value -> positiveFileId(value.fileId()))
|
||||
.collect(Collectors.toSet());
|
||||
if (fileIds.size() != 3 || fileIds.stream().anyMatch(value -> value == null || value <= 0)) {
|
||||
if (fileIds.size() != 3) {
|
||||
throw conflict("Unload photo file IDs must be positive and unique");
|
||||
}
|
||||
return request.photos().stream()
|
||||
@@ -523,6 +523,16 @@ public class ReceivingContainerService {
|
||||
.map(value -> new ReceivingContainerResponses.SignedPhoto(value.role(), value.fileId())).toList();
|
||||
}
|
||||
|
||||
private long positiveFileId(String value) {
|
||||
try {
|
||||
long fileId = Long.parseLong(value);
|
||||
if (fileId > 0) return fileId;
|
||||
} catch (NumberFormatException ignored) {
|
||||
// Validation normally rejects this before the service; keep the domain fail-closed.
|
||||
}
|
||||
throw conflict("Unload photo file ID must be a positive decimal string");
|
||||
}
|
||||
|
||||
private SkuRef requireSku(InboundRef ref, String skuCode) {
|
||||
SkuRef result = skuRef(ref, skuCode);
|
||||
if (result == null) throw notFound("SKU is not part of the selected inbound code");
|
||||
|
||||
+4
@@ -18,6 +18,7 @@ import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanQueryMapper;
|
||||
import com.baiyue.lastleg.inboundplan.model.SourceOrderSnapshot;
|
||||
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -41,6 +42,7 @@ public class InboundPlanService {
|
||||
@Resource private InboundPlanPalletAllocationMapper allocationMapper;
|
||||
@Resource private InboundPlanOrderMapper orderMapper;
|
||||
@Resource private InboundPlanQueryMapper queryMapper;
|
||||
@Resource private InventoryBalanceMapper inventoryBalanceMapper;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResult<InboundPlanListResponse> page(String customerId, InboundPlanRequests.Query query) {
|
||||
@@ -147,6 +149,8 @@ public class InboundPlanService {
|
||||
if (orderMapper.markPlanned(customerId, order.orderId(), plan.getPlanNo(), count, now) != 1) {
|
||||
throw new IllegalStateException("Failed to update order " + order.orderId());
|
||||
}
|
||||
inventoryBalanceMapper.bindInboundPlan(customerId, order.orderId(), order.inboundCode(),
|
||||
plan.getPlanNo(), now);
|
||||
orderMapper.insertHistory(customerId, order.orderId(), order.currentStatus(),
|
||||
"Inbound order converted to inbound plan " + plan.getPlanNo(), now);
|
||||
}
|
||||
|
||||
+10
@@ -5,7 +5,9 @@ import com.baiyue.lastleg.inventory.model.InventorySummary;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public interface InventoryBalanceMapper extends BaseMapper<InventoryBalance> {
|
||||
@@ -29,6 +31,14 @@ public interface InventoryBalanceMapper extends BaseMapper<InventoryBalance> {
|
||||
@Param("inboundOrderId") Long inboundOrderId,
|
||||
@Param("inboundCode") String inboundCode);
|
||||
|
||||
@Update("UPDATE ll_inventory_balance SET inbound_order_no=#{planNo},updated_at=#{updatedAt} " +
|
||||
"WHERE customer_id=#{customerId} AND inbound_order_id=#{inboundOrderId} AND inbound_code=#{inboundCode}")
|
||||
int bindInboundPlan(@Param("customerId") String customerId,
|
||||
@Param("inboundOrderId") Long inboundOrderId,
|
||||
@Param("inboundCode") String inboundCode,
|
||||
@Param("planNo") String planNo,
|
||||
@Param("updatedAt") LocalDateTime updatedAt);
|
||||
|
||||
@Select("<script>SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId}" +
|
||||
"<if test='overseasWarehouseId != null and overseasWarehouseId != \"\"'> AND overseas_warehouse_id=#{overseasWarehouseId}</if>" +
|
||||
"<if test='destinationWarehouseCode != null and destinationWarehouseCode != \"\"'> AND destination_warehouse_code=#{destinationWarehouseCode}</if>" +
|
||||
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
package com.baiyue.lastleg.masterdata.exchange;
|
||||
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/** 注册尾程主数据域的所有 Mapper。 */
|
||||
@Configuration
|
||||
@MapperScan("com.baiyue.lastleg.masterdata")
|
||||
public class ExchangeMapperConfiguration {
|
||||
}
|
||||
+1
-1
@@ -24,7 +24,7 @@ public interface PdaPalletOperationMapper extends BaseMapper<PdaPalletOperation>
|
||||
@Param("idempotencyKey") String idempotencyKey);
|
||||
|
||||
@Select(CONTENT_SELECT + "WHERE b.overseas_warehouse_id=#{warehouseId} AND p.pallet_id=#{palletId} " +
|
||||
"AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " +
|
||||
"AND (p.available_packages+p.hold_packages+p.allocated_packages+p.picked_packages+p.receiving_packages)>0 " +
|
||||
"ORDER BY b.customer_id,b.inbound_code,p.id")
|
||||
List<PalletInventoryRow> content(@Param("warehouseId") String warehouseId,
|
||||
@Param("palletId") long palletId);
|
||||
|
||||
+23
-1
@@ -71,6 +71,9 @@ public class PdaPalletOperationService {
|
||||
|
||||
public PalletOperationResponses.Operation assemble(PdaWarehouseIdentity identity,
|
||||
PalletOperationRequests.Assemble request) {
|
||||
PalletOperationResponses.Operation replay = replayIfPresent(identity, "PALLET_ASSEMBLE",
|
||||
request.idempotencyKey(), request);
|
||||
if (replay != null) return replay;
|
||||
Map<String, PalletPhysicalGateway.PalletReference> pallets = resolvePallets(identity.overseasWarehouseId(),
|
||||
request.target(), request.sources());
|
||||
PalletPhysicalGateway.PalletReference target = pallets.get(code(request.target().palletCode()));
|
||||
@@ -85,6 +88,9 @@ public class PdaPalletOperationService {
|
||||
|
||||
public PalletOperationResponses.Operation disassemble(PdaWarehouseIdentity identity,
|
||||
PalletOperationRequests.Disassemble request) {
|
||||
PalletOperationResponses.Operation replay = replayIfPresent(identity, "PALLET_DISASSEMBLE",
|
||||
request.idempotencyKey(), request);
|
||||
if (replay != null) return replay;
|
||||
LinkedHashMap<String, PalletOperationRequests.PalletExpectation> expectations = new LinkedHashMap<>();
|
||||
expectations.put(code(request.source().palletCode()), request.source());
|
||||
request.targets().forEach(value -> expectations.put(code(value.target().palletCode()), value.target()));
|
||||
@@ -109,6 +115,9 @@ public class PdaPalletOperationService {
|
||||
|
||||
public PalletOperationResponses.Operation swap(PdaWarehouseIdentity identity,
|
||||
PalletOperationRequests.Swap request) {
|
||||
PalletOperationResponses.Operation replay = replayIfPresent(identity, "PALLET_SWAP",
|
||||
request.idempotencyKey(), request);
|
||||
if (replay != null) return replay;
|
||||
Map<String, PalletPhysicalGateway.PalletReference> pallets = resolvePallets(identity.overseasWarehouseId(),
|
||||
request.source(), List.of(request.target()));
|
||||
PalletPhysicalGateway.PalletReference source = pallets.get(code(request.source().palletCode()));
|
||||
@@ -129,6 +138,9 @@ public class PdaPalletOperationService {
|
||||
|
||||
public PalletOperationResponses.Operation unbind(PdaWarehouseIdentity identity,
|
||||
PalletOperationRequests.Unbind request) {
|
||||
PalletOperationResponses.Operation replay = replayIfPresent(identity, "PALLET_UNBIND",
|
||||
request.idempotencyKey(), request);
|
||||
if (replay != null) return replay;
|
||||
Map<String, PalletPhysicalGateway.PalletReference> pallets = resolvePallets(identity.overseasWarehouseId(),
|
||||
List.of(request.source()));
|
||||
PalletPhysicalGateway.PalletReference source = pallets.get(code(request.source().palletCode()));
|
||||
@@ -264,7 +276,8 @@ public class PdaPalletOperationService {
|
||||
ledger.setBusinessType(operation.getOperationType()); ledger.setBusinessOrderId(operation.getId());
|
||||
ledger.setBusinessOrderNo(String.valueOf(operation.getId())); ledger.setInboundCode(balance.getInboundCode());
|
||||
ledger.setInboundOrderNo(balance.getInboundOrderNo()); ledger.setIdempotencyKey(idempotencyKey);
|
||||
ledger.setStatusFrom("PALLET:" + sourcePallet); ledger.setStatusTo("PALLET:" + targetPallet);
|
||||
ledger.setStatusFrom("OCCUPIED");
|
||||
ledger.setStatusTo("UNBOUND".equals(targetPallet) ? "UNBOUND" : "OCCUPIED");
|
||||
ledger.setBeforePackages(before); ledger.setDeltaPackages(delta); ledger.setAfterPackages(after);
|
||||
ledger.setOperatorId(identity.operatorKey()); ledger.setRemark(trim(reason));
|
||||
ledger.setOccurredAt(LocalDateTime.now()); ledger.setCreatedAt(ledger.getOccurredAt()); ledgers.insert(ledger);
|
||||
@@ -412,6 +425,15 @@ public class PdaPalletOperationService {
|
||||
return value;
|
||||
}
|
||||
|
||||
private PalletOperationResponses.Operation replayIfPresent(PdaWarehouseIdentity identity, String type,
|
||||
String key, Object request) {
|
||||
String normalizedKey = required(key, "幂等键不能为空");
|
||||
PdaPalletOperation existing = mapper.findOperation(identity.operatorKey(), normalizedKey);
|
||||
if (existing == null) return null;
|
||||
validateExisting(existing, type, hash(request));
|
||||
return read(existing).asReplay();
|
||||
}
|
||||
|
||||
private void saveTransitions(long operationId, List<PalletPhysicalGateway.Transition> transitions) {
|
||||
newTransaction.executeWithoutResult(status -> {
|
||||
PdaPalletOperation operation = mapper.selectById(operationId);
|
||||
|
||||
+5
-6
@@ -117,8 +117,7 @@ public class PdaInventoryMoveService {
|
||||
position.setWarehouseLocationId(target.locationId()); position.setWarehouseLocationCode(target.locationCode());
|
||||
position.setVersion(value(position.getVersion()) + 1); position.setUpdatedAt(completedAt);
|
||||
positions.updateById(position);
|
||||
ledger(operation, position, balance, operatorKey, key, request.reason(), source.locationCode(),
|
||||
target.locationCode(), completedAt);
|
||||
ledger(operation, position, balance, operatorKey, key, request.reason(), completedAt);
|
||||
}
|
||||
operation.setPalletVersionAfter(relocated.afterVersion()); operation.setStatus("COMPLETED");
|
||||
operation.setCompletedAt(completedAt);
|
||||
@@ -150,18 +149,18 @@ public class PdaInventoryMoveService {
|
||||
}
|
||||
|
||||
private void ledger(PdaInventoryMove operation, InventoryPosition position, InventoryBalance balance,
|
||||
String operator, String key, String reason, String source, String target,
|
||||
LocalDateTime now) {
|
||||
String operator, String key, String reason, LocalDateTime now) {
|
||||
InventoryLedger row = new InventoryLedger(); row.setId(IdWorker.getId()); row.setCustomerId(position.getCustomerId());
|
||||
row.setBalanceId(balance.getId()); row.setPositionId(position.getId());
|
||||
row.setOverseasWarehouseId(String.valueOf(operation.getOverseasWarehouseId()));
|
||||
row.setDestinationWarehouseCode(balance.getDestinationWarehouseCode()); row.setInboundOrderId(balance.getInboundOrderId());
|
||||
row.setPalletId(position.getPalletId()); row.setPalletNo(position.getPalletNo()); row.setBatchNo(position.getBatchNo());
|
||||
row.setSkuCode(balance.getSkuCode()); row.setWarehouseLocationCode(target); row.setBusinessType("INTERNAL_MOVE");
|
||||
row.setSkuCode(balance.getSkuCode()); row.setWarehouseLocationCode(position.getWarehouseLocationCode());
|
||||
row.setBusinessType("INTERNAL_MOVE");
|
||||
row.setBusinessOrderId(operation.getId()); row.setBusinessOrderNo(operation.getMoveNo());
|
||||
row.setInboundCode(balance.getInboundCode()); row.setInboundOrderNo(balance.getInboundOrderNo());
|
||||
row.setIdempotencyKey("MOVE:" + operation.getId() + ":" + position.getId());
|
||||
row.setStatusFrom(source); row.setStatusTo(target); long packages = physical(position);
|
||||
row.setStatusFrom("OCCUPIED"); row.setStatusTo("OCCUPIED"); long packages = physical(position);
|
||||
row.setBeforePackages(packages); row.setDeltaPackages(0L); row.setAfterPackages(packages);
|
||||
row.setOperatorId(operator); row.setRemark(reason.trim()); row.setOccurredAt(now); row.setCreatedAt(now);
|
||||
ledgers.insert(row);
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ public interface PdaStocktakeItemMapper extends BaseMapper<PdaStocktakeItem> {
|
||||
int counted(@Param("taskId") long taskId);
|
||||
|
||||
@Select("SELECT COUNT(*) FROM ll_pda_stocktake_item WHERE task_id=#{taskId} " +
|
||||
"AND actual_packages IS NOT NULL AND difference_packages<>0")
|
||||
"AND actual_packages IS NOT NULL AND difference_packages<>0")
|
||||
int differences(@Param("taskId") long taskId);
|
||||
|
||||
@Select("SELECT id item_id,customer_id,balance_id,position_id,sku_code,product_name,inbound_code," +
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package com.baiyue.lastleg.security.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
|
||||
import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.Acknowledgement;
|
||||
import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationRequest;
|
||||
import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationResponse;
|
||||
import com.baiyue.lastleg.security.service.LastlegSecurityProjectionService;
|
||||
import com.baiyue.lastleg.security.service.LastlegSecurityValidationService;
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import jakarta.validation.Valid;
|
||||
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.RestController;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Hidden
|
||||
@RestController
|
||||
@RequestMapping("/internal/lastleg/security/v1")
|
||||
public class LastlegSecurityController {
|
||||
private final InternalServiceTokenGuard tokenGuard;
|
||||
private final LastlegSecurityProjectionService projections;
|
||||
private final LastlegSecurityValidationService validations;
|
||||
|
||||
public LastlegSecurityController(InternalServiceTokenGuard tokenGuard,
|
||||
LastlegSecurityProjectionService projections,
|
||||
LastlegSecurityValidationService validations) {
|
||||
this.tokenGuard = tokenGuard;
|
||||
this.projections = projections;
|
||||
this.validations = validations;
|
||||
}
|
||||
|
||||
@PostMapping("/events")
|
||||
public ApiResponse<Acknowledgement> receive(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@RequestBody EventEnvelope envelope) {
|
||||
tokenGuard.verify(token);
|
||||
var result = projections.consume(envelope);
|
||||
return ApiResponse.success(new Acknowledgement(envelope.eventId(), result.result(), Instant.now()));
|
||||
}
|
||||
|
||||
@PostMapping("/validate")
|
||||
public ApiResponse<ValidationResponse> validate(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@Valid @RequestBody ValidationRequest request) {
|
||||
tokenGuard.verify(token);
|
||||
return ApiResponse.success(validations.validate(request));
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.baiyue.lastleg.security.dto;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public final class LastlegSecurityDtos {
|
||||
private LastlegSecurityDtos() { }
|
||||
|
||||
public record Acknowledgement(String eventId, String result, Instant receivedAt) { }
|
||||
|
||||
public record ValidationRequest(@NotNull @Min(1) Long userId,
|
||||
@NotNull @Min(0) Long tenantId,
|
||||
@NotNull @Min(0) Long permissionVersion) { }
|
||||
|
||||
public record ValidationResponse(boolean valid, FailureReason failureReason) {
|
||||
public static ValidationResponse accepted() { return new ValidationResponse(true, null); }
|
||||
public static ValidationResponse rejected(FailureReason reason) {
|
||||
return new ValidationResponse(false, reason);
|
||||
}
|
||||
}
|
||||
|
||||
public enum FailureReason {
|
||||
SECURITY_PROJECTION_MISSING,
|
||||
SECURITY_PROJECTION_NOT_READY,
|
||||
USER_DISABLED,
|
||||
TENANT_DISABLED,
|
||||
PERMISSION_VERSION_STALE
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.baiyue.lastleg.security.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.event.TransactionPhase;
|
||||
import org.springframework.transaction.event.TransactionalEventListener;
|
||||
|
||||
/** 非授权真相的可重建 Redis 镜像;失败不影响已提交的 MySQL 投影。 */
|
||||
@Component
|
||||
@Slf4j
|
||||
public class LastlegSecurityProjectionRedisMirror {
|
||||
private final StringRedisTemplate redis;
|
||||
private final ObjectMapper objectMapper;
|
||||
@Value("${baiyue.security.permission-snapshot-key-prefix:baiyue:permission-snapshot}")
|
||||
private String keyPrefix;
|
||||
|
||||
public LastlegSecurityProjectionRedisMirror(StringRedisTemplate redis, ObjectMapper objectMapper) {
|
||||
this.redis = redis;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
|
||||
public void mirror(LastlegSecurityProjectionService.ProjectionChanged event) {
|
||||
var value = event.projection();
|
||||
try {
|
||||
redis.opsForValue().set(keyPrefix + ':' + value.aggregateType() + ':' + value.aggregateId(),
|
||||
objectMapper.writeValueAsString(value));
|
||||
} catch (Exception exception) {
|
||||
log.warn("海外安全投影 Redis 镜像写入失败 aggregateType={}, aggregateId={}",
|
||||
value.aggregateType(), value.aggregateId());
|
||||
}
|
||||
}
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
package com.baiyue.lastleg.security.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.common.event.EventPayloadCanonicalizer;
|
||||
import com.baiyue.common.event.EventPayloadHash;
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
import com.baiyue.common.event.IamSecurityEventsV1;
|
||||
import com.baiyue.common.event.RegionCode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
@Service
|
||||
public class LastlegSecurityProjectionService {
|
||||
private static final List<String> TYPES = List.of(EventTypes.IAM_USER_STATUS_CHANGED,
|
||||
EventTypes.IAM_TENANT_STATUS_CHANGED, EventTypes.IAM_PERMISSION_VERSION_CHANGED);
|
||||
private final JdbcTemplate jdbc;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ApplicationEventPublisher events;
|
||||
|
||||
public LastlegSecurityProjectionService(JdbcTemplate jdbc, ObjectMapper objectMapper,
|
||||
ApplicationEventPublisher events) {
|
||||
this.jdbc = jdbc;
|
||||
this.objectMapper = objectMapper;
|
||||
this.events = events;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ConsumeResult consume(EventEnvelope envelope) {
|
||||
envelope.requireValid();
|
||||
requireSecurityEnvelope(envelope);
|
||||
String payloadHash = validatedStorageHash(envelope);
|
||||
String payloadJson = envelope.payload().toString();
|
||||
try {
|
||||
jdbc.update("INSERT INTO ll_security_event_inbox(event_id,event_type,payload_hash,payload_json,status,received_at) "
|
||||
+ "VALUES (?,?,?,?, 'RECEIVED', NOW(3))", envelope.eventId(), envelope.eventType(),
|
||||
payloadHash, payloadJson);
|
||||
} catch (DuplicateKeyException duplicate) {
|
||||
InboxRow existing = jdbc.queryForObject(
|
||||
"SELECT event_type,payload_hash,status FROM ll_security_event_inbox "
|
||||
+ "WHERE event_id=? FOR UPDATE",
|
||||
(rs, row) -> new InboxRow(rs.getString(1), rs.getString(2), rs.getString(3)),
|
||||
envelope.eventId());
|
||||
if (existing == null || !Objects.equals(existing.eventType(), envelope.eventType())
|
||||
|| existing.payloadHash() == null
|
||||
|| !existing.payloadHash().equalsIgnoreCase(payloadHash)) {
|
||||
throw new BizException(ResultCode.CONFLICT, "相同 eventId 的 payloadHash 冲突");
|
||||
}
|
||||
if ("PROCESSED".equals(existing.status())) return new ConsumeResult("DUPLICATE", null);
|
||||
}
|
||||
|
||||
ProjectionInput input = input(envelope, payloadHash);
|
||||
ProjectionRow current = findForUpdate(input.aggregateType(), input.aggregateId());
|
||||
SecurityProjectionMonotonicity.Decision decision = SecurityProjectionMonotonicity.decide(
|
||||
current == null ? null : current.aggregateVersion(), current == null ? null : current.payloadHash(),
|
||||
input.aggregateVersion(), input.payloadHash());
|
||||
String result = decision.name();
|
||||
if (decision == SecurityProjectionMonotonicity.Decision.APPLY) {
|
||||
upsert(input);
|
||||
result = "APPLIED";
|
||||
events.publishEvent(new ProjectionChanged(input));
|
||||
}
|
||||
jdbc.update("UPDATE ll_security_event_inbox SET status='PROCESSED',result=?,processed_at=NOW(3) "
|
||||
+ "WHERE event_id=?", result, envelope.eventId());
|
||||
return new ConsumeResult(result, input);
|
||||
}
|
||||
|
||||
private void requireSecurityEnvelope(EventEnvelope envelope) {
|
||||
if (!TYPES.contains(envelope.eventType()) || envelope.eventVersion() != IamSecurityEventsV1.VERSION
|
||||
|| envelope.sourceRegion() != RegionCode.CN || envelope.targetRegion() != RegionCode.OVERSEAS) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "仅接受 CN→OVERSEAS IAM V1 安全事件");
|
||||
}
|
||||
}
|
||||
|
||||
static String validatedStorageHash(EventEnvelope envelope) {
|
||||
if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()),
|
||||
envelope.payloadHash())) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "payloadHash 与 IAM 安全事件负载不一致");
|
||||
}
|
||||
// 表内只保存固定 64 位摘要;信封传输契约仍保留 sha256: 前缀。
|
||||
return envelope.payloadHash().substring("sha256:".length()).toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private ProjectionInput input(EventEnvelope envelope, String payloadHash) {
|
||||
try {
|
||||
return switch (envelope.eventType()) {
|
||||
case EventTypes.IAM_USER_STATUS_CHANGED -> {
|
||||
var value = objectMapper.treeToValue(envelope.payload(),
|
||||
IamSecurityEventsV1.UserStatusChanged.class);
|
||||
requireIdentity(envelope, "USER", value.authUserId(), value.tenantId());
|
||||
requirePayload(value.status(), value.changedAt(), value.permissionVersion(),
|
||||
envelope.aggregateVersion());
|
||||
requireStatus(value.status());
|
||||
yield new ProjectionInput("USER", String.valueOf(value.authUserId()), value.tenantId(),
|
||||
value.status(), value.permissionVersion(), envelope.aggregateVersion(),
|
||||
payloadHash, envelope.eventId(), value.changedAt());
|
||||
}
|
||||
case EventTypes.IAM_TENANT_STATUS_CHANGED -> {
|
||||
var value = objectMapper.treeToValue(envelope.payload(),
|
||||
IamSecurityEventsV1.TenantStatusChanged.class);
|
||||
requireIdentity(envelope, "TENANT", value.tenantId(), value.tenantId());
|
||||
requirePayload(value.status(), value.changedAt(), envelope.aggregateVersion(),
|
||||
envelope.aggregateVersion());
|
||||
requireStatus(value.status());
|
||||
yield new ProjectionInput("TENANT", String.valueOf(value.tenantId()), value.tenantId(),
|
||||
value.status(), null, envelope.aggregateVersion(), payloadHash,
|
||||
envelope.eventId(), value.changedAt());
|
||||
}
|
||||
case EventTypes.IAM_PERMISSION_VERSION_CHANGED -> {
|
||||
var value = objectMapper.treeToValue(envelope.payload(),
|
||||
IamSecurityEventsV1.PermissionVersionChanged.class);
|
||||
requireIdentity(envelope, "AUTHORIZATION", value.authUserId(), value.tenantId());
|
||||
requirePayload(value.appCode(), value.changedAt(), value.permissionVersion(),
|
||||
envelope.aggregateVersion());
|
||||
yield new ProjectionInput("AUTHORIZATION", String.valueOf(value.authUserId()), value.tenantId(),
|
||||
null, value.permissionVersion(), envelope.aggregateVersion(), payloadHash,
|
||||
envelope.eventId(), value.changedAt());
|
||||
}
|
||||
default -> throw new IllegalArgumentException("unsupported type");
|
||||
};
|
||||
} catch (BizException exception) {
|
||||
throw exception;
|
||||
} catch (Exception exception) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "IAM 安全事件 payload 无效");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireIdentity(EventEnvelope envelope, String aggregateType,
|
||||
long aggregateId, long tenantId) {
|
||||
if (!aggregateType.equals(envelope.aggregateType())
|
||||
|| !String.valueOf(aggregateId).equals(envelope.aggregateId())
|
||||
|| !String.valueOf(tenantId).equals(envelope.tenantId())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "IAM 安全事件信封与 payload 身份不一致");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requirePayload(String requiredText, Instant changedAt,
|
||||
long payloadVersion, long aggregateVersion) {
|
||||
if (requiredText == null || requiredText.isBlank() || changedAt == null
|
||||
|| payloadVersion != aggregateVersion) {
|
||||
throw new BizException(ResultCode.CONFLICT, "IAM 安全事件 payload 版本或必填字段无效");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireStatus(String status) {
|
||||
if (!"ACTIVE".equals(status) && !"DISABLED".equals(status)) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "IAM 安全状态仅允许 ACTIVE 或 DISABLED");
|
||||
}
|
||||
}
|
||||
|
||||
private ProjectionRow findForUpdate(String type, String id) {
|
||||
List<ProjectionRow> rows = jdbc.query(
|
||||
"SELECT aggregate_version,payload_hash FROM ll_security_projection "
|
||||
+ "WHERE aggregate_type=? AND aggregate_id=? FOR UPDATE",
|
||||
(rs, row) -> new ProjectionRow(rs.getLong(1), rs.getString(2)), type, id);
|
||||
return rows.isEmpty() ? null : rows.get(0);
|
||||
}
|
||||
|
||||
private void upsert(ProjectionInput value) {
|
||||
jdbc.update("INSERT INTO ll_security_projection(aggregate_type,aggregate_id,tenant_id,security_status,"
|
||||
+ "permission_version,aggregate_version,payload_hash,last_event_id,changed_at,updated_at) "
|
||||
+ "VALUES (?,?,?,?,?,?,?,?,?,NOW(3)) ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id),"
|
||||
+ "security_status=VALUES(security_status),permission_version=VALUES(permission_version),"
|
||||
+ "aggregate_version=VALUES(aggregate_version),payload_hash=VALUES(payload_hash),"
|
||||
+ "last_event_id=VALUES(last_event_id),changed_at=VALUES(changed_at),updated_at=NOW(3)",
|
||||
value.aggregateType(), value.aggregateId(), value.tenantId(), value.status(),
|
||||
value.permissionVersion(), value.aggregateVersion(), value.payloadHash(), value.eventId(),
|
||||
value.changedAt());
|
||||
}
|
||||
|
||||
private record InboxRow(String eventType, String payloadHash, String status) { }
|
||||
private record ProjectionRow(long aggregateVersion, String payloadHash) { }
|
||||
public record ConsumeResult(String result, ProjectionInput projection) { }
|
||||
public record ProjectionChanged(ProjectionInput projection) { }
|
||||
public record ProjectionInput(String aggregateType, String aggregateId, long tenantId, String status,
|
||||
Long permissionVersion, long aggregateVersion, String payloadHash,
|
||||
String eventId, Instant changedAt) { }
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package com.baiyue.lastleg.security.service;
|
||||
|
||||
import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.FailureReason;
|
||||
import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationRequest;
|
||||
import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationResponse;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class LastlegSecurityValidationService {
|
||||
private final JdbcTemplate jdbc;
|
||||
|
||||
public LastlegSecurityValidationService(JdbcTemplate jdbc) {
|
||||
this.jdbc = jdbc;
|
||||
}
|
||||
|
||||
public ValidationResponse validate(ValidationRequest request) {
|
||||
String userStatus = status("USER", String.valueOf(request.userId()), request.tenantId());
|
||||
if (userStatus == null) {
|
||||
return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_MISSING);
|
||||
}
|
||||
if (!"ACTIVE".equals(userStatus)) {
|
||||
return ValidationResponse.rejected(FailureReason.USER_DISABLED);
|
||||
}
|
||||
if (request.tenantId() > 0) {
|
||||
String tenantStatus = status("TENANT", String.valueOf(request.tenantId()), request.tenantId());
|
||||
if (tenantStatus == null) {
|
||||
return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_MISSING);
|
||||
}
|
||||
if (!"ACTIVE".equals(tenantStatus)) {
|
||||
return ValidationResponse.rejected(FailureReason.TENANT_DISABLED);
|
||||
}
|
||||
}
|
||||
List<Long> projectedVersions = jdbc.query(
|
||||
"SELECT MAX(permission_version) FROM ll_security_projection "
|
||||
+ "WHERE aggregate_id=? AND tenant_id=? "
|
||||
+ "AND aggregate_type IN ('USER','AUTHORIZATION') HAVING COUNT(permission_version)>0",
|
||||
(rs, row) -> rs.getLong(1), String.valueOf(request.userId()), request.tenantId());
|
||||
if (projectedVersions.isEmpty()) {
|
||||
return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_MISSING);
|
||||
}
|
||||
long projectedVersion = projectedVersions.get(0);
|
||||
if (request.permissionVersion() < projectedVersion) {
|
||||
return ValidationResponse.rejected(FailureReason.PERMISSION_VERSION_STALE);
|
||||
}
|
||||
if (request.permissionVersion() > projectedVersion) {
|
||||
return ValidationResponse.rejected(FailureReason.SECURITY_PROJECTION_NOT_READY);
|
||||
}
|
||||
return ValidationResponse.accepted();
|
||||
}
|
||||
|
||||
private String status(String type, String id, long tenantId) {
|
||||
List<String> values = jdbc.query("SELECT security_status FROM ll_security_projection "
|
||||
+ "WHERE aggregate_type=? AND aggregate_id=? AND tenant_id=?",
|
||||
(rs, row) -> rs.getString(1), type, id, tenantId);
|
||||
return values.isEmpty() ? null : values.get(0);
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.baiyue.lastleg.security.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
final class SecurityProjectionMonotonicity {
|
||||
private SecurityProjectionMonotonicity() { }
|
||||
|
||||
static Decision decide(Long currentVersion, String currentHash, long incomingVersion, String incomingHash) {
|
||||
if (currentVersion == null || incomingVersion > currentVersion) return Decision.APPLY;
|
||||
if (incomingVersion < currentVersion) return Decision.STALE;
|
||||
if (Objects.equals(currentHash, incomingHash)) return Decision.DUPLICATE_VERSION;
|
||||
throw new BizException(ResultCode.CONFLICT, "相同聚合版本的安全事件 payloadHash 冲突");
|
||||
}
|
||||
|
||||
enum Decision { APPLY, STALE, DUPLICATE_VERSION }
|
||||
}
|
||||
+3
-14
@@ -7,8 +7,6 @@ import com.baiyue.lastleg.dropship.dto.DropshipRequests;
|
||||
import com.baiyue.lastleg.dropship.service.DropshipOrderService;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper;
|
||||
import com.baiyue.lastleg.inboundplan.service.InboundPlanService;
|
||||
import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse;
|
||||
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
|
||||
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
|
||||
import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper;
|
||||
@@ -37,7 +35,6 @@ public class LoadingCompletedConsumer {
|
||||
@Resource private LastlegOrderDetailMapper detailMapper;
|
||||
@Resource private LastlegOrderStatusHistoryMapper historyMapper;
|
||||
@Resource private InboundPlanCartonMapper cartonMapper;
|
||||
@Resource private InboundPlanService inboundPlans;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Resource private LastlegOrderNoGenerator orderNoGenerator;
|
||||
@Resource private DropshipOrderService dropshipOrders;
|
||||
@@ -228,12 +225,9 @@ public class LoadingCompletedConsumer {
|
||||
history.setOrderId(order.getId()); history.setToStatus("PENDING_ASSIGNMENT"); history.setEventId(event.eventId());
|
||||
history.setRemark("头程装柜完成自动生成"); history.setCreatedAt(LocalDateTime.now()); historyMapper.insert(history);
|
||||
if ("FIRST_LEG".equals(orderType)) {
|
||||
List<String> sourcePalletNos = source.pallets() == null ? List.of() : source.pallets().stream()
|
||||
.map(FirstLegLoadingPlanCompletedV1.PalletSnapshot::palletNo).filter(Objects::nonNull).toList();
|
||||
InboundPlanResponse plan = sourcePalletNos.isEmpty()
|
||||
? inboundPlans.convert(customerId, order.getId())
|
||||
: inboundPlans.convert(customerId, order.getId(), sourcePalletNos);
|
||||
registerInTransit(event, payload, source, order, plan.planNo());
|
||||
// The cross-region handover freezes the source inbound code only. An inbound plan is
|
||||
// created later by an explicit platform conversion, which replaces this display snapshot.
|
||||
registerInTransit(event, payload, source, order, source.inboundCode());
|
||||
}
|
||||
if ("ONE_CLICK_DROPSHIP".equals(orderType)) {
|
||||
long warehouseId;
|
||||
@@ -241,11 +235,6 @@ public class LoadingCompletedConsumer {
|
||||
catch (Exception exception) { throw new IllegalArgumentException("One-click dropship warehouseId is invalid", exception); }
|
||||
dropshipOrders.receive(new DropshipRequests.Receive(customerId, order.getId(), warehouseId,
|
||||
event.eventId() + ":DROPSHIP", dropshipPallets));
|
||||
InboundPlanResponse plan = inboundPlans.convertDropship(customerId, order.getId(), dropshipPallets.stream()
|
||||
.map(pallet -> new InboundPlanService.WholePalletInput(pallet.palletNo(), pallet.skuPackages().stream()
|
||||
.mapToInt(sku -> Math.toIntExact(sku.packageCount())).sum()))
|
||||
.toList());
|
||||
registerInTransit(event, payload, source, order, plan.planNo());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ baiyue:
|
||||
lease-seconds: ${FIRSTLEG_OUTBOX_RELAY_LEASE_SECONDS:60}
|
||||
retry-seconds: ${FIRSTLEG_OUTBOX_RELAY_RETRY_SECONDS:30}
|
||||
security:
|
||||
internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes}
|
||||
internal-service-token: ${INTERNAL_SERVICE_TOKEN:}
|
||||
jwt-mode: jwks
|
||||
session-mode: claims
|
||||
issuer: ${JWT_ISSUER:auth-service}
|
||||
|
||||
@@ -1750,3 +1750,41 @@ CREATE TABLE IF NOT EXISTS ll_customer_visit (
|
||||
KEY idx_ll_customer_visit_list(customer_id,status,delivery_completed_at),
|
||||
KEY idx_ll_customer_visit_delivery_no(customer_id,delivery_no)
|
||||
) ENGINE=InnoDB COMMENT='派送单维度客户回访记录';
|
||||
CREATE TABLE IF NOT EXISTS ll_security_event_inbox (
|
||||
event_id VARCHAR(64) NOT NULL COMMENT '跨区安全事件ID',
|
||||
event_type VARCHAR(96) NOT NULL COMMENT 'IAM安全事件类型',
|
||||
payload_hash CHAR(64) NOT NULL COMMENT '规范化载荷SHA-256哈希',
|
||||
payload_json JSON NOT NULL COMMENT '冻结事件载荷JSON',
|
||||
status VARCHAR(16) NOT NULL COMMENT '消费状态:RECEIVED或PROCESSED',
|
||||
result VARCHAR(32) NULL COMMENT '处理结果:APPLIED、STALE、DUPLICATE或DUPLICATE_VERSION',
|
||||
received_at DATETIME(3) NOT NULL COMMENT '接收时间',
|
||||
processed_at DATETIME(3) NULL COMMENT '处理完成时间',
|
||||
PRIMARY KEY (event_id),
|
||||
KEY idx_security_inbox_type_time (event_type,received_at)
|
||||
) ENGINE=InnoDB COMMENT='海外IAM安全事件消费者收件箱';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_security_projection (
|
||||
aggregate_type VARCHAR(32) NOT NULL COMMENT '聚合类型:USER、TENANT或AUTHORIZATION',
|
||||
aggregate_id VARCHAR(64) NOT NULL COMMENT '用户或客户聚合ID',
|
||||
tenant_id BIGINT NOT NULL COMMENT '平台0或客户ID',
|
||||
security_status VARCHAR(24) NULL COMMENT '当前安全状态:ACTIVE或DISABLED',
|
||||
permission_version BIGINT NULL COMMENT '当前单调权限版本',
|
||||
aggregate_version BIGINT NOT NULL COMMENT '事件聚合单调版本',
|
||||
payload_hash CHAR(64) NOT NULL COMMENT '当前版本载荷哈希',
|
||||
last_event_id VARCHAR(64) NOT NULL COMMENT '最后应用的事件ID',
|
||||
changed_at DATETIME(3) NOT NULL COMMENT '国内IAM事实发生时间',
|
||||
updated_at DATETIME(3) NOT NULL COMMENT '投影更新时间',
|
||||
PRIMARY KEY (aggregate_type,aggregate_id),
|
||||
KEY idx_security_projection_tenant (tenant_id),
|
||||
KEY idx_security_projection_permission (aggregate_id,permission_version)
|
||||
) ENGINE=InnoDB COMMENT='海外IAM安全状态与权限版本投影';
|
||||
|
||||
-- 开发基线固定管理员;后续真实 IAM 事件从 aggregate_version=1 单调覆盖该 version=0 投影。
|
||||
INSERT IGNORE INTO ll_security_projection (
|
||||
aggregate_type,aggregate_id,tenant_id,security_status,permission_version,aggregate_version,
|
||||
payload_hash,last_event_id,changed_at,updated_at
|
||||
) VALUES
|
||||
('USER','1000001',0,'ACTIVE',0,0,SHA2('lastleg-baseline:platform_admin:USER:0',256),
|
||||
'baseline-platform-admin-user-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000'),
|
||||
('AUTHORIZATION','1000001',0,NULL,0,0,SHA2('lastleg-baseline:platform_admin:AUTHORIZATION:0',256),
|
||||
'baseline-platform-admin-authorization-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000');
|
||||
|
||||
@@ -677,7 +677,7 @@ components:
|
||||
required: [role, fileId]
|
||||
properties:
|
||||
role: { type: string, enum: [LICENSE_PLATE, CONTAINER_SEAL, DOOR_OPEN] }
|
||||
fileId: { type: integer, format: int64, minimum: 1 }
|
||||
fileId: { type: string, pattern: '^[1-9][0-9]{0,18}$', description: Snowflake file ID kept as a decimal string }
|
||||
ReceivingBulk:
|
||||
type: object
|
||||
required: [inboundCode, skuCode, palletCode, packageCount, idempotencyKey]
|
||||
@@ -755,7 +755,7 @@ components:
|
||||
type: object
|
||||
required: [fileId, fileName, contentType, sizeBytes]
|
||||
properties:
|
||||
fileId: { type: integer, format: int64 }
|
||||
fileId: { type: string, pattern: '^[1-9][0-9]{0,18}$', description: Snowflake file ID kept as a decimal string }
|
||||
fileName: { type: string }
|
||||
contentType: { type: string }
|
||||
sizeBytes: { type: integer, format: int64 }
|
||||
|
||||
+12
-10
@@ -304,16 +304,18 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1);
|
||||
when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1);
|
||||
ReceivingContainerRequests.Sign request = new ReceivingContainerRequests.Sign(List.of(
|
||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.DOOR_OPEN, 3L),
|
||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 1L),
|
||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.CONTAINER_SEAL, 2L)), "sign-key");
|
||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.DOOR_OPEN, "3"),
|
||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, "2092476334483931138"),
|
||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.CONTAINER_SEAL, "2")), "sign-key");
|
||||
|
||||
var response = service.sign("7", "receiver", request);
|
||||
|
||||
org.assertj.core.api.Assertions.assertThat(response.photos()).extracting("role")
|
||||
.containsExactly(ReceivingPhotoRole.LICENSE_PLATE, ReceivingPhotoRole.CONTAINER_SEAL,
|
||||
ReceivingPhotoRole.DOOR_OPEN);
|
||||
verify(unloadPhotos).validate("100", 8L, 1L);
|
||||
org.assertj.core.api.Assertions.assertThat(response.photos()).extracting("fileId")
|
||||
.containsExactly("2092476334483931138", "2", "3");
|
||||
verify(unloadPhotos).validate("100", 8L, 2092476334483931138L);
|
||||
verify(unloadPhotos).validate("100", 8L, 2L);
|
||||
verify(unloadPhotos).validate("100", 8L, 3L);
|
||||
}
|
||||
@@ -322,9 +324,9 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
void signRejectsDuplicateRolesBeforeFileValidation() {
|
||||
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
|
||||
ReceivingContainerRequests.Sign request = new ReceivingContainerRequests.Sign(List.of(
|
||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 1L),
|
||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 2L),
|
||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.DOOR_OPEN, 3L)), "invalid-sign");
|
||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, "1"),
|
||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, "2"),
|
||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.DOOR_OPEN, "3")), "invalid-sign");
|
||||
|
||||
assertThatThrownBy(() -> service.sign("7", "receiver", request))
|
||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
|
||||
@@ -345,9 +347,9 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
void signRejectsDuplicateFileIdsBeforeFileValidation() {
|
||||
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
|
||||
ReceivingContainerRequests.Sign request = new ReceivingContainerRequests.Sign(List.of(
|
||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 1L),
|
||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.CONTAINER_SEAL, 1L),
|
||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.DOOR_OPEN, 3L)), "invalid-files");
|
||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, "1"),
|
||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.CONTAINER_SEAL, "1"),
|
||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.DOOR_OPEN, "3")), "invalid-files");
|
||||
|
||||
assertThatThrownBy(() -> service.sign("7", "receiver", request))
|
||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
|
||||
|
||||
+7
@@ -11,6 +11,7 @@ import com.baiyue.lastleg.inboundplan.mapper.InboundPlanOrderMapper;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper;
|
||||
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletMapper;
|
||||
import com.baiyue.lastleg.inboundplan.model.SourceOrderSnapshot;
|
||||
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
@@ -41,6 +42,7 @@ class InboundPlanServiceTest {
|
||||
@Mock InboundPlanPalletMapper palletMapper;
|
||||
@Mock InboundPlanPalletAllocationMapper allocationMapper;
|
||||
@Mock InboundPlanOrderMapper orderMapper;
|
||||
@Mock InventoryBalanceMapper inventoryBalanceMapper;
|
||||
|
||||
private InboundPlanService service;
|
||||
|
||||
@@ -52,6 +54,7 @@ class InboundPlanServiceTest {
|
||||
ReflectionTestUtils.setField(service, "palletMapper", palletMapper);
|
||||
ReflectionTestUtils.setField(service, "allocationMapper", allocationMapper);
|
||||
ReflectionTestUtils.setField(service, "orderMapper", orderMapper);
|
||||
ReflectionTestUtils.setField(service, "inventoryBalanceMapper", inventoryBalanceMapper);
|
||||
org.mockito.Mockito.lenient().when(lineMapper.selectList(any())).thenReturn(List.of());
|
||||
org.mockito.Mockito.lenient().when(palletMapper.selectList(any())).thenReturn(List.of());
|
||||
org.mockito.Mockito.lenient().when(allocationMapper.selectList(any())).thenReturn(List.of());
|
||||
@@ -89,6 +92,9 @@ class InboundPlanServiceTest {
|
||||
assertEquals(1, allocations.getAllValues().stream()
|
||||
.filter(item -> "PENDING_COUNT".equals(item.getStatus())).count());
|
||||
verify(orderMapper).markPlanned(any(), org.mockito.ArgumentMatchers.eq(2L), any(), org.mockito.ArgumentMatchers.anyInt(), any());
|
||||
verify(inventoryBalanceMapper).bindInboundPlan(org.mockito.ArgumentMatchers.eq("100"),
|
||||
org.mockito.ArgumentMatchers.eq(2L), org.mockito.ArgumentMatchers.eq("RK-2"),
|
||||
org.mockito.ArgumentMatchers.eq(result.planNo()), any());
|
||||
verify(orderMapper).insertHistory(any(), org.mockito.ArgumentMatchers.eq(2L), any(), any(), any());
|
||||
verify(orderMapper).lockOrder("100", 2L);
|
||||
assertEquals(2L, result.sourceOrderId());
|
||||
@@ -133,6 +139,7 @@ class InboundPlanServiceTest {
|
||||
assertEquals(1L, result.sourceOrderId());
|
||||
verify(planMapper, never()).insert(any(InboundPlan.class));
|
||||
verify(orderMapper, never()).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any());
|
||||
verify(inventoryBalanceMapper, never()).bindInboundPlan(any(), any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+8
@@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test;
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class MasterDataMapperConfigurationTest {
|
||||
@Test
|
||||
@@ -14,4 +15,11 @@ class MasterDataMapperConfigurationTest {
|
||||
assertThat(mapperScan.basePackages()).containsExactly("com.baiyue.lastleg.masterdata");
|
||||
assertThat(mapperScan.annotationClass()).isEqualTo(Mapper.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void broadExchangeMapperScanMustNotBeReintroduced() {
|
||||
assertThatThrownBy(() -> Class.forName(
|
||||
"com.baiyue.lastleg.masterdata.exchange.ExchangeMapperConfiguration"))
|
||||
.isInstanceOf(ClassNotFoundException.class);
|
||||
}
|
||||
}
|
||||
|
||||
+10
@@ -1,5 +1,7 @@
|
||||
package com.baiyue.lastleg.palletops.controller;
|
||||
|
||||
import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -24,4 +26,12 @@ class PdaPalletOperationControllerContractTest {
|
||||
.flatMap(annotation -> Arrays.stream(annotation.value())).collect(Collectors.toSet());
|
||||
assertEquals(Set.of("/pallets/resolve", "/assemble", "/disassemble", "/swap", "/unbind"), paths);
|
||||
}
|
||||
|
||||
@Test void palletContentAnnotationUsesNativeGreaterThanOperator() throws Exception {
|
||||
String sql = String.join("", PdaPalletOperationMapper.class.getMethod("content", String.class, long.class)
|
||||
.getAnnotation(Select.class).value());
|
||||
assertTrue(sql.contains("receiving_packages)>0"));
|
||||
assertFalse(sql.contains("<"));
|
||||
assertFalse(sql.contains(">"));
|
||||
}
|
||||
}
|
||||
|
||||
+45
@@ -3,6 +3,9 @@ package com.baiyue.lastleg.palletops.service;
|
||||
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
|
||||
import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper;
|
||||
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryLedger;
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
|
||||
import com.baiyue.lastleg.palletops.dto.PalletOperationRequests;
|
||||
import com.baiyue.lastleg.palletops.entity.PdaPalletOperation;
|
||||
import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway;
|
||||
@@ -15,6 +18,7 @@ import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
@@ -23,6 +27,7 @@ import org.springframework.transaction.support.DefaultTransactionStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
@@ -94,6 +99,46 @@ class PdaPalletOperationServiceTest {
|
||||
verifyNoInteractions(ledgers, history);
|
||||
}
|
||||
|
||||
@Test void inventoryLedgerStoresStateSemanticsInsteadOfLongPalletCodes() {
|
||||
InventoryBalance balance = new InventoryBalance(); balance.setId(200L); balance.setCustomerId("100");
|
||||
balance.setOverseasWarehouseId("88"); balance.setInboundOrderId(300L); balance.setInboundCode("IN-1");
|
||||
InventoryPosition position = new InventoryPosition(); position.setId(100L); position.setPalletId(20L);
|
||||
position.setPalletNo("PDAOPS-ADMIN-API-20260825095854036-SWAP-TARGET");
|
||||
position.setBatchNo("DEFAULT"); position.setWarehouseLocationCode("PDAOPS-LONG-LOCATION-CODE");
|
||||
PdaPalletOperation operation = new PdaPalletOperation(); operation.setId(400L);
|
||||
operation.setOperationType("PALLET_SWAP");
|
||||
|
||||
ReflectionTestUtils.invokeMethod(service, "ledger", identity(), operation, balance, position,
|
||||
"idem:out", 5L, -5L, 0L,
|
||||
"PDAOPS-ADMIN-API-20260825095854036-SOURCE",
|
||||
"PDAOPS-ADMIN-API-20260825095854036-TARGET", "swap");
|
||||
|
||||
ArgumentCaptor<InventoryLedger> ledger = ArgumentCaptor.forClass(InventoryLedger.class);
|
||||
verify(ledgers).insert(ledger.capture());
|
||||
assertEquals("OCCUPIED", ledger.getValue().getStatusFrom());
|
||||
assertEquals("OCCUPIED", ledger.getValue().getStatusTo());
|
||||
assertEquals("PDAOPS-LONG-LOCATION-CODE", ledger.getValue().getWarehouseLocationCode());
|
||||
}
|
||||
|
||||
@Test void exactReplayReturnsStoredResultBeforeValidatingChangedPalletVersions() throws Exception {
|
||||
var request = new PalletOperationRequests.Swap("idem-replay",
|
||||
new PalletOperationRequests.PalletExpectation("SOURCE", 1),
|
||||
new PalletOperationRequests.PalletExpectation("TARGET", 2), "swap");
|
||||
PdaPalletOperation existing = new PdaPalletOperation(); existing.setId(500L);
|
||||
existing.setOperationType("PALLET_SWAP"); existing.setStatus("COMPLETED");
|
||||
existing.setRequestHash(ReflectionTestUtils.invokeMethod(service, "hash", request));
|
||||
var stored = new com.baiyue.lastleg.palletops.dto.PalletOperationResponses.Operation(
|
||||
500L, "PALLET_SWAP", "idem-replay", "COMPLETED", false, LocalDateTime.now(), List.of());
|
||||
existing.setResultJson(new ObjectMapper().findAndRegisterModules().writeValueAsString(stored));
|
||||
when(mapper.findOperation("7", "idem-replay")).thenReturn(existing);
|
||||
|
||||
var replay = service.swap(identity(), request);
|
||||
|
||||
assertTrue(replay.replayed());
|
||||
assertEquals(500L, replay.operationId());
|
||||
verifyNoInteractions(physical);
|
||||
}
|
||||
|
||||
private static PdaWarehouseIdentity identity() { return new PdaWarehouseIdentity("7", "picker", 88L); }
|
||||
private static PalletPhysicalGateway.PalletReference pallet(long id, String code, long warehouse, int version) {
|
||||
return new PalletPhysicalGateway.PalletReference(id, code, warehouse, 1000L + id,
|
||||
|
||||
+9
@@ -4,6 +4,8 @@ import com.baiyue.lastleg.pda.move.controller.PdaInventoryMoveController;
|
||||
import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveRequest;
|
||||
import com.baiyue.lastleg.pda.stocktake.controller.PdaStocktakeController;
|
||||
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeRequests;
|
||||
import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeItemMapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
@@ -38,4 +40,11 @@ class PdaInventoryOperationContractTest {
|
||||
assertThat(fields).doesNotContainAnyElementsOf(forbidden);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void stocktakeAnnotationSqlUsesNativeComparisonOperators() throws Exception {
|
||||
String sql = String.join("", PdaStocktakeItemMapper.class.getMethod("differences", long.class)
|
||||
.getAnnotation(Select.class).value());
|
||||
assertThat(sql).contains("difference_packages<>0").doesNotContain("<", ">");
|
||||
}
|
||||
}
|
||||
|
||||
+10
-4
@@ -60,9 +60,10 @@ class PdaInventoryMoveServiceTest {
|
||||
|
||||
@Test
|
||||
void atomicallyRelocatesPhysicalPalletAndEveryWarehouseScopedPosition() {
|
||||
String targetLocationCode = "PDAOPS-ADMIN-API-20260825095854036-MOVE";
|
||||
when(warehouse.resolvePallet("PAL-1")).thenReturn(pallet(900L, 20L, 2));
|
||||
when(warehouse.resolveLocation(900L, 20L)).thenReturn(location(20L, "A-01"));
|
||||
when(warehouse.resolveLocation(900L, "B-01")).thenReturn(location(21L, "B-01"));
|
||||
when(warehouse.resolveLocation(900L, targetLocationCode)).thenReturn(location(21L, targetLocationCode));
|
||||
InventoryPosition position = new InventoryPosition(); position.setId(11L); position.setCustomerId("100");
|
||||
position.setBalanceId(10L); position.setOverseasWarehouseId("900"); position.setPalletId(30L);
|
||||
position.setPalletNo("PAL-1"); position.setWarehouseLocationId(20L); position.setWarehouseLocationCode("A-01");
|
||||
@@ -76,7 +77,7 @@ class PdaInventoryMoveServiceTest {
|
||||
eq(21L), anyString(), eq("库位优化"))).thenReturn(transition(false));
|
||||
|
||||
var response = service.move(900L, "operator-1",
|
||||
new PdaInventoryMoveRequest("PAL-1", "B-01", 2, "move-1", "库位优化"));
|
||||
new PdaInventoryMoveRequest("PAL-1", targetLocationCode, 2, "move-1", "库位优化"));
|
||||
|
||||
verify(physical).apply(eq(900L), argThat(value -> value.palletId() == 30L
|
||||
&& value.locationId() == 20L && value.version() == 2), eq("OCCUPIED"),
|
||||
@@ -84,11 +85,16 @@ class PdaInventoryMoveServiceTest {
|
||||
ArgumentCaptor<InventoryPosition> updated = ArgumentCaptor.forClass(InventoryPosition.class);
|
||||
verify(positions).updateById(updated.capture());
|
||||
assertThat(updated.getValue().getWarehouseLocationId()).isEqualTo(21L);
|
||||
assertThat(updated.getValue().getWarehouseLocationCode()).isEqualTo("B-01");
|
||||
assertThat(updated.getValue().getWarehouseLocationCode()).isEqualTo(targetLocationCode);
|
||||
assertThat(response.packageCount()).isEqualTo(6L);
|
||||
assertThat(response.palletVersion()).isEqualTo(3);
|
||||
assertThat(response.status()).isEqualTo("COMPLETED");
|
||||
verify(ledgers).insert(any(InventoryLedger.class)); verify(moves).updateById(any(PdaInventoryMove.class));
|
||||
ArgumentCaptor<InventoryLedger> ledger = ArgumentCaptor.forClass(InventoryLedger.class);
|
||||
verify(ledgers).insert(ledger.capture());
|
||||
assertThat(ledger.getValue().getStatusFrom()).isEqualTo("OCCUPIED");
|
||||
assertThat(ledger.getValue().getStatusTo()).isEqualTo("OCCUPIED");
|
||||
assertThat(ledger.getValue().getWarehouseLocationCode()).isEqualTo(targetLocationCode);
|
||||
verify(moves).updateById(any(PdaInventoryMove.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+6
@@ -9,4 +9,10 @@ class InternalServiceTokenGuardTest {
|
||||
assertEquals(401,assertThrows(ResponseStatusException.class,()->guard.verify("wrong")).getStatusCode().value());
|
||||
assertEquals(401,assertThrows(ResponseStatusException.class,()->guard.verify(null)).getStatusCode().value());
|
||||
}
|
||||
|
||||
@Test void missingServerConfigurationFailsClosed() {
|
||||
InternalServiceTokenGuard guard = new InternalServiceTokenGuard("");
|
||||
assertEquals(503, assertThrows(ResponseStatusException.class,
|
||||
() -> guard.verify("any-token")).getStatusCode().value());
|
||||
}
|
||||
}
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package com.baiyue.lastleg.security;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class LastlegSecurityContractTest {
|
||||
@Test void securityProjectionLivesInLastlegDatabaseWithReliableInbox() throws Exception {
|
||||
String baseline = read("lastleg-service/src/main/resources/db/lastleg.sql");
|
||||
String migration = read("deploy/sql/海外区域统一增量-MySQL8.sql");
|
||||
String authSeed = read("auth/src/main/resources/db/phase1-admin-accounts.sql");
|
||||
String service = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java");
|
||||
String validation = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java");
|
||||
String validationDto = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/dto/LastlegSecurityDtos.java");
|
||||
String mirror = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionRedisMirror.java");
|
||||
assertThat(baseline).contains("CREATE TABLE IF NOT EXISTS ll_security_event_inbox",
|
||||
"PRIMARY KEY (event_id)", "payload_hash CHAR(64)",
|
||||
"CREATE TABLE IF NOT EXISTS ll_security_projection",
|
||||
"PRIMARY KEY (aggregate_type,aggregate_id)", "aggregate_version BIGINT",
|
||||
"permission_version BIGINT");
|
||||
for (String sql : new String[]{baseline, migration}) {
|
||||
assertThat(sql).contains("('USER','1000001',0,'ACTIVE',0,0",
|
||||
"('AUTHORIZATION','1000001',0,NULL,0,0",
|
||||
"baseline-platform-admin-user-v0",
|
||||
"baseline-platform-admin-authorization-v0")
|
||||
.doesNotContain("baseline-merchant-admin");
|
||||
}
|
||||
assertThat(authSeed).contains("(1000001, 'platform_admin'", "default_tenant_id",
|
||||
"default_app_code");
|
||||
assertThat(migration).contains("USE baiyue_lastleg;", "table_schema='baiyue_lastleg'")
|
||||
.doesNotContain("CREATE DATABASE IF NOT EXISTS baiyue_overseas_security",
|
||||
"USE baiyue_overseas_security;", "CREATE TABLE IF NOT EXISTS overseas_security_event_inbox",
|
||||
"CREATE TABLE IF NOT EXISTS overseas_security_projection");
|
||||
assertThat(service).contains("SecurityProjectionMonotonicity.decide", "Decision.APPLY");
|
||||
assertThat(service).contains("EventPayloadHash.matches", "validatedStorageHash")
|
||||
.doesNotContain("LoadingCompletedConsumer");
|
||||
assertThat(mirror).contains("TransactionPhase.AFTER_COMMIT", "Redis 镜像写入失败");
|
||||
assertThat(validation).contains("SELECT MAX(permission_version)", "tenant_id=?",
|
||||
"SECURITY_PROJECTION_MISSING", "SECURITY_PROJECTION_NOT_READY", "USER_DISABLED",
|
||||
"TENANT_DISABLED", "PERMISSION_VERSION_STALE");
|
||||
assertThat(validationDto).contains("Long userId", "Long tenantId", "Long permissionVersion")
|
||||
.doesNotContain("Long tokenVersion");
|
||||
}
|
||||
|
||||
@Test void genericLastlegEventEndpointWhitelistsBusinessEventsOnly() throws Exception {
|
||||
String controller = read("lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java");
|
||||
assertThat(controller).contains("FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED",
|
||||
"FIRST_LEG_LOADING_PLAN_COMPLETED", "Lastleg 不消费该事件类型")
|
||||
.doesNotContain("IAM_USER_STATUS_CHANGED", "IAM_TENANT_STATUS_CHANGED",
|
||||
"IAM_PERMISSION_VERSION_CHANGED");
|
||||
}
|
||||
|
||||
@Test void internalControllerUsesLastlegOwnedPathsAndExistingTokenGuard() throws Exception {
|
||||
String controller = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java");
|
||||
String guard = read("lastleg-service/src/main/java/com/baiyue/lastleg/security/InternalServiceTokenGuard.java");
|
||||
String application = read("lastleg-service/src/main/resources/application.yml");
|
||||
assertThat(controller).contains("@RequestMapping(\"/internal/lastleg/security/v1\")",
|
||||
"@PostMapping(\"/events\")", "@PostMapping(\"/validate\")",
|
||||
"com.baiyue.lastleg.security.InternalServiceTokenGuard")
|
||||
.doesNotContain("/internal/overseas-security/v1");
|
||||
assertThat(guard).contains("MessageDigest.isEqual", "SERVICE_UNAVAILABLE", "UNAUTHORIZED");
|
||||
assertThat(application).contains("internal-service-token: ${INTERNAL_SERVICE_TOKEN:}")
|
||||
.doesNotContain("phase1-local-internal-token");
|
||||
}
|
||||
|
||||
private static String read(String relative) throws Exception {
|
||||
Path path = Path.of(relative);
|
||||
if (!Files.exists(path)) path = Path.of("..").resolve(relative);
|
||||
return Files.readString(path, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.baiyue.lastleg.security;
|
||||
|
||||
import com.tngtech.archunit.core.domain.JavaClasses;
|
||||
import com.tngtech.archunit.core.importer.ClassFileImporter;
|
||||
import com.tngtech.archunit.core.importer.ImportOption;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage;
|
||||
import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideOutsideOfPackage;
|
||||
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
|
||||
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
|
||||
|
||||
class SecurityProjectionArchitectureTest {
|
||||
private static final String LASTLEG = "com.baiyue.lastleg..";
|
||||
private static final String CONTROLLER = "com.baiyue.lastleg.security.controller..";
|
||||
private static final String DTO = "com.baiyue.lastleg.security.dto..";
|
||||
private static final String SERVICE = "com.baiyue.lastleg.security.service..";
|
||||
|
||||
private static final JavaClasses PRODUCTION_CLASSES = new ClassFileImporter()
|
||||
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
|
||||
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_JARS)
|
||||
.importPackages("com.baiyue.lastleg");
|
||||
|
||||
@Test
|
||||
void projectionInternalsDoNotDependOnLastlegBusinessSlices() {
|
||||
classes().that().resideInAnyPackage(CONTROLLER, DTO, SERVICE)
|
||||
.should().onlyDependOnClassesThat(
|
||||
resideOutsideOfPackage(LASTLEG)
|
||||
.or(resideInAPackage(CONTROLLER))
|
||||
.or(resideInAPackage(DTO))
|
||||
.or(resideInAPackage(SERVICE))
|
||||
.or(resideInAPackage("com.baiyue.lastleg.security")))
|
||||
.check(PRODUCTION_CLASSES);
|
||||
}
|
||||
|
||||
@Test
|
||||
void businessSlicesDoNotDependOnProjectionInternals() {
|
||||
noClasses().that().resideOutsideOfPackages(CONTROLLER, DTO, SERVICE)
|
||||
.should().dependOnClassesThat().resideInAnyPackage(CONTROLLER, DTO, SERVICE)
|
||||
.check(PRODUCTION_CLASSES);
|
||||
}
|
||||
|
||||
@Test
|
||||
void dtoAndServicesStayIndependentFromInternalWebAdapter() {
|
||||
noClasses().that().resideInAnyPackage(DTO, SERVICE)
|
||||
.should().dependOnClassesThat().resideInAPackage(CONTROLLER)
|
||||
.check(PRODUCTION_CLASSES);
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package com.baiyue.lastleg.security.service;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.common.event.EventPayloadCanonicalizer;
|
||||
import com.baiyue.common.event.EventPayloadHash;
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
import com.baiyue.common.event.IamSecurityEventsV1;
|
||||
import com.baiyue.common.event.RegionCode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class LastlegSecurityProjectionTransportTest {
|
||||
private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
|
||||
|
||||
@Test
|
||||
void validatesEnvelopeHashAndStoresTheSixtyFourCharacterDigest() {
|
||||
EventEnvelope envelope = envelope();
|
||||
|
||||
String stored = LastlegSecurityProjectionService.validatedStorageHash(envelope);
|
||||
|
||||
assertThat(stored).hasSize(64).doesNotStartWith("sha256:");
|
||||
assertThat(envelope.payloadHash()).isEqualTo("sha256:" + stored);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsTamperedPayloadBeforeInboxMutation() {
|
||||
EventEnvelope source = envelope();
|
||||
EventEnvelope tampered = new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(),
|
||||
source.occurredAt(), source.sourceRegion(), source.targetRegion(), source.tenantId(),
|
||||
source.aggregateType(), source.aggregateId(), source.aggregateVersion(), source.handoverVersion(),
|
||||
source.traceId(), source.correlationId(), source.causationId(), source.payload(),
|
||||
"sha256:" + "0".repeat(64));
|
||||
|
||||
assertThatThrownBy(() -> LastlegSecurityProjectionService.validatedStorageHash(tampered))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("payloadHash");
|
||||
}
|
||||
|
||||
private EventEnvelope envelope() {
|
||||
var value = new IamSecurityEventsV1.PermissionVersionChanged(
|
||||
101L, 0L, "PLATFORM", 3L, "contract", Instant.parse("2026-08-25T07:00:00Z"));
|
||||
var payload = objectMapper.valueToTree(value);
|
||||
return new EventEnvelope("00000000-0000-4000-8000-000000000091",
|
||||
EventTypes.IAM_PERMISSION_VERSION_CHANGED, IamSecurityEventsV1.VERSION,
|
||||
Instant.parse("2026-08-25T07:00:00Z"), RegionCode.CN, RegionCode.OVERSEAS, "0",
|
||||
"AUTHORIZATION", "101", 3L, null, "trace-12345678", "correlation-12345678",
|
||||
null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package com.baiyue.lastleg.security.service;
|
||||
|
||||
import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.FailureReason;
|
||||
import com.baiyue.lastleg.security.dto.LastlegSecurityDtos.ValidationRequest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
class LastlegSecurityValidationServiceTest {
|
||||
private final JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
private final LastlegSecurityValidationService service = new LastlegSecurityValidationService(jdbc);
|
||||
|
||||
@Test void disabledUserFailsClosed() {
|
||||
doReturn(List.of("DISABLED")).when(jdbc)
|
||||
.query(anyString(), any(RowMapper.class), any(Object[].class));
|
||||
assertThat(service.validate(new ValidationRequest(42L, 0L, 8L)).failureReason())
|
||||
.isEqualTo(FailureReason.USER_DISABLED);
|
||||
}
|
||||
|
||||
@Test void disabledCustomerFailsClosed() {
|
||||
doReturn(List.of("ACTIVE"), List.of("DISABLED")).when(jdbc)
|
||||
.query(anyString(), any(RowMapper.class), any(Object[].class));
|
||||
assertThat(service.validate(new ValidationRequest(42L, 100L, 8L)).failureReason())
|
||||
.isEqualTo(FailureReason.TENANT_DISABLED);
|
||||
}
|
||||
|
||||
@Test void missingUserProjectionFailsClosed() {
|
||||
doReturn(List.of()).when(jdbc)
|
||||
.query(anyString(), any(RowMapper.class), any(Object[].class));
|
||||
assertThat(service.validate(new ValidationRequest(42L, 0L, 8L)).failureReason())
|
||||
.isEqualTo(FailureReason.SECURITY_PROJECTION_MISSING);
|
||||
}
|
||||
|
||||
@Test void customerProjectionIsMandatoryButPlatformTenantDoesNotNeedOne() {
|
||||
doReturn(List.of("ACTIVE"), List.of(), List.of("ACTIVE"), List.of(8L)).when(jdbc)
|
||||
.query(anyString(), any(RowMapper.class), any(Object[].class));
|
||||
assertThat(service.validate(new ValidationRequest(42L, 100L, 8L)).failureReason())
|
||||
.isEqualTo(FailureReason.SECURITY_PROJECTION_MISSING);
|
||||
assertThat(service.validate(new ValidationRequest(42L, 0L, 8L)).valid()).isTrue();
|
||||
}
|
||||
|
||||
@Test void permissionVersionMustExactlyMatchReliableProjection() {
|
||||
doReturn(List.of("ACTIVE"), List.of(9L), List.of("ACTIVE"), List.of(9L),
|
||||
List.of("ACTIVE"), List.of(9L)).when(jdbc)
|
||||
.query(anyString(), any(RowMapper.class), any(Object[].class));
|
||||
assertThat(service.validate(new ValidationRequest(42L, 0L, 8L)).failureReason())
|
||||
.isEqualTo(FailureReason.PERMISSION_VERSION_STALE);
|
||||
assertThat(service.validate(new ValidationRequest(42L, 0L, 10L)).failureReason())
|
||||
.isEqualTo(FailureReason.SECURITY_PROJECTION_NOT_READY);
|
||||
assertThat(service.validate(new ValidationRequest(42L, 0L, 9L)).valid()).isTrue();
|
||||
}
|
||||
|
||||
@Test void missingPermissionProjectionFailsClosed() {
|
||||
doReturn(List.of("ACTIVE"), List.of()).when(jdbc)
|
||||
.query(anyString(), any(RowMapper.class), any(Object[].class));
|
||||
assertThat(service.validate(new ValidationRequest(42L, 0L, 9L)).failureReason())
|
||||
.isEqualTo(FailureReason.SECURITY_PROJECTION_MISSING);
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.baiyue.lastleg.security.service;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class SecurityProjectionMonotonicityTest {
|
||||
@Test void appliesFirstAndNewerVersions() {
|
||||
assertThat(SecurityProjectionMonotonicity.decide(null, null, 1, "hash-1"))
|
||||
.isEqualTo(SecurityProjectionMonotonicity.Decision.APPLY);
|
||||
assertThat(SecurityProjectionMonotonicity.decide(1L, "hash-1", 2, "hash-2"))
|
||||
.isEqualTo(SecurityProjectionMonotonicity.Decision.APPLY);
|
||||
}
|
||||
|
||||
@Test void ignoresOlderAndSameHashVersions() {
|
||||
assertThat(SecurityProjectionMonotonicity.decide(3L, "hash-3", 2, "hash-2"))
|
||||
.isEqualTo(SecurityProjectionMonotonicity.Decision.STALE);
|
||||
assertThat(SecurityProjectionMonotonicity.decide(3L, "hash-3", 3, "hash-3"))
|
||||
.isEqualTo(SecurityProjectionMonotonicity.Decision.DUPLICATE_VERSION);
|
||||
}
|
||||
|
||||
@Test void rejectsSameVersionWithDifferentHash() {
|
||||
assertThatThrownBy(() -> SecurityProjectionMonotonicity.decide(3L, "hash-a", 3, "hash-b"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("payloadHash 冲突");
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -1,10 +1,10 @@
|
||||
package com.baiyue.lastleg.service;
|
||||
import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com.baiyue.lastleg.mapper.*; import com.baiyue.lastleg.dropship.dto.DropshipRequests; import com.baiyue.lastleg.dropship.service.DropshipOrderService; import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; import com.baiyue.lastleg.inboundplan.service.InboundPlanService; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; import com.fasterxml.jackson.databind.*; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.time.Instant; import java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*;
|
||||
import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com.baiyue.lastleg.mapper.*; import com.baiyue.lastleg.dropship.dto.DropshipRequests; import com.baiyue.lastleg.dropship.service.DropshipOrderService; import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; import com.fasterxml.jackson.databind.*; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.time.Instant; import java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*;
|
||||
@ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class)
|
||||
class LoadingCompletedConsumerTest {
|
||||
@Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InboundPlanService inboundPlans; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service;
|
||||
@BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inboundPlans",inboundPlans); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"objectMapper",json); ReflectionTestUtils.setField(service,"orderNoGenerator",new LastlegOrderNoGenerator()); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); var plan=new InboundPlanResponse(1L,"LIP-1","100",1L,"MSCU1","PENDING_ASSIGNMENT",1,1,BigDecimal.ONE,List.of(),List.of(),List.of()); lenient().when(inboundPlans.convert(anyString(),anyLong())).thenReturn(plan); lenient().when(inboundPlans.convertDropship(anyString(),anyLong(),anyList())).thenReturn(plan); }
|
||||
@Test void createsOneOrderAndOneInboundPlanPerInbound(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); verify(orders,times(2)).insert(any(LastlegOrder.class)); verify(inboundPlans,times(2)).convert(eq("100"),anyLong()); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),any(InventoryCommandRequests.InTransit.class)); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor<LastlegOrder> c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&o.getOverseasWarehouseId()==null)); ArgumentCaptor<LastlegInboundOrder> snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); }
|
||||
@Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service;
|
||||
@BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"objectMapper",json); ReflectionTestUtils.setField(service,"orderNoGenerator",new LastlegOrderNoGenerator()); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); }
|
||||
@Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor<InventoryCommandRequests.InTransit> inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor<LastlegOrder> c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&o.getOverseasWarehouseId()==null)); ArgumentCaptor<LastlegInboundOrder> snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); }
|
||||
@Test void loadingCreatesAwaitingPickupReadyGateWithoutPollutingShippingVersion(){ service.consume(event()); verify(shippingGates).initialize("book-1","100","","MSCU1",0L,1L); }
|
||||
@Test void repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU1")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU1"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); }
|
||||
@Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); }
|
||||
@@ -17,7 +17,7 @@ class LoadingCompletedConsumerTest {
|
||||
assertTrue(table.contains("customer_id")); assertFalse(table.contains("tenant_id"));
|
||||
}
|
||||
@Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); }
|
||||
@Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","CONT-D","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor<DropshipRequests.Receive> captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); assertEquals("100",captor.getValue().customerId()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); ArgumentCaptor<List<InboundPlanService.WholePalletInput>> plans=ArgumentCaptor.forClass(List.class); verify(inboundPlans).convertDropship(eq("100"),anyLong(),plans.capture()); assertEquals(List.of(new InboundPlanService.WholePalletInput("PLT-1",2)),plans.getValue()); verify(inventory).registerInTransit(eq("100"),eq("SYSTEM"),any()); }
|
||||
@Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","CONT-D","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor<DropshipRequests.Receive> captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); verify(inventory,never()).registerInTransit(anyString(),anyString(),any()); assertEquals("100",captor.getValue().customerId()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); }
|
||||
@Test void oneDropshipInboundWithMultipleSkusCreatesOneOrderAndMultipleSkuDetails(){
|
||||
var itemA=new FirstLegLoadingPlanCompletedV1.Item("d-a","SKU-A","产品A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-A","MARK-A",null,List.of("BOX-A1","BOX-A2"));
|
||||
var itemB=new FirstLegLoadingPlanCompletedV1.Item("d-b","SKU-B","产品B","Product B",3,15,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-B","MARK-B",null,List.of("BOX-B1","BOX-B2","BOX-B3"));
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"relabel-transfer-discard->inventory",
|
||||
"relabel-transfer-discard->shared-technical",
|
||||
"relabel-transfer-discard->tailleg-handoff",
|
||||
"security-projection->shared-technical",
|
||||
"tailleg-handoff->dropship",
|
||||
"tailleg-handoff->inbound-pda",
|
||||
"tailleg-handoff->inventory",
|
||||
@@ -35,7 +36,13 @@
|
||||
"com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.VehicleRecord": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleRecord.java",
|
||||
"com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationService.java",
|
||||
"com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/model/VehicleModels.java",
|
||||
"com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java"
|
||||
"com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java",
|
||||
"com.baiyue.lastleg.security.controller.LastlegSecurityController": "src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java",
|
||||
"com.baiyue.lastleg.security.dto.LastlegSecurityDtos": "src/main/java/com/baiyue/lastleg/security/dto/LastlegSecurityDtos.java",
|
||||
"com.baiyue.lastleg.security.service.LastlegSecurityProjectionRedisMirror": "src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionRedisMirror.java",
|
||||
"com.baiyue.lastleg.security.service.LastlegSecurityProjectionService": "src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java",
|
||||
"com.baiyue.lastleg.security.service.LastlegSecurityValidationService": "src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java",
|
||||
"com.baiyue.lastleg.security.service.SecurityProjectionMonotonicity": "src/main/java/com/baiyue/lastleg/security/service/SecurityProjectionMonotonicity.java"
|
||||
},
|
||||
"applicationDependencyRule": {
|
||||
"packagePrefix": "com.baiyue.lastleg.masterdata.vehicle.application",
|
||||
@@ -49,6 +56,10 @@
|
||||
]
|
||||
},
|
||||
"sliceSpecificRules": {
|
||||
"security-projection": {
|
||||
"allowedOutgoing": ["shared-technical"],
|
||||
"allowedIncoming": []
|
||||
},
|
||||
"overseas-master-data": {
|
||||
"allowedOutgoing": ["shared-technical"],
|
||||
"allowedIncoming": ["procurement-trucking-looseboard"]
|
||||
|
||||
Reference in New Issue
Block a user