fix(order): make merchant draft edits and cancellations versioned and idempotent

This commit is contained in:
2026-10-02 21:33:39 +08:00
parent 4b531c7e22
commit 58dfcc21f6
10 changed files with 1227 additions and 28 deletions
@@ -12,7 +12,6 @@ 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;
@@ -154,8 +153,9 @@ public class MerchantFirstLegOrderController {
public ApiResponse<Void> update(
@AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "Order ID / 订单 ID", example = "190000000000000001", required = true) @PathVariable long id,
@Valid @RequestBody OrderRequests.SaveRequest request) {
service.updateDraft(OrderScopeGuard.merchant(user), user.userId(), id, request);
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody OrderRequests.DraftUpdateRequest request) {
service.updateDraft(OrderScopeGuard.merchant(user), user.userId(), id, request, idempotencyKey);
return ApiResponse.success(null);
}
@@ -211,14 +211,10 @@ public class MerchantFirstLegOrderController {
public ApiResponse<Void> cancel(
@AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "Order ID / 订单 ID", example = "190000000000000001", required = true) @PathVariable long id,
@Valid @RequestBody OrderRequests.ReasonRequest request) {
return transit(user, id, OrderStatus.CANCELLED, request.reason());
}
private ApiResponse<Void> transit(AuthenticatedUser user, long id, OrderStatus target, String reason) {
service.transit(
OrderScopeGuard.merchant(user), user.userId(), user.username(), id, target, reason
);
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody OrderRequests.CancelRequest request) {
service.cancel(OrderScopeGuard.merchant(user), user.userId(), user.username(), id, request, idempotencyKey);
return ApiResponse.success(null);
}
}
@@ -278,6 +278,18 @@ public final class OrderRequests {
) {
}
@Schema(name = "MerchantDraftUpdateRequest", description = "客户草稿版本化幂等编辑")
public record DraftUpdateRequest(
@NotNull @Min(0) Integer expectedVersion,
@NotNull @Valid SaveRequest draft
) { }
@Schema(name = "MerchantOrderCancelRequest", description = "客户订单版本化幂等取消")
public record CancelRequest(
@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max = 500) String reason
) { }
@Schema(name = "OrderReasonRequest", description = "Order lifecycle reason / 订单生命周期操作原因")
public record ReasonRequest(
@Schema(description = "Reason / 原因", example = "Commercial terms need revision / 商务条款需要调整", maxLength = 500, requiredMode = Schema.RequiredMode.REQUIRED)
@@ -122,25 +122,50 @@ public class OrderApplicationService {
}
@Transactional
public void updateDraft(long tenantId, long userId, long id, OrderRequests.SaveRequest request) {
updateOrderData(tenantId, userId, id, request, false);
public void updateDraft(long tenantId, long userId, long id, OrderRequests.DraftUpdateRequest request,
String idempotencyKey) {
requireExpectedVersion(request.expectedVersion());
if (request.draft() == null) throw new BizException(ResultCode.VALIDATION_FAILED, "草稿内容不能为空");
IdempotencyContext command = beginIdempotency(tenantId, userId, "UPDATE_DRAFT", id, idempotencyKey,
requestHash("UPDATE_DRAFT", List.of(id, request)));
if (command.replayed()) return;
updateOrderData(tenantId, userId, id, request.draft(), false, request.expectedVersion());
completeIdempotency(command, id);
}
@Transactional
public void cancel(long tenantId, long userId, String username, long id, OrderRequests.CancelRequest request,
String idempotencyKey) {
requireExpectedVersion(request.expectedVersion());
IdempotencyContext command = beginIdempotency(tenantId, userId, "CANCEL_ORDER", id, idempotencyKey,
requestHash("CANCEL_ORDER", List.of(id, request)));
if (command.replayed()) return;
transit(tenantId, userId, username, id, OrderStatus.CANCELLED, request.reason(), request.expectedVersion());
completeIdempotency(command, id);
}
private void requireExpectedVersion(Integer version) {
if (version == null || version < 0)
throw new BizException(ResultCode.VALIDATION_FAILED, "必须提供有效的订单版本");
}
/**
* 平台审核队列中的订单仍允许编辑。编辑只更新订单资料,不回退已提交订单的主状态。
* 商户端继续使用 {@link #updateDraft(long, long, long, OrderRequests.SaveRequest)},
* 商户端继续使用独立的版本化草稿编辑命令,
* 因此不会因为该兼容能力意外获得已提交订单的编辑权限。
*/
@Transactional
public void updateSubmittedOrder(long tenantId, long userId, long id, OrderRequests.SaveRequest request) {
updateOrderData(tenantId, userId, id, request, true);
updateOrderData(tenantId, userId, id, request, true, null);
}
private void updateOrderData(long tenantId, long userId, long id, OrderRequests.SaveRequest request,
boolean allowSubmitted) {
boolean allowSubmitted, Integer expectedVersion) {
AddressSnapshots addressSnapshots = resolveCustomerAddresses(tenantId, request);
validateWoodInspection(request);
OrderEntity order = require(tenantId, id);
if (expectedVersion != null && !expectedVersion.equals(order.getVersion()))
throw new BizException(ResultCode.CONFLICT, "订单版本已变化,请刷新后重试");
if (allowSubmitted) validateCompleteRequest(request);
else validateRequestCargoQuantities(request.cargoes(), false);
productImageGuard.requireOwned(tenantId, request.cargoes());
@@ -157,7 +182,7 @@ public class OrderApplicationService {
order.setStatus(OrderStatus.SUBMITTED);
}
try {
if (repository.saveOrder(tenantId, order) != 1) throw new BizException("Concurrent order update");
if (repository.saveOrder(tenantId, order) != 1) throw new BizException(ResultCode.CONFLICT, "Concurrent order update");
} catch (DuplicateKeyException duplicate) {
throw duplicateInboundCode(order, duplicate);
}
@@ -585,6 +585,44 @@ paths:
code: {type: string, example: '200'}
data: {$ref: '#/components/schemas/InboundCodePolicy'}
'403': {description: 需要 merchant:order:list、merchant:order:create 或 merchant:order:edit 权限之一}
/api/merchant/v1/orders/first-leg/{id}:
put:
summary: 客户版本化编辑草稿
description: >-
仅允许本客户 DRAFT/REJECTED 订单。必须携带读取详情时的 expectedVersion 和 Idempotency-Key;
不接受旧平铺保存请求。原键同载荷重放先返回首次成功,不再检查已递增版本或修改明细与历史;
同键不同订单/版本/内容、首次版本过期或状态不允许返回业务冲突 A0409。
版本或 draft 缺失为参数错误。主表、子明细、历史和幂等结果同事务提交或回滚。
parameters:
- $ref: '#/components/parameters/OrderId'
- $ref: '#/components/parameters/IdempotencyKey'
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/MerchantDraftUpdateRequest'}
responses:
'200': {description: 成功或业务失败,按响应体 code 判断}
'403': {description: 需要 merchant:order:edit 权限和本客户订单归属}
/api/merchant/v1/orders/first-leg/{id}/cancel:
post:
summary: 客户版本化幂等取消订单
description: >-
必须携带当前 expectedVersion、原因和 Idempotency-Key。原键同载荷返回首次成功,
不重复取消、聚合根订单、写历史或 Outbox;同键不同订单/版本/原因拒绝。
新命令仅在当前版本及取消状态门禁通过后执行,重复取消新命令返回 A0409。
取消、根履约聚合、历史、事件与幂等结果同事务完成;不接受缺版本的旧原因请求。
parameters:
- $ref: '#/components/parameters/OrderId'
- $ref: '#/components/parameters/IdempotencyKey'
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/MerchantOrderCancelRequest'}
responses:
'200': {description: 成功或业务失败,按响应体 code 判断}
'403': {description: 需要 merchant:order:cancel 权限和本客户订单归属}
/api/merchant/v1/orders/first-leg/{id}/submit:
post:
summary: 提交订单
@@ -1241,6 +1279,18 @@ components:
type: string
enum: [TAX_INCLUDED, TAX_EXCLUDED]
description: 报价包税维度;独立于订单 taxMode 的买单/退税语义
MerchantDraftUpdateRequest:
type: object
required: [expectedVersion, draft]
properties:
expectedVersion: {type: integer, minimum: 0}
draft: {$ref: '#/components/schemas/FirstLegOrderDraft'}
MerchantOrderCancelRequest:
type: object
required: [expectedVersion, reason]
properties:
expectedVersion: {type: integer, minimum: 0}
reason: {type: string, minLength: 1, maxLength: 500}
FirstLegOrderDraft:
type: object
required: [customerName, transportMode, cargoes, pickupMethod, receivingWarehouseId, receivingWarehouseName, channelCode, channelName, taxMode, pricingTaxMode, destinationType]
@@ -0,0 +1,80 @@
package com.baiyue.order.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.service.OrderApplicationService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
import java.util.Set;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/** Local MVC binding only: no server/socket or TEST requests. */
class MerchantOrderCommandBindingTest {
private MockMvc mvc;
private OrderApplicationService service;
private static final String PATH = "/api/merchant/v1/orders/first-leg/1";
private static final String DRAFT = """
{"customerName":"Offline","transportMode":"SEA","destinationCountryCode":"US",
"cargoes":[{"cargoName":"Offline cargo","packageType":"CARTON","packageCount":1,"woodenProduct":false}],
"pickupMethod":"SELF_EXPRESS","receivingWarehouseId":8,"receivingWarehouseName":"Offline",
"channelCode":"TEST","channelName":"Offline","taxMode":"BUY_DECLARATION",
"pricingTaxMode":"TAX_INCLUDED","destinationType":"OVERSEAS"}
""";
@BeforeEach
void prepare() {
service = mock(OrderApplicationService.class);
var controller = new MerchantFirstLegOrderController();
ReflectionTestUtils.setField(controller, "service", service);
mvc = MockMvcBuilders.standaloneSetup(controller).setCustomArgumentResolvers(new HandlerMethodArgumentResolver() {
public boolean supportsParameter(MethodParameter parameter) { return parameter.getParameterType() == AuthenticatedUser.class; }
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer model, NativeWebRequest request, WebDataBinderFactory binder) {
return new AuthenticatedUser(7L, 11L, 11L, 1L, "operator", "MERCHANT", "WEB", Set.of());
}
}).build();
}
@Test
void editRequiresHeaderAndVersionedDraftEnvelope() throws Exception {
String body = "{\"expectedVersion\":0,\"draft\":" + DRAFT + "}";
mvc.perform(put(PATH).contentType(MediaType.APPLICATION_JSON).content(body)).andExpect(status().isBadRequest());
for (String invalid : new String[] {DRAFT, "{\"draft\":" + DRAFT + "}", "{\"expectedVersion\":0}",
"{\"expectedVersion\":-1,\"draft\":" + DRAFT + "}", "{\"expectedVersion\":0,\"draft\":{}}"}) {
mvc.perform(put(PATH).header("Idempotency-Key", "edit").contentType(MediaType.APPLICATION_JSON).content(invalid))
.andExpect(status().isBadRequest());
}
verifyNoInteractions(service);
mvc.perform(put(PATH).header("Idempotency-Key", "edit").contentType(MediaType.APPLICATION_JSON).content(body))
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value("200"));
verify(service).updateDraft(eq(11L), eq(7L), eq(1L), argThat(request -> request.expectedVersion() == 0 && "Offline".equals(request.draft().customerName())), eq("edit"));
}
@Test
void cancelRejectsOldReasonOnlyBodyAndForwardsAuthenticatedIdentity() throws Exception {
String body = "{\"expectedVersion\":3,\"reason\":\"changed plan\"}";
mvc.perform(post(PATH + "/cancel").contentType(MediaType.APPLICATION_JSON).content(body)).andExpect(status().isBadRequest());
for (String invalid : new String[] {"{\"reason\":\"old\"}", "{\"expectedVersion\":null,\"reason\":\"old\"}",
"{\"expectedVersion\":-1,\"reason\":\"old\"}", "{\"expectedVersion\":0,\"reason\":\" \"}"}) {
mvc.perform(post(PATH + "/cancel").header("Idempotency-Key", "cancel").contentType(MediaType.APPLICATION_JSON).content(invalid))
.andExpect(status().isBadRequest());
}
verifyNoInteractions(service);
mvc.perform(post(PATH + "/cancel").header("Idempotency-Key", "cancel").contentType(MediaType.APPLICATION_JSON).content(body))
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value("200"));
verify(service).cancel(11L, 7L, "operator", 1L, new OrderRequests.CancelRequest(3, "changed plan"), "cancel");
}
}
@@ -0,0 +1,304 @@
package com.baiyue.order.service;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.client.WarehouseClient;
import com.baiyue.order.config.InboundCodeModeProvider;
import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.entity.*;
import com.baiyue.order.mapper.*;
import com.baiyue.order.repository.OrderRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.beans.factory.support.StaticListableBeanFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import java.lang.reflect.Modifier;
import java.math.BigDecimal;
import java.time.*;
import java.util.*;
import java.util.concurrent.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/** Real production repository/Mapper writes and transaction proxies; only remote guards are mocked. */
class MerchantOrderCommandTransactionTest {
private JdbcTemplate db;
private OrderApplicationService service;
private final ObjectMapper json = new ObjectMapper().findAndRegisterModules();
private OrderRequests.SaveRequest draft;
@BeforeEach
void prepare() throws Exception {
JdbcDataSource source = new JdbcDataSource();
source.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000");
db = new JdbcTemplate(source);
// Entity-shaped test tables keep all production Mapper columns. Unique/rollback constraints
// below are real database constraints, not mocked repository outcomes.
for (Class<?> type : List.of(OrderEntity.class, OrderCargoEntity.class, OrderPartyEntity.class,
OrderStatusHistoryEntity.class, OutboxEventEntity.class, OrderIdempotencyRecordEntity.class)) table(type);
db.execute("ALTER TABLE ord_idempotency_record ADD CONSTRAINT command_scope UNIQUE(tenant_id,operator_id,operation,idempotency_key)");
MybatisConfiguration config = new MybatisConfiguration();
config.setMapUnderscoreToCamelCase(true);
for (Class<?> type : List.of(OrderMapper.class, OrderCargoMapper.class, OrderPartyMapper.class,
OrderStatusHistoryMapper.class, OutboxEventMapper.class, OrderIdempotencyRecordMapper.class,
OrderChildPurgeMapper.class)) config.addMapper(type);
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
MybatisSqlSessionFactoryBean factory = new MybatisSqlSessionFactoryBean();
factory.setConfiguration(config); factory.setDataSource(source); factory.setPlugins(interceptor);
SqlSessionTemplate session = new SqlSessionTemplate(factory.getObject());
OrderRepository repository = new OrderRepository();
for (var field : OrderRepository.class.getDeclaredFields()) {
if (config.hasMapper(field.getType())) ReflectionTestUtils.setField(repository, field.getName(), session.getMapper(field.getType()));
}
DataSourceTransactionManager manager = new DataSourceTransactionManager(source);
OrderFulfillmentCompletionService completion = new OrderFulfillmentCompletionService();
ReflectionTestUtils.setField(completion, "orders", session.getMapper(OrderMapper.class));
ReflectionTestUtils.setField(completion, "histories", session.getMapper(OrderStatusHistoryMapper.class));
ReflectionTestUtils.setField(completion, "outbox", session.getMapper(OutboxEventMapper.class));
ReflectionTestUtils.setField(completion, "json", json);
OrderApplicationService target = new OrderApplicationService();
ReflectionTestUtils.setField(target, "repository", repository);
ReflectionTestUtils.setField(target, "fulfillmentCompletion", proxy(completion, manager));
ReflectionTestUtils.setField(target, "json", json);
ReflectionTestUtils.setField(target, "productImageGuard", mock(ProductImageGuard.class));
ReflectionTestUtils.setField(target, "inboundCodeModeProvider", mock(InboundCodeModeProvider.class));
ReflectionTestUtils.setField(target, "customerAddressServiceProvider", new StaticListableBeanFactory().getBeanProvider(CustomerAddressService.class));
OrderPricingChannelGuard pricing = mock(OrderPricingChannelGuard.class);
when(pricing.requireAvailable(anyLong(), anyLong(), anyString(), anyString())).thenReturn("TEST");
ReflectionTestUtils.setField(target, "pricingChannelGuard", pricing);
OrderMasterDataGuard master = mock(OrderMasterDataGuard.class);
var receiving = json.readValue("{\"warehouse\":{\"id\":8,\"warehouseName\":\"Offline\"},\"addresses\":[],\"capabilities\":[]}", WarehouseClient.Detail.class);
when(master.validate(anyLong(), any())).thenReturn(new OrderMasterDataGuard.Snapshots(receiving, null));
ReflectionTestUtils.setField(target, "masterDataGuard", master);
service = proxy(target, manager);
draft = json.readValue("""
{"customerName":"Offline","transportMode":"SEA","destinationCountryCode":"US",
"cargoes":[{"cargoName":"Offline cargo","packageType":"CARTON","packageCount":1}],
"pickupMethod":"SELF_EXPRESS","receivingWarehouseId":8,"receivingWarehouseName":"Offline",
"channelCode":"TEST","channelName":"Offline","taxMode":"BUY_DECLARATION",
"pricingTaxMode":"TAX_INCLUDED","destinationType":"OVERSEAS"}
""", OrderRequests.SaveRequest.class);
order(1, 11, "DRAFT", "MASTER", null);
db.update("INSERT INTO ord_order_cargo(id,tenant_id,order_id,item_no,cargo_name,package_count) VALUES(101,11,1,1,'original cargo',1)");
db.update("INSERT INTO ord_order_party(id,tenant_id,order_id) VALUES(102,11,1)");
}
@Test
void editReplaysOriginalCommandBeforeCurrentVersionAndRejectsAnyChangedIdentity() {
edit(11, 1, 0, "edit");
var before = snapshot();
edit(11, 1, 0, "edit");
assertThat(snapshot()).isEqualTo(before);
assertThat(version(1)).isEqualTo(1);
assertThat(count("ord_order_status_history")).isEqualTo(1);
assertThat(count("ord_idempotency_record")).isEqualTo(1);
assertThat(db.queryForObject("SELECT cargo_name FROM ord_order_cargo WHERE order_id=1", String.class)).isEqualTo("Offline cargo");
assertThatThrownBy(() -> edit(11, 1, 1, "edit")).isInstanceOf(BizException.class);
assertThatThrownBy(() -> edit(11, 2, 0, "edit")).isInstanceOf(BizException.class);
var changed = json.valueToTree(draft); ((com.fasterxml.jackson.databind.node.ObjectNode) changed).put("customerRemark", "changed");
((com.fasterxml.jackson.databind.node.ObjectNode) changed).retain(Arrays.stream(OrderRequests.SaveRequest.class.getRecordComponents()).map(java.lang.reflect.RecordComponent::getName).toList());
var changedDraft = json.convertValue(changed, OrderRequests.SaveRequest.class);
assertThatThrownBy(() -> service.updateDraft(11, 7, 1, new OrderRequests.DraftUpdateRequest(0, changedDraft), "edit")).isInstanceOf(BizException.class);
assertThat(snapshot()).isEqualTo(before);
}
@Test
void staleEditWrongCustomerAndSubmittedDraftCannotChangeAnyTable() {
edit(11, 1, 0, "first");
var before = snapshot();
assertThatThrownBy(() -> edit(11, 1, 0, "stale")).isInstanceOf(BizException.class).hasMessageContaining("版本");
assertThatThrownBy(() -> edit(12, 1, 1, "first")).isInstanceOf(BizException.class);
assertThat(snapshot()).isEqualTo(before);
db.update("UPDATE ord_order SET status='SUBMITTED' WHERE id=1");
before = snapshot();
assertThatThrownBy(() -> edit(11, 1, 1, "state")).isInstanceOf(BizException.class);
assertThat(snapshot()).isEqualTo(before);
}
@Test
void failureAfterReplacingChildrenRollsBackOrderChildrenHistoryAndCommand() {
db.execute("ALTER TABLE ord_order_status_history ADD CONSTRAINT fail_edit CHECK(action<>'UPDATE')");
var before = snapshot();
assertThatThrownBy(() -> edit(11, 1, 0, "retry-after-rollback")).isInstanceOf(RuntimeException.class);
assertThat(snapshot()).isEqualTo(before);
db.execute("ALTER TABLE ord_order_status_history DROP CONSTRAINT fail_edit");
edit(11, 1, 0, "retry-after-rollback");
assertThat(version(1)).isEqualTo(1);
}
@Test
void concurrentSameEditKeyBothSucceedWithOneHistoryAndOneVersion() throws Exception {
assertThat(concurrent(() -> edit(11, 1, 0, "same"), () -> edit(11, 1, 0, "same"))).containsExactly(true, true);
assertThat(version(1)).isEqualTo(1);
assertThat(count("ord_order_status_history")).isEqualTo(1);
assertThat(count("ord_idempotency_record")).isEqualTo(1);
}
@Test
void failedCommandCompletionRollsBackAllSuccessfulBusinessWrites() {
db.execute("ALTER TABLE ord_idempotency_record ADD CONSTRAINT fail_completion CHECK(status<>'COMPLETED')");
var before = snapshot();
assertThatThrownBy(() -> edit(11, 1, 0, "edit-rollback")).isInstanceOf(RuntimeException.class);
assertThat(snapshot()).isEqualTo(before);
assertThatThrownBy(() -> cancel(11, 1, 0, "cancel-rollback", "reason")).isInstanceOf(RuntimeException.class);
assertThat(snapshot()).isEqualTo(before);
}
@Test
void concurrentDifferentEditKeysCannotOverwriteSameOriginalVersion() throws Exception {
assertThat(concurrent(() -> edit(11, 1, 0, "a"), () -> edit(11, 1, 0, "b"))).containsExactlyInAnyOrder(true, false);
assertThat(version(1)).isEqualTo(1);
assertThat(count("ord_order_status_history")).isEqualTo(1);
assertThat(count("ord_idempotency_record")).isEqualTo(1);
}
@Test
void cancelReplaysWithoutRepeatingHistoryOutboxOrVersionAndRejectsDifferentPayload() {
cancel(11, 1, 0, "cancel", "reason");
var before = snapshot();
cancel(11, 1, 0, "cancel", "reason");
assertThat(snapshot()).isEqualTo(before);
assertThat(status(1)).isEqualTo("CANCELLED");
assertThat(count("ord_outbox_event")).isEqualTo(1);
assertThat(db.queryForObject("SELECT operator_name FROM ord_order_status_history WHERE order_id=1", String.class)).isEqualTo("operator");
assertThat(db.queryForObject("SELECT reason FROM ord_order_status_history WHERE order_id=1", String.class)).isEqualTo("reason");
assertThat(db.queryForObject("SELECT occurred_at FROM ord_order_status_history WHERE order_id=1", java.sql.Timestamp.class)).isNotNull();
assertThatThrownBy(() -> cancel(11, 1, 0, "cancel", "other")).isInstanceOf(BizException.class);
assertThatThrownBy(() -> cancel(11, 1, 1, "cancel", "reason")).isInstanceOf(BizException.class);
assertThatThrownBy(() -> cancel(11, 2, 0, "cancel", "reason")).isInstanceOf(BizException.class);
assertThatThrownBy(() -> cancel(11, 1, 1, "new-command", "reason")).isInstanceOf(BizException.class);
assertThat(snapshot()).isEqualTo(before);
}
@Test
void staleCancelAndCrossCustomerRejectWithoutLeavingCommandRecords() {
var before = snapshot();
assertThatThrownBy(() -> cancel(11, 1, 9, "stale", "reason")).isInstanceOf(BizException.class);
assertThatThrownBy(() -> cancel(12, 1, 0, "other", "reason")).isInstanceOf(BizException.class);
assertThat(snapshot()).isEqualTo(before);
}
@Test
void sameKeyIsIsolatedByCustomerAndOperator() {
cancel(11, 1, 0, "shared", "reason");
order(2, 12, "DRAFT", "MASTER", null);
cancel(12, 2, 0, "shared", "reason");
order(3, 11, "DRAFT", "MASTER", null);
service.cancel(11, 8, "another", 3, new OrderRequests.CancelRequest(0, "reason"), "shared");
assertThat(count("ord_idempotency_record")).isEqualTo(3);
var before = snapshot();
assertThatThrownBy(() -> cancel(12, 1, 0, "cross-customer", "reason")).isInstanceOf(BizException.class);
assertThat(snapshot()).isEqualTo(before);
}
@Test
void concurrentCancellationKeysSerializeAndOriginalKeyReplays() throws Exception {
assertThat(concurrent(() -> cancel(11, 1, 0, "same", "reason"), () -> cancel(11, 1, 0, "same", "reason"))).containsExactly(true, true);
assertThat(count("ord_outbox_event")).isEqualTo(1);
order(2, 11, "DRAFT", "MASTER", null);
assertThat(concurrent(() -> cancel(11, 2, 0, "a", "reason"), () -> cancel(11, 2, 0, "b", "reason"))).containsExactlyInAnyOrder(true, false);
assertThat(count("ord_outbox_event")).isEqualTo(2);
}
@Test
void cancellationAndFinalRootAggregationRollbackTogetherAndRetryOnce() {
db.update("UPDATE ord_order SET status='PROCESSING' WHERE id=1");
order(2, 11, "APPROVED", "CHILD", 1L);
db.update("INSERT INTO ord_order_status_history(id,tenant_id,order_id,action,occurred_at) VALUES(301,11,1,'OVERSEAS_DELIVERED',TIMESTAMP '2026-10-02 01:00:00')");
db.execute("ALTER TABLE ord_outbox_event ADD CONSTRAINT fail_root CHECK(event_type<>'FIRST_LEG_ORDER_FULFILLMENT_COMPLETED')");
var before = snapshot();
assertThatThrownBy(() -> cancel(11, 2, 0, "last-child", "reason")).isInstanceOf(RuntimeException.class);
assertThat(snapshot()).isEqualTo(before);
db.execute("ALTER TABLE ord_outbox_event DROP CONSTRAINT fail_root");
cancel(11, 2, 0, "last-child", "reason");
assertThat(status(1)).isEqualTo("COMPLETED");
assertThat(status(2)).isEqualTo("CANCELLED");
assertThat(count("ord_outbox_event")).isEqualTo(2);
before = snapshot();
cancel(11, 2, 0, "last-child", "reason");
assertThat(snapshot()).isEqualTo(before);
}
@Test
void missingVersionKeyOrDraftCannotReachPersistence() {
var before = snapshot();
assertThatThrownBy(() -> service.updateDraft(11, 7, 1, new OrderRequests.DraftUpdateRequest(null, draft), "x")).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.updateDraft(11, 7, 1, new OrderRequests.DraftUpdateRequest(0, null), "x")).isInstanceOf(BizException.class);
assertThatThrownBy(() -> edit(11, 1, 0, " ")).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.cancel(11, 7, "operator", 1, new OrderRequests.CancelRequest(-1, "reason"), "x")).isInstanceOf(BizException.class);
assertThatThrownBy(() -> cancel(11, 1, 0, null, "reason")).isInstanceOf(BizException.class);
assertThat(snapshot()).isEqualTo(before);
}
private void edit(long customer, long id, int version, String key) {
service.updateDraft(customer, 7, id, new OrderRequests.DraftUpdateRequest(version, draft), key);
}
private void cancel(long customer, long id, int version, String key, String reason) {
service.cancel(customer, 7, "operator", id, new OrderRequests.CancelRequest(version, reason), key);
}
private int version(long id) { return db.queryForObject("SELECT version FROM ord_order WHERE id=?", Integer.class, id); }
private String status(long id) { return db.queryForObject("SELECT status FROM ord_order WHERE id=?", String.class, id); }
private int count(String table) { return db.queryForObject("SELECT COUNT(*) FROM " + table, Integer.class); }
private List<Object> snapshot() {
return List.of("ord_order", "ord_order_cargo", "ord_order_party", "ord_order_status_history", "ord_outbox_event", "ord_idempotency_record")
.stream().map(table -> (Object) db.queryForList("SELECT * FROM " + table + " ORDER BY id")).toList();
}
private void order(long id, long customer, String status, String role, Long parent) {
db.update("INSERT INTO ord_order(id,tenant_id,customer_id,order_no,inbound_code,status,order_role,parent_order_id,root_order_id,version,deleted) VALUES(?,?,?,?,?,?,?,?,?,0,FALSE)",
id, customer, customer, "OFFLINE-" + id, "OFFLINE-IN-" + id, status, role, parent, parent == null ? id : parent);
}
private List<Boolean> concurrent(Runnable first, Runnable second) throws Exception {
var workers = Executors.newFixedThreadPool(2);
var start = new CountDownLatch(1);
try {
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; }
}));
start.countDown();
return List.of(results.get(0).get(15, TimeUnit.SECONDS), results.get(1).get(15, TimeUnit.SECONDS));
} finally { workers.shutdownNow(); }
}
@SuppressWarnings("unchecked")
private <T> T proxy(T target, DataSourceTransactionManager manager) {
ProxyFactory factory = new ProxyFactory(target); factory.setProxyTargetClass(true);
factory.addAdvice(new TransactionInterceptor(manager, new AnnotationTransactionAttributeSource()));
return (T) factory.getProxy();
}
private void table(Class<?> type) {
List<String> columns = new ArrayList<>();
for (Class<?> current = type; current != Object.class; current = current.getSuperclass()) {
for (var field : current.getDeclaredFields()) {
if (Modifier.isStatic(field.getModifiers()) || field.isSynthetic()) continue;
TableField mapping = field.getAnnotation(TableField.class);
if (mapping != null && !mapping.exist()) continue;
String name = mapping != null && !mapping.value().isEmpty() ? mapping.value()
: field.getName().replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase(Locale.ROOT);
Class<?> value = field.getType();
String sql = value == Long.class ? "BIGINT" : value == Integer.class ? "INT" : value == Boolean.class ? "BOOLEAN"
: value == BigDecimal.class ? "DECIMAL(24,6)" : value == LocalDateTime.class ? "TIMESTAMP"
: value == LocalDate.class ? "DATE" : "VARCHAR(16000)";
columns.add(name + " " + sql + (name.equals("id") ? " PRIMARY KEY" : name.equals("version") ? " DEFAULT 0" : name.equals("deleted") ? " DEFAULT FALSE" : ""));
}
}
db.execute("CREATE TABLE " + type.getAnnotation(TableName.class).value() + " (" + String.join(",", columns) + ")");
}
}
@@ -220,6 +220,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.DRAFT);
@@ -227,7 +228,7 @@ class OrderApplicationServiceStateTest {
when(masterData.validate(10001L, request)).thenReturn(snapshots());
when(repository.saveOrder(10001L, order)).thenReturn(1);
service(repository, eligibility, masterData).updateDraft(10001L, 2L, 1L, request);
service(repository, eligibility, masterData).updateDraft(10001L, 2L, 1L, new OrderRequests.DraftUpdateRequest(0, request), "edit-key");
verify(eligibility, never()).requireApproved(anyLong());
verify(repository).replaceDraftChildren(10001L, 1L);
@@ -247,7 +248,7 @@ class OrderApplicationServiceStateTest {
OrderApplicationService service = service(repository, mock(OrderEligibilityGuard.class), masterData);
ReflectionTestUtils.setField(service, "pricingChannelGuard", pricing);
service.updateDraft(10001L, 2L, 1L, request);
service.updateDraft(10001L, 2L, 1L, new OrderRequests.DraftUpdateRequest(0, request), "edit-key");
var ordered = inOrder(pricing, repository);
ordered.verify(pricing).requireAvailable(10001L, 8L, "CH", "OVERSEAS");
@@ -261,7 +262,7 @@ class OrderApplicationServiceStateTest {
when(repository.findOwned(10001L, 1L)).thenReturn(order);
assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class))
.updateDraft(10001L, 2L, 1L, requestWithCargoQuantities(5, 24, 119)))
.updateDraft(10001L, 2L, 1L, new OrderRequests.DraftUpdateRequest(0, requestWithCargoQuantities(5, 24, 119)), "edit-key"))
.isInstanceOf(BizException.class)
.hasMessage("第 1 条货物总数量应等于每箱数量 × 箱数(120)")
.extracting(exception -> ((BizException) exception).getResultCode())
@@ -693,6 +694,7 @@ class OrderApplicationServiceStateTest {
void merchantDraftEditorStillCannotEditSubmittedOrder() {
OrderRepository repository = mock(OrderRepository.class);
OrderEntity order = new OrderEntity();
order.setVersion(0);
order.setId(1L);
order.setTenantId(10001L);
order.setStatus(OrderStatus.SUBMITTED);
@@ -701,7 +703,7 @@ class OrderApplicationServiceStateTest {
when(masterData.validate(eq(10001L), any(OrderRequests.SaveRequest.class))).thenReturn(snapshots());
assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), masterData)
.updateDraft(10001L, 2L, 1L, request()))
.updateDraft(10001L, 2L, 1L, new OrderRequests.DraftUpdateRequest(0, request()), "edit-key"))
.isInstanceOf(BizException.class)
.hasMessage("当前订单状态不允许编辑订单资料")
.extracting(exception -> ((BizException) exception).getResultCode())
@@ -890,7 +892,7 @@ class OrderApplicationServiceStateTest {
OrderRepository repository = mock(OrderRepository.class);
assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class),
mock(OrderMasterDataGuard.class)).updateDraft(20002L, 2L, 1L, request()))
mock(OrderMasterDataGuard.class)).updateDraft(20002L, 2L, 1L, new OrderRequests.DraftUpdateRequest(0, request()), "edit-key"))
.isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.NOT_FOUND);
@@ -962,6 +964,7 @@ class OrderApplicationServiceStateTest {
OrderPricingChannelGuard pricing = mock(OrderPricingChannelGuard.class);
when(pricing.requireAvailable(anyLong(), anyLong(), anyString(), anyString())).thenReturn("PCHWK");
when(repository.nextInboundCodeSequence(any(LocalDate.class))).thenReturn(1L);
when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1);
ReflectionTestUtils.setField(service, "pricingChannelGuard", pricing);
ReflectionTestUtils.setField(service, "productImageGuard", mock(ProductImageGuard.class));
useInboundCodeMode(service, InboundCodeModeProvider.Mode.AUTO);
@@ -1127,6 +1130,7 @@ class OrderApplicationServiceStateTest {
private OrderEntity persistedOrder(OrderStatus status) {
OrderEntity order = new OrderEntity();
order.setVersion(0);
order.setId(1L);
order.setTenantId(10001L);
order.setStatus(status);