fix(order): enforce versioned platform commands and submissions
This commit is contained in:
+3
-2
@@ -196,8 +196,9 @@ public class MerchantFirstLegOrderController {
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Parameter(description = "Order ID / 订单 ID", example = "190000000000000001", required = true) @PathVariable long id,
|
||||
@Parameter(description = "幂等键,同一操作者的同一请求重试时必须保持不变", required = true)
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey) {
|
||||
service.submit(OrderScopeGuard.merchant(user), user.userId(), user.username(), id, idempotencyKey);
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@Valid @RequestBody OrderRequests.SubmitRequest request) {
|
||||
service.submit(OrderScopeGuard.merchant(user), user.userId(), user.username(), id, request, idempotencyKey);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
|
||||
+10
-6
@@ -122,9 +122,10 @@ public class PlatformFirstLegOrderController {
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Parameter(description = "目标租户 ID", required = true) @RequestParam long tenantId,
|
||||
@Parameter(description = "订单 ID", required = true) @PathVariable long id,
|
||||
@Valid @RequestBody OrderRequests.SaveRequest request) {
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@Valid @RequestBody OrderRequests.PlatformOrderUpdateRequest request) {
|
||||
long scopedTenant = OrderScopeGuard.platform(user, tenantId);
|
||||
service.updateSubmittedOrder(scopedTenant, user.userId(), id, request);
|
||||
service.updateSubmittedOrder(scopedTenant, user.userId(), id, request, idempotencyKey);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@@ -141,9 +142,10 @@ public class PlatformFirstLegOrderController {
|
||||
@Parameter(description = "目标租户 ID", required = true) @RequestParam long tenantId,
|
||||
@Parameter(description = "订单 ID", required = true) @PathVariable long id,
|
||||
@Parameter(description = "提交订单幂等键,同键同请求返回首次结果", required = true)
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey) {
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@Valid @RequestBody OrderRequests.SubmitRequest request) {
|
||||
long scopedTenant = OrderScopeGuard.platform(user, tenantId);
|
||||
service.submit(scopedTenant, user.userId(), user.username(), id, idempotencyKey);
|
||||
service.submit(scopedTenant, user.userId(), user.username(), id, request, idempotencyKey);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@@ -277,8 +279,10 @@ public class PlatformFirstLegOrderController {
|
||||
public ApiResponse<Void> cancel(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @RequestParam long tenantId,
|
||||
@Parameter(description = "Order ID / 订单 ID", example = "190000000000000001", required = true) @PathVariable long id,
|
||||
@Valid @RequestBody OrderRequests.ReasonRequest request) {
|
||||
return transit(user, tenantId, id, OrderStatus.CANCELLED, request.reason());
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@Valid @RequestBody OrderRequests.CancelRequest request) {
|
||||
service.cancel(OrderScopeGuard.platform(user, tenantId), user.userId(), user.username(), id, request, idempotencyKey);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "ORDER_START", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Start processing / 开始处理")
|
||||
|
||||
@@ -284,6 +284,15 @@ public final class OrderRequests {
|
||||
@NotNull @Valid SaveRequest draft
|
||||
) { }
|
||||
|
||||
@Schema(name = "PlatformOrderUpdateRequest", description = "平台订单版本化幂等编辑")
|
||||
public record PlatformOrderUpdateRequest(
|
||||
@NotNull @Min(0) Integer expectedVersion,
|
||||
@NotNull @Valid SaveRequest draft
|
||||
) { }
|
||||
|
||||
@Schema(name = "OrderSubmitRequest", description = "订单版本化幂等提交")
|
||||
public record SubmitRequest(@NotNull @Min(0) Integer expectedVersion) { }
|
||||
|
||||
@Schema(name = "MerchantOrderCancelRequest", description = "客户订单版本化幂等取消")
|
||||
public record CancelRequest(
|
||||
@NotNull @Min(0) Integer expectedVersion,
|
||||
|
||||
@@ -155,8 +155,15 @@ public class OrderApplicationService {
|
||||
* 因此不会因为该兼容能力意外获得已提交订单的编辑权限。
|
||||
*/
|
||||
@Transactional
|
||||
public void updateSubmittedOrder(long tenantId, long userId, long id, OrderRequests.SaveRequest request) {
|
||||
updateOrderData(tenantId, userId, id, request, true, null);
|
||||
public void updateSubmittedOrder(long tenantId, long userId, long id, OrderRequests.PlatformOrderUpdateRequest request,
|
||||
String idempotencyKey) {
|
||||
requireExpectedVersion(request.expectedVersion());
|
||||
if (request.draft() == null) throw new BizException(ResultCode.VALIDATION_FAILED, "订单内容不能为空");
|
||||
IdempotencyContext command = beginIdempotency(tenantId, userId, "UPDATE_SUBMITTED_ORDER", id, idempotencyKey,
|
||||
requestHash("UPDATE_SUBMITTED_ORDER", List.of(id, request)));
|
||||
if (command.replayed()) return;
|
||||
updateOrderData(tenantId, userId, id, request.draft(), true, request.expectedVersion());
|
||||
completeIdempotency(command, id);
|
||||
}
|
||||
|
||||
private void updateOrderData(long tenantId, long userId, long id, OrderRequests.SaveRequest request,
|
||||
@@ -234,7 +241,10 @@ public class OrderApplicationService {
|
||||
requirePrintableInboundCode(order);
|
||||
order.setSubmittedAt(LocalDateTime.now());
|
||||
}
|
||||
if (repository.saveOrder(tenantId, order) != 1) throw new BizException("Concurrent order update");
|
||||
if (repository.saveOrder(tenantId, order) != 1) {
|
||||
if (target == OrderStatus.SUBMITTED) throw new BizException(ResultCode.CONFLICT, "Concurrent order update");
|
||||
throw new BizException("Concurrent order update");
|
||||
}
|
||||
record(tenantId, id, from, target, target.name(), reason, userId, username);
|
||||
if (target == OrderStatus.SUBMITTED) prepareSubmittedInbound(order);
|
||||
if (target == OrderStatus.SUBMITTED || target == OrderStatus.APPROVED || target == OrderStatus.CANCELLED) {
|
||||
@@ -245,13 +255,15 @@ public class OrderApplicationService {
|
||||
|
||||
/** 提交订单的幂等入口。 */
|
||||
@Transactional
|
||||
public void submit(long tenantId, long userId, String username, long id, String idempotencyKey) {
|
||||
public void submit(long tenantId, long userId, String username, long id, OrderRequests.SubmitRequest request,
|
||||
String idempotencyKey) {
|
||||
requireExpectedVersion(request.expectedVersion());
|
||||
IdempotencyContext idempotency = beginIdempotency(tenantId, userId, "SUBMIT_ORDER", id, idempotencyKey,
|
||||
requestHash("SUBMIT_ORDER", Map.of("orderId", id)));
|
||||
requestHash("SUBMIT_ORDER", List.of(id, request)));
|
||||
if (idempotency.replayed()) {
|
||||
return;
|
||||
}
|
||||
transit(tenantId, userId, username, id, OrderStatus.SUBMITTED, null);
|
||||
transit(tenantId, userId, username, id, OrderStatus.SUBMITTED, null, request.expectedVersion());
|
||||
completeIdempotency(idempotency, id);
|
||||
}
|
||||
|
||||
|
||||
@@ -626,10 +626,15 @@ paths:
|
||||
/api/merchant/v1/orders/first-leg/{id}/submit:
|
||||
post:
|
||||
summary: 提交订单
|
||||
description: 每条货物的每箱数量、箱数和总数量均必填,且总数量必须等于每箱数量 × 箱数。
|
||||
description: 必须携带读取详情时的expectedVersion;原键同载荷重放先返回首次成功,不重复提交。改版本/订单的同键请求与过期首次提交为A0409。每条货物的每箱数量、箱数和总数量均必填,且总数量必须等于每箱数量 × 箱数。
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/OrderId'
|
||||
- $ref: '#/components/parameters/IdempotencyKey'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/OrderSubmitRequest'}
|
||||
responses:
|
||||
'200': {description: 成功或业务失败,按响应体 code 判断}
|
||||
/api/platform/v1/orders/first-leg:
|
||||
@@ -750,28 +755,52 @@ paths:
|
||||
/api/platform/v1/orders/first-leg/{id}/submit:
|
||||
post:
|
||||
summary: 平台代客提交指定订单
|
||||
description: 每条货物的每箱数量、箱数和总数量均必填,且总数量必须等于每箱数量 × 箱数。
|
||||
description: 必须携带读取详情时的expectedVersion;原键同载荷重放先返回首次成功,不重复提交。改版本/订单的同键请求与过期首次提交为A0409。每条货物的每箱数量、箱数和总数量均必填,且总数量必须等于每箱数量 × 箱数。
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/OrderId'
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
- $ref: '#/components/parameters/IdempotencyKey'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/OrderSubmitRequest'}
|
||||
responses:
|
||||
'200': {description: 成功或业务失败,按响应体 code 判断}
|
||||
'403': {description: 需要 platform:order:submit 权限}
|
||||
/api/platform/v1/orders/first-leg/{id}:
|
||||
put:
|
||||
summary: 平台编辑指定订单草稿
|
||||
summary: 平台版本化幂等编辑订单
|
||||
description: 仅DRAFT/REJECTED/SUBMITTED可编辑;SUBMITTED保持不回退。原键同载荷先返回首次成功,不重写明细历史或重复准备入仓。缺版本/旧平铺体拒绝,首次版本冲突或同键异载荷为A0409。
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/OrderId'
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
- $ref: '#/components/parameters/IdempotencyKey'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/FirstLegOrderDraft'}
|
||||
schema: {$ref: '#/components/schemas/PlatformOrderUpdateRequest'}
|
||||
responses:
|
||||
'200': {description: 成功或业务失败,按响应体 code 判断}
|
||||
'403': {description: 需要 platform:order:edit 权限}
|
||||
/api/platform/v1/orders/first-leg/{id}/cancel:
|
||||
post:
|
||||
summary: 平台版本化幂等取消订单
|
||||
description: 复用客户取消命令的版本、原因和幂等规则;原键同载荷成功重放不重复根聚合/历史/事件,同键异载荷或新命令重复取消为A0409。
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/OrderId'
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
- $ref: '#/components/parameters/IdempotencyKey'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/MerchantOrderCancelRequest'}
|
||||
responses:
|
||||
'200': {description: 成功或业务失败,按响应体 code 判断;无业务返回数据}
|
||||
'403': {description: 需要platform:order:cancel权限和平台身份}
|
||||
|
||||
/internal/order/v1/fulfillment-splits:
|
||||
post:
|
||||
summary: 创建父订单装柜拆分(Firstleg 内部)
|
||||
@@ -1279,6 +1308,17 @@ components:
|
||||
type: string
|
||||
enum: [TAX_INCLUDED, TAX_EXCLUDED]
|
||||
description: 报价包税维度;独立于订单 taxMode 的买单/退税语义
|
||||
PlatformOrderUpdateRequest:
|
||||
type: object
|
||||
required: [expectedVersion, draft]
|
||||
properties:
|
||||
expectedVersion: {type: integer, minimum: 0}
|
||||
draft: {$ref: '#/components/schemas/FirstLegOrderDraft'}
|
||||
OrderSubmitRequest:
|
||||
type: object
|
||||
required: [expectedVersion]
|
||||
properties:
|
||||
expectedVersion: {type: integer, minimum: 0}
|
||||
MerchantDraftUpdateRequest:
|
||||
type: object
|
||||
required: [expectedVersion, draft]
|
||||
|
||||
+43
-1
@@ -40,14 +40,56 @@ class MerchantOrderCommandBindingTest {
|
||||
service = mock(OrderApplicationService.class);
|
||||
var controller = new MerchantFirstLegOrderController();
|
||||
ReflectionTestUtils.setField(controller, "service", service);
|
||||
mvc = MockMvcBuilders.standaloneSetup(controller).setCustomArgumentResolvers(new HandlerMethodArgumentResolver() {
|
||||
var platform = new PlatformFirstLegOrderController();
|
||||
ReflectionTestUtils.setField(platform, "service", service);
|
||||
mvc = MockMvcBuilders.standaloneSetup(controller, platform).setCustomArgumentResolvers(new HandlerMethodArgumentResolver() {
|
||||
public boolean supportsParameter(MethodParameter parameter) { return parameter.getParameterType() == AuthenticatedUser.class; }
|
||||
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer model, NativeWebRequest request, WebDataBinderFactory binder) {
|
||||
if (parameter.getDeclaringClass() == PlatformFirstLegOrderController.class)
|
||||
return new AuthenticatedUser(7L, 0L, 0L, 1L, "operator", "PLATFORM", "WEB", Set.of());
|
||||
return new AuthenticatedUser(7L, 11L, 11L, 1L, "operator", "MERCHANT", "WEB", Set.of());
|
||||
}
|
||||
}).build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformEditRequiresVersionEnvelopeAndKey() throws Exception {
|
||||
String path = "/api/platform/v1/orders/first-leg/1?tenantId=11";
|
||||
String body = "{\"expectedVersion\":2,\"draft\":" + DRAFT + "}";
|
||||
mvc.perform(put(path).contentType(MediaType.APPLICATION_JSON).content(body)).andExpect(status().isBadRequest());
|
||||
for (String invalid : new String[] {DRAFT, "{\"draft\":" + DRAFT + "}", "{\"expectedVersion\":2}"})
|
||||
mvc.perform(put(path).header("Idempotency-Key", "platform-edit").contentType(MediaType.APPLICATION_JSON).content(invalid)).andExpect(status().isBadRequest());
|
||||
verifyNoInteractions(service);
|
||||
mvc.perform(put(path).header("Idempotency-Key", "platform-edit").contentType(MediaType.APPLICATION_JSON).content(body)).andExpect(status().isOk());
|
||||
verify(service).updateSubmittedOrder(eq(11L), eq(7L), eq(1L), argThat(r -> r.expectedVersion() == 2 && "Offline".equals(r.draft().customerName())), eq("platform-edit"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void bothSubmitRoutesRequireVersionAndKeepAuthenticatedScope() throws Exception {
|
||||
for (String path : new String[] {PATH + "/submit", "/api/platform/v1/orders/first-leg/1/submit?tenantId=11"}) {
|
||||
mvc.perform(post(path).contentType(MediaType.APPLICATION_JSON).content("{\"expectedVersion\":2}")).andExpect(status().isBadRequest());
|
||||
for (String invalid : new String[] {"{}", "{\"expectedVersion\":null}", "{\"expectedVersion\":-1}"})
|
||||
mvc.perform(post(path).header("Idempotency-Key", "submit").contentType(MediaType.APPLICATION_JSON).content(invalid)).andExpect(status().isBadRequest());
|
||||
mvc.perform(post(path).header("Idempotency-Key", "submit")).andExpect(status().isBadRequest());
|
||||
}
|
||||
verifyNoInteractions(service);
|
||||
for (String path : new String[] {PATH + "/submit", "/api/platform/v1/orders/first-leg/1/submit?tenantId=11"})
|
||||
mvc.perform(post(path).header("Idempotency-Key", "submit").contentType(MediaType.APPLICATION_JSON).content("{\"expectedVersion\":2}")).andExpect(status().isOk());
|
||||
verify(service, times(2)).submit(11L, 7L, "operator", 1L, new OrderRequests.SubmitRequest(2), "submit");
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformCancelUsesVersionedCommandWhileRejectKeepsReasonOnly() throws Exception {
|
||||
String path = "/api/platform/v1/orders/first-leg/1/cancel?tenantId=11";
|
||||
mvc.perform(post(path).header("Idempotency-Key", "cancel").contentType(MediaType.APPLICATION_JSON).content("{\"reason\":\"old\"}")).andExpect(status().isBadRequest());
|
||||
mvc.perform(post(path).contentType(MediaType.APPLICATION_JSON).content("{\"expectedVersion\":0,\"reason\":\"reason\"}")).andExpect(status().isBadRequest());
|
||||
verifyNoInteractions(service);
|
||||
mvc.perform(post(path).header("Idempotency-Key", "cancel").contentType(MediaType.APPLICATION_JSON).content("{\"expectedVersion\":0,\"reason\":\"reason\"}")).andExpect(status().isOk());
|
||||
verify(service).cancel(11L, 7L, "operator", 1L, new OrderRequests.CancelRequest(0, "reason"), "cancel");
|
||||
mvc.perform(post("/api/platform/v1/orders/first-leg/1/reject?tenantId=11").contentType(MediaType.APPLICATION_JSON).content("{\"reason\":\"reason\"}")).andExpect(status().isOk());
|
||||
verify(service).transit(11L, 7L, "operator", 1L, com.baiyue.order.entity.OrderStatus.REJECTED, "reason");
|
||||
}
|
||||
|
||||
@Test
|
||||
void editRequiresHeaderAndVersionedDraftEnvelope() throws Exception {
|
||||
String body = "{\"expectedVersion\":0,\"draft\":" + DRAFT + "}";
|
||||
|
||||
@@ -43,9 +43,9 @@ class OrderControllerPermissionTest {
|
||||
assertAuthority(PlatformFirstLegOrderController.class, "create", "hasAuthority('platform:order:create')",
|
||||
AuthenticatedUser.class, long.class, String.class, OrderRequests.SaveRequest.class);
|
||||
assertAuthority(PlatformFirstLegOrderController.class, "updateDraft", "hasAuthority('platform:order:edit')",
|
||||
AuthenticatedUser.class, long.class, long.class, OrderRequests.SaveRequest.class);
|
||||
AuthenticatedUser.class, long.class, long.class, String.class, OrderRequests.PlatformOrderUpdateRequest.class);
|
||||
assertAuthority(PlatformFirstLegOrderController.class, "submit", "hasAuthority('platform:order:submit')",
|
||||
AuthenticatedUser.class, long.class, long.class, String.class);
|
||||
AuthenticatedUser.class, long.class, long.class, String.class, OrderRequests.SubmitRequest.class);
|
||||
assertAuthority(PlatformFirstLegOrderController.class, "batchApprove", "hasAuthority('platform:order:approve')",
|
||||
AuthenticatedUser.class, long.class, OrderRequests.BatchReviewRequest.class);
|
||||
assertAuthority(PlatformFirstLegOrderController.class, "batchReject", "hasAuthority('platform:order:reject')",
|
||||
|
||||
+121
-2
@@ -42,6 +42,7 @@ class MerchantOrderCommandTransactionTest {
|
||||
private OrderApplicationService service;
|
||||
private final ObjectMapper json = new ObjectMapper().findAndRegisterModules();
|
||||
private OrderRequests.SaveRequest draft;
|
||||
private com.baiyue.order.client.FirstlegInboundClient firstleg;
|
||||
|
||||
@BeforeEach
|
||||
void prepare() throws Exception {
|
||||
@@ -77,6 +78,11 @@ class MerchantOrderCommandTransactionTest {
|
||||
ReflectionTestUtils.setField(target, "repository", repository);
|
||||
ReflectionTestUtils.setField(target, "fulfillmentCompletion", proxy(completion, manager));
|
||||
ReflectionTestUtils.setField(target, "json", json);
|
||||
ReflectionTestUtils.setField(target, "eligibilityGuard", mock(OrderEligibilityGuard.class));
|
||||
firstleg = mock(com.baiyue.order.client.FirstlegInboundClient.class);
|
||||
ReflectionTestUtils.setField(target, "firstlegProvider", new StaticListableBeanFactory(Map.of("firstleg", firstleg))
|
||||
.getBeanProvider(com.baiyue.order.client.FirstlegInboundClient.class));
|
||||
ReflectionTestUtils.setField(target, "internalToken", "offline-test-token");
|
||||
ReflectionTestUtils.setField(target, "productImageGuard", mock(ProductImageGuard.class));
|
||||
ReflectionTestUtils.setField(target, "inboundCodeModeProvider", mock(InboundCodeModeProvider.class));
|
||||
ReflectionTestUtils.setField(target, "customerAddressServiceProvider", new StaticListableBeanFactory().getBeanProvider(CustomerAddressService.class));
|
||||
@@ -90,7 +96,7 @@ class MerchantOrderCommandTransactionTest {
|
||||
service = proxy(target, manager);
|
||||
draft = json.readValue("""
|
||||
{"customerName":"Offline","transportMode":"SEA","destinationCountryCode":"US",
|
||||
"cargoes":[{"cargoName":"Offline cargo","packageType":"CARTON","packageCount":1}],
|
||||
"cargoes":[{"cargoName":"Offline cargo","packageType":"CARTON","packageCount":1,"unitsPerPackage":1,"totalQuantity":1,"grossWeightKg":1,"volumeCbm":1,"woodenProduct":false}],
|
||||
"pickupMethod":"SELF_EXPRESS","receivingWarehouseId":8,"receivingWarehouseName":"Offline",
|
||||
"channelCode":"TEST","channelName":"Offline","taxMode":"BUY_DECLARATION",
|
||||
"pricingTaxMode":"TAX_INCLUDED","destinationType":"OVERSEAS"}
|
||||
@@ -100,6 +106,116 @@ class MerchantOrderCommandTransactionTest {
|
||||
db.update("INSERT INTO ord_order_party(id,tenant_id,order_id) VALUES(102,11,1)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformSubmittedEditKeepsStateAndOriginalReplayDoesNotPrepareInboundAgain() {
|
||||
db.update("UPDATE ord_order SET status='SUBMITTED' WHERE id=1");
|
||||
platformEdit(11, 1, 0, "platform");
|
||||
assertThat(status(1)).isEqualTo("SUBMITTED");
|
||||
assertThat(version(1)).isEqualTo(1);
|
||||
verify(firstleg, times(1)).prepare(anyString(), any());
|
||||
var before = snapshot();
|
||||
platformEdit(11, 1, 0, "platform");
|
||||
assertThat(snapshot()).isEqualTo(before);
|
||||
verify(firstleg, times(1)).prepare(anyString(), any());
|
||||
assertThatThrownBy(() -> platformEdit(11, 1, 1, "platform")).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> platformEdit(11, 2, 0, "platform")).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> platformEdit(11, 1, 0, "stale")).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> platformEdit(12, 1, 1, "cross")).isInstanceOf(BizException.class);
|
||||
assertThat(snapshot()).isEqualTo(before);
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformEditRollbackDoesNotPublishAfterCommitPreparation() {
|
||||
db.update("UPDATE ord_order SET status='SUBMITTED' WHERE id=1");
|
||||
db.execute("ALTER TABLE ord_idempotency_record ADD CONSTRAINT platform_fail CHECK(status<>'COMPLETED')");
|
||||
var before = snapshot();
|
||||
assertThatThrownBy(() -> platformEdit(11, 1, 0, "platform")).isInstanceOf(RuntimeException.class);
|
||||
assertThat(snapshot()).isEqualTo(before);
|
||||
verifyNoInteractions(firstleg);
|
||||
db.execute("ALTER TABLE ord_idempotency_record DROP CONSTRAINT platform_fail");
|
||||
platformEdit(11, 1, 0, "platform");
|
||||
verify(firstleg).prepare(anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformEditDoesNotExpandToApprovedAndHasDistinctOperationScope() {
|
||||
edit(11, 1, 0, "same-key");
|
||||
platformEdit(11, 1, 1, "same-key");
|
||||
assertThat(count("ord_idempotency_record")).isEqualTo(2);
|
||||
db.update("UPDATE ord_order SET status='APPROVED' WHERE id=1");
|
||||
var before = snapshot();
|
||||
assertThatThrownBy(() -> platformEdit(11, 1, 2, "not-editable")).isInstanceOf(BizException.class);
|
||||
assertThat(snapshot()).isEqualTo(before);
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformConcurrentEditsKeepOneVersionAndOnePreparation() throws Exception {
|
||||
db.update("UPDATE ord_order SET status='SUBMITTED' WHERE id=1");
|
||||
assertThat(concurrent(() -> platformEdit(11, 1, 0, "same"), () -> platformEdit(11, 1, 0, "same"))).containsExactly(true, true);
|
||||
assertThat(concurrent(() -> platformEdit(11, 1, 1, "a"), () -> platformEdit(11, 1, 1, "b"))).containsExactlyInAnyOrder(true, false);
|
||||
assertThat(version(1)).isEqualTo(2);
|
||||
assertThat(count("ord_order_status_history")).isEqualTo(2);
|
||||
verify(firstleg, times(2)).prepare(anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void submitOriginalReplayKeepsInboundCodeHistoryOutboxAndPreparation() {
|
||||
edit(11, 1, 0, "ready");
|
||||
submit(11, 1, 1, "submit");
|
||||
assertThat(status(1)).isEqualTo("SUBMITTED");
|
||||
assertThat(version(1)).isEqualTo(2);
|
||||
assertThat(count("ord_outbox_event")).isEqualTo(1);
|
||||
var before = snapshot();
|
||||
submit(11, 1, 1, "submit");
|
||||
assertThatThrownBy(() -> submit(11, 1, 2, "submit")).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> submit(11, 2, 1, "submit")).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> submit(11, 1, 2, "new-command")).isInstanceOf(BizException.class);
|
||||
assertThat(snapshot()).isEqualTo(before);
|
||||
verify(firstleg, times(1)).prepare(anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void submitStaleVersionWrongCustomerAndMissingVersionDoNotWrite() {
|
||||
edit(11, 1, 0, "ready");
|
||||
var before = snapshot();
|
||||
assertThatThrownBy(() -> submit(11, 1, 0, "stale")).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> submit(12, 1, 1, "cross")).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> service.submit(11, 7, "operator", 1, new OrderRequests.SubmitRequest(null), "missing")).isInstanceOf(BizException.class);
|
||||
assertThat(snapshot()).isEqualTo(before);
|
||||
verifyNoInteractions(firstleg);
|
||||
}
|
||||
|
||||
@Test
|
||||
void submitOutboxFailureRollsBackStateHistoryCommandAndPreparation() {
|
||||
edit(11, 1, 0, "ready");
|
||||
db.execute("ALTER TABLE ord_outbox_event ADD CONSTRAINT submit_fail CHECK(event_type<>'FIRST_LEG_ORDER_PREPARED')");
|
||||
var before = snapshot();
|
||||
assertThatThrownBy(() -> submit(11, 1, 1, "submit")).isInstanceOf(RuntimeException.class);
|
||||
assertThat(snapshot()).isEqualTo(before);
|
||||
verifyNoInteractions(firstleg);
|
||||
db.execute("ALTER TABLE ord_outbox_event DROP CONSTRAINT submit_fail");
|
||||
submit(11, 1, 1, "submit");
|
||||
verify(firstleg).prepare(anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void concurrentSubmitCommandsCannotDuplicateTransition() throws Exception {
|
||||
edit(11, 1, 0, "ready");
|
||||
assertThat(concurrent(() -> submit(11, 1, 1, "same"), () -> submit(11, 1, 1, "same"))).containsExactly(true, true);
|
||||
order(2, 11, "DRAFT", "MASTER", null);
|
||||
edit(11, 2, 0, "ready-two");
|
||||
assertThat(concurrent(() -> submit(11, 2, 1, "a"), () -> submit(11, 2, 1, "b"))).containsExactlyInAnyOrder(true, false);
|
||||
assertThat(count("ord_outbox_event")).isEqualTo(2);
|
||||
verify(firstleg, times(2)).prepare(anyString(), any());
|
||||
}
|
||||
|
||||
private void platformEdit(long customer, long id, int version, String key) {
|
||||
service.updateSubmittedOrder(customer, 7, id, new OrderRequests.PlatformOrderUpdateRequest(version, draft), key);
|
||||
}
|
||||
private void submit(long customer, long id, int version, String key) {
|
||||
service.submit(customer, 7, "operator", id, new OrderRequests.SubmitRequest(version), key);
|
||||
}
|
||||
|
||||
@Test
|
||||
void editReplaysOriginalCommandBeforeCurrentVersionAndRejectsAnyChangedIdentity() {
|
||||
edit(11, 1, 0, "edit");
|
||||
@@ -271,7 +387,10 @@ class MerchantOrderCommandTransactionTest {
|
||||
List<Future<Boolean>> results = new ArrayList<>();
|
||||
for (Runnable work : List.of(first, second)) results.add(workers.submit(() -> {
|
||||
start.await();
|
||||
try { work.run(); return true; } catch (BizException conflict) { return false; }
|
||||
try { work.run(); return true; } catch (BizException conflict) {
|
||||
assertThat(conflict.getResultCode()).isEqualTo(com.baiyue.common.core.api.ResultCode.CONFLICT);
|
||||
return false;
|
||||
}
|
||||
}));
|
||||
start.countDown();
|
||||
return List.of(results.get(0).get(15, TimeUnit.SECONDS), results.get(1).get(15, TimeUnit.SECONDS));
|
||||
|
||||
@@ -277,6 +277,7 @@ class OrderApplicationServiceStateTest {
|
||||
OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class);
|
||||
OrderRequests.SaveRequest request = request();
|
||||
OrderEntity order = new OrderEntity();
|
||||
order.setVersion(0);
|
||||
order.setId(1L);
|
||||
order.setTenantId(10001L);
|
||||
order.setStatus(OrderStatus.SUBMITTED);
|
||||
@@ -285,7 +286,7 @@ class OrderApplicationServiceStateTest {
|
||||
when(repository.saveOrder(10001L, order)).thenReturn(1);
|
||||
|
||||
service(repository, mock(OrderEligibilityGuard.class), masterData)
|
||||
.updateSubmittedOrder(10001L, 2L, 1L, request);
|
||||
.updateSubmittedOrder(10001L, 2L, 1L, new OrderRequests.PlatformOrderUpdateRequest(0, request), "platform-edit");
|
||||
|
||||
assertThat(order.getStatus()).isEqualTo(OrderStatus.SUBMITTED);
|
||||
verify(repository).replaceDraftChildren(10001L, 1L);
|
||||
@@ -389,7 +390,7 @@ class OrderApplicationServiceStateTest {
|
||||
when(repository.findCargo(20002L, 1L)).thenReturn(List.of(valid, missing));
|
||||
|
||||
assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class))
|
||||
.submit(20002L, 2L, "u", 1L, "missing-sku"))
|
||||
.submit(20002L, 2L, "u", 1L, new OrderRequests.SubmitRequest(0), "missing-sku"))
|
||||
.isInstanceOf(BizException.class).hasMessage("第 2 条一件代发货物必须填写 SKU NO.")
|
||||
.extracting(exception -> ((BizException) exception).getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED);
|
||||
assertThat(order.getStatus()).isEqualTo(OrderStatus.DRAFT);
|
||||
@@ -434,7 +435,7 @@ class OrderApplicationServiceStateTest {
|
||||
source.customsInspection(), source.destinationType(), source.amazonWarehouseCode(), source.shipmentId(),
|
||||
source.referenceId(), source.inboundCode(), "ONE_CLICK_DROPSHIP");
|
||||
assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class))
|
||||
.updateSubmittedOrder(10001L, 2L, 1L, missingSku))
|
||||
.updateSubmittedOrder(10001L, 2L, 1L, new OrderRequests.PlatformOrderUpdateRequest(0, missingSku), "platform-edit"))
|
||||
.isInstanceOf(BizException.class).hasMessage("第 1 条一件代发货物必须填写 SKU NO.")
|
||||
.extracting(exception -> ((BizException) exception).getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED);
|
||||
verify(repository, never()).saveOrder(anyLong(), any());
|
||||
@@ -463,7 +464,7 @@ class OrderApplicationServiceStateTest {
|
||||
when(repository.findCargo(20002L, 1L)).thenReturn(List.of(
|
||||
mixedCargo("SKU-1", 10), mixedCargo("SKU-2", 11), third));
|
||||
assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class))
|
||||
.submit(20002L, 2L, "u", 1L, "mixed-carton-conflict"))
|
||||
.submit(20002L, 2L, "u", 1L, new OrderRequests.SubmitRequest(0), "mixed-carton-conflict"))
|
||||
.isInstanceOf(BizException.class).hasMessage("同箱 SKU 的箱数、包装类型、箱规、重量和体积必须一致")
|
||||
.extracting(exception -> ((BizException) exception).getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED);
|
||||
assertThat(order.getStatus()).isEqualTo(OrderStatus.DRAFT);
|
||||
@@ -501,7 +502,7 @@ class OrderApplicationServiceStateTest {
|
||||
when(repository.findOwned(10001L, 1L)).thenReturn(order);
|
||||
var request = mixedCartonRequest(31);
|
||||
assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class))
|
||||
.updateSubmittedOrder(10001L, 2L, 1L, request))
|
||||
.updateSubmittedOrder(10001L, 2L, 1L, new OrderRequests.PlatformOrderUpdateRequest(0, request), "platform-edit"))
|
||||
.isInstanceOf(BizException.class).hasMessage("同箱 SKU 的箱数、包装类型、箱规、重量和体积必须一致")
|
||||
.extracting(exception -> ((BizException) exception).getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED);
|
||||
verify(repository, never()).saveOrder(anyLong(), any());
|
||||
@@ -717,6 +718,7 @@ class OrderApplicationServiceStateTest {
|
||||
OrderRepository repository = mock(OrderRepository.class);
|
||||
OrderEligibilityGuard eligibility = mock(OrderEligibilityGuard.class);
|
||||
OrderEntity order = new OrderEntity();
|
||||
order.setVersion(0);
|
||||
order.setId(1L);
|
||||
order.setTenantId(10001L);
|
||||
order.setStatus(OrderStatus.DRAFT);
|
||||
@@ -725,7 +727,7 @@ class OrderApplicationServiceStateTest {
|
||||
.when(eligibility).requireApproved(10001L);
|
||||
|
||||
assertThatThrownBy(() -> service(repository, eligibility, mock(OrderMasterDataGuard.class))
|
||||
.submit(10001L, 2L, "u", 1L, "submit-before-approval"))
|
||||
.submit(10001L, 2L, "u", 1L, new OrderRequests.SubmitRequest(0), "submit-before-approval"))
|
||||
.isInstanceOf(BizException.class)
|
||||
.extracting(exception -> ((BizException) exception).getResultCode())
|
||||
.isEqualTo(ResultCode.FORBIDDEN);
|
||||
@@ -1068,6 +1070,7 @@ class OrderApplicationServiceStateTest {
|
||||
|
||||
private OrderEntity persistedDraft(String pickupMethod, String destinationType) {
|
||||
OrderEntity order = new OrderEntity();
|
||||
order.setVersion(0);
|
||||
order.setId(1L);
|
||||
order.setTenantId(20002L);
|
||||
order.setStatus(OrderStatus.DRAFT);
|
||||
|
||||
@@ -121,7 +121,7 @@ class OrderIdempotencyServiceTest {
|
||||
when(repository.findIdempotency(10001L, 20001L, "SUBMIT_ORDER", "submit-key"))
|
||||
.thenReturn(existing);
|
||||
|
||||
service(repository).submit(10001L, 20001L, "operator", 998L, "submit-key");
|
||||
service(repository).submit(10001L, 20001L, "operator", 998L, new OrderRequests.SubmitRequest(0), "submit-key");
|
||||
|
||||
verify(repository, never()).findOwned(anyLong(), anyLong());
|
||||
verify(repository, never()).saveOrder(anyLong(), any());
|
||||
|
||||
Reference in New Issue
Block a user