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]