fix(order): make merchant draft edits and cancellations versioned and idempotent
This commit is contained in:
+7
-11
@@ -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");
|
||||
}
|
||||
}
|
||||
+304
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user