Merge branch 'test' into test-02huogui

# Conflicts:
#	deploy/sql/头程数据库统一增量-MySQL8.sql
#	firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java
#	firstleg/src/main/resources/application.yml
#	firstleg/src/main/resources/openapi/fba-pricing-v1.yaml
#	firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java
#	order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java
#	order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java
This commit is contained in:
2026-08-07 19:49:09 +08:00
192 changed files with 10729 additions and 391 deletions
@@ -32,7 +32,7 @@ public interface FirstlegInboundClient {
long tenantId, long orderId, String orderNo, String inboundCode, Long customerId, String customerName,
Long warehouseId, String warehouseName, Long deliveryWarehouseId, String deliveryWarehouseName,
LocalDateTime expectedArrivalAt, String businessType, String channelCode, String channelName,
Long carrierId, String carrierName, String taxMode, String containerMode,
Long carrierId, String carrierName, String taxMode, String pricingTaxMode, String containerMode,
Boolean customsInspection, String destinationType, Long destinationWarehouseId,
String destinationCode, String destinationName, String publicRemark,
String expressCompanyName, String expressTrackingNo
@@ -0,0 +1,17 @@
package com.baiyue.order.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
@FeignClient(name = "firstleg-service", contextId = "orderFirstlegPricingClient",
url = "${baiyue.clients.firstleg.url:}", path = "/internal/firstleg/v1/pricing")
public interface FirstlegPricingClient {
@PostMapping("/channels/validate")
ApiResponse<Void> validateChannel(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody ChannelValidationRequest request);
record ChannelValidationRequest(long tenantId, long originWarehouseId, String channelCode) {}
}
@@ -0,0 +1,19 @@
package com.baiyue.order.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
@FeignClient(name = "file-service", contextId = "orderProductImageClient",
url = "${baiyue.clients.file.url:}", path = "/internal/file/v1/product-images")
public interface ProductImageClient {
@GetMapping("/{tenantId}/{fileId}")
ApiResponse<FileMetadata> validate(
@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long tenantId,
@PathVariable long fileId);
record FileMetadata(Long fileId, String fileName, String contentType, Long sizeBytes) {}
}
@@ -17,6 +17,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.List;
/** 供头程履约服务校验入仓单所关联订单货物明细。 */
@RestController
@@ -51,6 +52,22 @@ public class InternalOrderCargoController {
cargo.getNetWeightKg(), cargo.getCargoRemark()));
}
@GetMapping("/{tenantId}/{orderId}")
public ApiResponse<List<CargoLabelBinding>> listForCartonLabels(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable long tenantId,
@PathVariable long orderId) {
requireToken(token);
if (tenantId <= 0 || orderId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "租户和订单ID必须大于0");
}
return ApiResponse.success(orders.findCargo(tenantId, orderId).stream()
.map(cargo -> new CargoLabelBinding(cargo.getId(), cargo.getOrderId(), cargo.getItemNo(),
cargo.getCargoName(), cargo.getPackageCount(), cargo.getLengthCm(), cargo.getWidthCm(),
cargo.getHeightCm()))
.toList());
}
private void requireToken(String token) {
if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) {
throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
@@ -70,4 +87,9 @@ public class InternalOrderCargoController {
originalGrossWeightKg, originalVolumeCbm, null, null, null, null, null, null);
}
}
public record CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
Integer packageCount, java.math.BigDecimal lengthCm,
java.math.BigDecimal widthCm, java.math.BigDecimal heightCm) {
}
}
@@ -149,7 +149,7 @@ public class PlatformFirstLegOrderController {
}
@BusinessAudit(event = "ORDER_APPROVE", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Approve order / 审核通过")
@Operation(summary = "Approve / 审核通过", description = "Approves a submitted order without requiring carrier assignment and records its status history. / 审核通过已提交订单,不要求预先指派承运商,并记录状态历史。")
@Operation(summary = "Approve / 审核通过", description = "Approves a submitted order, advances it to measurement, and records its status history. / 审核通过已提交订单,进入待测量阶段并记录状态历史。")
@PostMapping("/{id}/approve")
@PreAuthorize("hasAuthority('platform:order:approve')")
public ApiResponse<Void> approve(@AuthenticationPrincipal AuthenticatedUser user,
@@ -56,11 +56,13 @@ public final class OrderRequests {
@NotBlank @Size(max = 128) String receivingWarehouseName,
@NotBlank @Size(max = 64) String channelCode,
@NotBlank @Size(max = 128) String channelName,
@Schema(description = "Carrier ID snapshot; optional until the order channel resolves a carrier / 承运商 ID 快照,渠道明确承运商后可传")
@Schema(description = "Legacy internal transport supplier snapshot; new order clients should omit it / 历史内部运输供应商快照,新订单调用方应留空")
@Min(1) Long carrierId,
@Schema(description = "Carrier name snapshot; optional until the order channel resolves a carrier / 承运商名称快照,渠道明确承运商后可传", maxLength = 128)
@Schema(description = "Legacy internal transport supplier name snapshot; new order clients should omit it / 历史内部运输供应商名称快照,新订单调用方应留空", maxLength = 128)
@Size(max = 128) String carrierName,
@NotBlank @Pattern(regexp = "BUY_DECLARATION|TAX_REFUND") String taxMode,
@Schema(description = "Estimate pricing tax mode; independent from taxMode / 参考报价包税维度,独立于 taxMode", allowableValues = {"TAX_INCLUDED", "TAX_EXCLUDED"})
@NotBlank @Pattern(regexp = "TAX_INCLUDED|TAX_EXCLUDED") String pricingTaxMode,
@Pattern(regexp = "FULL_CONTAINER|LCL") String containerMode,
@NotNull Boolean customsInspection,
@NotBlank @Pattern(regexp = "FBA|OVERSEAS|PRIVATE") String destinationType,
@@ -74,13 +76,6 @@ public final class OrderRequests {
}
}
@Schema(name = "FirstLegOrderCarrierRequest", description = "Platform carrier assignment / 平台指派承运商")
public record CarrierRequest(
@Schema(description = "Active public carrier ID / 启用的公共承运商 ID", example = "193000000000300001", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull @Min(1) Long carrierId
) {
}
@Schema(name = "FirstLegOrderParty", description = "Sender or receiver contact and address / 发货方或收货方联系人及地址")
public record Party(
@Schema(description = "Company name / 公司名称", example = "Example Logistics LLC", maxLength = 128, requiredMode = Schema.RequiredMode.REQUIRED)
@@ -140,7 +135,13 @@ public final class OrderRequests {
@Size(max = 128) String material,
@Size(max = 128) String usage,
@Size(max = 64) String model,
@Size(max = 64) String brand
@Size(max = 64) String brand,
@DecimalMin("0.01") BigDecimal productLengthCm,
@DecimalMin("0.01") BigDecimal productWidthCm,
@DecimalMin("0.01") BigDecimal productHeightCm,
@Size(max = 64) String shipmentId,
@Size(max = 64) String referenceId,
@Schema(description = "Product image file ID / 产品图片文件ID") Long productImageFileId
) {
}
@@ -21,6 +21,7 @@ public final class UnifiedOrderResponse {
Integer totalPackageCount, BigDecimal actualGrossWeightKg, BigDecimal actualVolumeCbm,
MeasurementSummary measurement, PreloadSummary preload,
LoadingSummary loading, DeliverySummary delivery, TransportSummary transport,
String customerRemark, String latestInternalNote,
LocalDateTime createdAt, LocalDateTime updatedAt
) {}
@@ -12,7 +12,7 @@ import com.baiyue.common.core.model.BaseEntity; import com.baomidou.mybatisplus.
* 申报价值及危险品标识等货物信息。</p>
*/
@TableName("ord_order_cargo") public class OrderCargoEntity extends BaseEntity {
@TableId(type=IdType.ASSIGN_ID) private Long id; private Long tenantId; private Long orderId; private Integer itemNo; private String cargoName; private String hsCode; private String packageType; private Integer packageCount; private BigDecimal grossWeightKg; private BigDecimal volumeCbm; private BigDecimal lengthCm; private BigDecimal widthCm; private BigDecimal heightCm; private BigDecimal declaredValue; private String currency; private Boolean dangerousGoods; private String cargoRemark; private String skuNo; private String upcNo; private String cargoNameEn; private String customsHsCode; private Integer unitsPerPackage; private Integer totalQuantity; private BigDecimal netWeightKg; private BigDecimal unitDeclaredValue; private String material; private String productUsage; private String model; private String brand;
@TableId(type=IdType.ASSIGN_ID) private Long id; private Long tenantId; private Long orderId; private Integer itemNo; private String cargoName; private String hsCode; private String packageType; private Integer packageCount; private BigDecimal grossWeightKg; private BigDecimal volumeCbm; private BigDecimal lengthCm; private BigDecimal widthCm; private BigDecimal heightCm; private BigDecimal declaredValue; private String currency; private Boolean dangerousGoods; private String cargoRemark; private String skuNo; private String upcNo; private String cargoNameEn; private String customsHsCode; private Integer unitsPerPackage; private Integer totalQuantity; private BigDecimal netWeightKg; private BigDecimal unitDeclaredValue; private String material; private String productUsage; private String model; private String brand; private BigDecimal productLengthCm; private BigDecimal productWidthCm; private BigDecimal productHeightCm; private String shipmentId; private String referenceId; private Long productImageFileId;
}
@@ -42,6 +42,7 @@ public class OrderEntity extends BaseEntity {
private Long carrierId;
private String carrierName;
private String taxMode;
private String pricingTaxMode;
private String containerMode;
private Boolean customsInspection;
private String destinationType;
@@ -219,6 +219,23 @@ public class OrderRepository {
.orderByDesc(OrderStatusHistoryEntity::getOccurredAt));
}
public List<OrderEntity> findByIds(Long tenantId, List<Long> orderIds) {
if (orderIds.isEmpty()) return List.of();
return orderMapper.selectList(new LambdaQueryWrapper<OrderEntity>()
.eq(tenantId != null, OrderEntity::getTenantId, tenantId)
.in(OrderEntity::getId, orderIds));
}
public List<OrderInternalNoteEntity> findNotesByOrderIds(Long tenantId, List<Long> orderIds) {
if (orderIds.isEmpty()) return List.of();
return noteMapper.selectList(new LambdaQueryWrapper<OrderInternalNoteEntity>()
.eq(tenantId != null, OrderInternalNoteEntity::getTenantId, tenantId)
.in(OrderInternalNoteEntity::getOrderId, orderIds)
.orderByAsc(OrderInternalNoteEntity::getOrderId)
.orderByDesc(OrderInternalNoteEntity::getCreatedAt)
.orderByDesc(OrderInternalNoteEntity::getId));
}
public Map<Long, Long> countByTenantIds(List<Long> tenantIds) {
if (tenantIds.isEmpty()) return Map.of();
var rows = orderMapper.selectMaps(new QueryWrapper<OrderEntity>()
@@ -22,6 +22,7 @@ import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.stream.Collectors;
@@ -31,6 +32,8 @@ public class OrderApplicationService {
@Resource private ObjectMapper json;
@Resource private OrderEligibilityGuard eligibilityGuard;
@Resource private OrderMasterDataGuard masterDataGuard;
@Resource private OrderPricingChannelGuard pricingChannelGuard;
@Resource private ProductImageGuard productImageGuard;
/** 创建草稿时使用数据库唯一键保证请求幂等。 */
@Transactional
@@ -47,7 +50,9 @@ public class OrderApplicationService {
private Long createDraftInternal(long tenantId, long userId, OrderRequests.SaveRequest request) {
validatePrototypeFields(request);
productImageGuard.requireOwned(tenantId, request.cargoes());
var snapshots = masterDataGuard.validate(tenantId, request);
pricingChannelGuard.requireAvailable(tenantId, request.receivingWarehouseId(), request.channelCode());
OrderEntity order = new OrderEntity();
long orderId = IdWorker.getId();
order.setId(orderId); order.setTenantId(tenantId); order.setOrderNo(nextNo(orderId)); order.setStatus(OrderStatus.DRAFT);
@@ -73,29 +78,18 @@ public class OrderApplicationService {
updateOrderData(tenantId, userId, id, request, true);
}
@Transactional
public void assignCarrier(long tenantId, long userId, long id, OrderRequests.CarrierRequest request) {
OrderEntity order = require(tenantId, id);
if (order.getStatus() != OrderStatus.SUBMITTED) {
throw new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "仅待审核订单可以指派承运商");
}
var carrier = masterDataGuard.validateCarrier(request.carrierId());
order.setCarrierId(carrier.id());
order.setCarrierName(carrier.partnerName());
order.setUpdatedBy(userId);
if (repository.saveOrder(tenantId, order) != 1) throw new BizException("Concurrent order update");
}
private void updateOrderData(long tenantId, long userId, long id, OrderRequests.SaveRequest request,
boolean allowSubmitted) {
OrderEntity order = require(tenantId, id);
validatePrototypeFields(request);
productImageGuard.requireOwned(tenantId, request.cargoes());
var snapshots = masterDataGuard.validate(tenantId, request);
OrderStatus before = order.getStatus();
if (before != OrderStatus.DRAFT && before != OrderStatus.REJECTED
&& !(allowSubmitted && before == OrderStatus.SUBMITTED)) {
throw new BizException("当前订单状态不允许编辑订单资料");
}
pricingChannelGuard.requireAvailable(tenantId, request.receivingWarehouseId(), request.channelCode());
apply(order, request, snapshots); order.setStatus(OrderStatus.DRAFT); order.setUpdatedBy(userId);
if (before == OrderStatus.REJECTED) order.setInboundCode(null);
if (before == OrderStatus.SUBMITTED) {
@@ -118,10 +112,7 @@ public class OrderApplicationService {
validatePersistedPrototypeFields(tenantId, order);
masterDataGuard.validatePersisted(tenantId, order.getCustomerId(), order.getReceivingWarehouseId(),
order.getDestinationWarehouseId(), order.getDestinationType());
}
if (target == OrderStatus.APPROVED && order.getCarrierId() != null) {
var carrier = masterDataGuard.validateCarrier(order.getCarrierId());
order.setCarrierName(carrier.partnerName());
pricingChannelGuard.requireAvailable(tenantId, order.getReceivingWarehouseId(), order.getChannelCode());
}
if ((target == OrderStatus.REJECTED || target == OrderStatus.CANCELLED) && !StringUtils.hasText(reason)) {
throw new BizException("Reason is required");
@@ -201,14 +192,19 @@ public class OrderApplicationService {
throw new BizException("FBA orders require Amazon warehouse code, Shipment ID and Reference ID");
}
for (var cargo : request.cargoes()) {
if (!StringUtils.hasText(cargo.skuNo()) || !StringUtils.hasText(cargo.upcNo())) {
throw new BizException("FBA cargo requires SKU NO. and UPC NO.");
if (!StringUtils.hasText(cargo.skuNo()) || !StringUtils.hasText(cargo.upcNo())
|| !StringUtils.hasText(cargo.shipmentId()) || !StringUtils.hasText(cargo.referenceId())) {
throw new BizException("FBA cargo requires SKU NO., UPC NO., Shipment ID and Reference ID.");
}
}
}
}
private void validatePersistedPrototypeFields(long tenantId, OrderEntity order) {
if (!"TAX_INCLUDED".equals(order.getPricingTaxMode())
&& !"TAX_EXCLUDED".equals(order.getPricingTaxMode())) {
throw new BizException("请选择包税或不包税报价");
}
if (!StringUtils.hasText(order.getPickupMethod()) || order.getReceivingWarehouseId() == null
|| !StringUtils.hasText(order.getReceivingWarehouseName())
|| !StringUtils.hasText(order.getChannelCode()) || !StringUtils.hasText(order.getChannelName())
@@ -231,8 +227,10 @@ public class OrderApplicationService {
throw new BizException("FBA orders require Amazon warehouse code, Shipment ID and Reference ID");
}
for (var cargo : cargoes) {
if (!StringUtils.hasText(cargo.getSkuNo()) || !StringUtils.hasText(cargo.getUpcNo())) {
throw new BizException("FBA cargo requires SKU NO. and UPC NO.");
if (!StringUtils.hasText(cargo.getSkuNo()) || !StringUtils.hasText(cargo.getUpcNo())
|| !StringUtils.hasText(cargo.getShipmentId())
|| !StringUtils.hasText(cargo.getReferenceId())) {
throw new BizException("FBA cargo requires SKU NO., UPC NO., Shipment ID and Reference ID.");
}
}
}
@@ -347,12 +345,15 @@ public class OrderApplicationService {
order.setReceivingWarehouseName(snapshots.receiving().warehouse().warehouseName());
order.setReceivingWarehouseAddress(formatAddress(snapshots.receiving()));
order.setReceivingTime(formatReceivingTime(snapshots.receiving()));
order.setChannelCode(request.channelCode());
// The price-book catalog is case-insensitive at the boundary, but orders
// keep one canonical code so later estimation and audit lookups are stable.
order.setChannelCode(request.channelCode().trim().toUpperCase(Locale.ROOT));
order.setChannelName(request.channelName());
order.setCarrierId(request.carrierId());
order.setCarrierName(request.carrierId() == null || !StringUtils.hasText(request.carrierName())
? null : request.carrierName().trim());
order.setTaxMode(request.taxMode());
order.setPricingTaxMode(request.pricingTaxMode());
order.setContainerMode(request.containerMode()); order.setCustomsInspection(request.customsInspection());
order.setDestinationType(request.destinationType());
order.setAmazonWarehouseCode("FBA".equals(request.destinationType()) && snapshots.destination() != null
@@ -389,6 +390,10 @@ public class OrderApplicationService {
item.setTotalQuantity(cargo.totalQuantity()); item.setNetWeightKg(cargo.netWeightKg());
item.setUnitDeclaredValue(cargo.unitDeclaredValue()); item.setMaterial(cargo.material());
item.setProductUsage(cargo.usage()); item.setModel(cargo.model()); item.setBrand(cargo.brand());
item.setProductLengthCm(cargo.productLengthCm()); item.setProductWidthCm(cargo.productWidthCm());
item.setProductHeightCm(cargo.productHeightCm()); item.setShipmentId(cargo.shipmentId());
item.setReferenceId(cargo.referenceId());
item.setProductImageFileId(cargo.productImageFileId());
item.setCreatedBy(userId); item.setUpdatedBy(userId); repository.insertCargo(item);
}
if (request.sender() != null) saveParty(tenantId, userId, orderId, "SENDER", request.sender());
@@ -422,7 +427,7 @@ public class OrderApplicationService {
event.setPublishStatus("PENDING"); event.setRetryCount(0); event.setCreatedAt(LocalDateTime.now());
repository.insertOutbox(event);
}
private FirstlegInboundClient.OrderApprovedInboundCommand inboundCommand(OrderEntity order) {
static FirstlegInboundClient.OrderApprovedInboundCommand inboundCommand(OrderEntity order) {
return new FirstlegInboundClient.OrderApprovedInboundCommand(
order.getTenantId(), order.getId(), order.getOrderNo(), order.getInboundCode(),
order.getCustomerId(), order.getCustomerName(),
@@ -430,7 +435,7 @@ public class OrderApplicationService {
order.getDestinationWarehouseName(), order.getExpectedShipDate() == null ? null : order.getExpectedShipDate().atStartOfDay(),
order.getDestinationType() + "_" + order.getTransportMode(), order.getChannelCode(), order.getChannelName(),
order.getCarrierId(), order.getCarrierName(),
"BUY_DECLARATION".equals(order.getTaxMode()) ? "TAX_INCLUDED" : "TAX_EXCLUDED",
order.getTaxMode(), order.getPricingTaxMode(),
order.getContainerMode(), order.getCustomsInspection(), order.getDestinationType(),
order.getDestinationWarehouseId(), order.getAmazonWarehouseCode(), order.getDestinationWarehouseName(),
order.getCustomerRemark(), order.getExpressCompany(), order.getExpressTrackingNo());
@@ -39,12 +39,28 @@ public class OrderInboundOutboxPublisher {
failPermanently(event, "invalid event payload");
continue;
}
if (command == null) {
failPermanently(event, "empty event payload");
continue;
}
if (event.getTenantId() == null || event.getAggregateId() == null
|| event.getTenantId() != command.tenantId()
|| event.getAggregateId() != command.orderId()) {
failPermanently(event, "event ownership does not match payload");
continue;
}
if (incomplete(command)) {
var order = repository.findOwned(event.getTenantId(), event.getAggregateId());
if (order == null) {
failPermanently(event, "order snapshot is unavailable");
continue;
}
command = OrderApplicationService.inboundCommand(order);
if (incomplete(command)) {
failPermanently(event, "rebuilt order snapshot is incomplete");
continue;
}
}
try {
ApiResponse<Long> response = client.create(internalToken, command);
if (response == null || !response.successful()) {
@@ -57,6 +73,13 @@ public class OrderInboundOutboxPublisher {
}
}
private boolean incomplete(FirstlegInboundClient.OrderApprovedInboundCommand command) {
return command.orderNo() == null || command.orderNo().isBlank()
|| command.inboundCode() == null || command.inboundCode().isBlank()
|| command.customerName() == null || command.customerName().isBlank()
|| command.warehouseId() == null || command.warehouseId() <= 0;
}
private void failPermanently(OutboxEventEntity event, String reason) {
log.warn("Order outbox event rejected: reason={}, eventId={}, tenantId={}, aggregateId={}, eventType={}",
reason, event.getId(), event.getTenantId(), event.getAggregateId(), event.getEventType());
@@ -0,0 +1,42 @@
package com.baiyue.order.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.client.FirstlegPricingClient;
import feign.FeignException;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Service
public class OrderPricingChannelGuard {
@Resource private FirstlegPricingClient client;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
public void requireAvailable(long tenantId, long originWarehouseId, String channelCode) {
try {
ApiResponse<Void> response = client.validateChannel(internalToken,
new FirstlegPricingClient.ChannelValidationRequest(tenantId, originWarehouseId, channelCode));
if (response == null) {
throw unavailable();
}
if (!response.successful()) {
ResultCode code = ResultCode.of(response.code());
String message = response.message() == null || response.message().isBlank()
? code.message() : response.message();
throw new BizException(code, message);
}
} catch (BizException exception) {
throw exception;
} catch (FeignException exception) {
throw unavailable();
} catch (RuntimeException exception) {
throw unavailable();
}
}
private BizException unavailable() {
return new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程价格渠道校验服务暂时不可用");
}
}
@@ -0,0 +1,58 @@
package com.baiyue.order.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.client.ProductImageClient;
import com.baiyue.order.dto.OrderRequests;
import feign.FeignException;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@Service
public class ProductImageGuard {
@Resource private ProductImageClient client;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
public void requireOwned(long tenantId, List<OrderRequests.Cargo> cargoes) {
Set<Long> validated = new HashSet<>();
for (OrderRequests.Cargo cargo : cargoes) {
Long fileId = cargo.productImageFileId();
if (fileId == null || !validated.add(fileId)) continue;
if (fileId <= 0) throw new BizException(ResultCode.VALIDATION_FAILED, "产品图片文件ID无效");
validate(tenantId, fileId);
}
}
private void validate(long tenantId, long fileId) {
try {
ApiResponse<ProductImageClient.FileMetadata> response = client.validate(internalToken, tenantId, fileId);
if (response == null) throw unavailable();
if (!response.successful()) {
ResultCode code = ResultCode.of(response.code());
String message = response.message() == null || response.message().isBlank()
? code.message() : response.message();
throw new BizException(code, message);
}
if (response.data() == null || response.data().fileId() == null
|| response.data().fileId() != fileId) {
throw unavailable();
}
} catch (BizException exception) {
throw exception;
} catch (FeignException exception) {
throw unavailable();
} catch (RuntimeException exception) {
throw unavailable();
}
}
private static BizException unavailable() {
return new BizException(ResultCode.SERVICE_UNAVAILABLE, "产品图片校验服务暂时不可用");
}
}
@@ -9,6 +9,7 @@ import com.baiyue.order.dto.OrderDetailResponse;
import com.baiyue.order.dto.UnifiedOrderQuery;
import com.baiyue.order.dto.UnifiedOrderResponse;
import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.entity.OrderInternalNoteEntity;
import com.baiyue.order.entity.OrderStatus;
import com.baiyue.order.repository.OrderRepository;
import feign.FeignException;
@@ -54,8 +55,9 @@ public class UnifiedOrderQueryService {
Map<Long, FirstlegUnifiedOrderClient.Summary> inboundByOrder = "PENDING_REVIEW".equals(query.mainStatus())
? Map.of()
: summaries(tenantId, source.records().stream().map(OrderEntity::getId).toList());
Map<Long, String> latestNotes = latestNotes(tenantId, source.records().stream().map(OrderEntity::getId).toList());
List<UnifiedOrderResponse.Row> rows = source.records().stream()
.map(order -> row(order, inboundByOrder.get(order.getId()))).toList();
.map(order -> row(order, inboundByOrder.get(order.getId()), latestNotes.get(order.getId()))).toList();
return new PageResult<>(rows, source.page(), source.size(), source.total(), source.pages());
}
@@ -67,7 +69,8 @@ public class UnifiedOrderQueryService {
orders.findHistory(tenantId, orderId));
FirstlegUnifiedOrderClient.Detail inbound = order.getStatus() == OrderStatus.SUBMITTED
? null : firstlegDetail(tenantId, orderId);
UnifiedOrderResponse.Row summary = row(order, inbound == null ? null : inbound.summary());
String latestNote = orderDetail.internalNotes().isEmpty() ? null : orderDetail.internalNotes().get(0).getContent();
UnifiedOrderResponse.Row summary = row(order, inbound == null ? null : inbound.summary(), latestNote);
List<UnifiedOrderResponse.TimelineEvent> timeline = new ArrayList<>();
orderDetail.statusHistory().forEach(item -> timeline.add(new UnifiedOrderResponse.TimelineEvent(
"ORDER", "STATUS", orderMainStatus(item.getToStatus()), item.getAction(), item.getReason(),
@@ -97,7 +100,13 @@ public class UnifiedOrderQueryService {
query.page(), query.size());
PageResult<FirstlegUnifiedOrderClient.Summary> source = require(
call(() -> firstleg.page(internalToken, request)), "统一订单履约查询响应无效");
List<UnifiedOrderResponse.Row> records = source.records().stream().map(this::row).toList();
List<Long> orderIds = source.records().stream().map(item -> item.inbound().orderId()).toList();
Map<Long, OrderEntity> orderById = orders.findByIds(tenantId, orderIds).stream()
.collect(Collectors.toMap(OrderEntity::getId, Function.identity(), (left, right) -> left));
Map<Long, String> latestNotes = latestNotes(tenantId, orderIds);
List<UnifiedOrderResponse.Row> records = source.records().stream()
.map(summary -> row(summary, orderById.get(summary.inbound().orderId()), latestNotes.get(summary.inbound().orderId())))
.toList();
return new PageResult<>(records, source.page(), source.size(), source.total(), source.pages());
}
@@ -116,7 +125,12 @@ public class UnifiedOrderQueryService {
new FirstlegUnifiedOrderClient.DetailRequest(tenantId, orderId))), "统一订单履约详情响应无效");
}
private UnifiedOrderResponse.Row row(OrderEntity order, FirstlegUnifiedOrderClient.Summary summary) {
private Map<Long, String> latestNotes(Long tenantId, List<Long> orderIds) {
return orders.findNotesByOrderIds(tenantId, orderIds).stream().collect(Collectors.toMap(
OrderInternalNoteEntity::getOrderId, OrderInternalNoteEntity::getContent, (latest, ignored) -> latest));
}
private UnifiedOrderResponse.Row row(OrderEntity order, FirstlegUnifiedOrderClient.Summary summary, String latestInternalNote) {
if (summary == null) {
String mainStatus = orderMainStatus(order.getStatus());
return new UnifiedOrderResponse.Row(order.getId(), order.getTenantId(), order.getOrderNo(), mainStatus,
@@ -126,6 +140,7 @@ public class UnifiedOrderQueryService {
order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), null, null,
order.getInboundCode(),
order.getExpectedShipDate(), null, null, null, null, null, null, null, null,
order.getCustomerRemark(), latestInternalNote,
order.getCreatedAt(), order.getUpdatedAt());
}
FirstlegUnifiedOrderClient.Inbound inbound = summary.inbound();
@@ -135,10 +150,12 @@ public class UnifiedOrderQueryService {
order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), inbound.id(), inbound.version(), inbound.inboundCode(),
order.getExpectedShipDate(), inbound.totalPackageCount(), inbound.actualGrossWeightKg(),
inbound.actualVolumeCbm(), measurement(inbound), preload(summary.preload()), loading(summary.loading()),
delivery(summary.delivery()), transport(inbound), order.getCreatedAt(), order.getUpdatedAt());
delivery(summary.delivery()), transport(inbound), order.getCustomerRemark(), latestInternalNote,
order.getCreatedAt(), order.getUpdatedAt());
}
private UnifiedOrderResponse.Row row(FirstlegUnifiedOrderClient.Summary summary) {
private UnifiedOrderResponse.Row row(FirstlegUnifiedOrderClient.Summary summary, OrderEntity order,
String latestInternalNote) {
var inbound = summary.inbound();
return new UnifiedOrderResponse.Row(inbound.orderId(), inbound.tenantId(), inbound.orderNo(),
summary.mainStatus(), summary.nextAction(), parseOrderStatus(inbound.orderStatusSnapshot()),
@@ -146,7 +163,8 @@ public class UnifiedOrderQueryService {
inbound.warehouseName(), inbound.deliveryWarehouseId(), inbound.deliveryWarehouseName(),
inbound.id(), inbound.version(), inbound.inboundCode(), null, inbound.totalPackageCount(), inbound.actualGrossWeightKg(),
inbound.actualVolumeCbm(), measurement(inbound), preload(summary.preload()), loading(summary.loading()),
delivery(summary.delivery()), transport(inbound), inbound.createdAt(), inbound.updatedAt());
delivery(summary.delivery()), transport(inbound), order == null ? null : order.getCustomerRemark(), latestInternalNote,
inbound.createdAt(), inbound.updatedAt());
}
private UnifiedOrderResponse.MeasurementSummary measurement(FirstlegUnifiedOrderClient.Inbound inbound) {
+2
View File
@@ -33,6 +33,8 @@ baiyue:
url: ${IAM_SERVICE_URL:}
warehouse:
url: ${WAREHOUSE_SERVICE_URL:}
file:
url: ${FILE_SERVICE_URL:}
logging:
file:
+7
View File
@@ -30,6 +30,7 @@ CREATE TABLE IF NOT EXISTS ord_order (
carrier_id BIGINT UNSIGNED NULL COMMENT '船司逻辑ID快照',
carrier_name VARCHAR(128) NULL COMMENT '船司名称快照',
tax_mode VARCHAR(24) NULL COMMENT '买单或退税',
pricing_tax_mode VARCHAR(16) NULL COMMENT '报价包税维度:TAX_INCLUDED或TAX_EXCLUDED;独立于tax_mode',
container_mode VARCHAR(24) NULL COMMENT '整柜或散货',
customs_inspection TINYINT(1) NULL COMMENT '是否商检',
destination_type VARCHAR(24) NULL COMMENT 'FBA/OVERSEAS/PRIVATE',
@@ -110,6 +111,12 @@ CREATE TABLE IF NOT EXISTS ord_order_cargo (
product_usage VARCHAR(128) NULL COMMENT '用途',
model VARCHAR(64) NULL COMMENT '型号',
brand VARCHAR(64) NULL COMMENT '品牌',
product_length_cm DECIMAL(12,2) NULL COMMENT '产品长度(厘米)',
product_width_cm DECIMAL(12,2) NULL COMMENT '产品宽度(厘米)',
product_height_cm DECIMAL(12,2) NULL COMMENT '产品高度(厘米)',
shipment_id VARCHAR(64) NULL COMMENT '货物级 Amazon Shipment ID',
reference_id VARCHAR(64) NULL COMMENT '货物级 Amazon Reference ID',
product_image_file_id BIGINT UNSIGNED NULL COMMENT '文件中心产品图片逻辑ID',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
@@ -54,6 +54,21 @@ CALL phase1_add_column('ord_order_cargo', 'material', "VARCHAR(128) NULL COMMENT
CALL phase1_add_column('ord_order_cargo', 'product_usage', "VARCHAR(128) NULL COMMENT 'Usage'");
CALL phase1_add_column('ord_order_cargo', 'model', "VARCHAR(64) NULL COMMENT 'Model'");
CALL phase1_add_column('ord_order_cargo', 'brand', "VARCHAR(64) NULL COMMENT 'Brand'");
CALL phase1_add_column('ord_order_cargo', 'product_length_cm', "DECIMAL(12,2) NULL COMMENT 'Product length in centimeters'");
CALL phase1_add_column('ord_order_cargo', 'product_width_cm', "DECIMAL(12,2) NULL COMMENT 'Product width in centimeters'");
CALL phase1_add_column('ord_order_cargo', 'product_height_cm', "DECIMAL(12,2) NULL COMMENT 'Product height in centimeters'");
CALL phase1_add_column('ord_order_cargo', 'shipment_id', "VARCHAR(64) NULL COMMENT 'Cargo Amazon Shipment ID'");
CALL phase1_add_column('ord_order_cargo', 'reference_id', "VARCHAR(64) NULL COMMENT 'Cargo Amazon Reference ID'");
CALL phase1_add_column('ord_order_cargo', 'product_image_file_id', "BIGINT UNSIGNED NULL COMMENT 'Product image logical file ID'");
-- 货物级字段上线前,FBA 编号只保存在主单;回填后提交校验可统一按每条货物执行。
UPDATE ord_order_cargo cargo
JOIN ord_order orders ON orders.id = cargo.order_id AND orders.tenant_id = cargo.tenant_id
SET cargo.shipment_id = COALESCE(NULLIF(cargo.shipment_id, ''), orders.shipment_id),
cargo.reference_id = COALESCE(NULLIF(cargo.reference_id, ''), orders.reference_id)
WHERE orders.destination_type = 'FBA'
AND ((cargo.shipment_id IS NULL OR cargo.shipment_id = '')
OR (cargo.reference_id IS NULL OR cargo.reference_id = ''));
DROP PROCEDURE phase1_add_column;
+74 -25
View File
@@ -2,7 +2,7 @@ openapi: 3.0.3
info:
title: 柏悦头程订单 API
description: 业务成功和失败均使用 HTTP 200;仅响应体 code 为字符串 "200" 时表示成功,其他 code 表示失败。
version: 1.4.0
version: 1.5.0
security:
- bearerAuth: []
paths:
@@ -16,6 +16,7 @@ paths:
才是 C04 loading/complete 的 expectedVersion,loadingVersion 当前等于 pickingVersion(兼容别名)。
tenantId 省略时跨全部客户;mainStatus 省略时按订单创建时间分页,指定 PENDING_REVIEW 时由 Order 查询,
指定其他主状态时由 Firstleg 精确分页。旧 /orders/first-leg 接口保持兼容。
列表行同时返回 customerRemark(客户下单备注)和 latestInternalNote(最新一条平台内部备注)。
parameters:
- {name: tenantId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- $ref: '#/components/parameters/UnifiedKeyword'
@@ -112,7 +113,11 @@ paths:
parameters:
- $ref: '#/components/parameters/UnifiedOrderId'
responses:
'200': {description: 成功或业务失败,按响应体 code 判断}
'200':
description: 成功或业务失败,按响应体 code 判断
content:
application/json:
schema: {$ref: '#/components/schemas/FirstLegOrderSummary'}
'403': {description: 需要 merchant:order:view 权限}
/api/merchant/v1/orders/first-leg:
get:
@@ -138,7 +143,10 @@ paths:
description: 成功或业务失败,响应体 code 为字符串 200 时成功
content:
application/json:
schema: {type: object}
schema:
type: object
properties:
items: {type: array, items: {$ref: '#/components/schemas/FirstLegOrderSummary'}}
post:
summary: 创建订单草稿
parameters:
@@ -147,7 +155,7 @@ paths:
required: true
content:
application/json:
schema: {type: object}
schema: {$ref: '#/components/schemas/FirstLegOrderDraft'}
responses:
'200': {description: 成功或业务失败,按响应体 code 判断}
/api/merchant/v1/orders/first-leg/{id}/submit:
@@ -180,7 +188,7 @@ paths:
required: true
content:
application/json:
schema: {type: object}
schema: {$ref: '#/components/schemas/FirstLegOrderDraft'}
responses:
'200': {description: 成功或业务失败,按响应体 code 判断}
'403': {description: 需要 platform:order:create 权限}
@@ -216,35 +224,76 @@ paths:
required: true
content:
application/json:
schema: {type: object}
schema: {$ref: '#/components/schemas/FirstLegOrderDraft'}
responses:
'200': {description: 成功或业务失败,按响应体 code 判断}
'403': {description: 需要 platform:order:edit 权限}
/api/platform/v1/orders/first-leg/{id}/carrier:
put:
summary: 为待审核订单指派承运商
description: 可选的独立指派动作;服务端从公共往来单位主数据校验 CARRIER 类型并写入可信名称快照,不作为审核通过的前置条件。
parameters:
- $ref: '#/components/parameters/OrderId'
- $ref: '#/components/parameters/TenantId'
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [carrierId]
properties:
carrierId: {type: string, pattern: '^[1-9][0-9]*$'}
responses:
'200': {description: 指派成功或业务失败,按响应体 code 判断}
'403': {description: 需要 platform:order:approve 权限}
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
schemas:
PricingTaxMode:
type: string
enum: [TAX_INCLUDED, TAX_EXCLUDED]
description: 报价包税维度;独立于订单 taxMode 的买单/退税语义
FirstLegOrderDraft:
type: object
required: [pricingTaxMode, cargoes]
properties:
pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'}
cargoes:
type: array
minItems: 1
items: {$ref: '#/components/schemas/FirstLegOrderCargo'}
description: >-
订单保存请求中的完整字段以服务端 FirstLegOrderSaveRequest 为准;pricingTaxMode
是新建和编辑订单的必填报价维度;历史存量订单的响应字段仍允许为空。
FirstLegOrderCargo:
type: object
required: [cargoName, packageType, packageCount, grossWeightKg, volumeCbm]
properties:
skuNo: {type: string, maxLength: 64}
cargoName: {type: string, maxLength: 128}
cargoNameEn: {type: string, maxLength: 128}
productImageFileId: {type: string, pattern: '^[1-9][0-9]*$'}
hsCode: {type: string, maxLength: 20, description: 报关 HS CODE}
customsHsCode: {type: string, maxLength: 20, description: 清关 HS CODE}
lengthCm: {type: number, minimum: 0, description: 箱规长(厘米)}
widthCm: {type: number, minimum: 0, description: 箱规宽(厘米)}
heightCm: {type: number, minimum: 0, description: 箱规高(厘米)}
unitsPerPackage: {type: integer, minimum: 1, description: 每箱数量}
packageCount: {type: integer, minimum: 1, description: 箱数}
grossWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, description: 总毛重}
volumeCbm: {type: number, exclusiveMinimum: true, minimum: 0, description: 总体积}
totalQuantity: {type: integer, minimum: 1, description: 总数量}
netWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, description: 总净重}
unitDeclaredValue: {type: number, minimum: 0, description: 清关申报单价(USD)}
declaredValue: {type: number, minimum: 0, description: 清关申报金额(USD)}
material: {type: string, maxLength: 128}
usage: {type: string, maxLength: 128}
model: {type: string, maxLength: 64}
brand: {type: string, maxLength: 64}
productLengthCm: {type: number, minimum: 0}
productWidthCm: {type: number, minimum: 0}
productHeightCm: {type: number, minimum: 0}
shipmentId: {type: string, maxLength: 64}
referenceId: {type: string, maxLength: 64}
upcNo: {type: string, maxLength: 64}
cargoRemark: {type: string, maxLength: 500}
packageType: {type: string, maxLength: 32}
dangerousGoods: {type: boolean}
description: >-
原型货物明细字段。FBA 货物的 skuNo、upcNo、shipmentId、referenceId 由服务端按每条货物校验;
productImageFileId 必须属于当前登录客户。
FirstLegOrderSummary:
type: object
properties:
orderId: {type: string, pattern: '^[1-9][0-9]*$'}
orderNo: {type: string}
pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'}
parameters:
UnifiedKeyword:
name: keyword