This commit is contained in:
2026-08-30 17:02:19 +08:00
parent 0aa77327d5
commit ca9e7b60e8
40 changed files with 1471 additions and 6 deletions
@@ -10,11 +10,13 @@ import com.baiyue.order.dto.MerchantBatchDraftDtos;
import com.baiyue.order.dto.MerchantInboundOrderQuery;
import com.baiyue.order.dto.MerchantInboundOrderRow;
import com.baiyue.order.dto.InboundCodePolicyResponse;
import com.baiyue.order.dto.InboundCodeAssociationSummary;
import com.baiyue.order.entity.OrderStatus;
import com.baiyue.order.service.OrderApplicationService;
import com.baiyue.order.service.OrderScopeGuard;
import com.baiyue.order.service.MerchantInboundOrderListService;
import com.baiyue.order.service.MerchantBatchDraftService;
import com.baiyue.order.service.InboundCodeAssociationService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
@@ -50,6 +52,8 @@ public class MerchantFirstLegOrderController {
private MerchantInboundOrderListService inboundOrderListService;
@Resource
private MerchantBatchDraftService batchDraftService;
@Resource
private InboundCodeAssociationService inboundCodeAssociations;
@GetMapping("/inbound-code-policy")
@PreAuthorize("hasAnyAuthority('merchant:order:list', 'merchant:order:create', 'merchant:order:edit')")
@@ -57,6 +61,17 @@ public class MerchantFirstLegOrderController {
return ApiResponse.success(service.inboundCodePolicy());
}
@GetMapping("/inbound-code-associations/{inboundCode}")
@PreAuthorize("hasAuthority('merchant:order:list')")
@Operation(summary = "按入仓码查询本客户订单基础关联",
description = "客户范围只取登录态;其他客户的入仓码按不存在处理。")
public ApiResponse<InboundCodeAssociationSummary> inboundCodeAssociation(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String inboundCode) {
return ApiResponse.success(inboundCodeAssociations.findForCustomer(
OrderScopeGuard.merchant(user), inboundCode));
}
@BusinessAudit(event = "ORDER_CREATE", resourceType = "FIRST_LEG_ORDER", description = "Create draft / 新建草稿")
@Operation(summary = "Create draft / 新建草稿", description = "Creates a first-leg draft order in the authenticated merchant tenant. / 在当前认证商户租户下创建头程订单草稿。")
@ApiResponses({
@@ -9,12 +9,14 @@ import com.baiyue.order.dto.BatchOrderReviewResponse;
import com.baiyue.order.dto.OrderDetailResponse;
import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.dto.InboundCodePolicyResponse;
import com.baiyue.order.dto.InboundCodeAssociationSummary;
import com.baiyue.order.dto.TenantOrderCountResponse;
import com.baiyue.order.dto.TenantOrderStatisticsResponse;
import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.entity.OrderStatus;
import com.baiyue.order.service.OrderApplicationService;
import com.baiyue.order.service.OrderScopeGuard;
import com.baiyue.order.service.InboundCodeAssociationService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
@@ -47,6 +49,8 @@ import java.util.List;
public class PlatformFirstLegOrderController {
@Resource
private OrderApplicationService service;
@Resource
private InboundCodeAssociationService inboundCodeAssociations;
@GetMapping("/inbound-code-policy")
@PreAuthorize("hasAnyAuthority('platform:order:list', 'platform:order:create', 'platform:order:edit', 'platform:order:submit')")
@@ -54,6 +58,17 @@ public class PlatformFirstLegOrderController {
return ApiResponse.success(service.inboundCodePolicy());
}
@GetMapping("/inbound-code-associations/{inboundCode}")
@PreAuthorize("hasAuthority('platform:order:list')")
@Operation(summary = "按入仓码查询订单基础关联",
description = "入仓码和订单号是主要展示键;Long ID 仅供路由关联。")
public ApiResponse<InboundCodeAssociationSummary> inboundCodeAssociation(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String inboundCode) {
OrderScopeGuard.platform(user);
return ApiResponse.success(inboundCodeAssociations.findForPlatform(inboundCode));
}
@BusinessAudit(event = "ORDER_CREATE", resourceType = "FIRST_LEG_ORDER", description = "平台代客创建草稿")
@Operation(summary = "平台代客创建草稿", description = "在明确指定的目标租户内创建头程订单草稿,必须携带幂等键。")
@ApiResponses({
@@ -0,0 +1,19 @@
package com.baiyue.order.dto;
import io.swagger.v3.oas.annotations.media.Schema;
/** 以全局唯一入仓码定位的订单基础关联摘要;技术 ID 统一使用字符串。 */
public record InboundCodeAssociationSummary(
@Schema(description = "主要展示键:全局唯一入仓码") String inboundCode,
@Schema(description = "主要展示键:订单业务号") String orderNo,
@Schema(description = "订单交易状态") String orderStatus,
@Schema(description = "订单内部 ID,仅供路由关联") String orderId,
@Schema(description = "客户 ID,字符串避免前端精度损失") String customerId,
String customerName,
String destinationType,
String destinationCountryCode,
@Schema(description = "目的仓内部 ID,仅供路由关联") String destinationWarehouseId,
@Schema(description = "目的仓业务编码;FBA 场景为 Amazon 仓码") String destinationWarehouseCode,
String destinationWarehouseName
) {
}
@@ -121,6 +121,21 @@ public class OrderRepository {
.eq(OrderEntity::getId, orderId));
}
/** 平台按数据库全局唯一键查询,不接受客户筛选参数。 */
public OrderEntity findByInboundCode(String inboundCode) {
return orderMapper.selectOne(new LambdaQueryWrapper<OrderEntity>()
.eq(OrderEntity::getInboundCode, inboundCode)
.eq(OrderEntity::getDeleted, false));
}
/** 客户查询必须在 SQL 条件中同时绑定登录客户与入仓码。 */
public OrderEntity findOwnedByInboundCode(long tenantId, String inboundCode) {
return orderMapper.selectOne(new LambdaQueryWrapper<OrderEntity>()
.eq(OrderEntity::getTenantId, tenantId)
.eq(OrderEntity::getInboundCode, inboundCode)
.eq(OrderEntity::getDeleted, false));
}
public boolean inboundCodeExists(String inboundCode, Long excludedOrderId) {
if (!StringUtils.hasText(inboundCode)) return false;
return orderMapper.selectCount(new LambdaQueryWrapper<OrderEntity>()
@@ -0,0 +1,65 @@
package com.baiyue.order.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.dto.InboundCodeAssociationSummary;
import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.repository.OrderRepository;
import org.springframework.stereotype.Service;
import java.util.regex.Pattern;
/** 从 Order 权威快照按入仓码读取只读基础关联。 */
@Service
public class InboundCodeAssociationService {
private static final Pattern INBOUND_CODE = Pattern.compile("[A-Za-z0-9._:-]{1,40}");
private final OrderRepository repository;
public InboundCodeAssociationService(OrderRepository repository) {
this.repository = repository;
}
public InboundCodeAssociationSummary findForPlatform(String inboundCode) {
String normalized = normalize(inboundCode);
return summary(require(repository.findByInboundCode(normalized)), normalized);
}
public InboundCodeAssociationSummary findForCustomer(long tenantId, String inboundCode) {
if (tenantId <= 0) throw new BizException(ResultCode.FORBIDDEN, "Customer scope is required");
String normalized = normalize(inboundCode);
return summary(require(repository.findOwnedByInboundCode(tenantId, normalized)), normalized);
}
private String normalize(String value) {
String normalized = value == null ? "" : value.trim();
if (!INBOUND_CODE.matcher(normalized).matches()) {
throw new BizException(ResultCode.BAD_REQUEST, "入仓码格式无效");
}
return normalized;
}
private OrderEntity require(OrderEntity order) {
if (order == null) throw new BizException(ResultCode.NOT_FOUND, "入仓码关联订单不存在");
return order;
}
private InboundCodeAssociationSummary summary(OrderEntity order, String inboundCode) {
return new InboundCodeAssociationSummary(
inboundCode,
order.getOrderNo(),
order.getStatus() == null ? null : order.getStatus().name(),
string(order.getId()),
string(order.getCustomerId()),
order.getCustomerName(),
order.getDestinationType(),
order.getDestinationCountryCode(),
string(order.getDestinationWarehouseId()),
order.getAmazonWarehouseCode(),
order.getDestinationWarehouseName());
}
private String string(Long value) {
return value == null ? null : value.toString();
}
}
+58 -1
View File
@@ -2,7 +2,7 @@ openapi: 3.0.3
info:
title: 柏悦头程订单 API
description: 业务成功和失败均使用 HTTP 200;仅响应体 code 为字符串 "200" 时表示成功,其他 code 表示失败。
version: 1.6.0
version: 1.7.0
security:
- bearerAuth: []
paths:
@@ -21,6 +21,21 @@ paths:
code: {type: string, example: '200'}
data: {$ref: '#/components/schemas/InboundCodePolicy'}
'403': {description: 需要平台订单列表、创建、编辑或提交权限之一}
/api/platform/v1/orders/first-leg/inbound-code-associations/{inboundCode}:
get:
summary: 平台按全局入仓码查询订单基础关联
description: >-
需要 platform:order:list。入仓码和订单业务号是主要展示键;Long ID 均以字符串返回,
只供页面路由和后续接口关联。查询直接使用 Order 全局唯一入仓码事实,不跨库读取 Firstleg。
parameters:
- $ref: '#/components/parameters/InboundCodePath'
responses:
'200':
description: 查询成功或业务失败,按响应体 code 判断
content:
application/json:
schema: {$ref: '#/components/schemas/InboundCodeAssociationEnvelope'}
'403': {description: 需要平台身份和 platform:order:list 权限}
/api/platform/v1/orders:
get:
summary: 平台统一订单列表
@@ -187,6 +202,21 @@ paths:
schema: {$ref: '#/components/schemas/FirstLegOrderDraft'}
responses:
'200': {description: 成功或业务失败,按响应体 code 判断}
/api/merchant/v1/orders/first-leg/inbound-code-associations/{inboundCode}:
get:
summary: 客户按入仓码查询本客户订单基础关联
description: >-
需要 merchant:order:list。客户范围仅取登录态,不接受客户 ID 参数;其他客户的入仓码统一按不存在处理。
入仓码和订单业务号是主要展示键,Long ID 只供路由关联。
parameters:
- $ref: '#/components/parameters/InboundCodePath'
responses:
'200':
description: 查询成功、不存在或其他业务失败,按响应体 code 判断
content:
application/json:
schema: {$ref: '#/components/schemas/InboundCodeAssociationEnvelope'}
'403': {description: 需要客户身份和 merchant:order:list 权限}
/api/merchant/v1/orders/first-leg/batch:
post:
summary: 批量创建头程订单草稿
@@ -671,6 +701,27 @@ components:
properties:
mode: {type: string, enum: [AUTO, LEGACY_MANUAL]}
manualEntryRequired: {type: boolean}
InboundCodeAssociationEnvelope:
type: object
properties:
code: {type: string, example: '200'}
message: {type: string}
data: {$ref: '#/components/schemas/InboundCodeAssociationSummary'}
InboundCodeAssociationSummary:
type: object
required: [inboundCode, orderNo, orderStatus, orderId, customerId, customerName, destinationType]
properties:
inboundCode: {type: string, maxLength: 40, pattern: '^[A-Za-z0-9._:-]+$', description: 主要展示键:全局唯一入仓码}
orderNo: {type: string, description: 主要展示键:订单业务号}
orderStatus: {type: string, description: Order 权威交易状态}
orderId: {type: string, pattern: '^[1-9][0-9]*$', description: 内部 ID,仅供路由关联}
customerId: {type: string, pattern: '^[1-9][0-9]*$', description: 客户 ID,字符串避免精度损失}
customerName: {type: string}
destinationType: {type: string}
destinationCountryCode: {type: string, nullable: true}
destinationWarehouseId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 内部 ID,仅供路由关联}
destinationWarehouseCode: {type: string, nullable: true, description: 目的仓业务编码;FBA 场景为 Amazon 仓码}
destinationWarehouseName: {type: string, nullable: true}
FirstLegBatchDraftItem:
type: object
required: [clientRowId, draft]
@@ -778,6 +829,12 @@ components:
packageCount: {type: integer}
loadingPlanId: {type: string}
parameters:
InboundCodePath:
name: inboundCode
in: path
required: true
schema: {type: string, minLength: 1, maxLength: 40, pattern: '^[A-Za-z0-9._:-]+$'}
description: 全局唯一业务入仓码
InternalToken:
name: X-Internal-Service-Token
in: header
@@ -0,0 +1,48 @@
package com.baiyue.order.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.order.dto.InboundCodeAssociationSummary;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
class InboundCodeAssociationContractTest {
@Test
void platformAndCustomerEndpointsUseListPermissionAndSameSummary() throws Exception {
assertEndpoint(PlatformFirstLegOrderController.class, "platform:order:list");
assertEndpoint(MerchantFirstLegOrderController.class, "merchant:order:list");
}
@Test
void openApiMakesBusinessKeysPrimaryAndRouteIdsStrings() throws Exception {
String openapi = new ClassPathResource("openapi/order-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(openapi)
.contains("/api/platform/v1/orders/first-leg/inbound-code-associations/{inboundCode}:")
.contains("/api/merchant/v1/orders/first-leg/inbound-code-associations/{inboundCode}:")
.contains("InboundCodeAssociationSummary:")
.contains("入仓码和订单业务号是主要展示键")
.contains("orderId: {type: string")
.contains("customerId: {type: string")
.contains("destinationWarehouseId: {type: string")
.contains("其他客户的入仓码统一按不存在处理")
.doesNotContain("tenantId, in: query, required: true, description: 入仓码关联查询");
}
private void assertEndpoint(Class<?> controller, String authority) throws Exception {
var method = controller.getDeclaredMethod(
"inboundCodeAssociation", AuthenticatedUser.class, String.class);
assertThat(method.getAnnotation(GetMapping.class).value())
.containsExactly("/inbound-code-associations/{inboundCode}");
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('" + authority + "')");
assertThat(method.getGenericReturnType().getTypeName())
.contains(InboundCodeAssociationSummary.class.getName());
}
}
@@ -21,6 +21,8 @@ class OrderControllerPermissionTest {
AuthenticatedUser.class, MerchantInboundOrderQuery.class);
assertAuthority(MerchantFirstLegOrderController.class, "detail", "hasAuthority('merchant:order:view')",
AuthenticatedUser.class, long.class);
assertAuthority(MerchantFirstLegOrderController.class, "inboundCodeAssociation",
"hasAuthority('merchant:order:list')", AuthenticatedUser.class, String.class);
}
@Test
@@ -31,6 +33,8 @@ class OrderControllerPermissionTest {
AuthenticatedUser.class, long.class, long.class);
assertAuthority(PlatformFirstLegOrderController.class, "tenantCounts", "hasAuthority('platform:order:list')",
AuthenticatedUser.class, List.class);
assertAuthority(PlatformFirstLegOrderController.class, "inboundCodeAssociation",
"hasAuthority('platform:order:list')", AuthenticatedUser.class, String.class);
}
@Test
@@ -7,6 +7,7 @@ import com.baiyue.order.dto.BatchOrderReviewResponse;
import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.entity.OrderStatus;
import com.baiyue.order.service.OrderApplicationService;
import com.baiyue.order.service.InboundCodeAssociationService;
import org.junit.jupiter.api.Test;
import jakarta.annotation.Resource;
import org.springframework.boot.test.mock.mockito.MockBean;
@@ -26,6 +27,7 @@ import static org.mockito.Mockito.verifyNoInteractions;
@SpringJUnitConfig(PlatformFirstLegOrderController.class)
class PlatformOrderPageControllerTest {
@MockBean private OrderApplicationService service;
@MockBean private InboundCodeAssociationService inboundCodeAssociations;
@Resource private PlatformFirstLegOrderController controller;
private final OrderRequests.QueryRequest query = new OrderRequests.QueryRequest(
null, null, null, null, null, null, 1, 20);
@@ -126,6 +126,36 @@ class OrderRepositoryIsolationUpdateTest {
.contains("FIRST_LEG_ORDER", "PENDING");
}
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void customerInboundCodeLookupBindsCustomerAndGlobalBusinessKey() {
OrderMapper mapper = mock(OrderMapper.class);
OrderRepository repository = repository("orderMapper", mapper);
repository.findOwnedByInboundCode(11L, "ZBYHK2608300001");
ArgumentCaptor<Wrapper<OrderEntity>> wrapper = ArgumentCaptor.forClass(Wrapper.class);
verify(mapper).selectOne(wrapper.capture());
assertThat(wrapper.getValue().getSqlSegment()).contains("tenant_id", "inbound_code", "deleted");
assertThat(params(wrapper.getValue()).values()).contains(11L, "ZBYHK2608300001", false);
}
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void platformInboundCodeLookupUsesGlobalUniqueBusinessKeyWithoutCustomerPredicate() {
OrderMapper mapper = mock(OrderMapper.class);
OrderRepository repository = repository("orderMapper", mapper);
repository.findByInboundCode("ZBYHK2608300001");
ArgumentCaptor<Wrapper<OrderEntity>> wrapper = ArgumentCaptor.forClass(Wrapper.class);
verify(mapper).selectOne(wrapper.capture());
assertThat(wrapper.getValue().getSqlSegment())
.contains("inbound_code", "deleted")
.doesNotContain("tenant_id");
assertThat(params(wrapper.getValue()).values()).contains("ZBYHK2608300001", false);
}
private static <T> void initialize(Class<T> entityType, String resource) {
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), resource), entityType);
@@ -0,0 +1,83 @@
package com.baiyue.order.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.entity.OrderStatus;
import com.baiyue.order.repository.OrderRepository;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class InboundCodeAssociationServiceTest {
private final OrderRepository repository = mock(OrderRepository.class);
private final InboundCodeAssociationService service = new InboundCodeAssociationService(repository);
@Test
void platformReadsGlobalBusinessKeyAndReturnsStringRouteIds() {
when(repository.findByInboundCode("ZBYHK2608300001")).thenReturn(order());
var summary = service.findForPlatform(" ZBYHK2608300001 ");
assertThat(summary.inboundCode()).isEqualTo("ZBYHK2608300001");
assertThat(summary.orderNo()).isEqualTo("FL202608300001");
assertThat(summary.orderStatus()).isEqualTo("APPROVED");
assertThat(summary.orderId()).isEqualTo("9007199254740993");
assertThat(summary.customerId()).isEqualTo("9007199254740995");
assertThat(summary.customerName()).isEqualTo("客户甲");
assertThat(summary.destinationType()).isEqualTo("FBA");
assertThat(summary.destinationCountryCode()).isEqualTo("US");
assertThat(summary.destinationWarehouseId()).isEqualTo("9007199254740997");
assertThat(summary.destinationWarehouseCode()).isEqualTo("ONT8");
assertThat(summary.destinationWarehouseName()).isEqualTo("ONT8 仓");
}
@Test
void customerQueryAlwaysUsesAuthenticatedCustomerScope() {
when(repository.findOwnedByInboundCode(10001L, "ZBYHK2608300001")).thenReturn(order());
service.findForCustomer(10001L, "ZBYHK2608300001");
verify(repository).findOwnedByInboundCode(10001L, "ZBYHK2608300001");
verify(repository, never()).findByInboundCode("ZBYHK2608300001");
}
@Test
void anotherCustomersCodeIsIndistinguishableFromMissingCode() {
when(repository.findOwnedByInboundCode(10001L, "ZBYHK2608300001")).thenReturn(null);
assertThatThrownBy(() -> service.findForCustomer(10001L, "ZBYHK2608300001"))
.isInstanceOfSatisfying(BizException.class,
error -> assertThat(error.getResultCode()).isEqualTo(ResultCode.NOT_FOUND))
.hasMessage("入仓码关联订单不存在");
}
@Test
void invalidInboundCodeIsRejectedBeforeRepositoryAccess() {
assertThatThrownBy(() -> service.findForPlatform("bad/code"))
.isInstanceOfSatisfying(BizException.class,
error -> assertThat(error.getResultCode()).isEqualTo(ResultCode.BAD_REQUEST));
verifyNoInteractions(repository);
}
private OrderEntity order() {
OrderEntity order = new OrderEntity();
order.setId(9007199254740993L);
order.setOrderNo("FL202608300001");
order.setStatus(OrderStatus.APPROVED);
order.setCustomerId(9007199254740995L);
order.setCustomerName("客户甲");
order.setDestinationType("FBA");
order.setDestinationCountryCode("US");
order.setDestinationWarehouseId(9007199254740997L);
order.setAmazonWarehouseCode("ONT8");
order.setDestinationWarehouseName("ONT8 仓");
return order;
}
}