Merge remote-tracking branch 'origin/test' into dev

This commit is contained in:
2026-09-01 20:46:48 +08:00
4 changed files with 333 additions and 1 deletions
@@ -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<ExportBatchResponse> 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));
}
}
@@ -261,6 +261,13 @@ public class OrderRepository {
.in(OrderEntity::getId, orderIds));
}
public List<OrderEntity> findByOrderNos(List<String> orderNos) {
if (orderNos == null || orderNos.isEmpty()) return List.of();
return orderMapper.selectList(new LambdaQueryWrapper<OrderEntity>()
.in(OrderEntity::getOrderNo, orderNos)
.eq(OrderEntity::getDeleted, false));
}
public List<OrderEntity> findChildrenByParentOrders(List<OrderEntity> parentOrders) {
if (parentOrders.isEmpty()) return List.of();
List<Long> tenantIds = parentOrders.stream().map(OrderEntity::getTenantId).distinct().toList();
@@ -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<String> 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<ExportColumn> 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<UnifiedOrderResponse.Row> 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<String> batchOrderNos = value.selectedOrderNos().subList(offset, end);
List<UnifiedOrderResponse.Row> 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<UnifiedOrderResponse.Row> 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<UnifiedOrderResponse.Row> 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<String, Object> row(UnifiedOrderResponse.Row value) {
Map<String, Object> 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<String, Object> parameters = request.parameters() == null ? Map.of() : request.parameters();
if (!PARAMETER_KEYS.containsAll(parameters.keySet())) throw invalid("包含不支持的订单筛选字段");
List<String> 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<String> selected(Object raw) {
if (raw == null) return List.of();
if (!(raw instanceof Iterable<?> values)) throw invalid("selectedOrderNos 必须为数组");
List<String> 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<String, Object> 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<String> 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<ExportColumn> 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<String, Object> parameters,
List<String> 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+" 必须为布尔值"); }
}
}
@@ -57,6 +57,23 @@ public class UnifiedOrderQueryService {
return page(tenantId, query, true);
}
/** 平台导出选择项按业务订单号读取;不暴露内部 ID 作为选择契约。 */
public List<UnifiedOrderResponse.Row> platformRowsByOrderNos(List<String> orderNos) {
if (orderNos == null || orderNos.isEmpty()) return List.of();
Map<String, OrderEntity> orderByNo = orders.findByOrderNos(orderNos).stream()
.collect(Collectors.toMap(OrderEntity::getOrderNo, Function.identity(), (left, right) -> left));
List<OrderEntity> selected = orderNos.stream().map(orderByNo::get).filter(Objects::nonNull).toList();
List<Long> orderIds = selected.stream().map(OrderEntity::getId).toList();
Map<Long, FirstlegUnifiedOrderClient.Summary> inboundByOrder = summaries(null,
selected.stream().filter(order -> order.getStatus() != OrderStatus.SUBMITTED)
.map(OrderEntity::getId).toList());
Map<Long, String> latestNotes = latestNotes(null, orderIds);
Map<Long, CargoSummary> cargoSummaries = cargoSummaries(null, orderIds);
Map<Long, Integer> 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<UnifiedOrderResponse.Row> page(Long tenantId, UnifiedOrderQuery query,
boolean includeCoLoadGroups) {
validate(query);
@@ -163,7 +180,7 @@ public class UnifiedOrderQueryService {
}
private Map<Long, FirstlegUnifiedOrderClient.Summary> summaries(Long tenantId, List<Long> orderIds) {
if (orderIds.isEmpty()) return Map.of();
if (orderIds == null || orderIds.isEmpty()) return Map.of();
List<FirstlegUnifiedOrderClient.Summary> data = require(
call(() -> firstleg.summaries(internalToken,
new FirstlegUnifiedOrderClient.BatchRequest(tenantId, orderIds))),