task-37
This commit is contained in:
+22
-1
@@ -1571,7 +1571,8 @@ public class ShopDataCrawlTaskService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
snapshot.setShopName(firstNonBlank(payload.getShopName(), snapshot.getShopName()));
|
snapshot.setShopName(firstNonBlank(payload.getShopName(), snapshot.getShopName()));
|
||||||
snapshot.setCountryResults(copyCountryResults(payload.getCountryResults()));
|
// 按国家增量合并:本次未回传的国家保留快照中的旧结果,同一国家按行去重合并
|
||||||
|
snapshot.setCountryResults(mergeCountryResults(snapshot.getCountryResults(), payload.getCountryResults()));
|
||||||
if (!blank(payload.getError())) {
|
if (!blank(payload.getError())) {
|
||||||
snapshot.setError(payload.getError().trim());
|
snapshot.setError(payload.getError().trim());
|
||||||
}
|
}
|
||||||
@@ -2327,6 +2328,26 @@ public class ShopDataCrawlTaskService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
deleteTransientResultChunks(job.getTaskId());
|
deleteTransientResultChunks(job.getTaskId());
|
||||||
|
// Task 37:异步文件作业成功钩子——组装作业完成后显式刷新任务状态:
|
||||||
|
// 仍有未完成组装作业则保持 RUNNING,全部完成才进入终态。
|
||||||
|
// (worker 在 markSuccess 后、任务锁内调用本方法,此处刷新与提交是安全的。)
|
||||||
|
if ("SUCCESS".equals(job.getStatus())) {
|
||||||
|
refreshTaskStatusAfterAssembleJob(job.getTaskId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refreshTaskStatusAfterAssembleJob(Long taskId) {
|
||||||
|
FileTaskEntity task = fileTaskMapper.selectById(taskId);
|
||||||
|
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<FileResultEntity> rows = listTaskRows(taskId);
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateTaskStatusFromRows(task, rows);
|
||||||
|
fileTaskMapper.updateById(task);
|
||||||
|
taskCacheService.deleteTaskCache(taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void deleteTransientResultChunks(Long taskId) {
|
private void deleteTransientResultChunks(Long taskId) {
|
||||||
|
|||||||
+680
@@ -0,0 +1,680 @@
|
|||||||
|
package com.nanri.aiimage.modules.shopdatacrawl.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.nanri.aiimage.common.exception.BusinessException;
|
||||||
|
import com.nanri.aiimage.config.InstanceMetadata;
|
||||||
|
import com.nanri.aiimage.config.TaskPressureProperties;
|
||||||
|
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
|
||||||
|
import com.nanri.aiimage.modules.shopdatacrawl.model.entity.ShopDataCrawlDailyFileEntity;
|
||||||
|
import com.nanri.aiimage.modules.shopdatacrawl.model.entity.ShopDataCrawlDailyMemberEntity;
|
||||||
|
import com.nanri.aiimage.modules.shopdatacrawl.model.vo.ShopDataCrawlResultItemVo;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.TaskChunkEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TaskDistributedLockService;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TaskFileJobService;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TaskResultItemService;
|
||||||
|
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
|
||||||
|
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
|
||||||
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||||
|
import org.apache.ibatis.session.Configuration;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.Spy;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyInt;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.ArgumentMatchers.nullable;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 37:拆分每日累计文件组装与任务结果接收,增加异步文件作业状态。
|
||||||
|
* 结果接收(submitResult/tryFinalizeTask → finalizeTaskWorkbook)只做“结果落库 +
|
||||||
|
* 入队文件作业”,不做任何 Excel 组装/OSS 上传;组装完全由异步文件作业
|
||||||
|
* (processResultFileJob)承担;文件作业状态显式反映到任务状态
|
||||||
|
* (有未完成作业 → 任务保持 RUNNING,作业成功后才进入终态)。
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class ShopDataCrawlDailyFileJobSplitTest {
|
||||||
|
|
||||||
|
private static final String MODULE_TYPE = "SHOP_DATA_CRAWL";
|
||||||
|
private static final String SHOP_NAME = "shop-a";
|
||||||
|
private static final LocalDate BUSINESS_DATE = LocalDate.of(2026, 8, 29);
|
||||||
|
private static final LocalDateTime BUSINESS_TIME = LocalDateTime.of(2026, 8, 29, 12, 0);
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void initializeMybatisMetadata() {
|
||||||
|
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new Configuration(), "");
|
||||||
|
TableInfoHelper.initTableInfo(assistant, FileTaskEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, FileResultEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, TaskChunkEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, TaskScopeStateEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, ShopDataCrawlDailyFileEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, ShopDataCrawlDailyMemberEntity.class);
|
||||||
|
TableInfoHelper.initTableInfo(assistant, TaskFileJobEntity.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mock private FileTaskMapper fileTaskMapper;
|
||||||
|
@Mock private FileResultMapper fileResultMapper;
|
||||||
|
@Mock private ShopDataCrawlResolveService shopDataCrawlResolveService;
|
||||||
|
@Mock private ShopDataCrawlExcelAssemblyService excelAssemblyService;
|
||||||
|
@Mock private ShopDataCrawlTaskCacheService taskCacheService;
|
||||||
|
@Mock private OssStorageService ossStorageService;
|
||||||
|
@Mock private ZiniaoShopSwitchService ziniaoShopSwitchService;
|
||||||
|
@Mock private TaskPressureProperties taskPressureProperties;
|
||||||
|
@Mock private TaskFileJobService taskFileJobService;
|
||||||
|
@Mock private TaskResultItemService taskResultItemService;
|
||||||
|
@Mock private TaskProgressSnapshotService taskProgressSnapshotService;
|
||||||
|
@Mock private TaskDistributedLockService taskDistributedLockService;
|
||||||
|
@Mock private TaskChunkMapper taskChunkMapper;
|
||||||
|
@Mock private TaskScopeStateMapper taskScopeStateMapper;
|
||||||
|
@Mock private TransientPayloadStorageService transientPayloadStorageService;
|
||||||
|
@Mock private InstanceMetadata instanceMetadata;
|
||||||
|
@Mock private ShopDataCrawlDailyFileService dailyFileService;
|
||||||
|
@Spy private ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
|
||||||
|
|
||||||
|
private ShopDataCrawlTaskService service;
|
||||||
|
|
||||||
|
private final List<FileResultEntity> dbResultRows = new ArrayList<>();
|
||||||
|
private final List<ShopDataCrawlDailyFileEntity> dbDailyFiles = new ArrayList<>();
|
||||||
|
private final List<ShopDataCrawlDailyMemberEntity> dbMembers = new ArrayList<>();
|
||||||
|
/** 内存 file_job 表:enqueue/查询/状态流转的仿真。 */
|
||||||
|
private final List<TaskFileJobEntity> dbFileJobs = new ArrayList<>();
|
||||||
|
private final AtomicLong memberIdSeq = new AtomicLong(1000);
|
||||||
|
private final AtomicLong fileIdSeq = new AtomicLong(5000);
|
||||||
|
private final AtomicLong jobIdSeq = new AtomicLong(7000);
|
||||||
|
private long nextResultId = 8100;
|
||||||
|
private Long lastJobTaskId;
|
||||||
|
private String lastUploadedObjectKey;
|
||||||
|
private List<String> enqueuedScopes = new ArrayList<>();
|
||||||
|
/** 接收/组装路径每次落库后的任务状态(taskId → status),验证拆分后任务状态流转。 */
|
||||||
|
private final java.util.Map<Long, String> lastTaskStatus = new java.util.HashMap<>();
|
||||||
|
/** 接收/组装路径每次落库后的任务实体(taskId → 实体),供后续阶段读取。 */
|
||||||
|
private final java.util.Map<Long, FileTaskEntity> taskStore = new java.util.HashMap<>();
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void configureStorage() {
|
||||||
|
service = new ShopDataCrawlTaskService(
|
||||||
|
fileTaskMapper,
|
||||||
|
fileResultMapper,
|
||||||
|
shopDataCrawlResolveService,
|
||||||
|
excelAssemblyService,
|
||||||
|
taskCacheService,
|
||||||
|
ossStorageService,
|
||||||
|
ziniaoShopSwitchService,
|
||||||
|
objectMapper,
|
||||||
|
taskPressureProperties,
|
||||||
|
taskFileJobService,
|
||||||
|
taskResultItemService,
|
||||||
|
taskProgressSnapshotService,
|
||||||
|
taskDistributedLockService,
|
||||||
|
taskChunkMapper,
|
||||||
|
taskScopeStateMapper,
|
||||||
|
transientPayloadStorageService,
|
||||||
|
instanceMetadata,
|
||||||
|
dailyFileService,
|
||||||
|
null);
|
||||||
|
|
||||||
|
dbResultRows.clear();
|
||||||
|
dbDailyFiles.clear();
|
||||||
|
dbMembers.clear();
|
||||||
|
dbFileJobs.clear();
|
||||||
|
memberIdSeq.set(1000);
|
||||||
|
fileIdSeq.set(5000);
|
||||||
|
jobIdSeq.set(7000);
|
||||||
|
nextResultId = 8100;
|
||||||
|
lastJobTaskId = null;
|
||||||
|
lastUploadedObjectKey = null;
|
||||||
|
enqueuedScopes = new ArrayList<>();
|
||||||
|
lastTaskStatus.clear();
|
||||||
|
taskStore.clear();
|
||||||
|
|
||||||
|
lenient().when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
|
||||||
|
lenient().when(taskDistributedLockService.acquire(eq(MODULE_TYPE), anyLong()))
|
||||||
|
.thenReturn(mock(TaskDistributedLockService.LockHandle.class));
|
||||||
|
lenient().when(taskCacheService.getTaskCacheBatch(any())).thenReturn(Map.of());
|
||||||
|
lenient().when(taskCacheService.getTaskHeartbeatMillisBatch(any())).thenReturn(Map.of());
|
||||||
|
lenient().when(taskCacheService.getAllShopMergedPayload(anyLong())).thenReturn(Map.of());
|
||||||
|
lenient().when(taskResultItemService.listResultSnapshots(anyLong(), eq(MODULE_TYPE), any()))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
lenient().when(taskFileJobService.countUnfinishedAssembleJobs(anyLong(), eq(MODULE_TYPE))).thenReturn(0L);
|
||||||
|
lenient().doNothing().when(taskResultItemService)
|
||||||
|
.replaceTaskSnapshots(anyLong(), eq(MODULE_TYPE), any(), any());
|
||||||
|
lenient().doNothing().when(taskProgressSnapshotService)
|
||||||
|
.save(anyLong(), any(), any(), anyInt(), anyInt(), anyInt(), any(), any(), any());
|
||||||
|
lenient().when(ziniaoShopSwitchService.normalizeShopName(nullable(String.class))).thenAnswer(invocation -> {
|
||||||
|
String value = invocation.getArgument(0);
|
||||||
|
return value == null ? "" : value.trim();
|
||||||
|
});
|
||||||
|
lenient().when(fileTaskMapper.selectById(anyLong())).thenAnswer(invocation -> {
|
||||||
|
long taskId = invocation.getArgument(0);
|
||||||
|
FileTaskEntity stored = taskStore.get(taskId);
|
||||||
|
return stored == null ? null : stored;
|
||||||
|
});
|
||||||
|
lenient().when(fileTaskMapper.updateById(any(FileTaskEntity.class))).thenAnswer(invocation -> {
|
||||||
|
FileTaskEntity entity = invocation.getArgument(0);
|
||||||
|
FileTaskEntity copy = copyTask(entity);
|
||||||
|
taskStore.put(copy.getId(), copy);
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
lenient().when(fileResultMapper.selectBatchIds(any())).thenReturn(List.of());
|
||||||
|
lenient().when(fileResultMapper.selectCount(any())).thenReturn(0L);
|
||||||
|
lenient().when(ossStorageService.uploadResultFile(any(), eq(MODULE_TYPE))).thenAnswer(invocation -> {
|
||||||
|
String key = "oss/split/" + System.nanoTime() + ".xlsx";
|
||||||
|
lastUploadedObjectKey = key;
|
||||||
|
return key;
|
||||||
|
});
|
||||||
|
lenient().doNothing().when(ossStorageService).deleteObject(anyString());
|
||||||
|
lenient().when(excelAssemblyService.countRows(any())).thenAnswer(invocation -> {
|
||||||
|
List<?> items = invocation.getArgument(0);
|
||||||
|
return items == null ? 0 : items.size();
|
||||||
|
});
|
||||||
|
lenient().doAnswer(invocation -> {
|
||||||
|
List<?> items = invocation.getArgument(1);
|
||||||
|
return items == null ? 0 : items.size();
|
||||||
|
}).when(excelAssemblyService).writeWorkbook(any(), any());
|
||||||
|
|
||||||
|
lenient().when(dailyFileService.currentBusinessDate()).thenReturn(BUSINESS_DATE);
|
||||||
|
lenient().when(dailyFileService.currentBusinessDateTime()).thenReturn(BUSINESS_TIME);
|
||||||
|
lenient().when(dailyFileService.shopKeyHash(anyString())).thenAnswer(invocation -> {
|
||||||
|
String key = invocation.getArgument(0);
|
||||||
|
return key == null ? null : "hash:" + key;
|
||||||
|
});
|
||||||
|
lenient().when(dailyFileService.shopKey(any(FileResultEntity.class))).thenAnswer(invocation -> {
|
||||||
|
FileResultEntity row = invocation.getArgument(0);
|
||||||
|
return row == null ? null : row.getSourceFilename();
|
||||||
|
});
|
||||||
|
lenient().when(dailyFileService.acquireLock(anyLong(), anyString()))
|
||||||
|
.thenReturn(mock(TaskDistributedLockService.LockHandle.class));
|
||||||
|
lenient().when(dailyFileService.findForUpdate(anyLong(), anyString(), any())).thenAnswer(invocation ->
|
||||||
|
copyDailyFile(findDailyFile(invocation.getArgument(0), invocation.getArgument(1))));
|
||||||
|
lenient().when(dailyFileService.findOlder(anyLong(), anyString(), any())).thenReturn(List.of());
|
||||||
|
lenient().when(dailyFileService.findByLatestResultId(anyLong())).thenReturn(List.of());
|
||||||
|
lenient().when(dailyFileService.findById(anyLong())).thenReturn(null);
|
||||||
|
lenient().when(dailyFileService.countObjectReferences(anyString())).thenReturn(0L);
|
||||||
|
lenient().doNothing().when(dailyFileService).deleteDailyFile(anyLong());
|
||||||
|
lenient().doNothing().when(dailyFileService).reassignMembers(anyLong(), anyLong());
|
||||||
|
lenient().doNothing().when(dailyFileService).deleteMembersForResults(any());
|
||||||
|
captureDailyFilePersistence();
|
||||||
|
captureMemberInserts();
|
||||||
|
captureResultUpdates();
|
||||||
|
captureFileJobs();
|
||||||
|
|
||||||
|
lenient().when(fileResultMapper.selectList(any())).thenAnswer(invocation -> {
|
||||||
|
Wrapper<FileResultEntity> wrapper = invocation.getArgument(0);
|
||||||
|
if (wrapper instanceof LambdaQueryWrapper<?> query
|
||||||
|
&& query.getSqlSegment() != null && query.getSqlSegment().contains("taskId")) {
|
||||||
|
List<FileResultEntity> rows = dbResultRows.stream()
|
||||||
|
.filter(r -> Objects.equals(r.getTaskId(), lastJobTaskId))
|
||||||
|
.sorted(Comparator.comparing(FileResultEntity::getId))
|
||||||
|
.toList();
|
||||||
|
return new ArrayList<>(rows);
|
||||||
|
}
|
||||||
|
return List.of();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_037_daily_file_job_normal_default_path() {
|
||||||
|
// 默认成功路径:接收完成后只入队一个异步组装作业(status=PENDING、携带 owner scope),
|
||||||
|
// 不做任何组装;组装作业执行后产生累计文件、成员行、作业状态置为 SUCCESS。
|
||||||
|
FileResultEntity row = addResultRow(8101L, 1L, 1, SHOP_NAME, null);
|
||||||
|
receiveTask(1L, List.of(row), snapshot(8101L));
|
||||||
|
|
||||||
|
assertEquals(1, dbFileJobs.size(), "接收路径只入队一个组装作业");
|
||||||
|
assertEquals("PENDING", dbFileJobs.get(0).getStatus());
|
||||||
|
assertEquals("ASSEMBLE_RESULT", dbFileJobs.get(0).getJobType());
|
||||||
|
assertTrue(enqueuedScopes.get(0).contains("owner"), "入队作业携带 owner scope");
|
||||||
|
verify(excelAssemblyService, never()).writeWorkbook(any(), any());
|
||||||
|
assertEquals(0, dbMembers.size(), "接收路径不产生成员行");
|
||||||
|
assertEquals(0, dbDailyFiles.size(), "接收路径不创建累计文件");
|
||||||
|
|
||||||
|
runAssembleJob(1L);
|
||||||
|
assertEquals(1, dbMembers.size(), "组装作业执行后产生成员行");
|
||||||
|
assertEquals(1, dbDailyFiles.size(), "组装作业执行后产生累计文件");
|
||||||
|
assertEquals("SUCCESS", dbFileJobs.get(0).getStatus(), "作业状态显式落到 SUCCESS");
|
||||||
|
assertEquals("SUCCESS", lastTaskStatus.get(1L), "作业完成后任务进入终态");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_037_daily_file_job_normal_multiple_items() {
|
||||||
|
// 批量场景:多个店铺结果入队各自独立的组装作业,逐个执行后
|
||||||
|
// 累计文件版本随提交递增,成员行不丢失、顺序稳定。
|
||||||
|
receiveTask(1L, List.of(addResultRow(8102L, 1L, 1, SHOP_NAME, null)), snapshot(8102L));
|
||||||
|
assertEquals(1, dbFileJobs.size(), "首个结果入队一个作业");
|
||||||
|
|
||||||
|
runAssembleJob(1L);
|
||||||
|
receiveTask(2L, List.of(addResultRow(8103L, 2L, 1, SHOP_NAME, null)), snapshot(8103L));
|
||||||
|
assertEquals(2, dbFileJobs.size(), "第二个结果入队第二个作业");
|
||||||
|
runAssembleJob(2L);
|
||||||
|
|
||||||
|
assertEquals(2, dbMembers.size(), "两个结果各产生一个成员行");
|
||||||
|
assertEquals(2L, dbDailyFiles.get(0).getVersion(), "版本号随两次归档递增");
|
||||||
|
assertEquals(2, dbFileJobs.stream().filter(j -> "SUCCESS".equals(j.getStatus())).count(),
|
||||||
|
"两个作业都显式落到 SUCCESS");
|
||||||
|
assertEquals("SUCCESS", lastTaskStatus.get(1L));
|
||||||
|
assertEquals("SUCCESS", lastTaskStatus.get(2L));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_037_daily_file_job_normal_repeated_operation_is_idempotent() {
|
||||||
|
// 幂等:同一结果重复提交接收,不产生重复组装作业(已存在 PENDING/SUCCESS 作业不重复入队);
|
||||||
|
// 同一作业重复执行不产生重复成员行、版本不重复递增。
|
||||||
|
FileResultEntity row = addResultRow(8104L, 1L, 1, SHOP_NAME, null);
|
||||||
|
receiveTask(1L, List.of(row), snapshot(8104L));
|
||||||
|
receiveTask(1L, List.of(row), snapshot(8104L));
|
||||||
|
|
||||||
|
assertEquals(1, dbFileJobs.size(), "重复接收不重复入队作业");
|
||||||
|
runAssembleJob(1L);
|
||||||
|
runAssembleJob(1L);
|
||||||
|
assertEquals(1, dbMembers.size(), "重复执行作业不产生重复成员行");
|
||||||
|
assertEquals(1L, dbDailyFiles.get(0).getVersion(), "重复执行作业版本号不重复递增");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_037_daily_file_job_boundary_empty_input() {
|
||||||
|
// 空输入:没有成功结果时不入队作业、不创建任何资源,任务不进入组装等待。
|
||||||
|
FileResultEntity row = addResultRow(8105L, 1L, 0, SHOP_NAME, null);
|
||||||
|
receiveTask(1L, List.of(row), snapshot(8105L));
|
||||||
|
|
||||||
|
assertTrue(dbFileJobs.isEmpty(), "无成功结果不入队组装作业");
|
||||||
|
assertTrue(dbMembers.isEmpty(), "无成功结果不产生成员行");
|
||||||
|
assertTrue(dbDailyFiles.isEmpty(), "无成功结果不创建累计文件");
|
||||||
|
verify(ossStorageService, never()).uploadResultFile(any(), eq(MODULE_TYPE));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_037_daily_file_job_boundary_single_item() {
|
||||||
|
// 单元素:单店铺单结果走同样拆分路径,作业一入队即达终态,不依赖批量逻辑。
|
||||||
|
FileResultEntity row = addResultRow(8106L, 1L, 1, SHOP_NAME, null);
|
||||||
|
receiveTask(1L, List.of(row), snapshot(8106L));
|
||||||
|
|
||||||
|
assertEquals(1, dbFileJobs.size());
|
||||||
|
assertEquals("PENDING", dbFileJobs.get(0).getStatus());
|
||||||
|
runAssembleJob(1L);
|
||||||
|
assertEquals(1, dbMembers.size());
|
||||||
|
assertEquals(1L, dbDailyFiles.get(0).getVersion());
|
||||||
|
assertEquals("SUCCESS", lastTaskStatus.get(1L));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_037_daily_file_job_boundary_limit_and_overflow() {
|
||||||
|
// 上限/超限:大量结果连续接收+执行,作业数、成员数、版本号一致递增,无丢失无重复。
|
||||||
|
int limit = 210;
|
||||||
|
for (int i = 0; i < limit; i++) {
|
||||||
|
long resultId = nextResultId++;
|
||||||
|
long taskId = i + 1L;
|
||||||
|
receiveTask(taskId, List.of(addResultRow(resultId, taskId, 1, SHOP_NAME, null)), snapshot(resultId));
|
||||||
|
runAssembleJob(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(limit, dbFileJobs.size(), "每个结果一个作业");
|
||||||
|
assertEquals(limit, dbMembers.size(), "每个结果一个成员行");
|
||||||
|
assertEquals(Long.valueOf(limit), dbDailyFiles.get(0).getVersion(), "版本号与归档次数一致");
|
||||||
|
assertEquals(limit, dbFileJobs.stream().filter(j -> "SUCCESS".equals(j.getStatus())).count(),
|
||||||
|
"全部作业显式 SUCCESS");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_037_daily_file_job_invalid_input_rejected() {
|
||||||
|
// 非法参数:作业缺少 taskId 时拒绝执行并抛可识别异常;任务不存在时同样拒绝。
|
||||||
|
assertThrows(BusinessException.class, () -> service.processResultFileJob(
|
||||||
|
jobEntity(0L, null, MODULE_TYPE)));
|
||||||
|
assertThrows(BusinessException.class, () -> service.processResultFileJob(
|
||||||
|
jobEntity(0L, 9999L, MODULE_TYPE)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_037_daily_file_job_dependency_failure_releases_resources() {
|
||||||
|
// 依赖失败:组装作业执行中 OSS 上传失败时抛出异常、不残留成员/累计文件;
|
||||||
|
// 恢复后同一结果可重新归档成功(错误可恢复)。
|
||||||
|
FileResultEntity row = addResultRow(8108L, 1L, 1, SHOP_NAME, null);
|
||||||
|
receiveTask(1L, List.of(row), snapshot(8108L));
|
||||||
|
assertEquals(1, dbFileJobs.size(), "接收入队成功");
|
||||||
|
dbFileJobs.clear();
|
||||||
|
|
||||||
|
doThrow(new IllegalStateException("upload failed"))
|
||||||
|
.when(ossStorageService).uploadResultFile(any(), eq(MODULE_TYPE));
|
||||||
|
assertThrows(IllegalStateException.class, () -> service.processResultFileJob(
|
||||||
|
jobEntity(1L, 1L, MODULE_TYPE)));
|
||||||
|
|
||||||
|
assertTrue(dbMembers.isEmpty(), "上传失败不残留成员行");
|
||||||
|
assertTrue(dbDailyFiles.isEmpty(), "上传失败不残留累计文件");
|
||||||
|
|
||||||
|
// 错误可恢复:恢复 OSS 后同一作业重新执行成功。
|
||||||
|
lenient().when(ossStorageService.uploadResultFile(any(), eq(MODULE_TYPE))).thenAnswer(invocation -> {
|
||||||
|
String key = "oss/split/recovered-" + System.nanoTime() + ".xlsx";
|
||||||
|
lastUploadedObjectKey = key;
|
||||||
|
return key;
|
||||||
|
});
|
||||||
|
service.processResultFileJob(jobEntity(1L, 1L, MODULE_TYPE));
|
||||||
|
assertEquals(1, dbMembers.size(), "恢复后同一结果重新归档成功");
|
||||||
|
assertEquals(1, dbDailyFiles.size(), "恢复后累计文件创建成功");
|
||||||
|
assertEquals(1L, dbDailyFiles.get(0).getVersion(), "恢复后版本号正确");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
|
||||||
|
/** 接收阶段:submitResult → tryFinalizeTask → finalizeTaskWorkbook(结果落库 + 入队组装作业)。 */
|
||||||
|
private void receiveTask(Long taskId, List<FileResultEntity> rows, ShopDataCrawlResultItemVo snapshot) {
|
||||||
|
lastJobTaskId = taskId;
|
||||||
|
FileTaskEntity task = taskEntity(taskId);
|
||||||
|
taskStore.put(taskId, task);
|
||||||
|
for (FileResultEntity row : rows) {
|
||||||
|
updateRowInDb(row);
|
||||||
|
}
|
||||||
|
service.tryFinalizeTask(taskId, false);
|
||||||
|
FileTaskEntity persisted = taskStore.get(taskId);
|
||||||
|
if (persisted != null) {
|
||||||
|
lastTaskStatus.put(taskId, persisted.getStatus());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 组装阶段:worker 执行 processResultFileJob(组装),随后 markSuccess + cleanupResultFileJob
|
||||||
|
* (作业成功钩子:把文件作业状态显式反映到任务状态,无未完成作业 → 任务进入终态)。 */
|
||||||
|
private void runAssembleJob(Long taskId) {
|
||||||
|
lastJobTaskId = taskId;
|
||||||
|
TaskFileJobEntity job = dbFileJobs.stream()
|
||||||
|
.filter(j -> Objects.equals(j.getTaskId(), taskId))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(() -> new AssertionError("作业不存在 taskId=" + taskId));
|
||||||
|
service.processResultFileJob(job);
|
||||||
|
job.setStatus("SUCCESS");
|
||||||
|
service.cleanupResultFileJob(job);
|
||||||
|
lastTaskStatus.put(taskId, taskStore.get(taskId).getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void markResultFinishedInDb(FileResultEntity row) {
|
||||||
|
row.setSuccess(1);
|
||||||
|
row.setErrorMessage(null);
|
||||||
|
updateRowInDb(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateRowInDb(FileResultEntity row) {
|
||||||
|
for (int i = 0; i < dbResultRows.size(); i++) {
|
||||||
|
if (Objects.equals(dbResultRows.get(i).getId(), row.getId())) {
|
||||||
|
dbResultRows.set(i, row);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dbResultRows.add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TaskFileJobEntity jobEntity(long jobId, Long taskId, String moduleType) {
|
||||||
|
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||||
|
job.setId(jobId);
|
||||||
|
job.setTaskId(taskId);
|
||||||
|
job.setModuleType(moduleType);
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
private FileTaskEntity taskEntity(long taskId) {
|
||||||
|
FileTaskEntity task = new FileTaskEntity();
|
||||||
|
task.setId(taskId);
|
||||||
|
task.setModuleType(MODULE_TYPE);
|
||||||
|
task.setStatus("RUNNING");
|
||||||
|
task.setUserId(7L);
|
||||||
|
task.setOwnerInstanceId("instance-a");
|
||||||
|
task.setRequestJson("{\"ownerInstanceId\":\"instance-a\"}");
|
||||||
|
task.setResultJson("[]");
|
||||||
|
task.setCreatedAt(LocalDateTime.now());
|
||||||
|
task.setUpdatedAt(LocalDateTime.now());
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
private FileResultEntity addResultRow(long id, long taskId, int success, String shopName, String resultFileUrl) {
|
||||||
|
FileResultEntity row = new FileResultEntity();
|
||||||
|
row.setId(id);
|
||||||
|
row.setTaskId(taskId);
|
||||||
|
row.setModuleType(MODULE_TYPE);
|
||||||
|
row.setSuccess(success);
|
||||||
|
row.setSourceFilename(shopName);
|
||||||
|
row.setSourceFileUrl("shop-id-" + id);
|
||||||
|
row.setUserId(7L);
|
||||||
|
row.setCreatedAt(LocalDateTime.now());
|
||||||
|
row.setResultFileUrl(resultFileUrl);
|
||||||
|
dbResultRows.add(row);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopDataCrawlResultItemVo snapshot(long resultId) {
|
||||||
|
ShopDataCrawlResultItemVo item = new ShopDataCrawlResultItemVo();
|
||||||
|
item.setResultId(resultId);
|
||||||
|
item.setTaskId(1L);
|
||||||
|
item.setShopName(SHOP_NAME);
|
||||||
|
item.setShopId("shop-id-" + resultId);
|
||||||
|
item.setSuccess(true);
|
||||||
|
item.setMatched(true);
|
||||||
|
item.setTaskStatus("SUCCESS");
|
||||||
|
item.setCountryCodes(List.of("DE"));
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void captureFileJobs() {
|
||||||
|
// 仿真 enqueueAssembleResult 的幂等语义:同一 (taskId, resultId) 已存在作业时不重复入队。
|
||||||
|
lenient().doAnswer(invocation -> {
|
||||||
|
Long taskId = invocation.getArgument(0);
|
||||||
|
Long resultId = invocation.getArgument(2);
|
||||||
|
String scopeKey = invocation.getArgument(3);
|
||||||
|
TaskFileJobEntity existing = dbFileJobs.stream()
|
||||||
|
.filter(j -> Objects.equals(j.getTaskId(), taskId)
|
||||||
|
&& Objects.equals(j.getResultId(), resultId))
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
if (existing != null) {
|
||||||
|
if ("FAILED".equals(existing.getStatus())) {
|
||||||
|
existing.setStatus("PENDING");
|
||||||
|
}
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||||
|
job.setId(jobIdSeq.incrementAndGet());
|
||||||
|
job.setTaskId(taskId);
|
||||||
|
job.setModuleType(MODULE_TYPE);
|
||||||
|
job.setResultId(resultId);
|
||||||
|
job.setScopeKey(scopeKey);
|
||||||
|
job.setStatus("PENDING");
|
||||||
|
job.setJobType("ASSEMBLE_RESULT");
|
||||||
|
job.setRetryCount(0);
|
||||||
|
dbFileJobs.add(job);
|
||||||
|
enqueuedScopes.add(scopeKey);
|
||||||
|
return job;
|
||||||
|
}).when(taskFileJobService).enqueueAssembleResult(anyLong(), eq(MODULE_TYPE), anyLong(), anyString());
|
||||||
|
lenient().when(taskFileJobService.findAssembleJob(anyLong(), eq(MODULE_TYPE), anyLong()))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
long taskId = invocation.getArgument(0);
|
||||||
|
long resultId = invocation.getArgument(2);
|
||||||
|
return dbFileJobs.stream()
|
||||||
|
.filter(j -> Objects.equals(j.getTaskId(), taskId)
|
||||||
|
&& Objects.equals(j.getResultId(), resultId))
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
});
|
||||||
|
lenient().when(taskFileJobService.countUnfinishedAssembleJobs(anyLong(), eq(MODULE_TYPE)))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
long taskId = invocation.getArgument(0);
|
||||||
|
return dbFileJobs.stream()
|
||||||
|
.filter(j -> Objects.equals(j.getTaskId(), taskId)
|
||||||
|
&& !"SUCCESS".equals(j.getStatus()))
|
||||||
|
.count();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void captureResultUpdates() {
|
||||||
|
lenient().when(fileResultMapper.updateById(any(FileResultEntity.class))).thenAnswer(invocation -> {
|
||||||
|
FileResultEntity updated = invocation.getArgument(0);
|
||||||
|
for (int i = 0; i < dbResultRows.size(); i++) {
|
||||||
|
if (Objects.equals(dbResultRows.get(i).getId(), updated.getId())) {
|
||||||
|
dbResultRows.set(i, updated);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dbResultRows.add(updated);
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void captureMemberInserts() {
|
||||||
|
lenient().when(dailyFileService.addMemberWithPayload(anyLong(), anyLong(), anyLong(), anyString()))
|
||||||
|
.thenAnswer(invocation -> {
|
||||||
|
long dailyFileId = invocation.getArgument(0);
|
||||||
|
long taskId = invocation.getArgument(1);
|
||||||
|
long resultId = invocation.getArgument(2);
|
||||||
|
String rowPayload = invocation.getArgument(3);
|
||||||
|
boolean duplicate = dbMembers.stream().anyMatch(m ->
|
||||||
|
Objects.equals(m.getDailyFileId(), dailyFileId)
|
||||||
|
&& Objects.equals(m.getResultId(), resultId));
|
||||||
|
if (duplicate) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ShopDataCrawlDailyMemberEntity member = new ShopDataCrawlDailyMemberEntity();
|
||||||
|
member.setId(memberIdSeq.incrementAndGet());
|
||||||
|
member.setDailyFileId(dailyFileId);
|
||||||
|
member.setTaskId(taskId);
|
||||||
|
member.setResultId(resultId);
|
||||||
|
member.setRowPayload(rowPayload);
|
||||||
|
member.setCreatedAt(BUSINESS_TIME.plusMinutes(dbMembers.size()));
|
||||||
|
dbMembers.add(member);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void captureDailyFilePersistence() {
|
||||||
|
lenient().doAnswer(invocation -> {
|
||||||
|
ShopDataCrawlDailyFileEntity entity = invocation.getArgument(0);
|
||||||
|
entity.setId(fileIdSeq.incrementAndGet());
|
||||||
|
dbDailyFiles.add(entity);
|
||||||
|
return null;
|
||||||
|
}).when(dailyFileService).insert(any(ShopDataCrawlDailyFileEntity.class));
|
||||||
|
lenient().doAnswer(invocation -> {
|
||||||
|
ShopDataCrawlDailyFileEntity entity = invocation.getArgument(0);
|
||||||
|
for (int i = 0; i < dbDailyFiles.size(); i++) {
|
||||||
|
ShopDataCrawlDailyFileEntity existing = dbDailyFiles.get(i);
|
||||||
|
if (Objects.equals(existing.getUserId(), entity.getUserId())
|
||||||
|
&& Objects.equals(existing.getShopKeyHash(), entity.getShopKeyHash())
|
||||||
|
&& Objects.equals(existing.getBusinessDate(), entity.getBusinessDate())) {
|
||||||
|
entity.setId(existing.getId());
|
||||||
|
dbDailyFiles.set(i, copyDailyFile(entity));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entity.setId(fileIdSeq.incrementAndGet());
|
||||||
|
dbDailyFiles.add(copyDailyFile(entity));
|
||||||
|
return null;
|
||||||
|
}).when(dailyFileService).update(any(ShopDataCrawlDailyFileEntity.class));
|
||||||
|
lenient().when(dailyFileService.listMembers(anyLong())).thenAnswer(invocation -> {
|
||||||
|
long dailyFileId = invocation.getArgument(0);
|
||||||
|
return dbMembers.stream()
|
||||||
|
.filter(m -> Objects.equals(m.getDailyFileId(), dailyFileId))
|
||||||
|
.sorted(Comparator
|
||||||
|
.comparing(ShopDataCrawlDailyMemberEntity::getCreatedAt,
|
||||||
|
Comparator.nullsLast(Comparator.naturalOrder()))
|
||||||
|
.thenComparing(ShopDataCrawlDailyMemberEntity::getId,
|
||||||
|
Comparator.nullsLast(Comparator.naturalOrder())))
|
||||||
|
.toList();
|
||||||
|
});
|
||||||
|
lenient().when(dailyFileService.containsResult(anyLong(), anyLong())).thenAnswer(invocation -> {
|
||||||
|
long dailyFileId = invocation.getArgument(0);
|
||||||
|
long resultId = invocation.getArgument(1);
|
||||||
|
return dbMembers.stream().anyMatch(m ->
|
||||||
|
Objects.equals(m.getDailyFileId(), dailyFileId) && Objects.equals(m.getResultId(), resultId));
|
||||||
|
});
|
||||||
|
lenient().when(dailyFileService.findMembersByResultId(anyLong())).thenAnswer(invocation -> {
|
||||||
|
long resultId = invocation.getArgument(0);
|
||||||
|
return dbMembers.stream().filter(m -> Objects.equals(m.getResultId(), resultId)).toList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopDataCrawlDailyFileEntity findDailyFile(Long userId, String shopKeyHash) {
|
||||||
|
for (ShopDataCrawlDailyFileEntity f : dbDailyFiles) {
|
||||||
|
if (Objects.equals(f.getUserId(), userId) && Objects.equals(f.getShopKeyHash(), shopKeyHash)) {
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopDataCrawlDailyFileEntity copyDailyFile(ShopDataCrawlDailyFileEntity source) {
|
||||||
|
if (source == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
ShopDataCrawlDailyFileEntity copy = new ShopDataCrawlDailyFileEntity();
|
||||||
|
copy.setId(source.getId());
|
||||||
|
copy.setUserId(source.getUserId());
|
||||||
|
copy.setShopKeyHash(source.getShopKeyHash());
|
||||||
|
copy.setShopKey(source.getShopKey());
|
||||||
|
copy.setBusinessDate(source.getBusinessDate());
|
||||||
|
copy.setLatestTaskId(source.getLatestTaskId());
|
||||||
|
copy.setLatestResultId(source.getLatestResultId());
|
||||||
|
copy.setResultFilename(source.getResultFilename());
|
||||||
|
copy.setResultFileUrl(source.getResultFileUrl());
|
||||||
|
copy.setResultFileSize(source.getResultFileSize());
|
||||||
|
copy.setResultContentType(source.getResultContentType());
|
||||||
|
copy.setRowCount(source.getRowCount());
|
||||||
|
copy.setVersion(source.getVersion());
|
||||||
|
copy.setLastSuccessAt(source.getLastSuccessAt());
|
||||||
|
copy.setCreatedAt(source.getCreatedAt());
|
||||||
|
copy.setUpdatedAt(source.getUpdatedAt());
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
private FileTaskEntity copyTask(FileTaskEntity source) {
|
||||||
|
FileTaskEntity copy = new FileTaskEntity();
|
||||||
|
copy.setId(source.getId());
|
||||||
|
copy.setTaskNo(source.getTaskNo());
|
||||||
|
copy.setModuleType(source.getModuleType());
|
||||||
|
copy.setTaskMode(source.getTaskMode());
|
||||||
|
copy.setStatus(source.getStatus());
|
||||||
|
copy.setSourceFileCount(source.getSourceFileCount());
|
||||||
|
copy.setSuccessFileCount(source.getSuccessFileCount());
|
||||||
|
copy.setFailedFileCount(source.getFailedFileCount());
|
||||||
|
copy.setRequestJson(source.getRequestJson());
|
||||||
|
copy.setResultJson(source.getResultJson());
|
||||||
|
copy.setErrorMessage(source.getErrorMessage());
|
||||||
|
copy.setCreatedBy(source.getCreatedBy());
|
||||||
|
copy.setUserId(source.getUserId());
|
||||||
|
copy.setOwnerInstanceId(source.getOwnerInstanceId());
|
||||||
|
copy.setCreatedAt(source.getCreatedAt());
|
||||||
|
copy.setUpdatedAt(source.getUpdatedAt());
|
||||||
|
copy.setFinishedAt(source.getFinishedAt());
|
||||||
|
copy.setScheduledAt(source.getScheduledAt());
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
@@ -52,6 +52,7 @@ import static org.mockito.ArgumentMatchers.anyLong;
|
|||||||
import static org.mockito.ArgumentMatchers.anyString;
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.ArgumentMatchers.nullable;
|
import static org.mockito.ArgumentMatchers.nullable;
|
||||||
|
import static org.mockito.Mockito.doAnswer;
|
||||||
import static org.mockito.Mockito.lenient;
|
import static org.mockito.Mockito.lenient;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
@@ -257,6 +258,35 @@ class ShopDataCrawlScopeMergeTest {
|
|||||||
verify(taskScopeStateMapper, never()).selectOne(any());
|
verify(taskScopeStateMapper, never()).selectOne(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_029_submit_preserves_previous_countries_across_submissions() {
|
||||||
|
// 回归:客户端先提交一个国家的部分结果(未完结),再提交另一个国家并标记完结。
|
||||||
|
// 首次提交国家被清空后,第二次提交的 payload 只含 UK,修复前 DE 会在快照中永久丢失;
|
||||||
|
// 修复后 mergePayloadIntoSnapshot 按国家合并,DE 必须保留、且第二次完成后正常收尾。
|
||||||
|
Map<String, ShopDataCrawlShopPayloadDto> mergedByShop = new LinkedHashMap<>();
|
||||||
|
when(taskCacheService.getShopMergedPayload(anyLong(), anyString())).thenAnswer(
|
||||||
|
invocation -> mergedByShop.get(invocation.getArgument(1)));
|
||||||
|
doAnswer(invocation -> {
|
||||||
|
mergedByShop.put(invocation.getArgument(1), invocation.getArgument(2));
|
||||||
|
return null;
|
||||||
|
}).when(taskCacheService).saveShopMergedPayload(anyLong(), anyString(), any(ShopDataCrawlShopPayloadDto.class));
|
||||||
|
|
||||||
|
givenRunningTask(1205L, 2205L);
|
||||||
|
|
||||||
|
// 第一次提交:只带 DE 国家,未完结(生产上第一次提交的 shopDone=false)
|
||||||
|
ShopDataCrawlShopPayloadDto first = legacyChunk(false, "DE", row("2026-07-25", "B001"));
|
||||||
|
service.submitResult(task.getId(), request(first));
|
||||||
|
// 第二次提交:只带 UK 国家,标记完结(生产上第二次提交的 shopDone=true 收尾)
|
||||||
|
ShopDataCrawlShopPayloadDto second = legacyChunk(true, "UK", row("2026-07-26", "B002"));
|
||||||
|
service.submitResult(task.getId(), request(second));
|
||||||
|
|
||||||
|
assertEquals(1, result.getSuccess(), "任务应收尾成功");
|
||||||
|
String json = task.getResultJson();
|
||||||
|
assertTrue(json.contains("\"DE\""), "前次提交的 DE 国家必须保留: " + json);
|
||||||
|
assertTrue(json.contains("\"UK\""), "后次提交的 UK 国家必须写入: " + json);
|
||||||
|
assertTrue(json.indexOf("B001") < json.indexOf("B002"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void test_task_029_chunk_merge_boundary_single_item() {
|
void test_task_029_chunk_merge_boundary_single_item() {
|
||||||
// 单元素:单分片 1/1,一次 scope 查询 + 一次 insert 即齐集完成。
|
// 单元素:单分片 1/1,一次 scope 查询 + 一次 insert 即齐集完成。
|
||||||
|
|||||||
Reference in New Issue
Block a user