diff --git a/file/src/main/resources/openapi/domestic-order-export-v1.yaml b/file/src/main/resources/openapi/domestic-order-export-v1.yaml index b29e1b41b..e2b788aad 100644 --- a/file/src/main/resources/openapi/domestic-order-export-v1.yaml +++ b/file/src/main/resources/openapi/domestic-order-export-v1.yaml @@ -19,8 +19,27 @@ paths: application/json: schema: {$ref: '#/components/schemas/CreateDomesticOrderExportTask'} responses: - '200': {description: 返回异步任务,初始状态 PENDING} + '200': + description: 返回异步任务,初始状态 PENDING + content: + application/json: + schema: {$ref: '#/components/schemas/ExportTaskRetryState'} '403': {description: 缺少 platform:order:export} + /api/platform/v1/files/export-tasks/{taskId}/retry: + post: + summary: 人工重试订单导出任务 + description: >- + FAILED 任务重新开始一次执行;PENDING 且已自动失败过的退避任务立即到期并复用当前投递消息, + 不新增并发任务。RUNNING、初次等待消费及已完成任务不允许重复重试。 + parameters: + - {name: taskId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + responses: + '200': + description: 返回已重新进入立即执行队列的任务 + content: + application/json: + schema: {$ref: '#/components/schemas/ExportTaskRetryState'} + '409': {description: 任务当前状态不允许重试或已进入执行队列} /api/platform/v1/files/export-tasks/{taskId}/download: get: summary: 下载本人已完成的订单导出文件 @@ -37,6 +56,16 @@ paths: schema: {type: string, format: binary} components: schemas: + ExportTaskRetryState: + type: object + required: [id, status, attemptCount, maxAttempts] + properties: + id: {type: string, pattern: '^[1-9][0-9]*$'} + status: {type: string, enum: [PENDING, RUNNING, SUCCEEDED, FAILED, EXPIRED]} + failureMessage: {type: string, nullable: true} + attemptCount: {type: integer, minimum: 0} + maxAttempts: {type: integer, minimum: 1} + nextRetryAt: {type: string, format: date-time, nullable: true} CreateDomesticOrderExportTask: type: object required: [exportType, format, taskName, parameters] diff --git a/file/src/test/java/com/baiyue/file/config/DomesticWarehouseOrderExportContractTest.java b/file/src/test/java/com/baiyue/file/config/DomesticWarehouseOrderExportContractTest.java index 9e315807c..fbe7daa45 100644 --- a/file/src/test/java/com/baiyue/file/config/DomesticWarehouseOrderExportContractTest.java +++ b/file/src/test/java/com/baiyue/file/config/DomesticWarehouseOrderExportContractTest.java @@ -39,6 +39,7 @@ class DomesticWarehouseOrderExportContractTest { "file/src/main/resources/openapi/domestic-order-export-v1.yaml")); Map document = map(new Yaml().load(source)); Map schemas = map(map(document.get("components")).get("schemas")); + Map paths = map(document.get("paths")); Map parameters = map(map(schemas.get("DomesticOrderExportParameters")).get("properties")); Map columns = map(map(schemas.get("DomesticOrderExportColumns")).get("properties")); @@ -57,6 +58,10 @@ class DomesticWarehouseOrderExportContractTest { .doesNotContainKeys("orderId", "tenantId", "customerId", "inboundId", "receivingWarehouseId", "destinationWarehouseId"); assertThat(source).contains("platform:order:export", "selectedOrderNos 非空时优先", "no-store"); + assertThat(paths).containsKey("/api/platform/v1/files/export-tasks/{taskId}/retry"); + assertThat(map(map(schemas.get("ExportTaskRetryState")).get("properties"))) + .containsKeys("attemptCount", "maxAttempts", "nextRetryAt", "failureMessage"); + assertThat(source).contains("PENDING 且已自动失败过的退避任务立即到期并复用当前投递消息"); } private static Path repositoryRoot() { diff --git a/file/src/test/java/com/baiyue/file/mapper/ExportMapperContractTest.java b/file/src/test/java/com/baiyue/file/mapper/ExportMapperContractTest.java index af8f29d64..3583f87d3 100644 --- a/file/src/test/java/com/baiyue/file/mapper/ExportMapperContractTest.java +++ b/file/src/test/java/com/baiyue/file/mapper/ExportMapperContractTest.java @@ -43,6 +43,19 @@ class ExportMapperContractTest { assertFalse(sql.contains("CLAIMED")); } + @Test void manualExpediteOnlyAdvancesTheCurrentPendingBackoffMessage() throws Exception { + String taskSql=update(FileExportTaskMapper.class,"expeditePendingRetry"); + String outboxSql=update(FileExportOutboxMapper.class,"expeditePendingRetry"); + for (String sql : java.util.List.of(taskSql,outboxSql)) { + assertTrue(sql.contains("dispatch_version=#{dispatchVersion}")); + assertTrue(sql.contains("status='PENDING'")); + assertTrue(sql.contains("next_retry_at>CURRENT_TIMESTAMP(3)")); + assertFalse(sql.contains("dispatch_version=dispatch_version+1")); + } + assertTrue(taskSql.contains("attempt_count>0")); + assertTrue(outboxSql.contains("task_id=#{taskId}")); + } + private String update(Class type,String method) throws Exception { Method target=java.util.Arrays.stream(type.getDeclaredMethods()).filter(candidate -> candidate.getName().equals(method)).findFirst().orElseThrow(); return String.join(" ",target.getAnnotation(Update.class).value()).replaceAll("\\s+"," "); diff --git a/file/src/test/java/com/baiyue/file/service/ExportStateServiceTest.java b/file/src/test/java/com/baiyue/file/service/ExportStateServiceTest.java index e6dd2aa36..7237343eb 100644 --- a/file/src/test/java/com/baiyue/file/service/ExportStateServiceTest.java +++ b/file/src/test/java/com/baiyue/file/service/ExportStateServiceTest.java @@ -95,6 +95,19 @@ class ExportStateServiceTest { verify(tasks,never()).failQuarantined(anyLong(),anyInt(),anyString()); } + @Test void pendingBackoffIsExpeditedWithoutCreatingADuplicateCommand() { + FileExportTask task=task(9L,3,"PENDING"); + when(outbox.expeditePendingRetry(9L,3)).thenReturn(true); + when(tasks.expeditePendingRetry(9L,3)).thenReturn(true); + + org.junit.jupiter.api.Assertions.assertTrue(service.expeditePendingRetry(task)); + + InOrder order=inOrder(outbox,tasks); + order.verify(outbox).expeditePendingRetry(9L,3); + order.verify(tasks).expeditePendingRetry(9L,3); + verifyNoInteractions(commands); + } + private FileExportTask task(long id,int version,String status){ FileExportTask task=new FileExportTask();task.setId(id);task.setDispatchVersion(version);task.setStatus(status);return task; } diff --git a/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java b/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java index b9347a7ba..d715efdae 100644 --- a/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java +++ b/file/src/test/java/com/baiyue/file/service/ExportTaskServiceTest.java @@ -171,6 +171,23 @@ class ExportTaskServiceTest { assertTrue(denied.getMessage().contains("权限")); } + @Test + void pendingTaskInAutomaticBackoffCanBeRetriedImmediately() { + FileExportTask task = new FileExportTask(); + task.setId(9L); task.setTenantId(0L); task.setAppCode("PLATFORM"); task.setRequestedBy(1L); + task.setExportType("CUSTOMER_LIST"); task.setStatus("PENDING"); task.setAttemptCount(2); + when(repository.find(9L)).thenReturn(task); + when(registry.require("CUSTOMER_LIST")).thenReturn(definition()); + when(states.expeditePendingRetry(task)).thenReturn(true); + + var response = service.retry(user(1L, Set.of("platform:customer:export")), 9L); + + assertEquals("PENDING", response.status()); + assertEquals(2, response.attemptCount()); + verify(states).expeditePendingRetry(task); + verify(storage, org.mockito.Mockito.never()).delete(any()); + } + private AuthenticatedUser user(long id, Set permissions) { return new AuthenticatedUser(id, 0L, 0L, id, "user" + id, "PLATFORM", "WEB", permissions); }