fix(order): remove manual completion bypass of fulfillment gate

This commit is contained in:
2026-09-30 15:26:35 +08:00
parent 22584e7fde
commit 71202bce70
6 changed files with 31 additions and 16 deletions
@@ -291,16 +291,6 @@ public class PlatformFirstLegOrderController {
return transit(user, tenantId, id, OrderStatus.PROCESSING, null);
}
@BusinessAudit(event = "ORDER_COMPLETE", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Complete order / 完成订单")
@Operation(summary = "Complete / 完成订单", description = "Marks a processing order as completed and publishes the integration event. / 将处理中订单标记为完成,并发布集成事件。")
@PostMapping("/{id}/complete")
@PreAuthorize("hasAuthority('platform:order:complete')")
public ApiResponse<Void> complete(@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) {
return transit(user, tenantId, id, OrderStatus.COMPLETED, null);
}
/** 仅履约权限可登记快递信息,商户端保持只读。 */
@PutMapping("/{id}/express")
@PreAuthorize("hasAuthority('platform:order:fulfillment')")
@@ -174,6 +174,9 @@ public class OrderApplicationService {
@Transactional
public void transit(long tenantId, long userId, String username, long id, OrderStatus target, String reason,
Integer expectedVersion) {
if (target == OrderStatus.COMPLETED) {
throw new BizException(ResultCode.CONFLICT, "订单完成须由真实签收和全部有效履约分片完成后自动确认");
}
if (target == OrderStatus.CANCELLED) fulfillmentCompletion.lockCancellationRoot(tenantId, id);
OrderEntity order = require(tenantId, id);
if (expectedVersion != null && !expectedVersion.equals(order.getVersion())) {
@@ -205,7 +208,6 @@ public class OrderApplicationService {
requirePrintableInboundCode(order);
order.setSubmittedAt(LocalDateTime.now());
}
if (target == OrderStatus.COMPLETED) order.setCompletedAt(LocalDateTime.now());
if (repository.saveOrder(tenantId, order) != 1) throw new BizException("Concurrent order update");
record(tenantId, id, from, target, target.name(), reason, userId, username);
if (target == OrderStatus.SUBMITTED) prepareSubmittedInbound(order);
@@ -585,7 +587,6 @@ public class OrderApplicationService {
case SUBMITTED -> target == OrderStatus.APPROVED || target == OrderStatus.REJECTED || target == OrderStatus.CANCELLED;
case APPROVED -> target == OrderStatus.ACCEPTED || target == OrderStatus.CANCELLED;
case ACCEPTED -> target == OrderStatus.PROCESSING || target == OrderStatus.CANCELLED;
case PROCESSING -> target == OrderStatus.COMPLETED;
case REJECTED -> target == OrderStatus.DRAFT || target == OrderStatus.CANCELLED;
default -> false;
};
@@ -16,6 +16,8 @@ paths:
已完成才完成根订单;来源子单独立完成并在根锁下重新评估聚合。明确 CANCELLED 子单
不参与完成门禁,其他未完成状态均阻断。重复事实不得重复写历史。响应明确返回来源状态
status 和履约根 rootOrderId/rootStatus;根尚未聚合完成时允许保留其原状态。
订单完成仅由此可信履约回写和取消后聚合门禁产生,普通订单状态流转禁止目标 COMPLETED,
平台不提供手工完成订单接口。
parameters:
- name: X-Internal-Service-Token
in: header
@@ -49,6 +49,32 @@ class OrderApplicationServiceStateTest {
static class TestConfig {
@Bean ObjectMapper objectMapper() { return new ObjectMapper().findAndRegisterModules(); }
}
@Test
void manualCompletionRejectsEveryStateAndRoleWithoutWritingFulfillmentFacts() {
for (String role : List.of("MASTER", "CHILD")) {
for (OrderStatus status : OrderStatus.values()) {
OrderRepository repository = mock(OrderRepository.class);
OrderEntity order = persistedOrder(status);
order.setOrderRole(role);
when(repository.findOwned(10001L, 1L)).thenReturn(order);
clearInvocations(repository);
OrderFulfillmentCompletionService completion = mock(OrderFulfillmentCompletionService.class);
var app = service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class));
ReflectionTestUtils.setField(app, "fulfillmentCompletion", completion);
assertThatThrownBy(() -> app.transit(10001, 2, "u", 1, OrderStatus.COMPLETED, null))
.isInstanceOf(BizException.class)
.hasMessageContaining("真实签收");
assertThatThrownBy(() -> app.transit(10001, 2, "u", 1, OrderStatus.COMPLETED, null, 0))
.isInstanceOf(BizException.class)
.hasMessageContaining("真实签收");
verifyNoInteractions(repository, completion);
assertThat(order.getStatus()).isEqualTo(status);
assertThat(order.getCompletedAt()).isNull();
}
}
}
@Test
void cancellationLocksRootBeforeSourceWriteAndThenAggregates() {
OrderRepository repository = mock(OrderRepository.class);