fix(dropship): generate pallet numbers atomically and extend full regressions

This commit is contained in:
2026-09-09 12:19:16 +08:00
100 changed files with 2889 additions and 234 deletions
@@ -0,0 +1,82 @@
package com.baiyue.lastleg.delivery.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*;
import com.baiyue.lastleg.delivery.model.DeliveryDocumentCategory;
import com.baiyue.lastleg.delivery.service.DeliveryDocumentService;
import com.baiyue.lastleg.security.OverseasPermissions;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.util.List;
@RestController
@RequestMapping("/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/documents")
public class DeliveryDocumentController {
@Resource private DeliveryDocumentService service;
@GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_LIST + "')")
public ApiResponse<Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String deliveryNo, @RequestParam String customerId) {
scope(user, customerId); return ApiResponse.success(authorized(service.detail(customerId, deliveryNo)));
}
@PutMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_DOCUMENTS + "')")
public ApiResponse<Detail> save(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String deliveryNo, @RequestParam String customerId, @Valid @RequestBody Save request) {
scope(user, customerId); return ApiResponse.success(authorized(service.save(customerId, deliveryNo, operator(user), request)));
}
@PostMapping(value="/{category}/files", consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_DOCUMENTS + "')")
public ApiResponse<File> upload(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String deliveryNo, @RequestParam String customerId,
@PathVariable DeliveryDocumentCategory category, @RequestPart("file") MultipartFile file) {
scope(user, customerId); return ApiResponse.success(service.upload(customerId, deliveryNo, category, operator(user), file));
}
@GetMapping("/history")
@PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_LIST + "')")
public ApiResponse<List<History>> history(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String deliveryNo, @RequestParam String customerId) {
scope(user, customerId); return ApiResponse.success(service.history(customerId, deliveryNo).stream()
.map(h -> new History(h.version(), h.operatorId(), h.savedAt(), authorized(h.snapshot()))).toList());
}
@GetMapping("/{category}/files/{fileId}/download")
@PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_LIST + "')")
public ResponseEntity<byte[]> download(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String deliveryNo, @RequestParam String customerId,
@PathVariable DeliveryDocumentCategory category, @PathVariable long fileId,
@RequestParam(required=false) Long revisionVersion) {
scope(user, customerId);
DeliveryDocumentService.Download downloaded = service.download(customerId, deliveryNo, category, fileId, revisionVersion);
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.header("X-Content-Type-Options", "nosniff")
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
.filename(downloaded.file().fileName(), StandardCharsets.UTF_8).build().toString())
.body(downloaded.content());
}
private static Detail authorized(Detail detail) {
var authentication = SecurityContextHolder.getContext().getAuthentication();
boolean canManage = authentication != null && authentication.isAuthenticated()
&& authentication.getAuthorities().stream().anyMatch(a -> OverseasPermissions.DELIVERY_DOCUMENTS.equals(a.getAuthority()));
return new Detail(detail.deliveryNo(), detail.customerId(), detail.version(), detail.bolNo(),
detail.uploadedCategoryCount(), detail.totalCategoryCount(), detail.categories(),
canManage ? detail.allowedActions() : List.of(), detail.replayed());
}
private static void scope(AuthenticatedUser user, String customerId) {
if (user == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if (!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId()))
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅平台可管理派送资料");
try { if (Long.parseLong(customerId) <= 0) throw new NumberFormatException(); }
catch (NumberFormatException e) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "请指定有效客户"); }
}
private static String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : user.userId().toString(); }
}
@@ -0,0 +1,26 @@
package com.baiyue.lastleg.delivery.dto;
import com.baiyue.lastleg.delivery.model.DeliveryDocumentCategory;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.time.LocalDateTime;
import java.util.List;
public final class DeliveryDocumentDtos {
private DeliveryDocumentDtos() { }
public record Save(@NotNull @Min(0) Long expectedVersion,
@NotBlank @Size(max=128) String idempotencyKey,
@Size(max=128) String bolNo,
@NotNull @Size(max=3) List<@NotNull @Valid Binding> documents) { }
public record Binding(@NotNull DeliveryDocumentCategory category,
@NotNull @Size(max=20) List<@NotNull @Min(1) Long> fileIds) { }
public record File(Long fileId, String fileName, String contentType, long sizeBytes,
LocalDateTime uploadedAt) { }
public record Category(DeliveryDocumentCategory category, String status, int fileCount, List<File> files) { }
public record CategorySummary(DeliveryDocumentCategory category, String status, int fileCount) { }
public record Summary(int uploadedCategoryCount, int totalCategoryCount, List<CategorySummary> categories) { }
public record Detail(String deliveryNo, String customerId, long version, String bolNo,
int uploadedCategoryCount, int totalCategoryCount, List<Category> categories,
List<String> allowedActions, boolean replayed) { }
public record History(long version, String operatorId, LocalDateTime savedAt, Detail snapshot) { }
}
@@ -24,7 +24,8 @@ public final class DeliveryResponses {
int packageCount, BigDecimal totalWeightKg, BigDecimal totalVolumeCbm,
LocalDateTime plannedDeliveryAt, LocalDateTime dispatchedAt,
LocalDateTime expectedArrivalAt, LocalDateTime signedAt,
String remark, LocalDateTime createdAt, long version, List<String> allowedActions) {}
String remark, LocalDateTime createdAt, long version, List<String> allowedActions,
String bolNo, DeliveryDocumentDtos.Summary documentSummary) {}
public record DeliverySku(String inboundCode, String customerName, String destinationWarehouseCode,
String receivingAddress, String shipmentNo, String fbaTrackingNo,
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.delivery.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("ll_delivery_document_revision")
public class DeliveryDocumentRevision {
@TableId(type=IdType.ASSIGN_ID) private Long id;
private String customerId;
private Long deliveryOrderId;
private Long version;
private String idempotencyKey;
private String requestHash;
private String snapshotJson;
private String operatorId;
private LocalDateTime createdAt;
}
@@ -25,6 +25,8 @@ public class DeliveryOrder {
private String customerName;
private String status;
private String waybillNo;
private String bolNo;
private String documentsJson;
private String deliveryMethod;
private String carrierName;
private String pickupMethod;
@@ -0,0 +1,11 @@
package com.baiyue.lastleg.delivery.gateway;
import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.File;
import com.baiyue.lastleg.delivery.model.DeliveryDocumentCategory;
import org.springframework.web.multipart.MultipartFile;
public interface DeliveryDocumentFileGateway {
File upload(String customerId, long deliveryId, DeliveryDocumentCategory category, String operatorId, MultipartFile file);
File metadata(String customerId, long deliveryId, DeliveryDocumentCategory category, long fileId);
byte[] download(String customerId, long deliveryId, DeliveryDocumentCategory category, long fileId);
}
@@ -0,0 +1,85 @@
package com.baiyue.lastleg.delivery.gateway;
import com.baiyue.lastleg.config.LastlegRestClientFactory;
import com.baiyue.lastleg.config.LastlegRestClientProperties;
import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.File;
import com.baiyue.lastleg.delivery.model.DeliveryDocumentCategory;
import jakarta.annotation.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.*;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.Objects;
@Component
public class HttpDeliveryDocumentFileGateway implements DeliveryDocumentFileGateway {
private static final String PATH = "/internal/file/v1/business-files/{customer}/{type}/{delivery}";
@Resource private RestClient.Builder restClientBuilder;
@Resource private LastlegRestClientFactory restClientFactory;
@Resource private LastlegRestClientProperties restClientProperties;
@Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
private RestClient client;
@PostConstruct void initializeClient() {
client = restClientFactory.build(restClientBuilder, baseUrl, restClientProperties.getCustomerVisitFile());
}
@Override public File upload(String customerId, long deliveryId, DeliveryDocumentCategory category,
String operatorId, MultipartFile file) {
if (file == null || file.isEmpty() || file.getSize() > 52428800L)
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "派送资料须为非空且不超过50MiB的文件");
MultipartBodyBuilder body = new MultipartBodyBuilder();
try {
body.part("file", new ByteArrayResource(file.getBytes()) {
@Override public String getFilename() { return file.getOriginalFilename(); }
}).contentType(file.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM
: MediaType.parseMediaType(file.getContentType()));
} catch (IOException e) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "无法读取派送资料", e); }
Envelope response = client.post().uri(PATH, customerId, category.businessType(), deliveryId)
.header("X-Internal-Service-Token", internalToken).header("X-Operator-Id", operatorId)
.contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> { throw failure(result.getStatusCode()); })
.body(Envelope.class);
return verified(response, customerId, deliveryId, category, null);
}
@Override public File metadata(String customerId, long deliveryId, DeliveryDocumentCategory category, long fileId) {
Envelope response = client.get().uri(PATH + "/{file}", customerId, category.businessType(), deliveryId, fileId)
.header("X-Internal-Service-Token", internalToken).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> { throw failure(result.getStatusCode()); })
.body(Envelope.class);
return verified(response, customerId, deliveryId, category, fileId);
}
@Override public byte[] download(String customerId, long deliveryId, DeliveryDocumentCategory category, long fileId) {
return client.get().uri(PATH + "/{file}/content", customerId, category.businessType(), deliveryId, fileId)
.header("X-Internal-Service-Token", internalToken).retrieve()
.onStatus(HttpStatusCode::isError, (request, result) -> { throw failure(result.getStatusCode()); })
.body(byte[].class);
}
private static File verified(Envelope response, String customerId, long deliveryId,
DeliveryDocumentCategory category, Long expectedFileId) {
if (response == null) throw failure(HttpStatus.SERVICE_UNAVAILABLE);
if (!"200".equals(response.code())) throw new ResponseStatusException(
response.code() != null && response.code().startsWith("A04") ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE,
"派送资料文件不可用或归属校验失败");
Metadata file = response.data();
if (file == null || file.fileId() == null || file.fileId() <= 0 || file.createdAt() == null
|| file.fileName() == null || file.sizeBytes() == null || file.sizeBytes() <= 0
|| file.sizeBytes() > 52428800L || !Objects.equals(String.valueOf(file.tenantId()), customerId)
|| !category.businessType().equals(file.businessType()) || !String.valueOf(deliveryId).equals(file.businessId())
|| (expectedFileId != null && !expectedFileId.equals(file.fileId())))
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "文件不属于当前客户、派送单或资料类别");
return new File(file.fileId(), file.fileName(), file.contentType(), file.sizeBytes(), file.createdAt());
}
private static ResponseStatusException failure(HttpStatusCode status) {
return new ResponseStatusException(status.is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE,
"派送资料文件请求失败");
}
private record Envelope(String code, String message, Metadata data) { }
private record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
String fileName, String contentType, Long sizeBytes, LocalDateTime createdAt) { }
}
@@ -0,0 +1,14 @@
package com.baiyue.lastleg.delivery.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.delivery.entity.DeliveryDocumentRevision;
import org.apache.ibatis.annotations.*;
import java.util.List;
@Mapper
public interface DeliveryDocumentRevisionMapper extends BaseMapper<DeliveryDocumentRevision> {
@Select("SELECT * FROM ll_delivery_document_revision WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryId} AND idempotency_key=#{key}")
DeliveryDocumentRevision findCommand(@Param("customerId") String customerId, @Param("deliveryId") long deliveryId, @Param("key") String key);
@Select("SELECT * FROM ll_delivery_document_revision WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryId} ORDER BY version DESC")
List<DeliveryDocumentRevision> history(@Param("customerId") String customerId, @Param("deliveryId") long deliveryId);
}
@@ -0,0 +1,9 @@
package com.baiyue.lastleg.delivery.model;
public enum DeliveryDocumentCategory {
LOADING_PHOTO("DELIVERY_LOADING_PHOTO"), BOL("DELIVERY_BOL"), RECEIPT("DELIVERY_RECEIPT");
private final String businessType;
DeliveryDocumentCategory(String businessType) { this.businessType = businessType; }
public String businessType() { return businessType; }
}
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.delivery.model;
import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.*;
/** Saved metadata only; listing never calls File and never counts pending uploads. */
public final class DeliveryDocumentSnapshot {
private static final ObjectMapper JSON = new ObjectMapper().findAndRegisterModules();
private DeliveryDocumentSnapshot() { }
public static List<Category> categories(String json) {
if (json == null || json.isBlank()) return Arrays.stream(DeliveryDocumentCategory.values())
.map(category -> new Category(category, "MISSING", 0, List.of())).toList();
try { return JSON.readValue(json, new TypeReference<List<Category>>() { }); }
catch (JsonProcessingException e) { throw new IllegalStateException("派送资料快照损坏", e); }
}
public static Summary summary(String json) {
List<Category> categories = categories(json);
return new Summary((int) categories.stream().filter(c -> !c.files().isEmpty()).count(), 3,
categories.stream().map(c -> new CategorySummary(c.category(), c.status(), c.fileCount())).toList());
}
}
@@ -0,0 +1,128 @@
package com.baiyue.lastleg.delivery.service;
import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos;
import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*;
import com.baiyue.lastleg.delivery.entity.*;
import com.baiyue.lastleg.delivery.gateway.DeliveryDocumentFileGateway;
import com.baiyue.lastleg.delivery.mapper.*;
import com.baiyue.lastleg.delivery.model.*;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.time.LocalDateTime;
import java.util.*;
@Service
public class DeliveryDocumentService {
@Resource private DeliveryOrderMapper orders;
@Resource private DeliveryDocumentRevisionMapper revisions;
@Resource private DeliveryOperationMapper operations;
@Resource private DeliveryDocumentFileGateway files;
@Resource private ObjectMapper objectMapper;
public Detail detail(String customerId, String deliveryNo) { return detail(require(customerId, deliveryNo), false); }
public DeliveryDocumentDtos.File upload(String customerId, String deliveryNo, DeliveryDocumentCategory category,
String operatorId, MultipartFile file) {
DeliveryOrder order = require(customerId, deliveryNo);
return files.upload(customerId, order.getId(), category, operatorId, file);
}
/** Lock the same aggregate as status commands, validate the whole set, then commit one snapshot. */
@Transactional
public Detail save(String customerId, String deliveryNo, String operatorId, Save request) {
DeliveryOrder order = orders.lockByNo(customerId, deliveryNo);
if (order == null) throw missing();
String hash = hash(request);
DeliveryDocumentRevision replay = revisions.findCommand(customerId, order.getId(), request.idempotencyKey());
if (replay != null) {
if (!hash.equals(replay.getRequestHash())) throw conflict("相同幂等键的派送资料内容不同");
Detail saved = read(replay.getSnapshotJson());
return new Detail(saved.deliveryNo(), saved.customerId(), saved.version(), saved.bolNo(),
saved.uploadedCategoryCount(), 3, saved.categories(), saved.allowedActions(), true);
}
if (!Objects.equals(version(order), request.expectedVersion())) throw conflict("派送单版本已变化,请刷新后重试");
Map<DeliveryDocumentCategory, List<DeliveryDocumentDtos.File>> bindings = new EnumMap<>(DeliveryDocumentCategory.class);
Set<Long> unique = new HashSet<>();
for (Binding binding : request.documents()) {
if (bindings.containsKey(binding.category())) throw bad("资料类别不能重复");
List<DeliveryDocumentDtos.File> metadata = new ArrayList<>();
for (Long fileId : binding.fileIds()) {
if (!unique.add(fileId)) throw bad("同一文件不能重复绑定");
metadata.add(files.metadata(customerId, order.getId(), binding.category(), fileId));
}
bindings.put(binding.category(), List.copyOf(metadata));
}
List<Category> categories = Arrays.stream(DeliveryDocumentCategory.values()).map(category -> {
List<DeliveryDocumentDtos.File> values = bindings.getOrDefault(category, List.of());
return new Category(category, values.isEmpty() ? "MISSING" : "UPLOADED", values.size(), values);
}).toList();
LocalDateTime now = LocalDateTime.now();
order.setBolNo(request.bolNo() == null ? "" : request.bolNo().trim());
order.setDocumentsJson(write(categories));
order.setVersion(version(order) + 1);
order.setUpdatedAt(now);
orders.updateById(order);
Detail result = detail(order, false);
DeliveryDocumentRevision revision = new DeliveryDocumentRevision();
revision.setCustomerId(customerId); revision.setDeliveryOrderId(order.getId()); revision.setVersion(order.getVersion());
revision.setIdempotencyKey(request.idempotencyKey()); revision.setRequestHash(hash); revision.setSnapshotJson(write(result));
revision.setOperatorId(operatorId); revision.setCreatedAt(now); revisions.insert(revision);
DeliveryOperation operation = new DeliveryOperation();
operation.setCustomerId(customerId); operation.setDeliveryOrderId(order.getId()); operation.setOperatorId(operatorId);
operation.setOperationNode("SAVE_DOCUMENTS"); operation.setResult("保存派送资料:" + result.uploadedCategoryCount() + "/3类");
operation.setCreatedAt(now); operations.insert(operation);
return result;
}
public List<History> history(String customerId, String deliveryNo) {
DeliveryOrder order = require(customerId, deliveryNo);
return revisions.history(customerId, order.getId()).stream().map(revision -> new History(revision.getVersion(),
revision.getOperatorId(), revision.getCreatedAt(), read(revision.getSnapshotJson()))).toList();
}
public Download download(String customerId, String deliveryNo, DeliveryDocumentCategory category, long fileId, Long revisionVersion) {
DeliveryOrder order = require(customerId, deliveryNo);
Detail snapshot = detail(order, false);
if (revisionVersion != null) {
snapshot = revisions.history(customerId, order.getId()).stream().filter(r -> revisionVersion.equals(r.getVersion()))
.findFirst().map(r -> read(r.getSnapshotJson())).orElseThrow(DeliveryDocumentService::missing);
}
DeliveryDocumentDtos.File file = snapshot.categories().stream().filter(c -> c.category() == category)
.flatMap(c -> c.files().stream()).filter(f -> f.fileId() == fileId).findFirst().orElseThrow(DeliveryDocumentService::missing);
files.metadata(customerId, order.getId(), category, fileId);
return new Download(file, files.download(customerId, order.getId(), category, fileId));
}
public record Download(DeliveryDocumentDtos.File file, byte[] content) { }
private DeliveryOrder require(String customerId, String deliveryNo) {
DeliveryOrder order = orders.findByNo(customerId, deliveryNo);
if (order == null) throw missing();
return order;
}
private Detail detail(DeliveryOrder order, boolean replayed) {
List<Category> categories = DeliveryDocumentSnapshot.categories(order.getDocumentsJson());
return new Detail(order.getDeliveryNo(), order.getCustomerId(), version(order), order.getBolNo(),
(int) categories.stream().filter(c -> !c.files().isEmpty()).count(), 3, categories, List.of("MANAGE_DOCUMENTS"), replayed);
}
private static long version(DeliveryOrder order) { return Objects.requireNonNullElse(order.getVersion(), 0L); }
private String hash(Object value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(value).getBytes(StandardCharsets.UTF_8))); }
catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
}
private String write(Object value) {
try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException e) { throw new IllegalStateException("无法保存派送资料", e); }
}
private Detail read(String value) {
try { return objectMapper.readValue(value, Detail.class); }
catch (JsonProcessingException e) { throw new IllegalStateException("派送资料历史损坏", e); }
}
private static ResponseStatusException missing() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "派送单或已绑定资料不存在"); }
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
}
@@ -638,7 +638,7 @@ public class DeliveryTransferService {
candidate.getInboundCode(), candidate.getSkuCode(), candidate.getProductName(),
value(candidate.getAvailablePackages()));
}
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus())); }
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus()), d.getBolNo(), com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(d.getDocumentsJson())); }
private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), null, d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); }
private DeliveryResponses.DeliveryFeeRow feeRow(DeliveryFee f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getFeeDirection(), f.getFeeType(), f.getCurrency(), f.getAmount(), f.getRemark()); }
private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); }
@@ -22,6 +22,7 @@ public final class OverseasPermissions {
public static final String DELIVERY_LIST = "platform:overseas-warehouse:delivery:list";
public static final String DELIVERY_RETURN = "platform:overseas-warehouse:delivery:return";
public static final String DELIVERY_STATUS = "platform:overseas-warehouse:delivery:status";
public static final String DELIVERY_DOCUMENTS = "platform:overseas-warehouse:delivery:documents";
public static final String DISPATCH_LIST = "platform:overseas-warehouse:dispatch:list";
public static final String DISPATCH_EDIT = "platform:overseas-warehouse:dispatch:edit";
public static final String DISPATCH_CANCEL = "platform:overseas-warehouse:dispatch:cancel";
@@ -260,6 +260,8 @@ CREATE TABLE IF NOT EXISTS ll_delivery_order (
customer_name VARCHAR(255) NULL COMMENT '客户名称快照',
status VARCHAR(32) NOT NULL DEFAULT 'WAIT_DELIVERY' COMMENT '派送状态:WAIT_DELIVERY待派送及后续正式状态',
waybill_no VARCHAR(128) NULL COMMENT '承运运单号',
bol_no VARCHAR(128) NULL COMMENT '派送资料BOL号,不作为文件归属标识',
documents_json JSON NULL COMMENT '派送三类资料当前绑定及权威文件元数据快照',
delivery_method VARCHAR(32) NULL COMMENT '派送方式',
carrier_name VARCHAR(255) NULL COMMENT '派送承运商',
pickup_method VARCHAR(64) NULL COMMENT '提货方式',
@@ -306,6 +308,21 @@ CREATE TABLE IF NOT EXISTS ll_delivery_fee (
amount DECIMAL(18,2) NOT NULL COMMENT '费用金额', remark VARCHAR(500) NULL COMMENT '费用备注', created_by VARCHAR(128) NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), KEY idx_ll_delivery_fee_order(customer_id,delivery_order_id,id)
) ENGINE=InnoDB COMMENT='派送单费用明细';
CREATE TABLE IF NOT EXISTS ll_delivery_document_revision (
id BIGINT NOT NULL COMMENT '派送资料历史ID',
customer_id VARCHAR(128) NOT NULL COMMENT '派送所属客户隔离键',
delivery_order_id BIGINT NOT NULL COMMENT '派送单ID',
version BIGINT NOT NULL COMMENT '保存成功时派送单版本',
idempotency_key VARCHAR(128) NOT NULL COMMENT '资料保存幂等键',
request_hash VARCHAR(64) NOT NULL COMMENT '完整保存请求哈希',
snapshot_json JSON NOT NULL COMMENT '不可变三类资料及BOL号结果快照',
operator_id VARCHAR(128) NOT NULL COMMENT '保存操作人ID',
created_at DATETIME(3) NOT NULL COMMENT '资料保存时间',
PRIMARY KEY(id),
UNIQUE KEY uk_delivery_document_command(customer_id,delivery_order_id,idempotency_key),
UNIQUE KEY uk_delivery_document_version(customer_id,delivery_order_id,version)
) ENGINE=InnoDB COMMENT='派送资料版本历史与幂等保存记录';
CREATE TABLE IF NOT EXISTS ll_delivery_operation (
id BIGINT NOT NULL COMMENT '派送操作主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', delivery_order_id BIGINT NOT NULL COMMENT '派送单ID',
operation_node VARCHAR(64) NOT NULL COMMENT '操作节点', operator_id VARCHAR(128) NULL COMMENT '操作人ID', operator_name VARCHAR(128) NULL COMMENT '操作人名称',
@@ -0,0 +1,180 @@
openapi: 3.0.3
info:
title: 派送单三类资料
version: 1.0.0
description: |
所有入口只允许平台,customerId 必须为真实派送所属客户;不得按 BOL 文本查找或复用其他客户/派送单文件。
GET /delivery-orders 及 GET /delivery-orders/{deliveryNo} 的 order 行新增 bolNo 和 documentSummary,version/allowedActions 保持原契约。
状态 PUT /delivery-orders/{deliveryNo}/status 请求保持 {status,expectedVersion,remark},expectedVersion 必传。
START_DELIVERY=DELIVERING(派送中);COMPLETE_DELIVERY=DELIVERED(完成并记录签收时间、生成待回访)。同状态重放不重复副作用。
资料不设必传门禁,不以三类齐全作为派送完成条件。业务失败使用统一 ApiResponse(A0422参数、A0409冲突),通常HTTP200。
security: [{ bearerAuth: [] }]
paths:
/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/documents:
parameters:
- { $ref: '#/components/parameters/DeliveryNo' }
- { $ref: '#/components/parameters/CustomerId' }
get:
summary: 查询已保存三类资料
description: 权限 platform:overseas-warehouse:delivery:list;空资料仍返回全部三类MISSING。上传未保存不计入摘要。
responses:
'200': { description: 当前快照, content: { application/json: { schema: { $ref: '#/components/schemas/DetailResponse' } } } }
put:
summary: 原子保存全部类别绑定与BOL号
description: |
权限 platform:overseas-warehouse:delivery:documents。documents为全量快照,遗漏类别清空;Admin须提交完整三类。
同一派送单锁下校验expectedVersion、全部文件客户/派送单/类别归属后一起提交,任一失败不写任何绑定。
保存增加派送version,与状态修改共享版本;幂等键按客户+派送单限定,同键同请求重放原结果、异请求冲突。
替换/删除通过修改fileIds并保存表达,只解除当前绑定,历史对象和版本快照不可变保留。
requestBody:
required: true
content: { application/json: { schema: { $ref: '#/components/schemas/Save' } } }
responses:
'200': { description: 保存结果或统一业务错误, content: { application/json: { schema: { $ref: '#/components/schemas/DetailResponse' } } } }
/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/documents/{category}/files:
parameters:
- { $ref: '#/components/parameters/DeliveryNo' }
- { $ref: '#/components/parameters/CustomerId' }
- { $ref: '#/components/parameters/Category' }
post:
summary: 上传一个待绑定文件
description: |
权限 platform:overseas-warehouse:delivery:documents。FormData唯一file,无额外幂等头。
File沿用ARBITRARY任意格式、非空且不超过52428800字节(50MiB),每类别最多20个文件由保存校验。
上传仅产生不可变对象,不修改派送version或摘要;取消弹框/未保存不落绑定,不物理删除File对象。
requestBody:
required: true
content:
multipart/form-data:
schema: { type: object, required: [file], properties: { file: { type: string, format: binary } } }
responses:
'200':
description: 单文件权威元数据
content:
application/json:
schema:
allOf:
- { $ref: '#/components/schemas/Envelope' }
- type: object
properties: { data: { $ref: '#/components/schemas/File' } }
/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/documents/history:
parameters:
- { $ref: '#/components/parameters/DeliveryNo' }
- { $ref: '#/components/parameters/CustomerId' }
get:
summary: 查看不可变资料保存历史
description: 权限 platform:overseas-warehouse:delivery:list;版本倒序,仅当前客户和派送单。
responses:
'200':
description: 历史快照
content:
application/json:
schema:
allOf:
- { $ref: '#/components/schemas/Envelope' }
- type: object
properties:
data:
type: array
items:
type: object
properties:
version: { type: integer, format: int64 }
operatorId: { type: string }
savedAt: { type: string, format: date-time }
snapshot: { $ref: '#/components/schemas/Detail' }
/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/documents/{category}/files/{fileId}/download:
parameters:
- { $ref: '#/components/parameters/DeliveryNo' }
- { $ref: '#/components/parameters/CustomerId' }
- { $ref: '#/components/parameters/Category' }
- { in: path, name: fileId, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
- { in: query, name: revisionVersion, required: false, schema: { type: integer, format: int64, minimum: 1 } }
get:
summary: 下载当前或指定保存版本已绑定文件
description: 权限 platform:overseas-warehouse:delivery:list;省略revisionVersion只允许当前绑定。历史版本仍严格校验客户/单据/类别。未保存或跨单文件拒绝;附件响应no-store/nosniff。
responses:
'200': { description: 文件内容, content: { application/octet-stream: { schema: { type: string, format: binary } } } }
components:
securitySchemes:
bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
parameters:
DeliveryNo: { in: path, name: deliveryNo, required: true, schema: { type: string } }
CustomerId: { in: query, name: customerId, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } }
Category: { in: path, name: category, required: true, schema: { $ref: '#/components/schemas/CategoryCode' } }
schemas:
CategoryCode:
type: string
enum: [LOADING_PHOTO, BOL, RECEIPT]
description: 依次为装车照片、BOL文件(含签名)、签收文件(POD签收/快递签收)。BOL签名由用户提供,不进行OCR或电子签署验证。
Save:
type: object
required: [expectedVersion, idempotencyKey, documents]
properties:
expectedVersion: { type: integer, format: int64, minimum: 0 }
idempotencyKey: { type: string, minLength: 1, maxLength: 128 }
bolNo: { type: string, nullable: true, maxLength: 128, description: 不作为文件复用或归属匹配依据;空值清空 }
documents:
type: array
maxItems: 3
items:
type: object
required: [category, fileIds]
properties:
category: { $ref: '#/components/schemas/CategoryCode' }
fileIds: { type: array, maxItems: 20, uniqueItems: true, items: { type: string, pattern: '^[1-9][0-9]*$' } }
File:
type: object
required: [fileId, fileName, sizeBytes, uploadedAt]
properties:
fileId: { type: string, pattern: '^[1-9][0-9]*$' }
fileName: { type: string }
contentType: { type: string, nullable: true }
sizeBytes: { type: integer, format: int64, minimum: 1, maximum: 52428800 }
uploadedAt: { type: string, format: date-time, description: File持久化的真实创建时间 }
CategorySummary:
type: object
required: [category, status, fileCount]
properties:
category: { $ref: '#/components/schemas/CategoryCode' }
status: { type: string, enum: [UPLOADED, MISSING] }
fileCount: { type: integer, minimum: 0, maximum: 20 }
DocumentSummary:
type: object
required: [uploadedCategoryCount, totalCategoryCount, categories]
properties:
uploadedCategoryCount: { type: integer, minimum: 0, maximum: 3 }
totalCategoryCount: { type: integer, enum: [3] }
categories: { type: array, minItems: 3, maxItems: 3, items: { $ref: '#/components/schemas/CategorySummary' } }
Detail:
type: object
required: [deliveryNo, customerId, version, uploadedCategoryCount, totalCategoryCount, categories, allowedActions, replayed]
properties:
deliveryNo: { type: string }
customerId: { type: string }
version: { type: integer, format: int64 }
bolNo: { type: string, nullable: true }
uploadedCategoryCount: { type: integer, minimum: 0, maximum: 3 }
totalCategoryCount: { type: integer, enum: [3] }
categories:
type: array
minItems: 3
maxItems: 3
items:
allOf:
- { $ref: '#/components/schemas/CategorySummary' }
- type: object
required: [files]
properties: { files: { type: array, maxItems: 20, items: { $ref: '#/components/schemas/File' } } }
allowedActions: { type: array, items: { type: string, enum: [MANAGE_DOCUMENTS] }, description: 返回时按当前认证授权裁剪;只读用户为空,历史和幂等重放也不能沿用旧权限 }
replayed: { type: boolean }
Envelope:
type: object
properties:
code: { type: string }
message: { type: string }
DetailResponse:
allOf:
- { $ref: '#/components/schemas/Envelope' }
- type: object
properties: { data: { $ref: '#/components/schemas/Detail' } }
@@ -1,6 +1,7 @@
openapi: 3.0.3
info:
title: Lastleg Overseas Business API
description: 派送资料完整契约见同目录 delivery-documents-v1.yaml;派送列表/详情行返回 bolNo、documentSummary(三类状态与x/3),状态仍使用 version 和 allowedActions。
version: 1.0.0
paths:
/api/platform/v1/last-leg/inventory/balances/{balanceId}:
@@ -107,6 +107,30 @@ class CustomerVisitServiceTest {
assertThat(tail.getSubOrderStatus()).isEqualTo("DELIVERY:DELIVERED");
}
@Test
void startMeansDeliveringAndOnlyCompleteSignsAndCreatesVisit() {
DeliveryOrder delivery = delivery("WAIT_DELIVERY"); delivery.setVersion(0L);
when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery);
var started = service.updateDeliveryStatus("1001", "9001", "DL-1",
new CustomerVisitRequests.DeliveryStatus("DELIVERING", 0L, "开始派送"));
assertThat(started.deliveryStatus()).isEqualTo("DELIVERING");
assertThat(delivery.getVersion()).isEqualTo(1);
assertThat(delivery.getDispatchedAt()).isNotNull();
assertThat(delivery.getSignedAt()).isNull();
verify(visits, never()).insert(org.mockito.ArgumentMatchers.any(CustomerVisit.class));
CustomerVisit visit = new CustomerVisit(); visit.setId(33L); visit.setStatus("PENDING");
when(visits.findByDelivery("1001", 11L)).thenReturn(visit);
var completed = service.updateDeliveryStatus("1001", "9001", "DL-1",
new CustomerVisitRequests.DeliveryStatus("DELIVERED", 1L, null));
assertThat(completed.deliveryStatus()).isEqualTo("DELIVERED");
assertThat(delivery.getSignedAt()).isNotNull();
assertThat(delivery.getVersion()).isEqualTo(2);
assertThat(completed.visitId()).isEqualTo(33);
assertThat(service.updateDeliveryStatus("1001", "9001", "DL-1",
new CustomerVisitRequests.DeliveryStatus("DELIVERED", 1L, null)).replayed()).isTrue();
verify(deliveryOrders, org.mockito.Mockito.times(2)).updateById(delivery);
}
private DeliveryOrder delivery(String status) {
DeliveryOrder delivery = new DeliveryOrder();
delivery.setId(11L);
@@ -0,0 +1,53 @@
package com.baiyue.lastleg.delivery;
import com.baiyue.lastleg.delivery.controller.DeliveryDocumentController;
import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*;
import com.baiyue.lastleg.delivery.model.DeliveryDocumentCategory;
import com.baiyue.lastleg.customervisit.dto.CustomerVisitRequests;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.validation.Validation;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import java.util.*;
import java.nio.file.*;
import static org.assertj.core.api.Assertions.*;
class DeliveryDocumentContractTest {
@Test void missingVersionNestedNullIdsAndTooManyFilesAreRejected() {
try(var factory=Validation.buildDefaultValidatorFactory()) {
var validator=factory.getValidator();
assertThat(validator.validate(new CustomerVisitRequests.DeliveryStatus("DELIVERING",null,"123")))
.anyMatch(v->v.getPropertyPath().toString().equals("expectedVersion"));
assertThat(validator.validate(new Save(null,"key",null,List.of()))).isNotEmpty();
assertThat(validator.validate(new Save(0L,"key",null,List.of(new Binding(DeliveryDocumentCategory.BOL,
Arrays.asList((Long)null)))))).isNotEmpty();
assertThat(validator.validate(new Save(0L,"key",null,List.of(new Binding(DeliveryDocumentCategory.BOL,
java.util.stream.LongStream.rangeClosed(1,21).boxed().toList()))))).isNotEmpty();
}
}
@Test void removedCategoriesAreNotAcceptedAndOnlyThreeRemainInOrder() {
assertThat(DeliveryDocumentCategory.values()).containsExactly(DeliveryDocumentCategory.LOADING_PHOTO,
DeliveryDocumentCategory.BOL, DeliveryDocumentCategory.RECEIPT);
for(String category:List.of("POD","EXPRESS_RECEIPT","CUSTOMER_DOCUMENT","BILL_OF_LADING","DELIVERY_PHOTO")) {
assertThatThrownBy(()->new ObjectMapper().readValue("\""+category+"\"",DeliveryDocumentCategory.class))
.isInstanceOf(com.fasterxml.jackson.core.JsonProcessingException.class);
}
}
@Test void readDownloadAndWritePermissionsAreSeparate() {
for(var method:DeliveryDocumentController.class.getDeclaredMethods()) {
if(!Set.of("detail","history","download","save","upload").contains(method.getName())) continue;
String suffix=Set.of("save","upload").contains(method.getName())?"documents":"list";
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('platform:overseas-warehouse:delivery:"+suffix+"')");
}
}
@Test void baselineAndOverseasEntryContainSameImmutableRevisionTable() throws Exception {
String baseline=Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
String overseas=Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"));
String marker="CREATE TABLE IF NOT EXISTS ll_delivery_document_revision (";
String a=baseline.substring(baseline.indexOf(marker),baseline.indexOf(";",baseline.indexOf(marker))+1);
String b=overseas.substring(overseas.indexOf(marker),overseas.indexOf(";",overseas.indexOf(marker))+1);
assertThat(a.replaceAll("\\s+","")).isEqualTo(b.replaceAll("\\s+",""));
assertThat(a).contains("uk_delivery_document_command","uk_delivery_document_version");
}
}
@@ -66,6 +66,7 @@ class DeliveryLongIdJsonContractTest {
DELIVERY_ID,
null, null, "PS-1", null, null, null, null, "CREATED", null,
null, null, null, null, null, null, null, null, null, null, null,
0, 0, null, null, null, null, null, null, null, null, 0, java.util.List.of());
0, 0, null, null, null, null, null, null, null, null, 0, java.util.List.of(), null,
com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(null));
}
}
@@ -0,0 +1,39 @@
package com.baiyue.lastleg.delivery.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*;
import com.baiyue.lastleg.delivery.service.DeliveryDocumentService;
import com.baiyue.lastleg.security.OverseasPermissions;
import org.junit.jupiter.api.*;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.util.ReflectionTestUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.LocalDateTime;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class DeliveryDocumentControllerTest {
@AfterEach void clear() { SecurityContextHolder.clearContext(); }
@Test void readonlyUserGetsNoWriteActionsInCurrentReplayOrHistory() throws Exception { verifyActions(false); }
@Test void currentWritePermissionRetainsManagementAction() throws Exception { verifyActions(true); }
private void verifyActions(boolean writer) throws Exception {
var service=mock(DeliveryDocumentService.class);
var controller=new DeliveryDocumentController(); ReflectionTestUtils.setField(controller,"service",service);
var user=new AuthenticatedUser(9L,0L,null,null,"reader","PLATFORM","WEB",Set.of());
var authorities=writer?List.of(new SimpleGrantedAuthority(OverseasPermissions.DELIVERY_DOCUMENTS)):
List.of(new SimpleGrantedAuthority(OverseasPermissions.DELIVERY_LIST));
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,null,authorities));
var snapshot=new Detail("DL-1","100",1,null,0,3,List.of(),List.of("MANAGE_DOCUMENTS"),true);
when(service.detail("100","DL-1")).thenReturn(snapshot);
when(service.history("100","DL-1")).thenReturn(List.of(new History(1,"9",LocalDateTime.now(),snapshot)));
var request=new Save(0L,"key",null,List.of());
when(service.save("100","DL-1","9",request)).thenReturn(snapshot);
var json=new ObjectMapper().findAndRegisterModules();
assertThat(json.valueToTree(controller.detail(user,"DL-1","100")).at("/data/allowedActions").size()).isEqualTo(writer?1:0);
assertThat(json.valueToTree(controller.save(user,"DL-1","100",request)).at("/data/allowedActions").size()).isEqualTo(writer?1:0);
assertThat(json.valueToTree(controller.history(user,"DL-1","100")).at("/data/0/snapshot/allowedActions").size()).isEqualTo(writer?1:0);
}
}
@@ -0,0 +1,51 @@
package com.baiyue.lastleg.delivery.gateway;
import com.baiyue.lastleg.delivery.model.DeliveryDocumentCategory;
import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.http.MediaType;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
import static org.assertj.core.api.Assertions.*;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.*;
import static org.springframework.test.web.client.response.MockRestResponseCreators.*;
class HttpDeliveryDocumentFileGatewayTest {
HttpDeliveryDocumentFileGateway gateway;
MockRestServiceServer server;
@BeforeEach void setUp() {
var builder=RestClient.builder().baseUrl("http://file.test");
server=MockRestServiceServer.bindTo(builder).build();
gateway=new HttpDeliveryDocumentFileGateway();
ReflectionTestUtils.setField(gateway,"client",builder.build());
ReflectionTestUtils.setField(gateway,"internalToken","test-service-token");
}
@Test void validatesExactOwnershipAndUsesFileCreationTime() {
expect(metadata("100","10","DELIVERY_BOL","11"));
var file=gateway.metadata("100",10,DeliveryDocumentCategory.BOL,11);
assertThat(file.fileId()).isEqualTo(11);
assertThat(file.uploadedAt().toString()).isEqualTo("2026-09-09T08:00");
server.verify();
}
@ParameterizedTest
@ValueSource(strings={"customer","delivery","category","file"})
void rejectsCrossCustomerCrossDeliveryWrongCategoryAndWrongFile(String mismatch) {
expect(metadata(mismatch.equals("customer")?"200":"100",mismatch.equals("delivery")?"20":"10",
mismatch.equals("category")?"DELIVERY_RECEIPT":"DELIVERY_BOL",mismatch.equals("file")?"12":"11"));
assertThatThrownBy(()->gateway.metadata("100",10,DeliveryDocumentCategory.BOL,11))
.hasMessageContaining("不属于");
server.verify();
}
private void expect(String json) {
server.expect(requestTo("http://file.test/internal/file/v1/business-files/100/DELIVERY_BOL/10/11"))
.andExpect(header("X-Internal-Service-Token","test-service-token"))
.andRespond(withSuccess(json,MediaType.APPLICATION_JSON));
}
private String metadata(String customer,String delivery,String type,String file) {
return "{\"code\":\"200\",\"data\":{\"fileId\":"+file+",\"tenantId\":"+customer+
",\"businessType\":\""+type+"\",\"businessId\":\""+delivery+"\",\"fileName\":\"test.pdf\","+
"\"contentType\":\"application/pdf\",\"sizeBytes\":10,\"createdAt\":\"2026-09-09T08:00:00\"}}";
}
}
@@ -0,0 +1,126 @@
package com.baiyue.lastleg.delivery.service;
import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*;
import com.baiyue.lastleg.delivery.entity.*;
import com.baiyue.lastleg.delivery.gateway.DeliveryDocumentFileGateway;
import com.baiyue.lastleg.delivery.mapper.*;
import com.baiyue.lastleg.delivery.model.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.*;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.MockMultipartFile;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static com.baiyue.lastleg.delivery.model.DeliveryDocumentCategory.*;
@ExtendWith(MockitoExtension.class)
class DeliveryDocumentServiceTest {
@Mock DeliveryOrderMapper orders;
@Mock DeliveryDocumentRevisionMapper revisions;
@Mock DeliveryOperationMapper operations;
@Mock DeliveryDocumentFileGateway files;
DeliveryDocumentService service;
DeliveryOrder order;
@BeforeEach void setUp() {
service = new DeliveryDocumentService();
ReflectionTestUtils.setField(service,"orders",orders);
ReflectionTestUtils.setField(service,"revisions",revisions);
ReflectionTestUtils.setField(service,"operations",operations);
ReflectionTestUtils.setField(service,"files",files);
ReflectionTestUtils.setField(service,"objectMapper",new ObjectMapper().findAndRegisterModules());
order = new DeliveryOrder(); order.setId(10L); order.setCustomerId("100");
order.setDeliveryNo("DL-1"); order.setVersion(0L); order.setStatus("DELIVERED");
}
@Test void savedCategoryCountCountsCategoriesNotFilesAndSurvivesReload() {
when(orders.lockByNo("100","DL-1")).thenReturn(order);
when(files.metadata("100",10,BOL,11)).thenReturn(file(11));
when(files.metadata("100",10,BOL,12)).thenReturn(file(12));
var result=service.save("100","DL-1","9",request(0,"save",List.of(new Binding(BOL,List.of(11L,12L)))));
assertThat(result.uploadedCategoryCount()).isEqualTo(1);
assertThat(result.categories()).hasSize(3);
assertThat(result.categories().get(1).fileCount()).isEqualTo(2);
assertThat(result.version()).isEqualTo(1);
assertThat(result.categories().get(1).files().get(0).uploadedAt()).isEqualTo(file(11).uploadedAt());
when(orders.findByNo("100","DL-1")).thenReturn(order);
assertThat(service.detail("100","DL-1")).isEqualTo(result);
assertThat(DeliveryDocumentSnapshot.summary(order.getDocumentsJson()).uploadedCategoryCount()).isEqualTo(1);
verify(revisions).insert(any(DeliveryDocumentRevision.class)); verify(operations).insert(any(DeliveryOperation.class));
}
@Test void staleVersionDoesNotValidateFilesOrWrite() {
order.setVersion(2L); when(orders.lockByNo("100","DL-1")).thenReturn(order);
assertThatThrownBy(()->service.save("100","DL-1","9",request(1,"stale",List.of())))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("版本");
verifyNoInteractions(files,operations); verify(orders,never()).updateById(any(DeliveryOrder.class));
verify(revisions,never()).insert(any(DeliveryDocumentRevision.class));
}
@Test void anyInvalidOwnershipRejectsWholeSnapshotBeforeMutation() {
when(orders.lockByNo("100","DL-1")).thenReturn(order);
when(files.metadata("100",10,BOL,11)).thenReturn(file(11));
when(files.metadata("100",10,RECEIPT,12)).thenThrow(new ResponseStatusException(HttpStatus.BAD_REQUEST,"归属错误"));
assertThatThrownBy(()->service.save("100","DL-1","9",request(0,"bad",List.of(
new Binding(BOL,List.of(11L)),new Binding(RECEIPT,List.of(12L)))))).hasMessageContaining("归属");
assertThat(order.getDocumentsJson()).isNull(); assertThat(order.getVersion()).isZero();
verify(orders,never()).updateById(any(DeliveryOrder.class)); verifyNoInteractions(operations);
verify(revisions,never()).insert(any(DeliveryDocumentRevision.class));
}
@Test void exactReplayDoesNotWriteOrRevalidateAndChangedPayloadConflicts() {
when(orders.lockByNo("100","DL-1")).thenReturn(order);
var request=request(0,"same",List.of());
var first=service.save("100","DL-1","9",request);
ArgumentCaptor<DeliveryDocumentRevision> saved=ArgumentCaptor.forClass(DeliveryDocumentRevision.class);
verify(revisions).insert(saved.capture());
when(revisions.findCommand("100",10,"same")).thenReturn(saved.getValue());
assertThat(service.save("100","DL-1","9",request).replayed()).isTrue();
assertThat(first.version()).isEqualTo(1);
verify(orders,times(1)).updateById(any(DeliveryOrder.class));
assertThatThrownBy(()->service.save("100","DL-1","9",request(1,"same",List.of())))
.hasMessageContaining("幂等键");
}
@Test void replacementRemovalPreservesHistoryAndCurrentDownloadRejectsRemovedFile() {
when(orders.lockByNo("100","DL-1")).thenReturn(order);
when(files.metadata("100",10,BOL,11)).thenReturn(file(11));
service.save("100","DL-1","9",request(0,"first",List.of(new Binding(BOL,List.of(11L)))));
ArgumentCaptor<DeliveryDocumentRevision> first=ArgumentCaptor.forClass(DeliveryDocumentRevision.class);
verify(revisions).insert(first.capture());
assertThat(service.save("100","DL-1","9",request(1,"clear",List.of())).uploadedCategoryCount()).isZero();
when(orders.findByNo("100","DL-1")).thenReturn(order);
assertThatThrownBy(()->service.download("100","DL-1",BOL,11,null)).isInstanceOf(ResponseStatusException.class);
when(revisions.history("100",10)).thenReturn(List.of(first.getValue()));
when(files.download("100",10,BOL,11)).thenReturn(new byte[]{1});
assertThat(service.download("100","DL-1",BOL,11,1L).content()).containsExactly((byte)1);
}
@Test void uploadWithoutSaveNeverChangesSummaryAndUnboundFileCannotDownload() {
when(orders.findByNo("100","DL-1")).thenReturn(order);
var input=new MockMultipartFile("file","a.pdf","application/pdf",new byte[]{1});
when(files.upload("100",10,BOL,"9",input)).thenReturn(file(11));
assertThat(service.upload("100","DL-1",BOL,"9",input).fileId()).isEqualTo(11);
assertThat(service.detail("100","DL-1").uploadedCategoryCount()).isZero();
assertThatThrownBy(()->service.download("100","DL-1",BOL,11,null)).isInstanceOf(ResponseStatusException.class);
verifyNoInteractions(revisions,operations); verify(orders,never()).updateById(any(DeliveryOrder.class));
}
@Test void anotherCustomerOrDeliveryCannotLoadOrBindFiles() {
assertThatThrownBy(()->service.detail("200","DL-1")).isInstanceOf(ResponseStatusException.class);
assertThatThrownBy(()->service.save("100","DL-2","9",request(0,"wrong",List.of())))
.isInstanceOf(ResponseStatusException.class);
verifyNoInteractions(files,revisions,operations);
}
@Test void allThreeCategoriesCountToThreeRegardlessOfFileMultiplicity() {
when(orders.lockByNo("100","DL-1")).thenReturn(order);
when(files.metadata("100",10,LOADING_PHOTO,11)).thenReturn(file(11));
when(files.metadata("100",10,BOL,12)).thenReturn(file(12));
when(files.metadata("100",10,RECEIPT,13)).thenReturn(file(13));
var result=service.save("100","DL-1","9",request(0,"all",List.of(
new Binding(LOADING_PHOTO,List.of(11L)),new Binding(BOL,List.of(12L)),new Binding(RECEIPT,List.of(13L)))));
assertThat(result.uploadedCategoryCount()).isEqualTo(3); assertThat(result.totalCategoryCount()).isEqualTo(3);
assertThat(result.categories()).allMatch(c->c.status().equals("UPLOADED"));
}
private Save request(long version,String key,List<Binding> bindings) { return new Save(version,key,"BOL-1",bindings); }
private File file(long id) { return new File(id,"a.pdf","application/pdf",1,LocalDateTime.of(2026,9,9,8,0)); }
}