task-36
This commit is contained in:
+13
@@ -0,0 +1,13 @@
|
|||||||
|
package com.nanri.aiimage.modules.shopdatacrawl.service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 36:每日累计文件归档提交阶段的版本 CAS 冲突信号。
|
||||||
|
* 由提交阶段捕获并触发整次归档重试(释放店铺级锁后按最新状态重新组装),
|
||||||
|
* 区别于需要调用方直接失败的运行时异常。
|
||||||
|
*/
|
||||||
|
class DailyStateConflictException extends RuntimeException {
|
||||||
|
|
||||||
|
DailyStateConflictException() {
|
||||||
|
super("daily file state conflict");
|
||||||
|
}
|
||||||
|
}
|
||||||
+66
-33
@@ -83,6 +83,8 @@ public class ShopDataCrawlTaskService {
|
|||||||
private static final int RESULT_SUCCESS = 1;
|
private static final int RESULT_SUCCESS = 1;
|
||||||
/** 批量 IN 查询单批上限。 */
|
/** 批量 IN 查询单批上限。 */
|
||||||
private static final int ID_BATCH_SIZE = 500;
|
private static final int ID_BATCH_SIZE = 500;
|
||||||
|
/** Task 36:每日累计归档版本 CAS 冲突的最大重试次数(每次重试都释放店铺级锁)。 */
|
||||||
|
private static final int MAX_DAILY_AGGREGATION_ATTEMPTS = 3;
|
||||||
private static final String INTERRUPTED_MESSAGE = "Python 在该店铺结果提交完成前中断";
|
private static final String INTERRUPTED_MESSAGE = "Python 在该店铺结果提交完成前中断";
|
||||||
private static final String PARTIAL_RESULT_MESSAGE = "Python 中断,已保留已回传的部分数据";
|
private static final String PARTIAL_RESULT_MESSAGE = "Python 中断,已保留已回传的部分数据";
|
||||||
private static final String RESULT_CHUNK_SCOPE_PREFIX = "result-chunks:";
|
private static final String RESULT_CHUNK_SCOPE_PREFIX = "result-chunks:";
|
||||||
@@ -1742,46 +1744,73 @@ public class ShopDataCrawlTaskService {
|
|||||||
if (userId == null || shopKeyHash == null) {
|
if (userId == null || shopKeyHash == null) {
|
||||||
throw new BusinessException("店铺累计文件归属信息不完整");
|
throw new BusinessException("店铺累计文件归属信息不完整");
|
||||||
}
|
}
|
||||||
TaskDistributedLockService.LockHandle lock = dailyFileService.acquireLock(userId, shopKey);
|
// Task 36:版本号/CAS 短临界区。店铺级锁只覆盖“准备/提交”两个毫秒级短事务
|
||||||
if (lock == null) {
|
// (各自独立取锁/释放),整表 Excel 组装与 OSS 上传在两次取锁之间于锁外执行;
|
||||||
throw new BusinessException("店铺当天累计文件正在处理中,请稍后重试");
|
// 提交阶段按 daily_file.version CAS,冲突时释放锁、清理本次上传对象并按最新状态重试(最多 3 次)。
|
||||||
}
|
|
||||||
DailyAggregationResult persistedResult = null;
|
DailyAggregationResult persistedResult = null;
|
||||||
try {
|
DailyWorkbookArtifact artifact = null;
|
||||||
DailyAggregationPreparation preparation = executeShortTransaction(
|
for (int attempt = 1; attempt <= MAX_DAILY_AGGREGATION_ATTEMPTS; attempt++) {
|
||||||
() -> prepareDailyAggregation(userId, shopKeyHash, businessDate, row));
|
DailyAggregationPreparation preparation;
|
||||||
|
TaskDistributedLockService.LockHandle prepareLock = dailyFileService.acquireLock(userId, shopKey);
|
||||||
|
if (prepareLock == null) {
|
||||||
|
throw new BusinessException("店铺当天累计文件正在处理中,请稍后重试");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
preparation = executeShortTransaction(
|
||||||
|
() -> prepareDailyAggregation(userId, shopKeyHash, businessDate, row));
|
||||||
|
} finally {
|
||||||
|
prepareLock.close();
|
||||||
|
}
|
||||||
if (preparation.alreadyArchived()) {
|
if (preparation.alreadyArchived()) {
|
||||||
return new DailyAggregationResult(List.of(), false);
|
return new DailyAggregationResult(List.of(), false);
|
||||||
}
|
}
|
||||||
|
ShopDataCrawlDailyFileEntity baseForAttempt = resolveBaseDailyFile(
|
||||||
int addedRowCount = excelAssemblyService.countRows(List.of(snapshot));
|
|
||||||
ShopDataCrawlDailyFileEntity baseDailyFile = resolveBaseDailyFile(
|
|
||||||
preparation.dailyFile(), userId, shopKeyHash, businessDate);
|
preparation.dailyFile(), userId, shopKeyHash, businessDate);
|
||||||
DailyWorkbookArtifact artifact = assembleDailyWorkbook(
|
// 组装在锁外执行。每次尝试都用本次准备阶段读到的最新 base 组装
|
||||||
task, snapshot, baseDailyFile, addedRowCount);
|
// (冲突重试时 base 已变化,复用过期的组装结果会把并发写入的行丢在对象外);
|
||||||
try {
|
// 零行引用对象不重复上传。
|
||||||
persistedResult = executeShortTransaction(() -> persistDailyAggregation(
|
if (artifact == null || !artifact.uploaded()) {
|
||||||
task, row, userId, shopKey, shopKeyHash, businessDate,
|
artifact = assembleDailyWorkbook(task, snapshot, baseForAttempt,
|
||||||
preparation, artifact, snapshot));
|
excelAssemblyService.countRows(List.of(snapshot)));
|
||||||
if (persistedResult.discardUploadedObject() && artifact.uploaded()) {
|
}
|
||||||
deleteObjectQuietly(artifact.objectKey());
|
TaskDistributedLockService.LockHandle commitLock = dailyFileService.acquireLock(userId, shopKey);
|
||||||
}
|
if (commitLock == null) {
|
||||||
return persistedResult;
|
throw new BusinessException("店铺当天累计文件正在处理中,请稍后重试");
|
||||||
} catch (RuntimeException ex) {
|
|
||||||
if (artifact.uploaded()) {
|
|
||||||
deleteObjectQuietly(artifact.objectKey());
|
|
||||||
}
|
|
||||||
throw ex;
|
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
try {
|
try {
|
||||||
lock.close();
|
DailyAggregationPreparation preparationForAttempt = preparation;
|
||||||
} finally {
|
DailyWorkbookArtifact artifactForAttempt = artifact;
|
||||||
if (persistedResult != null) {
|
try {
|
||||||
persistedResult.obsoleteObjectKeys().forEach(this::deleteObjectQuietly);
|
persistedResult = executeShortTransaction(() -> persistDailyAggregation(
|
||||||
|
task, row, userId, shopKey, shopKeyHash, businessDate,
|
||||||
|
preparationForAttempt, artifactForAttempt, snapshot,
|
||||||
|
preparationForAttempt.dailyFile()));
|
||||||
|
if (persistedResult.discardUploadedObject() && artifactForAttempt.uploaded()) {
|
||||||
|
deleteObjectQuietly(artifactForAttempt.objectKey());
|
||||||
|
} else {
|
||||||
|
persistedResult.obsoleteObjectKeys().forEach(this::deleteObjectQuietly);
|
||||||
|
}
|
||||||
|
return persistedResult;
|
||||||
|
} catch (DailyStateConflictException conflictEx) {
|
||||||
|
// 冲突:本次上传对象作废并释放,按最新状态重新组装再试。
|
||||||
|
if (artifact.uploaded()) {
|
||||||
|
deleteObjectQuietly(artifact.objectKey());
|
||||||
|
artifact = null;
|
||||||
|
}
|
||||||
|
if (attempt >= MAX_DAILY_AGGREGATION_ATTEMPTS) {
|
||||||
|
throw new BusinessException("店铺累计文件并发更新冲突,请稍后重试");
|
||||||
|
}
|
||||||
|
} catch (RuntimeException ex) {
|
||||||
|
if (artifact != null && artifact.uploaded()) {
|
||||||
|
deleteObjectQuietly(artifact.objectKey());
|
||||||
|
}
|
||||||
|
throw ex;
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
commitLock.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
throw new BusinessException("店铺累计文件并发更新冲突,请稍后重试");
|
||||||
}
|
}
|
||||||
|
|
||||||
private DailyAggregationPreparation prepareDailyAggregation(Long userId,
|
private DailyAggregationPreparation prepareDailyAggregation(Long userId,
|
||||||
@@ -1937,14 +1966,18 @@ public class ShopDataCrawlTaskService {
|
|||||||
LocalDate businessDate,
|
LocalDate businessDate,
|
||||||
DailyAggregationPreparation preparation,
|
DailyAggregationPreparation preparation,
|
||||||
DailyWorkbookArtifact artifact,
|
DailyWorkbookArtifact artifact,
|
||||||
ShopDataCrawlResultItemVo snapshot) {
|
ShopDataCrawlResultItemVo snapshot,
|
||||||
|
ShopDataCrawlDailyFileEntity expectedBase) {
|
||||||
ShopDataCrawlDailyFileEntity dailyFile = dailyFileService.findForUpdate(
|
ShopDataCrawlDailyFileEntity dailyFile = dailyFileService.findForUpdate(
|
||||||
userId, shopKeyHash, businessDate);
|
userId, shopKeyHash, businessDate);
|
||||||
if (handleExistingDailyMembership(row, dailyFile)) {
|
if (handleExistingDailyMembership(row, dailyFile)) {
|
||||||
return new DailyAggregationResult(List.of(), true);
|
return new DailyAggregationResult(List.of(), true);
|
||||||
}
|
}
|
||||||
if (!sameDailyFileState(preparation.dailyFile(), dailyFile)) {
|
if (!Objects.equals(dailyFile == null ? null : dailyFile.getId(),
|
||||||
throw new BusinessException("当天累计文件状态已变化,请重试文件任务");
|
expectedBase == null ? null : expectedBase.getId())
|
||||||
|
|| !Objects.equals(dailyFile == null ? null : dailyFile.getVersion(),
|
||||||
|
expectedBase == null ? null : expectedBase.getVersion())) {
|
||||||
|
throw new DailyStateConflictException();
|
||||||
}
|
}
|
||||||
|
|
||||||
List<ShopDataCrawlDailyFileEntity> olderFiles = dailyFileService.findOlder(
|
List<ShopDataCrawlDailyFileEntity> olderFiles = dailyFileService.findOlder(
|
||||||
|
|||||||
+601
@@ -0,0 +1,601 @@
|
|||||||
|
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.AtomicInteger;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
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 36:为每日累计文件引入版本号/CAS,缩短店铺级锁的持有时间。
|
||||||
|
* 原实现持有店铺级锁覆盖“准备+组装+提交”全流程(含整表 Excel 组装与 OSS 上传,
|
||||||
|
* 耗时最长);重构为两个短临界区(准备 / 提交),组装在锁外执行,
|
||||||
|
* 提交阶段按 daily_file.version CAS,冲突时释放锁重试(最多 3 次),
|
||||||
|
* 从而把锁的持有时间从“秒级组装”缩短到“毫秒级两个短事务”。
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class ShopDataCrawlDailyFileLockTest {
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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;
|
||||||
|
|
||||||
|
/** 内存中的结果行表(listTaskRows 读取源 + updateById 回写目标)。 */
|
||||||
|
private final List<FileResultEntity> dbResultRows = new ArrayList<>();
|
||||||
|
/** 内存中的 daily_file 表。 */
|
||||||
|
private final List<ShopDataCrawlDailyFileEntity> dbDailyFiles = new ArrayList<>();
|
||||||
|
/** 内存中的 daily_member 表。 */
|
||||||
|
private final List<ShopDataCrawlDailyMemberEntity> dbMembers = new ArrayList<>();
|
||||||
|
private final AtomicLong memberIdSeq = new AtomicLong(1000);
|
||||||
|
private final AtomicLong fileIdSeq = new AtomicLong(5000);
|
||||||
|
private long nextResultId = 7200;
|
||||||
|
private Long lastJobTaskId;
|
||||||
|
private String lastUploadedObjectKey;
|
||||||
|
/** 店铺级锁获取次数(验证锁持有时间缩短:两次短临界区各取一次)。 */
|
||||||
|
private final AtomicInteger lockAcquireCount = new AtomicInteger();
|
||||||
|
private final AtomicReference<TaskDistributedLockService.LockHandle> lastLock = new AtomicReference<>();
|
||||||
|
/** 本次测试获取到的全部店铺级锁句柄(验证每个临界区取到的锁都被释放)。 */
|
||||||
|
private final List<TaskDistributedLockService.LockHandle> acquiredLocks = new ArrayList<>();
|
||||||
|
|
||||||
|
@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();
|
||||||
|
memberIdSeq.set(1000);
|
||||||
|
fileIdSeq.set(5000);
|
||||||
|
nextResultId = 7200;
|
||||||
|
lastJobTaskId = null;
|
||||||
|
lastUploadedObjectKey = null;
|
||||||
|
lockAcquireCount.set(0);
|
||||||
|
lastLock.set(null);
|
||||||
|
acquiredLocks.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(taskFileJobService.findAssembleJob(anyLong(), eq(MODULE_TYPE), anyLong())).thenReturn(null);
|
||||||
|
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())).thenReturn(null);
|
||||||
|
lenient().when(fileTaskMapper.updateById(any(FileTaskEntity.class))).thenReturn(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/lock/" + System.nanoTime() + ".xlsx";
|
||||||
|
lastUploadedObjectKey = key;
|
||||||
|
return key;
|
||||||
|
});
|
||||||
|
lenient().when(ossStorageService.readObjectBytes(anyString())).thenReturn(new byte[0]);
|
||||||
|
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.acquireLock(anyLong(), anyString())).thenAnswer(invocation -> {
|
||||||
|
TaskDistributedLockService.LockHandle handle = mock(TaskDistributedLockService.LockHandle.class);
|
||||||
|
lockAcquireCount.incrementAndGet();
|
||||||
|
lastLock.set(handle);
|
||||||
|
acquiredLocks.add(handle);
|
||||||
|
return handle;
|
||||||
|
});
|
||||||
|
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.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();
|
||||||
|
|
||||||
|
// 结果行读取:listTaskRows 按 taskId+moduleType 过滤并升序;其他查询返回空。
|
||||||
|
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_036_daily_file_lock_normal_default_path() {
|
||||||
|
// 正常路径:归档成功,版本号从 1 起步;锁只取两次(准备/提交短临界区),
|
||||||
|
// 整表组装在锁外完成;成员行与累计文件各一。
|
||||||
|
FileResultEntity row = addResultRow(7201L, 1L, 1, SHOP_NAME, null);
|
||||||
|
processJob(1L, List.of(row), snapshot(7201L));
|
||||||
|
|
||||||
|
assertEquals(1, dbMembers.size(), "默认路径产生一个成员行");
|
||||||
|
assertEquals(1, dbDailyFiles.size(), "默认路径产生一个累计文件");
|
||||||
|
assertEquals(1L, dbDailyFiles.get(0).getVersion(), "版本号从 1 起步");
|
||||||
|
assertEquals(2, lockAcquireCount.get(), "准备/提交两次短临界区各取一次锁");
|
||||||
|
assertNotNull(lastUploadedObjectKey, "锁外组装上传了新对象");
|
||||||
|
verify(ossStorageService, never()).readObjectBytes(anyString());
|
||||||
|
verify(excelAssemblyService, never()).replaceCountriesWorkbook(any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_036_daily_file_lock_normal_multiple_items() {
|
||||||
|
// 多结果批量场景:多个结果先后归档,版本号随每次提交递增,成员累积且顺序稳定。
|
||||||
|
processJob(1L, List.of(addResultRow(7202L, 1L, 1, SHOP_NAME, null)), snapshot(7202L));
|
||||||
|
assertEquals(1L, dbDailyFiles.get(0).getVersion(), "首次归档版本 1");
|
||||||
|
processJob(2L, List.of(addResultRow(7203L, 2L, 1, SHOP_NAME, null)), snapshot(7203L));
|
||||||
|
|
||||||
|
assertEquals(2, dbMembers.size(), "两个结果各一个成员行");
|
||||||
|
assertEquals(2L, dbDailyFiles.get(0).getVersion(), "第二次归档版本递增到 2");
|
||||||
|
List<ShopDataCrawlDailyMemberEntity> members = dbMembers.stream()
|
||||||
|
.sorted(Comparator.comparing(ShopDataCrawlDailyMemberEntity::getResultId))
|
||||||
|
.toList();
|
||||||
|
assertEquals(7202L, members.get(0).getResultId());
|
||||||
|
assertEquals(7203L, members.get(1).getResultId());
|
||||||
|
assertNotNull(members.get(0).getRowPayload());
|
||||||
|
assertNotNull(members.get(1).getRowPayload());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_036_daily_file_lock_normal_repeated_operation_is_idempotent() {
|
||||||
|
// 幂等:同一结果重复归档不产生第二个成员行、版本号不递增、不重复上传对象。
|
||||||
|
FileResultEntity row = addResultRow(7204L, 1L, 1, SHOP_NAME, null);
|
||||||
|
processJob(1L, List.of(row), snapshot(7204L));
|
||||||
|
long versionAfterFirst = dbDailyFiles.get(0).getVersion();
|
||||||
|
String objectAfterFirst = lastUploadedObjectKey;
|
||||||
|
processJob(1L, List.of(row), snapshot(7204L));
|
||||||
|
|
||||||
|
assertEquals(1, dbMembers.size(), "重复归档不产生重复成员行");
|
||||||
|
assertEquals(versionAfterFirst, dbDailyFiles.get(0).getVersion(), "重复归档版本号不递增");
|
||||||
|
assertEquals(objectAfterFirst, lastUploadedObjectKey, "重复归档复用既有对象,不重复上传");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_036_daily_file_lock_boundary_empty_input() {
|
||||||
|
// 空输入:没有成功结果时安全跳过,不取店铺级锁、不创建成员/累计文件、不上传对象。
|
||||||
|
FileResultEntity row = addResultRow(7205L, 1L, 0, SHOP_NAME, null);
|
||||||
|
processJob(1L, List.of(row), snapshot(7205L));
|
||||||
|
|
||||||
|
assertTrue(dbMembers.isEmpty(), "无成功结果不创建成员行");
|
||||||
|
assertTrue(dbDailyFiles.isEmpty(), "无成功结果不创建累计文件");
|
||||||
|
assertEquals(0, lockAcquireCount.get(), "无成功结果不获取店铺级锁");
|
||||||
|
verify(ossStorageService, never()).uploadResultFile(any(), eq(MODULE_TYPE));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_036_daily_file_lock_boundary_single_item() {
|
||||||
|
// 单元素:单结果归档同样只取两次锁(短临界区),版本号 1,结果正确。
|
||||||
|
FileResultEntity row = addResultRow(7206L, 1L, 1, SHOP_NAME, null);
|
||||||
|
processJob(1L, List.of(row), snapshot(7206L));
|
||||||
|
|
||||||
|
assertEquals(1, dbMembers.size());
|
||||||
|
assertEquals(1, dbDailyFiles.size());
|
||||||
|
assertEquals(1L, dbDailyFiles.get(0).getVersion());
|
||||||
|
assertEquals(2, lockAcquireCount.get(), "单结果也是两次短临界区取锁");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_036_daily_file_lock_boundary_limit_and_overflow() {
|
||||||
|
// 上限/超限:大量结果逐个归档,版本号与成员数一致递增,无重复无丢失,
|
||||||
|
// 每次归档的锁获取次数仍为两次(不随量级放大锁持有时间)。
|
||||||
|
int limit = 210;
|
||||||
|
for (int i = 0; i < limit; i++) {
|
||||||
|
long resultId = nextResultId++;
|
||||||
|
long taskId = i + 1L;
|
||||||
|
FileResultEntity row = addResultRow(resultId, taskId, 1, SHOP_NAME, null);
|
||||||
|
processJob(taskId, List.of(row), snapshot(resultId));
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(limit, dbMembers.size(), "大量结果成员行全部保留");
|
||||||
|
assertEquals(Long.valueOf(limit), dbDailyFiles.get(0).getVersion(), "版本号与归档次数一致");
|
||||||
|
assertEquals(limit * 2, lockAcquireCount.get(), "每次归档恰好两次短临界区取锁");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_036_daily_file_lock_invalid_input_rejected() {
|
||||||
|
// 非法参数/CAS 冲突:并发提交导致 version CAS 持续冲突时,重试 3 次后
|
||||||
|
// 抛出可识别的 BusinessException;每次冲突后上传对象被清理、锁被释放。
|
||||||
|
// 通过 findForUpdate 在每次读取后把版本号写回内存表(并返回读到的快照副本)
|
||||||
|
// 模拟并发写入:提交阶段读到的版本总是比准备阶段新 → 每次 CAS 都冲突。
|
||||||
|
// doAnswer().when() 覆盖 @BeforeEach 中同参数 when() 注册(后者重注册不生效)。
|
||||||
|
seedDailyFile();
|
||||||
|
FileResultEntity row = addResultRow(7207L, 1L, 1, SHOP_NAME, null);
|
||||||
|
lenient().doAnswer(invocation -> {
|
||||||
|
ShopDataCrawlDailyFileEntity current = findDailyFile(invocation.getArgument(0), invocation.getArgument(1));
|
||||||
|
if (current == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
ShopDataCrawlDailyFileEntity readCopy = copyDailyFile(current);
|
||||||
|
current.setVersion(readCopy.getVersion() + 1L);
|
||||||
|
return readCopy;
|
||||||
|
}).when(dailyFileService).findForUpdate(anyLong(), anyString(), any());
|
||||||
|
|
||||||
|
Exception ex = assertThrows(BusinessException.class,
|
||||||
|
() -> processJob(1L, List.of(row), snapshot(7207L)));
|
||||||
|
assertTrue(ex.getMessage().contains("并发"), "CAS 冲突超限后错误消息可识别");
|
||||||
|
assertTrue(dbMembers.isEmpty(), "冲突放弃后不残留成员行");
|
||||||
|
assertEquals(1, dbDailyFiles.size(), "冲突放弃后既有累计文件不受破坏");
|
||||||
|
assertEquals(6L, dbDailyFiles.get(0).getVersion(), "版本只随并发写入模拟推进(每次读取+1),冲突归档未提交");
|
||||||
|
assertEquals(6, acquiredLocks.size(), "三次尝试各取准备/提交两次锁");
|
||||||
|
for (TaskDistributedLockService.LockHandle handle : acquiredLocks) {
|
||||||
|
verify(handle).close();
|
||||||
|
}
|
||||||
|
verify(ossStorageService, times(3)).deleteObject(anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test_task_036_daily_file_lock_dependency_failure_releases_resources() {
|
||||||
|
// 依赖失败:提交阶段 DB 写入失败时错误可恢复,锁已释放、上传对象已清理、
|
||||||
|
// 不残留新成员/累计文件;组装成功路径不受影响(可再次归档)。
|
||||||
|
seedDailyFile();
|
||||||
|
doThrow(new RuntimeException("commit db down"))
|
||||||
|
.when(dailyFileService).update(any(ShopDataCrawlDailyFileEntity.class));
|
||||||
|
FileResultEntity row = addResultRow(7208L, 1L, 1, SHOP_NAME, null);
|
||||||
|
assertThrows(RuntimeException.class,
|
||||||
|
() -> processJob(1L, List.of(row), snapshot(7208L)));
|
||||||
|
|
||||||
|
assertTrue(dbMembers.isEmpty(), "提交失败不残留成员行");
|
||||||
|
assertEquals(1, dbDailyFiles.size(), "既有累计文件未被破坏");
|
||||||
|
assertEquals(0L, dbDailyFiles.get(0).getVersion(), "既有累计文件版本未被改动");
|
||||||
|
assertNotNull(lastLock.get(), "提交阶段已获取锁");
|
||||||
|
verify(lastLock.get()).close();
|
||||||
|
verify(ossStorageService).deleteObject(anyString());
|
||||||
|
|
||||||
|
// 错误可恢复:恢复 DB 后同一结果重新归档成功。
|
||||||
|
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, entity);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entity.setId(fileIdSeq.incrementAndGet());
|
||||||
|
dbDailyFiles.add(entity);
|
||||||
|
return null;
|
||||||
|
}).when(dailyFileService).update(any(ShopDataCrawlDailyFileEntity.class));
|
||||||
|
processJob(1L, List.of(row), snapshot(7208L));
|
||||||
|
assertEquals(1, dbMembers.size(), "恢复后同一结果重新归档成功");
|
||||||
|
assertEquals(1L, dbDailyFiles.get(0).getVersion(), "恢复后版本号递增");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
|
||||||
|
private void seedDailyFile() {
|
||||||
|
ShopDataCrawlDailyFileEntity file = new ShopDataCrawlDailyFileEntity();
|
||||||
|
file.setId(1L);
|
||||||
|
file.setUserId(7L);
|
||||||
|
file.setShopKeyHash("hash:" + SHOP_NAME);
|
||||||
|
file.setShopKey(SHOP_NAME);
|
||||||
|
file.setBusinessDate(BUSINESS_DATE);
|
||||||
|
file.setResultFilename("daily.xlsx");
|
||||||
|
file.setResultFileUrl("oss/daily/seed.xlsx");
|
||||||
|
file.setResultFileSize(10L);
|
||||||
|
file.setVersion(0L);
|
||||||
|
file.setRowCount(0);
|
||||||
|
file.setCreatedAt(BUSINESS_TIME);
|
||||||
|
file.setUpdatedAt(BUSINESS_TIME);
|
||||||
|
dbDailyFiles.add(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 processJob(long jobTaskId, List<FileResultEntity> rows, ShopDataCrawlResultItemVo snapshot) {
|
||||||
|
lastJobTaskId = jobTaskId;
|
||||||
|
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||||
|
job.setId(jobTaskId);
|
||||||
|
job.setTaskId(jobTaskId);
|
||||||
|
job.setModuleType(MODULE_TYPE);
|
||||||
|
FileTaskEntity task = taskEntity(jobTaskId);
|
||||||
|
lenient().when(fileTaskMapper.selectById(jobTaskId)).thenReturn(task);
|
||||||
|
lenient().when(taskResultItemService.listResultSnapshots(eq(jobTaskId), eq(MODULE_TYPE), any()))
|
||||||
|
.thenReturn(snapshot == null ? List.of() : List.of(snapshot));
|
||||||
|
service.processResultFileJob(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 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user