feat(file): allow immediate retry during export backoff

This commit is contained in:
2026-09-03 10:39:55 +08:00
parent 16a60a9175
commit 6b2339e50a
5 changed files with 78 additions and 1 deletions
@@ -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]
@@ -39,6 +39,7 @@ class DomesticWarehouseOrderExportContractTest {
"file/src/main/resources/openapi/domestic-order-export-v1.yaml"));
Map<String, Object> document = map(new Yaml().load(source));
Map<String, Object> schemas = map(map(document.get("components")).get("schemas"));
Map<String, Object> paths = map(document.get("paths"));
Map<String, Object> parameters = map(map(schemas.get("DomesticOrderExportParameters")).get("properties"));
Map<String, Object> 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() {
@@ -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+"," ");
@@ -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;
}
@@ -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<String> permissions) {
return new AuthenticatedUser(id, 0L, 0L, id, "user" + id, "PLATFORM", "WEB", permissions);
}