From 94c67b5c515e302aef2db6bf1c870db9190f35ab Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 20:41:22 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...arehouseOrderExportProviderController.java | 46 +++ .../order/repository/OrderRepository.java | 7 + ...icWarehouseOrderExportProviderService.java | 262 ++++++++++++++++++ .../service/UnifiedOrderQueryService.java | 19 +- 4 files changed, 333 insertions(+), 1 deletion(-) create mode 100644 order/src/main/java/com/baiyue/order/controller/DomesticWarehouseOrderExportProviderController.java create mode 100644 order/src/main/java/com/baiyue/order/service/DomesticWarehouseOrderExportProviderService.java diff --git a/order/src/main/java/com/baiyue/order/controller/DomesticWarehouseOrderExportProviderController.java b/order/src/main/java/com/baiyue/order/controller/DomesticWarehouseOrderExportProviderController.java new file mode 100644 index 000000000..a19acdf8c --- /dev/null +++ b/order/src/main/java/com/baiyue/order/controller/DomesticWarehouseOrderExportProviderController.java @@ -0,0 +1,46 @@ +package com.baiyue.order.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest; +import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse; +import com.baiyue.order.service.DomesticWarehouseOrderExportProviderService; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +/** 仅供 File 异步导出中心调用的国内仓订单数据 Provider。 */ +@RestController +@RequestMapping("/internal/exports/v1/DOMESTIC_WAREHOUSE_ORDER_LIST") +public class DomesticWarehouseOrderExportProviderController { + @Resource private DomesticWarehouseOrderExportProviderService service; + @Value("${baiyue.security.internal-service-token}") private String configuredToken; + private byte[] internalToken; + + @PostConstruct + void initializeToken() { + byte[] value = configuredToken == null ? new byte[0] : configuredToken.getBytes(StandardCharsets.UTF_8); + if (value.length < 32) throw new IllegalStateException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节"); + internalToken = value; + } + + @PostMapping("/batches") + public ApiResponse batch( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestBody ExportBatchRequest request) { + byte[] supplied = token == null ? new byte[0] : token.getBytes(StandardCharsets.UTF_8); + if (!MessageDigest.isEqual(internalToken, supplied)) { + throw new BizException(ResultCode.FORBIDDEN, "Invalid internal service token"); + } + return ApiResponse.success(service.exportBatch(request)); + } +} diff --git a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java index 8263e70ba..3a2da6fa2 100644 --- a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java +++ b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java @@ -261,6 +261,13 @@ public class OrderRepository { .in(OrderEntity::getId, orderIds)); } + public List findByOrderNos(List orderNos) { + if (orderNos == null || orderNos.isEmpty()) return List.of(); + return orderMapper.selectList(new LambdaQueryWrapper() + .in(OrderEntity::getOrderNo, orderNos) + .eq(OrderEntity::getDeleted, false)); + } + public List findChildrenByParentOrders(List parentOrders) { if (parentOrders.isEmpty()) return List.of(); List tenantIds = parentOrders.stream().map(OrderEntity::getTenantId).distinct().toList(); diff --git a/order/src/main/java/com/baiyue/order/service/DomesticWarehouseOrderExportProviderService.java b/order/src/main/java/com/baiyue/order/service/DomesticWarehouseOrderExportProviderService.java new file mode 100644 index 000000000..7597e0856 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/service/DomesticWarehouseOrderExportProviderService.java @@ -0,0 +1,262 @@ +package com.baiyue.order.service; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest; +import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse; +import com.baiyue.common.core.export.ExportContracts.ExportColumn; +import com.baiyue.common.core.export.ExportContracts.ExportColumnType; +import com.baiyue.order.dto.UnifiedOrderQuery; +import com.baiyue.order.dto.UnifiedOrderResponse; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** 为 File 提供分页数据,不在 Order 内生成文件。 */ +@Service +public class DomesticWarehouseOrderExportProviderService { + static final String EXPORT_TYPE = "DOMESTIC_WAREHOUSE_ORDER_LIST"; + static final int SCHEMA_VERSION = 1; + static final int MAX_SELECTION = 5_000; + static final long MAX_ROWS = 200_000; + private static final int MAX_BATCH_SIZE = 500; + private static final Set PARAMETER_KEYS = Set.of( + "selectedOrderNos", "tenantId", "keyword", "orderNo", "mainStatus", "customerId", + "receivingWarehouseId", "destinationWarehouseId", "inboundCode", "internalOrderStatus", + "expectedArrivalDate", "feeReviewStatus", "channelName", "containerNo", "cabinetNo", + "sailingDate", "loaded", "fullContainer", "measurementReviewStatus", "fbaCode", + "createdFrom", "createdTo"); + private static final List COLUMNS = List.of( + column("inboundCode", "入仓码", ExportColumnType.STRING, 24), + column("orderNo", "订单号", ExportColumnType.STRING, 24), + column("mainStatus", "订单主状态", ExportColumnType.STRING, 16), + column("customerName", "客户", ExportColumnType.STRING, 24), + column("channelName", "渠道", ExportColumnType.STRING, 20), + column("receivingWarehouseName", "定位仓库", ExportColumnType.STRING, 20), + column("destinationWarehouseName", "交货仓", ExportColumnType.STRING, 20), + column("expectedShipDate", "预计出运日期", ExportColumnType.DATE, 14), + column("totalPackageCount", "箱数", ExportColumnType.INTEGER, 12), + column("totalQuantity", "产品数量", ExportColumnType.INTEGER, 12), + column("actualGrossWeightKg", "实测毛重(kg)", ExportColumnType.DECIMAL, 16), + column("actualVolumeCbm", "实测体积(m³)", ExportColumnType.DECIMAL, 16), + column("chargeableWeightKg", "计费重量(kg)", ExportColumnType.DECIMAL, 16), + column("chargeableVolumeCbm", "计费体积(m³)", ExportColumnType.DECIMAL, 16), + column("feeReviewStatus", "费用审核状态", ExportColumnType.STRING, 16), + column("skuNos", "SKU", ExportColumnType.STRING, 28), + column("taxMode", "报关方式", ExportColumnType.STRING, 16), + column("containerMode", "装柜方式", ExportColumnType.STRING, 14), + column("customsInspection", "是否商检", ExportColumnType.BOOLEAN, 12), + column("containerNo", "集装箱号", ExportColumnType.STRING, 20), + column("cabinetNo", "柜次号", ExportColumnType.STRING, 20), + column("vesselName", "船名", ExportColumnType.STRING, 18), + column("voyageNo", "航次", ExportColumnType.STRING, 16), + column("createdAt", "创建时间", ExportColumnType.DATETIME, 21)); + private static final String SCHEMA_HASH = schemaHash(COLUMNS); + + @Resource private UnifiedOrderQueryService orders; + @Value("${baiyue.security.internal-service-token}") private String internalServiceToken; + private byte[] signingKey; + + @PostConstruct + void initializeSigningKey() { signingKey = internalServiceToken.getBytes(StandardCharsets.UTF_8); } + + public ExportBatchResponse exportBatch(ExportBatchRequest request) { + Validated validated = validate(request); + String snapshot = validated.snapshotToken() == null ? sign(validated) : verify(validated); + return validated.selectedOrderNos().isEmpty() + ? filtered(validated, snapshot) : selected(validated, snapshot); + } + + private ExportBatchResponse selected(Validated value, String snapshot) { + int offset = cursor(value.cursor(), 0); + if (offset > value.selectedOrderNos().size()) throw invalid("cursor exceeds selectedOrderNos"); + if (offset == 0) { + List visible = orders.platformRowsByOrderNos(value.selectedOrderNos()); + if (visible.size() != value.selectedOrderNos().size()) { + throw new BizException(ResultCode.NOT_FOUND, "所选订单不存在或不可见"); + } + } + int end = Math.min(offset + value.limit(), value.selectedOrderNos().size()); + List batchOrderNos = value.selectedOrderNos().subList(offset, end); + List rows = orders.platformRowsByOrderNos(batchOrderNos); + if (rows.size() != batchOrderNos.size()) throw new BizException(ResultCode.NOT_FOUND, "所选订单不存在或不可见"); + boolean more = end < value.selectedOrderNos().size(); + return response(snapshot, rows, more ? String.valueOf(end) : null, more, + offset == 0 ? (long) value.selectedOrderNos().size() : null); + } + + private ExportBatchResponse filtered(Validated value, String snapshot) { + int page = cursor(value.cursor(), 1); + UnifiedOrderQuery query = value.query(page); + PageResult source = orders.platformPage(value.tenantId(), query); + if (page == 1 && source.total() == 0) throw invalid("没有符合当前查询条件的可导出订单"); + if (page == 1 && source.total() > MAX_ROWS) throw invalid("导出订单数量超过最大限制 " + MAX_ROWS); + boolean more = page < source.pages(); + return response(snapshot, source.records(), more ? String.valueOf(page + 1) : null, more, + page == 1 ? source.total() : null); + } + + private ExportBatchResponse response(String snapshot, List source, + String nextCursor, boolean hasMore, Long totalRows) { + return new ExportBatchResponse(SCHEMA_VERSION, SCHEMA_HASH, snapshot, + totalRows == null ? List.of() : COLUMNS, source.stream().map(this::row).toList(), + nextCursor, hasMore, totalRows); + } + + private Map row(UnifiedOrderResponse.Row value) { + Map row = new LinkedHashMap<>(); + row.put("inboundCode", value.inboundCode()); row.put("orderNo", value.orderNo()); + row.put("mainStatus", value.mainStatus()); row.put("customerName", value.customerName()); + row.put("channelName", value.channelName()); row.put("receivingWarehouseName", value.receivingWarehouseName()); + row.put("destinationWarehouseName", value.destinationWarehouseName()); row.put("expectedShipDate", value.expectedShipDate()); + row.put("totalPackageCount", value.totalPackageCount()); row.put("totalQuantity", value.totalQuantity()); + row.put("actualGrossWeightKg", value.actualGrossWeightKg()); row.put("actualVolumeCbm", value.actualVolumeCbm()); + row.put("chargeableWeightKg", value.chargeableWeightKg()); row.put("chargeableVolumeCbm", value.chargeableVolumeCbm()); + row.put("feeReviewStatus", value.feeReviewStatus()); row.put("skuNos", join(value.skuNos())); + row.put("taxMode", value.taxMode()); row.put("containerMode", value.containerMode()); + row.put("customsInspection", value.customsInspection()); + var transport = value.transport(); + row.put("containerNo", transport == null ? null : transport.containerNo()); + row.put("cabinetNo", transport == null ? null : transport.cabinetNo()); + row.put("vesselName", transport == null ? null : transport.vesselName()); + row.put("voyageNo", transport == null ? null : transport.voyageNo()); + row.put("createdAt", value.createdAt()); + return row; + } + + private Validated validate(ExportBatchRequest request) { + if (request == null) throw invalid("Export batch request is required"); + positive(request.taskId(), "taskId"); positive(request.requestedBy(), "requestedBy"); + if (!"0".equals(request.tenantId()) || !"PLATFORM".equals(request.appCode())) { + throw invalid("国内仓订单导出仅支持平台范围"); + } + if (request.dispatchVersion() <= 0) throw invalid("dispatchVersion must be positive"); + if (request.limit() < 1 || request.limit() > MAX_BATCH_SIZE) throw invalid("limit must be between 1 and 500"); + Map parameters = request.parameters() == null ? Map.of() : request.parameters(); + if (!PARAMETER_KEYS.containsAll(parameters.keySet())) throw invalid("包含不支持的订单筛选字段"); + List selected = selected(parameters.get("selectedOrderNos")); + Long tenantId = positiveOptional(parameters.get("tenantId"), "tenantId"); + String snapshot = text(request.snapshotToken(), "snapshotToken", 512); + if (snapshot == null && text(request.cursor(), "cursor", 32) != null) throw invalid("cursor requires snapshotToken"); + return new Validated(request, parameters, selected, tenantId, snapshot, + text(request.cursor(), "cursor", 32), request.limit()); + } + + private List selected(Object raw) { + if (raw == null) return List.of(); + if (!(raw instanceof Iterable values)) throw invalid("selectedOrderNos 必须为数组"); + List result = new ArrayList<>(); + for (Object value : values) { + String orderNo = text(value, "selectedOrderNos", 64); + if (orderNo == null) throw invalid("selectedOrderNos 不能包含空值"); + result.add(orderNo); + if (result.size() > MAX_SELECTION) throw invalid("单次最多选择 " + MAX_SELECTION + " 张订单"); + } + if (new LinkedHashSet<>(result).size() != result.size()) throw invalid("selectedOrderNos 不能重复"); + return List.copyOf(result); + } + + private String sign(Validated value) { + return "v1." + Base64.getUrlEncoder().withoutPadding().encodeToString(hmac(subject(value))); + } + + private String verify(Validated value) { + String[] parts = value.snapshotToken().split("\\.", -1); + if (parts.length != 2 || !"v1".equals(parts[0])) throw invalid("snapshotToken is invalid"); + try { + if (!MessageDigest.isEqual(hmac(subject(value)), Base64.getUrlDecoder().decode(parts[1]))) { + throw invalid("snapshotToken does not match this export task"); + } + } catch (IllegalArgumentException exception) { throw invalid("snapshotToken is invalid"); } + return value.snapshotToken(); + } + + private String subject(Validated value) { + return String.join("\n", EXPORT_TYPE, value.source().taskId(), value.source().requestedBy(), + String.valueOf(value.source().dispatchVersion()), canonical(value.parameters())); + } + + private String canonical(Map values) { + return values.entrySet().stream().sorted(Map.Entry.comparingByKey()) + .map(entry -> entry.getKey() + "=" + String.valueOf(entry.getValue())).reduce("", (a, b) -> a + "\n" + b); + } + + private byte[] hmac(String value) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(signingKey, "HmacSHA256")); + return mac.doFinal(value.getBytes(StandardCharsets.UTF_8)); + } catch (GeneralSecurityException exception) { throw new IllegalStateException(exception); } + } + + private int cursor(String value, int defaultValue) { + if (value == null) return defaultValue; + try { int parsed = Integer.parseInt(value); if (parsed < 0) throw new NumberFormatException(); return parsed; } + catch (NumberFormatException exception) { throw invalid("cursor must be a non-negative integer"); } + } + + private Long positiveOptional(Object value, String name) { + if (value == null || String.valueOf(value).isBlank()) return null; + try { long parsed = Long.parseLong(String.valueOf(value)); if (parsed <= 0) throw new NumberFormatException(); return parsed; } + catch (NumberFormatException exception) { throw invalid(name + " 必须为正整数字符串"); } + } + + private void positive(String value, String name) { if (positiveOptional(value, name) == null) throw invalid(name + " is required"); } + + private String text(Object value, String name, int max) { + if (value == null) return null; + if (!(value instanceof String raw)) throw invalid(name + " 必须为字符串"); + String normalized = raw.isBlank() ? null : raw.trim(); + if (normalized != null && normalized.length() > max) throw invalid(name + " 长度超过限制"); + return normalized; + } + + private static String join(List values) { return values == null ? null : String.join(",", values); } + private static ExportColumn column(String key, String title, ExportColumnType type, int width) { + return new ExportColumn(key, title, type, width); + } + private static String schemaHash(List columns) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + for (ExportColumn column : columns) digest.update((column.key() + "\u001f" + column.title() + "\u001f" + + column.type() + "\u001f" + column.width() + "\n").getBytes(StandardCharsets.UTF_8)); + return "sha256:" + java.util.HexFormat.of().formatHex(digest.digest()); + } catch (GeneralSecurityException exception) { throw new ExceptionInInitializerError(exception); } + } + private BizException invalid(String message) { return new BizException(ResultCode.VALIDATION_FAILED, message); } + + private record Validated(ExportBatchRequest source, Map parameters, + List selectedOrderNos, Long tenantId, String snapshotToken, + String cursor, int limit) { + UnifiedOrderQuery query(int page) { + return new UnifiedOrderQuery(string("keyword"), string("orderNo"), string("mainStatus"), + number("customerId"), number("receivingWarehouseId"), number("destinationWarehouseId"), + string("inboundCode"), string("internalOrderStatus"), date("expectedArrivalDate"), + string("feeReviewStatus"), string("channelName"), string("containerNo"), string("cabinetNo"), + date("sailingDate"), bool("loaded"), bool("fullContainer"), string("measurementReviewStatus"), + string("fbaCode"), dateTime("createdFrom"), dateTime("createdTo"), page, limit); + } + private String string(String key) { Object value = parameters.get(key); return value == null || String.valueOf(value).isBlank() ? null : String.valueOf(value).trim(); } + private Long number(String key) { String value = string(key); if (value == null) return null; try { long parsed=Long.parseLong(value); if(parsed<=0)throw new NumberFormatException(); return parsed; } catch(NumberFormatException ex){throw new BizException(ResultCode.VALIDATION_FAILED,key+" 必须为正整数字符串");} } + private LocalDate date(String key) { String value=string(key); if(value==null)return null; try{return LocalDate.parse(value);}catch(Exception ex){throw new BizException(ResultCode.VALIDATION_FAILED,key+" 日期格式无效");} } + private LocalDateTime dateTime(String key) { String value=string(key); if(value==null)return null; try{return LocalDateTime.parse(value);}catch(Exception ex){throw new BizException(ResultCode.VALIDATION_FAILED,key+" 时间格式无效");} } + private Boolean bool(String key) { Object value=parameters.get(key); if(value==null)return null; if(value instanceof Boolean flag)return flag; if("true".equals(value)||"false".equals(value))return Boolean.valueOf(String.valueOf(value)); throw new BizException(ResultCode.VALIDATION_FAILED,key+" 必须为布尔值"); } + } +} diff --git a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java index 60449bbd2..dc12fedd0 100644 --- a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java +++ b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java @@ -57,6 +57,23 @@ public class UnifiedOrderQueryService { return page(tenantId, query, true); } + /** 平台导出选择项按业务订单号读取;不暴露内部 ID 作为选择契约。 */ + public List platformRowsByOrderNos(List orderNos) { + if (orderNos == null || orderNos.isEmpty()) return List.of(); + Map orderByNo = orders.findByOrderNos(orderNos).stream() + .collect(Collectors.toMap(OrderEntity::getOrderNo, Function.identity(), (left, right) -> left)); + List selected = orderNos.stream().map(orderByNo::get).filter(Objects::nonNull).toList(); + List orderIds = selected.stream().map(OrderEntity::getId).toList(); + Map inboundByOrder = summaries(null, + selected.stream().filter(order -> order.getStatus() != OrderStatus.SUBMITTED) + .map(OrderEntity::getId).toList()); + Map latestNotes = latestNotes(null, orderIds); + Map cargoSummaries = cargoSummaries(null, orderIds); + Map childCounts = childCounts(selected); + return selected.stream().map(order -> row(order, inboundByOrder.get(order.getId()), + latestNotes.get(order.getId()), childCounts, cargoSummaries.get(order.getId()), null)).toList(); + } + private PageResult page(Long tenantId, UnifiedOrderQuery query, boolean includeCoLoadGroups) { validate(query); @@ -163,7 +180,7 @@ public class UnifiedOrderQueryService { } private Map summaries(Long tenantId, List orderIds) { - if (orderIds.isEmpty()) return Map.of(); + if (orderIds == null || orderIds.isEmpty()) return Map.of(); List data = require( call(() -> firstleg.summaries(internalToken, new FirstlegUnifiedOrderClient.BatchRequest(tenantId, orderIds))),