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]
|
||||
|
||||
Reference in New Issue
Block a user