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]