暂存提交
This commit is contained in:
+16
-5
@@ -23,6 +23,7 @@ import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@@ -45,13 +46,18 @@ public class MerchantFirstLegOrderController {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Draft created / 草稿创建成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid order data / 订单数据无效"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Authentication required / 需要登录"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Merchant scope required / 需要商户端权限")
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Merchant scope required / 需要商户端权限"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "幂等键冲突或请求仍在处理中"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "503", description = "主数据下游服务暂时不可用")
|
||||
})
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAuthority('merchant:order:create')")
|
||||
public ApiResponse<Long> create(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Parameter(description = "幂等键,同一操作者的同一请求重试时必须保持不变", required = true)
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@Valid @RequestBody OrderRequests.SaveRequest request) {
|
||||
return ApiResponse.success(service.createDraft(OrderScopeGuard.merchant(user), user.userId(), request));
|
||||
return ApiResponse.success(service.createDraft(
|
||||
OrderScopeGuard.merchant(user), user.userId(), request, idempotencyKey));
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "ORDER_UPDATE", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Update draft / 修改草稿")
|
||||
@@ -104,15 +110,20 @@ public class MerchantFirstLegOrderController {
|
||||
@Operation(summary = "Submit / 提交", description = "Submits a draft order for platform review and records status history. / 提交订单草稿进入平台审核,并记录状态历史。")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Order submitted / 订单提交成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "缺少或使用了无效幂等键"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Order not found / 订单不存在"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Invalid status transition / 状态流转无效")
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Invalid status transition / 状态流转或幂等键冲突"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "503", description = "主数据下游服务暂时不可用")
|
||||
})
|
||||
@PostMapping("/{id}/submit")
|
||||
@PreAuthorize("hasAuthority('merchant:order:submit')")
|
||||
public ApiResponse<Void> submit(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Parameter(description = "Order ID / 订单 ID", example = "190000000000000001", required = true) @PathVariable long id) {
|
||||
return transit(user, id, OrderStatus.SUBMITTED, null);
|
||||
@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);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "ORDER_CANCEL", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Cancel order / 取消订单")
|
||||
|
||||
@@ -24,10 +24,12 @@ public class OrderRepository {
|
||||
private final OrderStatusHistoryMapper historyMapper;
|
||||
private final OutboxEventMapper outboxMapper;
|
||||
private final OrderChildPurgeMapper childPurgeMapper;
|
||||
private final OrderIdempotencyRecordMapper idempotencyMapper;
|
||||
|
||||
public OrderRepository(OrderMapper orderMapper, OrderCargoMapper cargoMapper, OrderPartyMapper partyMapper,
|
||||
OrderInternalNoteMapper noteMapper, OrderStatusHistoryMapper historyMapper,
|
||||
OutboxEventMapper outboxMapper, OrderChildPurgeMapper childPurgeMapper) {
|
||||
OutboxEventMapper outboxMapper, OrderChildPurgeMapper childPurgeMapper,
|
||||
OrderIdempotencyRecordMapper idempotencyMapper) {
|
||||
this.orderMapper = orderMapper;
|
||||
this.cargoMapper = cargoMapper;
|
||||
this.partyMapper = partyMapper;
|
||||
@@ -35,6 +37,7 @@ public class OrderRepository {
|
||||
this.historyMapper = historyMapper;
|
||||
this.outboxMapper = outboxMapper;
|
||||
this.childPurgeMapper = childPurgeMapper;
|
||||
this.idempotencyMapper = idempotencyMapper;
|
||||
}
|
||||
|
||||
public void insertOrder(OrderEntity order) { orderMapper.insert(order); }
|
||||
@@ -44,6 +47,23 @@ public class OrderRepository {
|
||||
public void insertNote(OrderInternalNoteEntity note) { noteMapper.insert(note); }
|
||||
public void insertHistory(OrderStatusHistoryEntity history) { historyMapper.insert(history); }
|
||||
public void insertOutbox(OutboxEventEntity event) { outboxMapper.insert(event); }
|
||||
public void insertIdempotency(OrderIdempotencyRecordEntity record) { idempotencyMapper.insert(record); }
|
||||
|
||||
public OrderIdempotencyRecordEntity findIdempotency(
|
||||
long tenantId, long operatorId, String operation, String idempotencyKey) {
|
||||
return idempotencyMapper.selectOne(new LambdaQueryWrapper<OrderIdempotencyRecordEntity>()
|
||||
.eq(OrderIdempotencyRecordEntity::getTenantId, tenantId)
|
||||
.eq(OrderIdempotencyRecordEntity::getOperatorId, operatorId)
|
||||
.eq(OrderIdempotencyRecordEntity::getOperation, operation)
|
||||
.eq(OrderIdempotencyRecordEntity::getIdempotencyKey, idempotencyKey));
|
||||
}
|
||||
|
||||
public int completeIdempotency(OrderIdempotencyRecordEntity record, Long resultOrderId) {
|
||||
record.setStatus("COMPLETED");
|
||||
record.setResultOrderId(resultOrderId);
|
||||
record.setCompletedAt(java.time.LocalDateTime.now());
|
||||
return idempotencyMapper.updateById(record);
|
||||
}
|
||||
|
||||
public OrderEntity findOwned(long tenantId, long orderId) {
|
||||
return orderMapper.selectOne(new LambdaQueryWrapper<OrderEntity>()
|
||||
|
||||
@@ -10,11 +10,15 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -26,8 +30,20 @@ public class OrderApplicationService {
|
||||
@Resource private OrderEligibilityGuard eligibilityGuard;
|
||||
@Resource private OrderMasterDataGuard masterDataGuard;
|
||||
|
||||
/** 创建草稿时使用数据库唯一键保证请求幂等。 */
|
||||
@Transactional
|
||||
public Long createDraft(long tenantId, long userId, OrderRequests.SaveRequest request) {
|
||||
public Long createDraft(long tenantId, long userId, OrderRequests.SaveRequest request, String idempotencyKey) {
|
||||
IdempotencyContext idempotency = beginIdempotency(tenantId, userId, "CREATE_DRAFT", 0L, idempotencyKey,
|
||||
requestHash("CREATE_DRAFT", request));
|
||||
if (idempotency.replayed()) {
|
||||
return idempotency.record().getResultOrderId();
|
||||
}
|
||||
Long orderId = createDraftInternal(tenantId, userId, request);
|
||||
completeIdempotency(idempotency, orderId);
|
||||
return orderId;
|
||||
}
|
||||
|
||||
private Long createDraftInternal(long tenantId, long userId, OrderRequests.SaveRequest request) {
|
||||
eligibilityGuard.requireApproved(tenantId);
|
||||
validatePrototypeFields(request);
|
||||
var snapshots = masterDataGuard.validate(tenantId, request);
|
||||
@@ -43,10 +59,10 @@ public class OrderApplicationService {
|
||||
|
||||
@Transactional
|
||||
public void updateDraft(long tenantId, long userId, long id, OrderRequests.SaveRequest request) {
|
||||
OrderEntity order = require(tenantId, id);
|
||||
eligibilityGuard.requireApproved(tenantId);
|
||||
validatePrototypeFields(request);
|
||||
var snapshots = masterDataGuard.validate(tenantId, request);
|
||||
OrderEntity order = require(tenantId, id);
|
||||
if (order.getStatus() != OrderStatus.DRAFT && order.getStatus() != OrderStatus.REJECTED) {
|
||||
throw new BizException("Only draft or rejected orders can be edited");
|
||||
}
|
||||
@@ -58,8 +74,8 @@ public class OrderApplicationService {
|
||||
|
||||
@Transactional
|
||||
public void transit(long tenantId, long userId, String username, long id, OrderStatus target, String reason) {
|
||||
if (target == OrderStatus.SUBMITTED) eligibilityGuard.requireApproved(tenantId);
|
||||
OrderEntity order = require(tenantId, id);
|
||||
if (target == OrderStatus.SUBMITTED) eligibilityGuard.requireApproved(tenantId);
|
||||
OrderStatus from = order.getStatus();
|
||||
if (!allowed(from, target)) throw new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
|
||||
from == target ? "该操作已完成,请勿重复提交" : "订单状态不允许当前操作: " + from + " -> " + target);
|
||||
@@ -79,6 +95,18 @@ public class OrderApplicationService {
|
||||
if (target == OrderStatus.APPROVED || target == OrderStatus.CANCELLED) writeOutbox(order, target);
|
||||
}
|
||||
|
||||
/** 提交订单的幂等入口。 */
|
||||
@Transactional
|
||||
public void submit(long tenantId, long userId, String username, long id, String idempotencyKey) {
|
||||
IdempotencyContext idempotency = beginIdempotency(tenantId, userId, "SUBMIT_ORDER", id, idempotencyKey,
|
||||
requestHash("SUBMIT_ORDER", Map.of("orderId", id)));
|
||||
if (idempotency.replayed()) {
|
||||
return;
|
||||
}
|
||||
transit(tenantId, userId, username, id, OrderStatus.SUBMITTED, null);
|
||||
completeIdempotency(idempotency, id);
|
||||
}
|
||||
|
||||
public PageResult<OrderEntity> page(long tenantId, OrderRequests.QueryRequest query) { return repository.page(tenantId, query); }
|
||||
public OrderDetailResponse detail(long tenantId, long id) {
|
||||
OrderEntity order = require(tenantId, id);
|
||||
@@ -149,9 +177,83 @@ public class OrderApplicationService {
|
||||
|
||||
private OrderEntity require(long tenantId, long id) {
|
||||
OrderEntity order = repository.findOwned(tenantId, id);
|
||||
if (order == null) throw new BizException("Order not found");
|
||||
if (order == null) throw new BizException(com.baiyue.common.core.api.ResultCode.NOT_FOUND, "Order not found");
|
||||
return order;
|
||||
}
|
||||
|
||||
private IdempotencyContext beginIdempotency(long tenantId, long operatorId, String operation, long resourceId,
|
||||
String rawKey, String requestHash) {
|
||||
if (!StringUtils.hasText(rawKey)) {
|
||||
throw new BizException(com.baiyue.common.core.api.ResultCode.BAD_REQUEST,
|
||||
"必须提供 Idempotency-Key");
|
||||
}
|
||||
String key = rawKey.trim();
|
||||
if (key.length() > 128) {
|
||||
throw new BizException(com.baiyue.common.core.api.ResultCode.VALIDATION_FAILED,
|
||||
"Idempotency-Key length must not exceed 128");
|
||||
}
|
||||
OrderIdempotencyRecordEntity record = new OrderIdempotencyRecordEntity();
|
||||
record.setId(IdWorker.getId());
|
||||
record.setTenantId(tenantId);
|
||||
record.setOperatorId(operatorId);
|
||||
record.setOperation(operation);
|
||||
record.setResourceId(resourceId);
|
||||
record.setIdempotencyKey(key);
|
||||
record.setRequestHash(requestHash);
|
||||
record.setStatus("PROCESSING");
|
||||
record.setCreatedAt(LocalDateTime.now());
|
||||
record.setUpdatedAt(LocalDateTime.now());
|
||||
try {
|
||||
repository.insertIdempotency(record);
|
||||
return IdempotencyContext.created(record);
|
||||
} catch (DuplicateKeyException duplicate) {
|
||||
OrderIdempotencyRecordEntity existing = repository.findIdempotency(
|
||||
tenantId, operatorId, operation, key);
|
||||
if (existing == null || !requestHash.equals(existing.getRequestHash())) {
|
||||
throw new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
|
||||
"Idempotency-Key conflicts with a different request");
|
||||
}
|
||||
if (!"COMPLETED".equals(existing.getStatus())) {
|
||||
throw new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
|
||||
"The idempotent request is still being processed");
|
||||
}
|
||||
return IdempotencyContext.replayed(existing);
|
||||
}
|
||||
}
|
||||
|
||||
private void completeIdempotency(IdempotencyContext context, Long resultOrderId) {
|
||||
if (context.enabled() && !context.replayed()) {
|
||||
if (repository.completeIdempotency(context.record(), resultOrderId) != 1) {
|
||||
throw new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
|
||||
"幂等记录已被并发修改");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String requestHash(String operation, Object request) {
|
||||
try {
|
||||
byte[] source = (operation + ":" + json.writeValueAsString(request)).getBytes(StandardCharsets.UTF_8);
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256").digest(source);
|
||||
StringBuilder result = new StringBuilder(64);
|
||||
for (byte value : digest) {
|
||||
result.append(String.format("%02x", value));
|
||||
}
|
||||
return result.toString();
|
||||
} catch (JsonProcessingException | NoSuchAlgorithmException exception) {
|
||||
throw new BizException(com.baiyue.common.core.api.ResultCode.INTERNAL_ERROR,
|
||||
"Cannot create idempotency request fingerprint");
|
||||
}
|
||||
}
|
||||
|
||||
private record IdempotencyContext(OrderIdempotencyRecordEntity record, boolean enabled, boolean replayed) {
|
||||
static IdempotencyContext disabled() { return new IdempotencyContext(null, false, false); }
|
||||
static IdempotencyContext created(OrderIdempotencyRecordEntity record) {
|
||||
return new IdempotencyContext(record, true, false);
|
||||
}
|
||||
static IdempotencyContext replayed(OrderIdempotencyRecordEntity record) {
|
||||
return new IdempotencyContext(record, true, true);
|
||||
}
|
||||
}
|
||||
private boolean allowed(OrderStatus from, OrderStatus target) {
|
||||
return switch (from) {
|
||||
case DRAFT -> target == OrderStatus.SUBMITTED || target == OrderStatus.CANCELLED;
|
||||
|
||||
@@ -40,6 +40,10 @@ public class OrderMasterDataGuard {
|
||||
throw new BizException(ResultCode.NOT_FOUND,"往来单位或仓库不存在、已停用或不属于当前企业");
|
||||
} catch (FeignException exception) {
|
||||
throw unavailable("主数据");
|
||||
} catch (BizException exception) {
|
||||
throw exception;
|
||||
} catch (RuntimeException exception) {
|
||||
throw unavailable("主数据");
|
||||
}
|
||||
}
|
||||
public void validatePersisted(long tenantId, Long partnerId, Long receivingWarehouseId, Long destinationWarehouseId) {
|
||||
@@ -51,7 +55,13 @@ public class OrderMasterDataGuard {
|
||||
if (destinationWarehouseId!=null) loadWarehouse(tenantId,destinationWarehouseId,"目的仓库");
|
||||
} catch (FeignException.NotFound exception) {
|
||||
throw new BizException(ResultCode.NOT_FOUND,"往来单位或仓库已停用、越权或不存在");
|
||||
} catch (FeignException exception) { throw unavailable("主数据"); }
|
||||
} catch (FeignException exception) {
|
||||
throw unavailable("主数据");
|
||||
} catch (BizException exception) {
|
||||
throw exception;
|
||||
} catch (RuntimeException exception) {
|
||||
throw unavailable("主数据");
|
||||
}
|
||||
}
|
||||
private PartnerClient.Partner loadPartner(long tenantId,long id) {
|
||||
var response=partners.active(token,tenantId,id);
|
||||
|
||||
@@ -161,3 +161,21 @@ CREATE TABLE IF NOT EXISTS ord_outbox_event (
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_outbox_publish (publish_status, next_retry_at, created_at)
|
||||
) ENGINE=InnoDB COMMENT='订单事务Outbox表:与订单业务变更在同一本地事务写入,供后台发布器可靠投递RocketMQ,保证数据库状态与领域事件最终一致;不是普通操作日志';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ord_idempotency_record (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '幂等记录ID',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '租户ID',
|
||||
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作者ID',
|
||||
operation VARCHAR(32) NOT NULL COMMENT '幂等操作类型',
|
||||
resource_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '资源ID,创建草稿时为0',
|
||||
idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键',
|
||||
request_hash CHAR(64) NOT NULL COMMENT '请求内容SHA-256摘要',
|
||||
status VARCHAR(16) NOT NULL COMMENT '处理状态:PROCESSING或COMPLETED',
|
||||
result_order_id BIGINT UNSIGNED NULL COMMENT '成功结果订单ID',
|
||||
completed_at DATETIME(3) NULL COMMENT '处理完成时间',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_order_idempotency_scope (tenant_id, operator_id, operation, idempotency_key),
|
||||
KEY idx_order_idempotency_created (created_at)
|
||||
) ENGINE=InnoDB COMMENT='订单写接口持久化幂等记录:唯一键保证同租户同操作者并发请求只执行一次';
|
||||
|
||||
@@ -59,6 +59,16 @@ class OrderMasterDataGuardTest {
|
||||
.isEqualTo(ResultCode.SERVICE_UNAVAILABLE);
|
||||
verifyNoInteractions(warehouses);
|
||||
}
|
||||
@Test
|
||||
void 下游非Feign运行时异常映射为服务不可用() {
|
||||
WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class);
|
||||
when(partners.active("token",10001,7)).thenThrow(new IllegalStateException("解码失败"));
|
||||
|
||||
assertThatThrownBy(() -> new OrderMasterDataGuard(warehouses,partners,"token").validate(10001,request(7L,8L)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.extracting(exception -> ((BizException) exception).getResultCode())
|
||||
.isEqualTo(ResultCode.SERVICE_UNAVAILABLE);
|
||||
}
|
||||
private OrderRequests.SaveRequest request(Long partner,Long warehouse) {
|
||||
var party=new OrderRequests.Party("公司","联系人","13800138000",null,"CN",null,"深圳",null,"地址",null);
|
||||
var cargo=new OrderRequests.Cargo("货物",null,"CARTON",1,BigDecimal.ONE,BigDecimal.ONE,
|
||||
|
||||
Reference in New Issue
Block a user