fix(order): enforce versioned platform commands and submissions

This commit is contained in:
2026-10-02 23:14:24 +08:00
parent 25580dab86
commit c55ecabbfb
27 changed files with 540 additions and 74 deletions
@@ -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);
}
@@ -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);
}
+44 -4
View File
@@ -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]
@@ -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')",
@@ -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());