task-129: shopdatacrawl 事务收缩(/result 无长事务契约固化 + deleteTask 纯计算抽静态纯函数)+ 8 条边界测试
This commit is contained in:
+28
-8
@@ -686,19 +686,13 @@ public class ShopDataCrawlTaskService {
|
||||
List<FileResultEntity> taskRows = listTaskRows(taskId);
|
||||
try (DailyLockSet dailyLocks = acquireDailyLocks(taskRows)) {
|
||||
ensureDailySyncCompletedBeforeDelete(taskRows);
|
||||
Set<Long> removedResultIds = taskRows.stream()
|
||||
.map(FileResultEntity::getId)
|
||||
.filter(id -> id != null && id > 0)
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
Set<Long> removedResultIds = collectResultIds(taskRows);
|
||||
// A task deletion is only a frontend task-record cleanup. The daily
|
||||
// workbook is an independent backend aggregate and must not roll
|
||||
// back when its source task is removed.
|
||||
DailyDeletionResult dailyResult = preserveDailyForTaskDeletion(removedResultIds);
|
||||
registerUploadedObjectRollback(dailyResult.uploadedObjectKeys());
|
||||
List<String> resultFileUrls = new ArrayList<>(dailyResult.obsoleteObjectKeys());
|
||||
resultFileUrls.addAll(taskRows.stream()
|
||||
.map(FileResultEntity::getResultFileUrl).filter(url -> !blank(url)).distinct().toList());
|
||||
resultFileUrls = resultFileUrls.stream().filter(url -> !blank(url)).distinct().toList();
|
||||
List<String> resultFileUrls = collectResultFileUrls(taskRows, dailyResult.obsoleteObjectKeys());
|
||||
fileResultMapper.delete(new LambdaQueryWrapper<FileResultEntity>()
|
||||
.eq(FileResultEntity::getTaskId, taskId)
|
||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE));
|
||||
@@ -713,6 +707,32 @@ public class ShopDataCrawlTaskService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除任务的纯计算:收集有效结果行 id 集合(过滤 null/非正数),无副作用。 */
|
||||
static Set<Long> collectResultIds(List<FileResultEntity> taskRows) {
|
||||
if (taskRows == null || taskRows.isEmpty()) {
|
||||
return Set.of();
|
||||
}
|
||||
return taskRows.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(FileResultEntity::getId)
|
||||
.filter(id -> id != null && id > 0)
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
}
|
||||
|
||||
/** 删除任务的纯计算:合并待清理对象键,过滤空白并去重,无副作用。 */
|
||||
static List<String> collectResultFileUrls(List<FileResultEntity> taskRows, List<String> obsoleteObjectKeys) {
|
||||
List<String> urls = new ArrayList<>(obsoleteObjectKeys == null ? List.of() : obsoleteObjectKeys);
|
||||
if (taskRows != null) {
|
||||
for (FileResultEntity row : taskRows) {
|
||||
if (row == null || row.getResultFileUrl() == null || row.getResultFileUrl().isBlank()) {
|
||||
continue;
|
||||
}
|
||||
urls.add(row.getResultFileUrl());
|
||||
}
|
||||
}
|
||||
return urls.stream().filter(url -> url != null && !url.isBlank()).distinct().toList();
|
||||
}
|
||||
|
||||
private void ensureDailySyncCompletedBeforeDelete(List<FileResultEntity> taskRows) {
|
||||
if (taskRows == null || taskRows.isEmpty()) {
|
||||
throw new BusinessException("后台店铺数据尚未完成同步,暂不能删除任务");
|
||||
|
||||
+312
@@ -0,0 +1,312 @@
|
||||
package com.nanri.aiimage.modules.shopdatacrawl.service;
|
||||
|
||||
import cn.hutool.crypto.digest.DigestUtil;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
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.dto.ShopDataCrawlCountryResultDto;
|
||||
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlRowDto;
|
||||
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlShopPayloadDto;
|
||||
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlSubmitResultRequest;
|
||||
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.service.TaskDistributedLockService;
|
||||
import com.nanri.aiimage.modules.task.service.TaskFileJobService;
|
||||
import com.nanri.aiimage.modules.task.service.TaskProgressLightAssembler;
|
||||
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.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.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.Spy;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
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-129:shopdatacrawl 事务收缩。
|
||||
* 审计结论:/result 提交路径(submitResult → persistResultChunk/mergeShopPayload/
|
||||
* persistProgressOrSnapshot)无 @Transactional、无长事务——写为单条原子写或
|
||||
* executeShortTransaction 短事务,事务边界已窄;deleteTask 事务方法内的纯计算
|
||||
* 段抽为无事务静态纯函数。本测试固化上述契约。
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ShopDataCrawlTaskServiceTxBoundaryTest {
|
||||
|
||||
private static final Long TASK_ID = 4242L;
|
||||
private static final Long USER_ID = 7L;
|
||||
private static final String SHOP_KEY = "amazon.de";
|
||||
|
||||
@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;
|
||||
@Spy private ObjectMapper objectMapper = new ObjectMapper();
|
||||
@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;
|
||||
@Mock private PlatformTransactionManager transactionManager;
|
||||
@Mock private TaskProgressLightAssembler taskProgressLightAssembler;
|
||||
|
||||
@InjectMocks private ShopDataCrawlTaskService service;
|
||||
|
||||
@BeforeAll
|
||||
static void initializeMybatisMetadata() {
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
|
||||
TableInfoHelper.initTableInfo(assistant, FileTaskEntity.class);
|
||||
TableInfoHelper.initTableInfo(assistant, FileResultEntity.class);
|
||||
TableInfoHelper.initTableInfo(assistant, TaskChunkEntity.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
lenient().when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
|
||||
lenient().when(taskPressureProperties.getDbSelectBatchSize()).thenReturn(500);
|
||||
}
|
||||
|
||||
@Test
|
||||
void collectResultIdsFiltersBrokenRows() {
|
||||
FileResultEntity ok = resultRow(1L);
|
||||
FileResultEntity nullId = new FileResultEntity();
|
||||
FileResultEntity zeroId = new FileResultEntity();
|
||||
zeroId.setId(0L);
|
||||
|
||||
assertEquals(java.util.Set.of(1L),
|
||||
service.collectResultIds(java.util.Arrays.asList(ok, nullId, zeroId, null)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void collectResultFileUrlsDeduplicatesAndSkipsBlank() {
|
||||
FileResultEntity a = resultRow(1L);
|
||||
a.setResultFileUrl("url-a");
|
||||
FileResultEntity b = resultRow(2L);
|
||||
b.setResultFileUrl("url-b");
|
||||
FileResultEntity dup = resultRow(3L);
|
||||
dup.setResultFileUrl("url-a");
|
||||
FileResultEntity blank = resultRow(4L);
|
||||
blank.setResultFileUrl(" ");
|
||||
|
||||
assertEquals(List.of("old-key", "url-a", "url-b"),
|
||||
service.collectResultFileUrls(List.of(a, b, dup, blank), List.of("old-key")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void pureHelpersAreStaticAndCarryNoTransactionAnnotation() throws Exception {
|
||||
for (String name : List.of("collectResultIds", "collectResultFileUrls")) {
|
||||
Method method = java.util.Arrays.stream(ShopDataCrawlTaskService.class.getDeclaredMethods())
|
||||
.filter(candidate -> candidate.getName().equals(name))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
assertTrue(Modifier.isStatic(method.getModifiers()), name + " 应为静态纯函数");
|
||||
assertNull(method.getAnnotation(Transactional.class), name + " 不得带 @Transactional");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void submitResultCarriesNoTransactionAnnotation() throws Exception {
|
||||
Method submit = ShopDataCrawlTaskService.class.getMethod(
|
||||
"submitResult", Long.class, ShopDataCrawlSubmitResultRequest.class);
|
||||
assertNull(submit.getAnnotation(Transactional.class),
|
||||
"submitResult 无 @Transactional(/result 提交路径无长事务)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void submitResultPathUsesNoLongTransaction() throws Exception {
|
||||
FileTaskEntity task = runningTask();
|
||||
when(fileTaskMapper.selectById(TASK_ID)).thenReturn(task);
|
||||
when(fileResultMapper.selectList(any())).thenReturn(List.of(resultRow(1L)));
|
||||
when(taskDistributedLockService.acquire(any(), anyLong()))
|
||||
.thenReturn(mock(TaskDistributedLockService.LockHandle.class), null);
|
||||
|
||||
service.submitResult(TASK_ID, submitRequest(false));
|
||||
|
||||
verify(transactionManager, never()).getTransaction(any());
|
||||
verify(taskCacheService).touchTaskHeartbeat(TASK_ID);
|
||||
verify(fileTaskMapper, times(1)).updateById(any(FileTaskEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void chunkPathPersistsWithSingleInsertWithoutTransaction() throws Exception {
|
||||
FileTaskEntity task = runningTask();
|
||||
when(fileTaskMapper.selectById(TASK_ID)).thenReturn(task);
|
||||
FileResultEntity row = resultRow(1L);
|
||||
row.setSourceFilename(SHOP_KEY);
|
||||
when(fileResultMapper.selectList(any())).thenReturn(List.of(row));
|
||||
when(taskDistributedLockService.acquire(any(), anyLong()))
|
||||
.thenReturn(mock(TaskDistributedLockService.LockHandle.class), null);
|
||||
lenient().when(transientPayloadStorageService.isSharedWriteEnabled()).thenReturn(true);
|
||||
when(transientPayloadStorageService.storeChunkPayloadVersioned(
|
||||
eq("SHOP_DATA_CRAWL"), eq(TASK_ID), any(), eq(1), any()))
|
||||
.thenReturn("\"rustfs:chunk\"");
|
||||
lenient().when(transientPayloadStorageService.extractPointer("\"rustfs:chunk\""))
|
||||
.thenReturn("rustfs:chunk");
|
||||
lenient().when(transientPayloadStorageService.resolvePayload(anyString(), anyString()))
|
||||
.thenReturn("[{\"country\":\"DE\",\"items\":[{\"asin\":\"B0TEST1234\"}]}]");
|
||||
lenient().when(taskChunkMapper.selectOne(any())).thenReturn(null);
|
||||
lenient().when(taskChunkMapper.selectCount(any())).thenReturn(1L);
|
||||
lenient().when(taskScopeStateMapper.selectOne(any())).thenReturn(null);
|
||||
when(taskChunkMapper.selectList(any())).thenReturn(List.of(completedChunk()));
|
||||
|
||||
service.submitResult(TASK_ID, chunkRequest());
|
||||
|
||||
verify(transactionManager, never()).getTransaction(any());
|
||||
verify(taskChunkMapper).insert(any(TaskChunkEntity.class));
|
||||
verify(fileResultMapper).updateById(any(FileResultEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteTaskKeepsTransactionAnnotationAndPureCompute() throws Exception {
|
||||
Method delete = ShopDataCrawlTaskService.class.getMethod("deleteTask", Long.class, Long.class);
|
||||
assertTrue(delete.getAnnotation(Transactional.class) != null,
|
||||
"deleteTask 落库删除必须保留 @Transactional(锁内删除语义不变)");
|
||||
// 删除路径使用抽出的纯函数(行为等价由既有 ShopDataCrawlCleanupTest 守门)
|
||||
assertTrue(ShopDataCrawlTaskService.collectResultIds(List.of()).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateChunkPathIsIdempotentWithoutLongTransaction() throws Exception {
|
||||
FileTaskEntity task = runningTask();
|
||||
when(fileTaskMapper.selectById(TASK_ID)).thenReturn(task);
|
||||
FileResultEntity row = resultRow(1L);
|
||||
row.setSourceFilename(SHOP_KEY);
|
||||
when(fileResultMapper.selectList(any())).thenReturn(List.of(row));
|
||||
when(taskDistributedLockService.acquire(any(), anyLong()))
|
||||
.thenReturn(mock(TaskDistributedLockService.LockHandle.class), null);
|
||||
lenient().when(transientPayloadStorageService.isSharedWriteEnabled()).thenReturn(true);
|
||||
java.util.concurrent.atomic.AtomicReference<String> chunkPayload =
|
||||
new java.util.concurrent.atomic.AtomicReference<>();
|
||||
when(transientPayloadStorageService.storeChunkPayloadVersioned(
|
||||
eq("SHOP_DATA_CRAWL"), eq(TASK_ID), any(), eq(1), any()))
|
||||
.thenAnswer(invocation -> {
|
||||
chunkPayload.set(invocation.getArgument(4));
|
||||
return "\"rustfs:chunk\"";
|
||||
});
|
||||
lenient().when(transientPayloadStorageService.extractPointer("\"rustfs:chunk\""))
|
||||
.thenReturn("rustfs:chunk");
|
||||
lenient().when(transientPayloadStorageService.resolvePayload(anyString(), anyString()))
|
||||
.thenReturn("[{\"country\":\"DE\",\"items\":[{\"asin\":\"B0TEST1234\"}]}]");
|
||||
lenient().when(taskChunkMapper.selectOne(any())).thenAnswer(invocation -> {
|
||||
TaskChunkEntity winner = completedChunk();
|
||||
winner.setPayloadHash(DigestUtil.sha256Hex(chunkPayload.get()));
|
||||
return winner;
|
||||
});
|
||||
lenient().when(taskChunkMapper.selectCount(any())).thenReturn(1L);
|
||||
lenient().when(taskScopeStateMapper.selectOne(any())).thenAnswer(invocation -> {
|
||||
// 重复提交:scope 计数器已持久化(received=1),幂等判定 completed
|
||||
com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity scope =
|
||||
new com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity();
|
||||
scope.setId(901L);
|
||||
scope.setTaskId(TASK_ID);
|
||||
scope.setReceivedChunkCount(1);
|
||||
scope.setChunkTotal(1);
|
||||
return scope;
|
||||
});
|
||||
when(taskChunkMapper.selectList(any())).thenReturn(List.of(completedChunk()));
|
||||
org.mockito.Mockito.doThrow(new org.springframework.dao.DuplicateKeyException("dup"))
|
||||
.when(taskChunkMapper).insert(any(TaskChunkEntity.class));
|
||||
|
||||
service.submitResult(TASK_ID, chunkRequest());
|
||||
|
||||
verify(transactionManager, never()).getTransaction(any());
|
||||
verify(transientPayloadStorageService).deletePayloadIfPresent("\"rustfs:chunk\"");
|
||||
}
|
||||
|
||||
private FileTaskEntity runningTask() {
|
||||
FileTaskEntity task = new FileTaskEntity();
|
||||
task.setId(TASK_ID);
|
||||
task.setModuleType("SHOP_DATA_CRAWL");
|
||||
task.setStatus("RUNNING");
|
||||
task.setUserId(USER_ID);
|
||||
task.setResultJson("{\"ownerInstanceId\":\"instance-a\"}");
|
||||
return task;
|
||||
}
|
||||
|
||||
private FileResultEntity resultRow(Long id) {
|
||||
FileResultEntity row = new FileResultEntity();
|
||||
row.setId(id);
|
||||
row.setTaskId(TASK_ID);
|
||||
row.setSourceFilename(SHOP_KEY);
|
||||
row.setModuleType("SHOP_DATA_CRAWL");
|
||||
return row;
|
||||
}
|
||||
|
||||
private TaskChunkEntity completedChunk() {
|
||||
TaskChunkEntity chunk = new TaskChunkEntity();
|
||||
chunk.setId(801L);
|
||||
chunk.setTaskId(TASK_ID);
|
||||
chunk.setModuleType("SHOP_DATA_CRAWL");
|
||||
chunk.setScopeKey("result-chunks:" + SHOP_KEY);
|
||||
chunk.setScopeHash("result-chunks-hash");
|
||||
chunk.setChunkIndex(1);
|
||||
chunk.setChunkTotal(1);
|
||||
chunk.setPayloadJson("\"rustfs:chunk\"");
|
||||
return chunk;
|
||||
}
|
||||
|
||||
private ShopDataCrawlSubmitResultRequest submitRequest(boolean done) {
|
||||
ShopDataCrawlShopPayloadDto shop = new ShopDataCrawlShopPayloadDto();
|
||||
shop.setShopName(SHOP_KEY);
|
||||
shop.setShopDone(done);
|
||||
ShopDataCrawlSubmitResultRequest request = new ShopDataCrawlSubmitResultRequest();
|
||||
request.setShops(List.of(shop));
|
||||
return request;
|
||||
}
|
||||
|
||||
private ShopDataCrawlSubmitResultRequest chunkRequest() {
|
||||
ShopDataCrawlRowDto row = new ShopDataCrawlRowDto();
|
||||
row.setAsin("B0TEST1234");
|
||||
ShopDataCrawlCountryResultDto country = new ShopDataCrawlCountryResultDto();
|
||||
country.setCountry("DE");
|
||||
country.setItems(List.of(row));
|
||||
ShopDataCrawlShopPayloadDto shop = new ShopDataCrawlShopPayloadDto();
|
||||
shop.setShopName(SHOP_KEY);
|
||||
shop.setChunkIndex(1);
|
||||
shop.setChunkTotal(1);
|
||||
shop.setCountryResults(List.of(country));
|
||||
ShopDataCrawlSubmitResultRequest request = new ShopDataCrawlSubmitResultRequest();
|
||||
request.setShops(List.of(shop));
|
||||
return request;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user