暂存提交

This commit is contained in:
2026-07-31 14:55:34 +08:00
parent bc3c18a6e9
commit 7a19e2f096
28 changed files with 655 additions and 46 deletions
@@ -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);
+18
View File
@@ -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,