新需求更新 同步更新
This commit is contained in:
+7
@@ -10,6 +10,13 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class AppearancePatentTaskServiceTest {
|
||||
|
||||
@Test
|
||||
void taskStatusDependsOnExecutionOutcomeOnly() {
|
||||
assertEquals("RUNNING", AppearancePatentTaskService.resolveTaskExecutionStatus(true, false));
|
||||
assertEquals("SUCCESS", AppearancePatentTaskService.resolveTaskExecutionStatus(false, false));
|
||||
assertEquals("FAILED", AppearancePatentTaskService.resolveTaskExecutionStatus(false, true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resultStatusFailsOnlyWhenConclusionIsEmpty() {
|
||||
assertEquals("\u5931\u8d25", AppearancePatentTaskService.resolveResultStatus(null));
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package com.nanri.aiimage.modules.auth.util;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class WerkzeugPasswordEncoderTest {
|
||||
|
||||
private final WerkzeugPasswordEncoder encoder = new WerkzeugPasswordEncoder();
|
||||
|
||||
@Test
|
||||
void matchesWerkzeugScryptHash() {
|
||||
String hash = "scrypt:32768:8:1$VEWwSnHkAFcK3B3E$2065ba2db25c34072bf4d7ae9bd47b8c483289fafc3f5e98ae49bb232d30bf989b21ae0e9f14adc6e893e34a81543cf58641731431961ed9ab353bcebec2e78a";
|
||||
|
||||
assertTrue(encoder.matches("test-password", hash));
|
||||
assertFalse(encoder.matches("wrong-password", hash));
|
||||
}
|
||||
|
||||
@Test
|
||||
void matchesGeneratedPbkdf2Hash() {
|
||||
String hash = encoder.hash("test-password");
|
||||
|
||||
assertTrue(encoder.matches("test-password", hash));
|
||||
assertFalse(encoder.matches("wrong-password", hash));
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.nanri.aiimage.modules.collectdata.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataSummaryRowDto;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.ss.usermodel.Workbook;
|
||||
import org.apache.poi.ss.usermodel.WorkbookFactory;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class CollectDataExcelAssemblyServiceTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@Test
|
||||
void writesAsinFilterToSummarySheet() throws Exception {
|
||||
CollectDataSummaryRowDto summary = new ObjectMapper().readValue(
|
||||
"{\"keyword\":\"phone case\",\"asinFilter\":3}",
|
||||
CollectDataSummaryRowDto.class);
|
||||
File output = tempDir.resolve("collect-data-result.xlsx").toFile();
|
||||
|
||||
new CollectDataExcelAssemblyService().writeWorkbook(output, List.of(), List.of(summary), List.of());
|
||||
|
||||
try (Workbook workbook = WorkbookFactory.create(output)) {
|
||||
Sheet sheet = workbook.getSheet("结果文件");
|
||||
assertThat(sheet.getRow(0).getCell(6).getStringCellValue()).isEqualTo("ASIN过滤");
|
||||
assertThat(sheet.getRow(1).getCell(6).getNumericCellValue()).isEqualTo(3);
|
||||
}
|
||||
}
|
||||
}
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
package com.nanri.aiimage.modules.collectdata.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataDashboardVo;
|
||||
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataTaskBatchVo;
|
||||
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.model.entity.FileResultEntity;
|
||||
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||
import com.nanri.aiimage.modules.task.service.TaskDistributedLockService;
|
||||
import com.nanri.aiimage.modules.task.service.TaskFileJobService;
|
||||
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.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class CollectDataServiceTest {
|
||||
|
||||
@Mock
|
||||
private FileTaskMapper fileTaskMapper;
|
||||
|
||||
@Mock
|
||||
private FileResultMapper fileResultMapper;
|
||||
|
||||
@Mock
|
||||
private TaskChunkMapper taskChunkMapper;
|
||||
|
||||
@Mock
|
||||
private TaskDistributedLockService taskDistributedLockService;
|
||||
|
||||
@Mock
|
||||
private TaskFileJobService taskFileJobService;
|
||||
|
||||
@Mock
|
||||
private TaskDistributedLockService.LockHandle lockHandle;
|
||||
|
||||
@Spy
|
||||
private ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@InjectMocks
|
||||
private CollectDataService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ReflectionTestUtils.setField(service, "staleTimeoutMinutes", 30L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void dashboardCountsPendingAndRunningTasksAsActive() {
|
||||
when(fileTaskMapper.selectCount(any())).thenReturn(5L, 3L, 2L);
|
||||
|
||||
CollectDataDashboardVo dashboard = service.dashboard(7L);
|
||||
|
||||
assertThat(dashboard.getPendingTaskCount()).isEqualTo(5L);
|
||||
assertThat(dashboard.getSuccessTaskCount()).isEqualTo(3L);
|
||||
assertThat(dashboard.getFailedTaskCount()).isEqualTo(2L);
|
||||
assertThat(dashboard.getProcessedTaskCount()).isEqualTo(5L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void progressBatchExposesProcessedKeywordProgress() {
|
||||
FileTaskEntity task = new FileTaskEntity();
|
||||
task.setId(91L);
|
||||
task.setTaskNo("COLLECT_DATA-91");
|
||||
task.setModuleType(CollectDataService.MODULE_TYPE);
|
||||
task.setStatus("RUNNING");
|
||||
task.setCreatedAt(LocalDateTime.of(2026, 8, 1, 10, 0));
|
||||
task.setResultJson("{\"totalRows\":10,\"receivedRows\":24,\"processedRows\":4}");
|
||||
|
||||
FileResultEntity result = new FileResultEntity();
|
||||
result.setId(101L);
|
||||
result.setTaskId(task.getId());
|
||||
result.setModuleType(CollectDataService.MODULE_TYPE);
|
||||
result.setRowCount(10);
|
||||
result.setCreatedAt(LocalDateTime.of(2026, 8, 1, 10, 0));
|
||||
|
||||
when(fileTaskMapper.selectList(any())).thenReturn(List.of(task));
|
||||
when(fileResultMapper.selectList(any())).thenReturn(List.of(result));
|
||||
|
||||
CollectDataTaskBatchVo progress = service.progressBatch(List.of(task.getId()));
|
||||
|
||||
assertThat(progress.getItems()).hasSize(1);
|
||||
assertThat(progress.getItems().getFirst().getItems()).hasSize(1);
|
||||
assertThat(progress.getItems().getFirst().getItems().getFirst().getTotalRows()).isEqualTo(10);
|
||||
assertThat(progress.getItems().getFirst().getItems().getFirst().getReceivedRows()).isEqualTo(24);
|
||||
assertThat(progress.getItems().getFirst().getItems().getFirst().getProcessedRows()).isEqualTo(4);
|
||||
assertThat(progress.getItems().getFirst().getItems().getFirst().getProgressPercent()).isEqualTo(40);
|
||||
}
|
||||
|
||||
@Test
|
||||
void failTaskMarksAnActivatedTaskAsFailed() {
|
||||
FileTaskEntity task = new FileTaskEntity();
|
||||
task.setId(92L);
|
||||
task.setUserId(7L);
|
||||
task.setModuleType(CollectDataService.MODULE_TYPE);
|
||||
task.setStatus("RUNNING");
|
||||
task.setResultJson("{\"totalRows\":10,\"receivedRows\":4}");
|
||||
|
||||
FileResultEntity result = new FileResultEntity();
|
||||
result.setId(102L);
|
||||
result.setTaskId(task.getId());
|
||||
result.setModuleType(CollectDataService.MODULE_TYPE);
|
||||
|
||||
when(fileTaskMapper.selectById(task.getId())).thenReturn(task);
|
||||
when(fileResultMapper.selectOne(any())).thenReturn(result);
|
||||
|
||||
service.failTask(task.getId(), task.getUserId(), "queue unavailable");
|
||||
|
||||
assertThat(task.getStatus()).isEqualTo("FAILED");
|
||||
assertThat(task.getErrorMessage()).isEqualTo("queue unavailable");
|
||||
assertThat(result.getSuccess()).isZero();
|
||||
assertThat(result.getErrorMessage()).isEqualTo("queue unavailable");
|
||||
verify(fileResultMapper).updateById(result);
|
||||
verify(fileTaskMapper).updateById(task);
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleTaskWithResultChunksEnqueuesPartialWorkbook() throws Exception {
|
||||
FileTaskEntity task = staleTask(93L);
|
||||
task.setResultJson("{\"finalRowCount\":343}");
|
||||
FileResultEntity result = taskResult(task, 103L);
|
||||
|
||||
when(fileTaskMapper.selectList(any())).thenReturn(List.of(task));
|
||||
when(taskDistributedLockService.acquire(CollectDataService.MODULE_TYPE, task.getId(), 0L))
|
||||
.thenReturn(lockHandle);
|
||||
when(fileTaskMapper.selectById(task.getId())).thenReturn(task);
|
||||
when(taskFileJobService.countUnfinishedAssembleJobs(task.getId(), CollectDataService.MODULE_TYPE))
|
||||
.thenReturn(0L);
|
||||
when(fileResultMapper.selectOne(any())).thenReturn(result);
|
||||
when(taskChunkMapper.selectCount(any())).thenReturn(170L);
|
||||
|
||||
service.finalizeStaleTasks();
|
||||
|
||||
verify(taskFileJobService).enqueueAssembleResult(
|
||||
task.getId(), CollectDataService.MODULE_TYPE, result.getId(), "task:" + task.getId());
|
||||
assertThat(task.getStatus()).isEqualTo("RUNNING");
|
||||
assertThat(result.getRowCount()).isEqualTo(343);
|
||||
verify(lockHandle).close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleTaskWithoutResultChunksFails() throws Exception {
|
||||
FileTaskEntity task = staleTask(94L);
|
||||
task.setResultJson("{\"finalRowCount\":0}");
|
||||
FileResultEntity result = taskResult(task, 104L);
|
||||
|
||||
when(fileTaskMapper.selectList(any())).thenReturn(List.of(task));
|
||||
when(taskDistributedLockService.acquire(CollectDataService.MODULE_TYPE, task.getId(), 0L))
|
||||
.thenReturn(lockHandle);
|
||||
when(fileTaskMapper.selectById(task.getId())).thenReturn(task);
|
||||
when(taskFileJobService.countUnfinishedAssembleJobs(task.getId(), CollectDataService.MODULE_TYPE))
|
||||
.thenReturn(0L);
|
||||
when(fileResultMapper.selectOne(any())).thenReturn(result);
|
||||
when(taskChunkMapper.selectCount(any())).thenReturn(0L);
|
||||
|
||||
service.finalizeStaleTasks();
|
||||
|
||||
assertThat(task.getStatus()).isEqualTo("FAILED");
|
||||
assertThat(task.getErrorMessage()).contains("Python 心跳");
|
||||
verify(taskFileJobService, never()).enqueueAssembleResult(any(), any(), any(), any());
|
||||
verify(lockHandle).close();
|
||||
}
|
||||
|
||||
private static FileTaskEntity staleTask(long taskId) {
|
||||
FileTaskEntity task = new FileTaskEntity();
|
||||
task.setId(taskId);
|
||||
task.setModuleType(CollectDataService.MODULE_TYPE);
|
||||
task.setStatus("RUNNING");
|
||||
task.setUpdatedAt(LocalDateTime.now().minusMinutes(31));
|
||||
return task;
|
||||
}
|
||||
|
||||
private static FileResultEntity taskResult(FileTaskEntity task, long resultId) {
|
||||
FileResultEntity result = new FileResultEntity();
|
||||
result.setId(resultId);
|
||||
result.setTaskId(task.getId());
|
||||
result.setModuleType(CollectDataService.MODULE_TYPE);
|
||||
result.setSourceFilename("collect.xlsx");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+46
-1
@@ -1,5 +1,8 @@
|
||||
package com.nanri.aiimage.modules.dedupe.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.dedupe.mapper.DedupeTotalDataMapper;
|
||||
import com.nanri.aiimage.modules.dedupe.model.dto.DedupeTotalDataCreateRequest;
|
||||
@@ -16,6 +19,7 @@ import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
@@ -159,7 +163,7 @@ class DedupeTotalDataServiceTest {
|
||||
when(dedupeTotalDataMapper.selectCount(any())).thenReturn(0L);
|
||||
when(dedupeTotalDataMapper.selectList(any())).thenReturn(List.of());
|
||||
|
||||
DedupeTotalDataPageVo page = service.page(1, 15, "", "other", 10L);
|
||||
DedupeTotalDataPageVo page = service.page(1, 15, "", "other", null, null, 10L);
|
||||
|
||||
assertEquals(0L, page.getTotal());
|
||||
assertTrue(page.getItems().isEmpty());
|
||||
@@ -168,6 +172,47 @@ class DedupeTotalDataServiceTest {
|
||||
verify(dedupeTotalDataMapper).selectList(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||
void pageUsesInclusiveDateRange() {
|
||||
when(adminUserMapper.selectById(1L)).thenReturn(user(1L, "super_admin", "root"));
|
||||
when(dedupeTotalDataMapper.selectCount(any())).thenReturn(0L);
|
||||
when(dedupeTotalDataMapper.selectList(any())).thenReturn(List.of());
|
||||
|
||||
service.page(
|
||||
1,
|
||||
15,
|
||||
"",
|
||||
"",
|
||||
LocalDate.of(2026, 7, 31),
|
||||
LocalDate.of(2026, 7, 31),
|
||||
1L);
|
||||
|
||||
ArgumentCaptor<LambdaQueryWrapper<DedupeTotalDataEntity>> queryCaptor =
|
||||
ArgumentCaptor.forClass((Class) LambdaQueryWrapper.class);
|
||||
verify(dedupeTotalDataMapper).selectCount(queryCaptor.capture());
|
||||
LambdaQueryWrapper<DedupeTotalDataEntity> query = queryCaptor.getValue();
|
||||
TableInfoHelper.initTableInfo(
|
||||
new MapperBuilderAssistant(new MybatisConfiguration(), ""),
|
||||
DedupeTotalDataEntity.class);
|
||||
query.getSqlSegment();
|
||||
assertTrue(query.getParamNameValuePairs().containsValue(LocalDate.of(2026, 7, 31).atStartOfDay()));
|
||||
assertTrue(query.getParamNameValuePairs().containsValue(LocalDate.of(2026, 8, 1).atStartOfDay()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void pageRejectsReversedDateRange() {
|
||||
assertThrows(BusinessException.class, () -> service.page(
|
||||
1,
|
||||
15,
|
||||
"",
|
||||
"",
|
||||
LocalDate.of(2026, 8, 1),
|
||||
LocalDate.of(2026, 7, 31),
|
||||
1L));
|
||||
verify(dedupeTotalDataMapper, never()).selectCount(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void comparableValueLookupRemainsGlobal() {
|
||||
when(dedupeTotalDataMapper.selectExistingDataValues(List.of("B012345678")))
|
||||
|
||||
+5
@@ -21,6 +21,7 @@ class OssStorageServiceTest {
|
||||
properties.setBucket("nanri-ai-images");
|
||||
properties.setImageVideoBucket("shufu-video");
|
||||
properties.setDigitalHumanBucket("nanri-ai-digital-human");
|
||||
properties.setTemplateBucket("aiimage-templates");
|
||||
properties.setAccessKeyId("test-access-key");
|
||||
properties.setAccessKeySecret("test-secret-key");
|
||||
storageService = new OssStorageService(properties);
|
||||
@@ -54,6 +55,10 @@ class OssStorageServiceTest {
|
||||
"https://oss.aishufu.top/nanri-ai-digital-human/digital-human/versions/demo.mp4",
|
||||
storageService.normalizeManagedPublicUrl(
|
||||
"https://nanri-ai-digital-human.oss.aishufu.top/digital-human/versions/demo.mp4"));
|
||||
assertEquals(
|
||||
"https://oss.aishufu.top/aiimage-templates/input/publish.xlsx",
|
||||
storageService.normalizeManagedPublicUrl(
|
||||
"http://47.110.241.161:9000/aiimage-templates/input/publish.xlsx"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package com.nanri.aiimage.modules.filetemplate;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
class ModuleTemplateControllerTest {
|
||||
|
||||
@Test
|
||||
void returnsXlsxWithUtf8AttachmentFilename() throws Exception {
|
||||
ModuleTemplateService service = mock(ModuleTemplateService.class);
|
||||
byte[] bytes = {1, 2, 3, 4};
|
||||
when(service.download("publish")).thenReturn(new ModuleTemplateService.TemplateDownload(
|
||||
"上架 文档格式.xlsx",
|
||||
ModuleTemplateService.XLSX_CONTENT_TYPE,
|
||||
bytes));
|
||||
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new ModuleTemplateController(service)).build();
|
||||
|
||||
mockMvc.perform(get("/api/module-templates/publish/download").param("user_id", "1"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().bytes(bytes))
|
||||
.andExpect(content().contentType(ModuleTemplateService.XLSX_CONTENT_TYPE))
|
||||
.andExpect(header().longValue(HttpHeaders.CONTENT_LENGTH, bytes.length))
|
||||
.andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"download.xlsx\"; "
|
||||
+ "filename*=UTF-8''%E4%B8%8A%E6%9E%B6%20%E6%96%87%E6%A1%A3%E6%A0%BC%E5%BC%8F.xlsx"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void propagatesUnknownModuleAndStorageStatusCodes() throws Exception {
|
||||
ModuleTemplateService service = mock(ModuleTemplateService.class);
|
||||
when(service.download("missing"))
|
||||
.thenThrow(new ResponseStatusException(HttpStatus.NOT_FOUND, "模板不存在"));
|
||||
when(service.download("publish"))
|
||||
.thenThrow(new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "模板存储暂不可用"));
|
||||
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new ModuleTemplateController(service)).build();
|
||||
|
||||
mockMvc.perform(get("/api/module-templates/missing/download"))
|
||||
.andExpect(status().isNotFound());
|
||||
mockMvc.perform(get("/api/module-templates/publish/download"))
|
||||
.andExpect(status().isServiceUnavailable());
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package com.nanri.aiimage.modules.filetemplate;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class ModuleTemplateRegistryTest {
|
||||
|
||||
@Test
|
||||
void exposesSixFixedModulesWithPackagedXlsxResources() throws Exception {
|
||||
ModuleTemplateRegistry registry = new ModuleTemplateRegistry();
|
||||
|
||||
assertEquals(Set.of(
|
||||
"publish",
|
||||
"delete-brand",
|
||||
"appearance-patent",
|
||||
"price-track",
|
||||
"collect-data",
|
||||
"similar-asin"),
|
||||
registry.templates().stream()
|
||||
.map(ModuleTemplateRegistry.ModuleTemplate::moduleCode)
|
||||
.collect(Collectors.toSet()));
|
||||
|
||||
for (ModuleTemplateRegistry.ModuleTemplate template : registry.templates()) {
|
||||
assertTrue(template.resourcePath().matches("templates/module-input/[a-z-]+\\.xlsx"));
|
||||
assertEquals("input/" + template.resourcePath().substring(template.resourcePath().lastIndexOf('/') + 1),
|
||||
template.objectKey());
|
||||
ClassPathResource resource = new ClassPathResource(template.resourcePath());
|
||||
assertTrue(resource.exists(), template.resourcePath());
|
||||
try (InputStream input = resource.getInputStream()) {
|
||||
assertArrayEquals(new byte[]{'P', 'K'}, input.readNBytes(2), template.resourcePath());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void lookupNormalizesCaseAndWhitespaceWithoutAcceptingUnknownCodes() {
|
||||
ModuleTemplateRegistry registry = new ModuleTemplateRegistry();
|
||||
|
||||
assertEquals("上架 文档格式.xlsx", registry.find(" PUBLISH ").orElseThrow().downloadFilename());
|
||||
assertTrue(registry.find("../../publish").isEmpty());
|
||||
assertTrue(registry.find(null).isEmpty());
|
||||
}
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
package com.nanri.aiimage.modules.filetemplate;
|
||||
|
||||
import com.nanri.aiimage.config.OssProperties;
|
||||
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
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.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class ModuleTemplateServiceTest {
|
||||
|
||||
private static final String BUCKET = "aiimage-templates";
|
||||
|
||||
private OssStorageService ossStorageService;
|
||||
private ModuleTemplateService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
OssProperties properties = new OssProperties();
|
||||
properties.setTemplateBucket(BUCKET);
|
||||
ossStorageService = mock(OssStorageService.class);
|
||||
service = new ModuleTemplateService(new ModuleTemplateRegistry(), properties, ossStorageService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void downloadsExistingObjectWithoutUploadingItAgain() {
|
||||
byte[] stored = {1, 2, 3};
|
||||
when(ossStorageService.objectExists(BUCKET, "input/publish.xlsx")).thenReturn(true);
|
||||
when(ossStorageService.readObjectBytes(BUCKET, "input/publish.xlsx")).thenReturn(stored);
|
||||
|
||||
ModuleTemplateService.TemplateDownload download = service.download("publish");
|
||||
|
||||
assertEquals("上架 文档格式.xlsx", download.filename());
|
||||
assertEquals(ModuleTemplateService.XLSX_CONTENT_TYPE, download.contentType());
|
||||
assertArrayEquals(stored, download.content());
|
||||
verify(ossStorageService).ensureBucketExists(BUCKET);
|
||||
verify(ossStorageService, never()).uploadBytes(
|
||||
eq(BUCKET), eq("input/publish.xlsx"), org.mockito.ArgumentMatchers.any(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void lazilyUploadsMissingObjectBeforeReadingItFromStorage() {
|
||||
byte[] stored = {4, 5, 6};
|
||||
when(ossStorageService.objectExists(BUCKET, "input/publish.xlsx")).thenReturn(false);
|
||||
when(ossStorageService.readObjectBytes(BUCKET, "input/publish.xlsx")).thenReturn(stored);
|
||||
|
||||
ModuleTemplateService.TemplateDownload download = service.download("publish");
|
||||
|
||||
assertArrayEquals(stored, download.content());
|
||||
ArgumentCaptor<byte[]> uploaded = ArgumentCaptor.forClass(byte[].class);
|
||||
verify(ossStorageService).uploadBytes(
|
||||
eq(BUCKET), eq("input/publish.xlsx"), uploaded.capture(),
|
||||
eq(ModuleTemplateService.XLSX_CONTENT_TYPE));
|
||||
assertTrue(uploaded.getValue().length > 2);
|
||||
assertArrayEquals(new byte[]{'P', 'K'}, new byte[]{uploaded.getValue()[0], uploaded.getValue()[1]});
|
||||
verify(ossStorageService).readObjectBytes(BUCKET, "input/publish.xlsx");
|
||||
}
|
||||
|
||||
@Test
|
||||
void startupSynchronizationUploadsOnlyMissingTemplates() {
|
||||
when(ossStorageService.objectExists(BUCKET, "input/publish.xlsx")).thenReturn(true);
|
||||
|
||||
assertEquals(5, service.synchronizeAll());
|
||||
|
||||
verify(ossStorageService).ensureBucketExists(BUCKET);
|
||||
verify(ossStorageService, times(6)).objectExists(eq(BUCKET), anyString());
|
||||
verify(ossStorageService, never()).uploadBytes(
|
||||
eq(BUCKET), eq("input/publish.xlsx"), org.mockito.ArgumentMatchers.any(), anyString());
|
||||
verify(ossStorageService, times(5)).uploadBytes(
|
||||
eq(BUCKET), anyString(), org.mockito.ArgumentMatchers.any(),
|
||||
eq(ModuleTemplateService.XLSX_CONTENT_TYPE));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownModuleReturnsNotFoundWithoutTouchingStorage() {
|
||||
ResponseStatusException ex = assertThrows(ResponseStatusException.class,
|
||||
() -> service.download("not-a-module"));
|
||||
|
||||
assertEquals(HttpStatus.NOT_FOUND, ex.getStatusCode());
|
||||
verifyNoInteractions(ossStorageService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void storageFailureReturnsServiceUnavailable() {
|
||||
doThrow(new IllegalStateException("MinIO unavailable"))
|
||||
.when(ossStorageService).ensureBucketExists(BUCKET);
|
||||
|
||||
ResponseStatusException ex = assertThrows(ResponseStatusException.class,
|
||||
() -> service.download("publish"));
|
||||
|
||||
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void initializerLeavesApplicationRunningWhenStartupStorageIsUnavailable() {
|
||||
ModuleTemplateService unavailableService = mock(ModuleTemplateService.class);
|
||||
doThrow(new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "unavailable"))
|
||||
.when(unavailableService).synchronizeAll();
|
||||
ModuleTemplateStorageInitializer initializer = new ModuleTemplateStorageInitializer(unavailableService);
|
||||
|
||||
assertDoesNotThrow(initializer::synchronize);
|
||||
verify(unavailableService).synchronizeAll();
|
||||
}
|
||||
}
|
||||
+28
@@ -248,4 +248,32 @@ class PermissionMenuControllerTest {
|
||||
verify(service).listImageVideoDataPermissionUsers(operator);
|
||||
verify(service).updateImageVideoDataPermissionUsers(operator, List.of(20L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shopDataCrawlPermissionEndpointsDelegateAuthenticatedOperator() {
|
||||
AdminAuthSupport authSupport = mock(AdminAuthSupport.class);
|
||||
PermissionMenuService service = mock(PermissionMenuService.class);
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
PermissionMenuController controller = new PermissionMenuController(authSupport, service);
|
||||
AdminUserEntity operator = new AdminUserEntity();
|
||||
operator.setId(1L);
|
||||
operator.setRole("super_admin");
|
||||
ImageVideoDataPermissionUserVo permissionUser = new ImageVideoDataPermissionUserVo();
|
||||
permissionUser.setId(20L);
|
||||
ImageVideoDataPermissionUpdateRequest body = new ImageVideoDataPermissionUpdateRequest();
|
||||
body.setUserIds(List.of(20L));
|
||||
|
||||
when(authSupport.requireAdmin(request)).thenReturn(operator);
|
||||
when(service.listShopDataCrawlDataPermissionUsers(operator)).thenReturn(List.of(permissionUser));
|
||||
when(service.updateShopDataCrawlDataPermissionUsers(operator, List.of(20L))).thenReturn(1);
|
||||
|
||||
var listResponse = controller.listShopDataCrawlDataPermissionUsers(request);
|
||||
var updateResponse = controller.updateShopDataCrawlDataPermissionUsers(request, body);
|
||||
|
||||
assertThat(listResponse.getData()).containsExactly(permissionUser);
|
||||
assertThat(updateResponse.getData()).isEqualTo(1);
|
||||
verify(authSupport, times(2)).requireAdmin(request);
|
||||
verify(service).listShopDataCrawlDataPermissionUsers(operator);
|
||||
verify(service).updateShopDataCrawlDataPermissionUsers(operator, List.of(20L));
|
||||
}
|
||||
}
|
||||
|
||||
+78
@@ -74,6 +74,28 @@ class PermissionMenuServiceTest {
|
||||
verify(permissionMapper, times(0)).insert(any(UserColumnPermissionEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void preservesBothIndependentDataPermissionsDuringGenericReplacement() {
|
||||
PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class);
|
||||
UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class);
|
||||
AdminUserMapper userMapper = mock(AdminUserMapper.class);
|
||||
PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper);
|
||||
when(userMapper.selectById(7L)).thenReturn(new AdminUserEntity());
|
||||
when(menuMapper.selectCount(any())).thenReturn(1L);
|
||||
when(menuMapper.selectOne(any())).thenReturn(imageVideoDataPermission(), shopDataCrawlDataPermission());
|
||||
when(permissionMapper.selectCount(any())).thenReturn(1L);
|
||||
|
||||
UserColumnPermissionUpdateRequest request = new UserColumnPermissionUpdateRequest();
|
||||
request.setColumnIds(List.of(2L));
|
||||
service.updateUserColumnPermissions(7L, request);
|
||||
|
||||
ArgumentCaptor<UserColumnPermissionEntity> captor = ArgumentCaptor.forClass(UserColumnPermissionEntity.class);
|
||||
verify(permissionMapper, times(3)).insert(captor.capture());
|
||||
assertThat(captor.getAllValues())
|
||||
.extracting(UserColumnPermissionEntity::getColumnId)
|
||||
.containsExactly(2L, 75L, 76L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void expandsDirectParentGrantToDescendantsWithoutPersistingChildren() {
|
||||
PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class);
|
||||
@@ -526,6 +548,55 @@ class PermissionMenuServiceTest {
|
||||
assertThat(inserted.getValue().getColumnId()).isEqualTo(75L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shopDataCrawlPermissionCanOnlyBeManagedBySuperAdmin() {
|
||||
PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class);
|
||||
UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class);
|
||||
AdminUserMapper userMapper = mock(AdminUserMapper.class);
|
||||
PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper);
|
||||
AdminUserEntity explicitAdmin = user(1L, "admin", 1);
|
||||
|
||||
assertThatThrownBy(() -> service.listShopDataCrawlDataPermissionUsers(explicitAdmin))
|
||||
.isInstanceOf(BusinessException.class)
|
||||
.hasMessageContaining("超级管理员");
|
||||
assertThatThrownBy(() -> service.updateShopDataCrawlDataPermissionUsers(explicitAdmin, List.of(2L)))
|
||||
.isInstanceOf(BusinessException.class)
|
||||
.hasMessageContaining("超级管理员");
|
||||
verify(menuMapper, never()).selectOne(any());
|
||||
verify(permissionMapper, never()).delete(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void superAdminCanListAndReplaceShopDataCrawlPermissions() {
|
||||
PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class);
|
||||
UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class);
|
||||
AdminUserMapper userMapper = mock(AdminUserMapper.class);
|
||||
PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper);
|
||||
AdminUserEntity operator = user(99L, "super_admin", 1);
|
||||
AdminUserEntity admin = user(1L, "admin", 1);
|
||||
admin.setUsername("admin");
|
||||
AdminUserEntity normal = user(2L, "normal", 0);
|
||||
normal.setUsername("normal");
|
||||
|
||||
when(menuMapper.selectOne(any())).thenReturn(shopDataCrawlDataPermission());
|
||||
when(permissionMapper.selectList(any())).thenReturn(List.of(grant(1L, 76L)));
|
||||
when(userMapper.selectList(any())).thenReturn(List.of(operator, admin, normal));
|
||||
|
||||
List<ImageVideoDataPermissionUserVo> users = service.listShopDataCrawlDataPermissionUsers(operator);
|
||||
int grantedCount = service.updateShopDataCrawlDataPermissionUsers(operator, List.of(2L));
|
||||
|
||||
assertThat(users).extracting(ImageVideoDataPermissionUserVo::getId).containsExactly(1L, 2L);
|
||||
assertThat(users.get(0).isGranted()).isTrue();
|
||||
assertThat(users.get(1).isGranted()).isFalse();
|
||||
assertThat(grantedCount).isEqualTo(1);
|
||||
verify(permissionMapper).deleteByMap(Map.of("column_id", 76L));
|
||||
ArgumentCaptor<UserColumnPermissionEntity> inserted =
|
||||
ArgumentCaptor.forClass(UserColumnPermissionEntity.class);
|
||||
verify(permissionMapper).insert(inserted.capture());
|
||||
assertThat(inserted.getValue().getUserId()).isEqualTo(2L);
|
||||
assertThat(inserted.getValue().getColumnId()).isEqualTo(76L);
|
||||
}
|
||||
|
||||
private PermissionMenuCreateRequest createRequest(Long parentId, String menuType) {
|
||||
PermissionMenuCreateRequest request = new PermissionMenuCreateRequest();
|
||||
request.setName("child");
|
||||
@@ -575,4 +646,11 @@ class PermissionMenuServiceTest {
|
||||
entity.setColumnKey("admin_image_video_task_data");
|
||||
return entity;
|
||||
}
|
||||
|
||||
private PermissionMenuEntity shopDataCrawlDataPermission() {
|
||||
PermissionMenuEntity entity = new PermissionMenuEntity();
|
||||
entity.setId(76L);
|
||||
entity.setColumnKey("admin_shop_data_crawl_task_data");
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
|
||||
+60
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.config.TaskPressureProperties;
|
||||
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
|
||||
import com.nanri.aiimage.modules.pricetrack.mapper.PriceTrackShopCandidateMapper;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackSubmitResultRequest;
|
||||
import com.nanri.aiimage.modules.shopkey.service.SkipPriceAsinService;
|
||||
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
|
||||
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
||||
@@ -24,8 +25,12 @@ import java.util.Map;
|
||||
|
||||
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.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@@ -86,4 +91,59 @@ class PriceTrackTaskServiceTest {
|
||||
verify(priceTrackLoopRunService).syncLoopRunAfterChildTerminal(taskId);
|
||||
verify(lock).close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void completedPayloadWithoutUsableRowsFailsWithoutCreatingResultFile() throws Exception {
|
||||
long taskId = 20818L;
|
||||
String shopName = "蔡建芳";
|
||||
|
||||
FileTaskEntity task = new FileTaskEntity();
|
||||
task.setId(taskId);
|
||||
task.setUserId(672L);
|
||||
task.setModuleType("PRICE_TRACK");
|
||||
task.setStatus("RUNNING");
|
||||
task.setRequestJson("{}");
|
||||
|
||||
FileResultEntity result = new FileResultEntity();
|
||||
result.setId(92327L);
|
||||
result.setTaskId(taskId);
|
||||
result.setModuleType("PRICE_TRACK");
|
||||
result.setSourceFilename(shopName);
|
||||
result.setSuccess(0);
|
||||
|
||||
PriceTrackSubmitResultRequest.AsinResult blankRow = new PriceTrackSubmitResultRequest.AsinResult();
|
||||
blankRow.setShopMallName("Cai Jianfang");
|
||||
blankRow.setAsin("");
|
||||
|
||||
PriceTrackSubmitResultRequest.ShopResult shopResult = new PriceTrackSubmitResultRequest.ShopResult();
|
||||
shopResult.setShopName(shopName);
|
||||
shopResult.setCountries(Map.of("DE", List.of(blankRow)));
|
||||
shopResult.setError("");
|
||||
shopResult.setSuccess(true);
|
||||
|
||||
PriceTrackSubmitResultRequest request = new PriceTrackSubmitResultRequest();
|
||||
request.setShops(List.of(shopResult));
|
||||
|
||||
TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
|
||||
when(taskDistributedLockService.acquire("PRICE_TRACK", taskId)).thenReturn(lock);
|
||||
when(priceTrackTaskCacheService.getTaskCacheBatch(List.of(taskId))).thenReturn(Map.of(taskId, task));
|
||||
when(fileResultMapper.selectList(any())).thenReturn(List.of(result));
|
||||
when(ziniaoShopSwitchService.normalizeShopName(shopName)).thenReturn(shopName);
|
||||
when(excelAssemblyService.normalizeCountriesMap(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(objectMapper.writeValueAsString(any())).thenReturn("[]");
|
||||
|
||||
service.submitResult(taskId, request);
|
||||
|
||||
assertEquals("FAILED", task.getStatus());
|
||||
assertEquals(shopName + ": 未收到有效跟价数据,未生成结果文件", task.getErrorMessage());
|
||||
assertNotNull(task.getFinishedAt());
|
||||
assertEquals(0, result.getSuccess());
|
||||
assertEquals("未收到有效跟价数据,未生成结果文件", result.getErrorMessage());
|
||||
assertEquals(0, result.getRowCount());
|
||||
assertNull(result.getResultFilename());
|
||||
assertNull(result.getResultFileUrl());
|
||||
verify(taskFileJobService, never()).enqueueAssembleResult(anyLong(), anyString(), anyLong(), anyString());
|
||||
verify(priceTrackLoopRunService).syncLoopRunAfterChildTerminal(taskId);
|
||||
verify(lock).close();
|
||||
}
|
||||
}
|
||||
|
||||
+52
@@ -22,6 +22,7 @@ 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.dto.TaskHeartbeatRequest;
|
||||
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;
|
||||
@@ -164,6 +165,19 @@ class PublishTaskServiceTest {
|
||||
taskCaptor.getValue().getResultJson()).path("ownerInstanceId").asText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void zeroOnlyHeartbeatDoesNotResetResultChunkProgress() {
|
||||
TaskHeartbeatRequest heartbeat = new TaskHeartbeatRequest();
|
||||
heartbeat.setCurrent(0);
|
||||
heartbeat.setTotal(0);
|
||||
|
||||
service.touchHeartbeat(20998L, heartbeat);
|
||||
|
||||
verify(fileTaskMapper).update(isNull(), any());
|
||||
verify(publishFileMapper, never()).selectOne(any());
|
||||
verify(publishFileMapper, never()).update(isNull(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void taskAccessRejectsAnotherInstanceForExistingRouterToForward() throws Exception {
|
||||
long taskId = 109L;
|
||||
@@ -344,6 +358,7 @@ class PublishTaskServiceTest {
|
||||
long resultId = 313L;
|
||||
FileTaskEntity task = task(taskId, 7L, "RUNNING");
|
||||
PublishFileEntity file = file(taskId, fileId, "RUNNING", "分片.xlsx");
|
||||
file.setTotalRows(2);
|
||||
FileResultEntity result = result(taskId, resultId);
|
||||
TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
|
||||
|
||||
@@ -357,15 +372,21 @@ class PublishTaskServiceTest {
|
||||
service.submitResult(taskId, chunkResultRequest(7L, fileId, 2, 2, List.of(row("2"))));
|
||||
|
||||
assertEquals("RUNNING", file.getStatus());
|
||||
assertEquals(1, file.getProcessedRows());
|
||||
assertEquals(1, storedChunks.size());
|
||||
assertEquals(1, storedScopes.getFirst().getReceivedChunkCount());
|
||||
assertEquals(0, storedScopes.getFirst().getCompleted());
|
||||
assertTrue(storedScopes.getFirst().getStateJson().contains("\"receivedRows\":1"));
|
||||
verify(publishItemMapper, never()).delete(any());
|
||||
verify(taskFileJobService, never()).enqueueAssembleResult(any(), any(), any(), any());
|
||||
|
||||
// Simulate a scope created by the pre-progress implementation.
|
||||
storedScopes.getFirst().setStateJson("{\"phase\":\"RECEIVING\"}");
|
||||
service.submitResult(taskId, chunkResultRequest(7L, fileId, 1, 2, List.of(row("1"))));
|
||||
|
||||
assertEquals("SUCCESS", file.getStatus());
|
||||
assertEquals(2, file.getProcessedRows());
|
||||
assertTrue(storedScopes.getFirst().getStateJson().contains("\"receivedRows\":2"));
|
||||
assertEquals(2, storedChunks.size());
|
||||
assertEquals(2, storedScopes.getFirst().getReceivedChunkCount());
|
||||
assertEquals(1, storedScopes.getFirst().getCompleted());
|
||||
@@ -386,6 +407,7 @@ class PublishTaskServiceTest {
|
||||
long fileId = 214L;
|
||||
FileTaskEntity task = task(taskId, 7L, "RUNNING");
|
||||
PublishFileEntity file = file(taskId, fileId, "RUNNING", "重试.xlsx");
|
||||
file.setTotalRows(2);
|
||||
TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
|
||||
PublishSubmitResultRequest request = chunkResultRequest(7L, fileId, 1, 2, List.of(row("1")));
|
||||
|
||||
@@ -399,11 +421,41 @@ class PublishTaskServiceTest {
|
||||
service.submitResult(taskId, request);
|
||||
|
||||
assertEquals(1, storedChunks.size());
|
||||
assertEquals(1, file.getProcessedRows());
|
||||
assertEquals(1, rustfsPayloads.size());
|
||||
verify(taskChunkMapper, times(1)).insert(any(TaskChunkEntity.class));
|
||||
verify(publishItemMapper, never()).delete(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void progressBatchExposesRowsReceivedByResultSubmission() {
|
||||
long taskId = 122L;
|
||||
long fileId = 222L;
|
||||
FileTaskEntity task = task(taskId, 7L, "RUNNING");
|
||||
PublishFileEntity file = file(taskId, fileId, "RUNNING", "progress.xlsx");
|
||||
FileResultEntity result = result(taskId, 322L);
|
||||
TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
|
||||
|
||||
when(taskDistributedLockService.acquire(PublishTaskService.MODULE_TYPE, taskId)).thenReturn(lock);
|
||||
when(fileTaskMapper.selectById(taskId)).thenReturn(task);
|
||||
when(fileTaskMapper.selectList(any())).thenReturn(List.of(task));
|
||||
when(publishFileMapper.selectById(fileId)).thenReturn(file);
|
||||
when(publishFileMapper.selectList(any())).thenReturn(List.of(file));
|
||||
when(fileResultMapper.selectOne(any())).thenReturn(result);
|
||||
when(fileResultMapper.selectList(any())).thenReturn(List.of(result));
|
||||
when(publishItemMapper.selectCount(any())).thenReturn(3L);
|
||||
when(taskFileJobService.findAssembleJobsByResultIds(any(), any())).thenReturn(Map.of());
|
||||
|
||||
service.submitResult(taskId, chunkResultRequest(7L, fileId, 1, 3, List.of(row("1"))));
|
||||
|
||||
var progress = service.getTaskProgress(7L, List.of(taskId));
|
||||
assertEquals(1, progress.getItems().getFirst().getTask().getProcessedRows());
|
||||
assertEquals(3, progress.getItems().getFirst().getTask().getTotalRows());
|
||||
assertEquals(1, progress.getItems().getFirst().getFiles().getFirst().getProcessedRows());
|
||||
assertEquals(3, progress.getItems().getFirst().getFiles().getFirst().getTotalRows());
|
||||
assertEquals(33, progress.getItems().getFirst().getFiles().getFirst().getPercent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resultChunkRetryWithDifferentContentIsRejected() {
|
||||
long taskId = 115L;
|
||||
|
||||
+32
-32
@@ -17,11 +17,9 @@ import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.zip.ZipFile;
|
||||
|
||||
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;
|
||||
@@ -37,33 +35,31 @@ class PublishWorkbookServiceTest {
|
||||
File valid = directory.resolve("valid.xlsx").toFile();
|
||||
try (Workbook workbook = new XSSFWorkbook();
|
||||
FileOutputStream output = new FileOutputStream(valid)) {
|
||||
Sheet uk = workbook.createSheet("英国数据");
|
||||
writeSourceSheet(uk, "英国", "B001");
|
||||
Sheet first = workbook.createSheet("first");
|
||||
writeSourceSheet(first, "UK", "B001");
|
||||
for (int index = PublishWorkbookService.SOURCE_HEADERS.size();
|
||||
index < PublishWorkbookService.RESULT_HEADERS.size(); index++) {
|
||||
uk.getRow(0).createCell(index)
|
||||
first.getRow(0).createCell(index)
|
||||
.setCellValue(PublishWorkbookService.RESULT_HEADERS.get(index));
|
||||
}
|
||||
uk.getRow(1).createCell(5).setCellValue("旧状态");
|
||||
uk.getRow(1).createCell(6).setCellValue("旧同步状态");
|
||||
uk.getRow(1).createCell(7).setCellValue("旧同步国家");
|
||||
workbook.createSheet("空白页");
|
||||
writeSourceSheet(workbook.createSheet("德国数据"), "DE", "B002");
|
||||
first.getRow(1).createCell(5).setCellValue("old status");
|
||||
workbook.createSheet("empty");
|
||||
writeSourceSheet(workbook.createSheet("second"), "DE", "B002");
|
||||
workbook.write(output);
|
||||
}
|
||||
|
||||
PublishWorkbookService.ParsedWorkbook parsed = service.parse(valid);
|
||||
assertEquals(2, parsed.rows().size());
|
||||
assertEquals("B001", parsed.rows().get(0).getAsin());
|
||||
assertNull(parsed.rows().get(0).getStatus());
|
||||
assertNull(parsed.rows().get(0).getSyncStatus());
|
||||
assertNull(parsed.rows().get(0).getSyncCountries());
|
||||
assertEquals("B001", parsed.rows().getFirst().getAsin());
|
||||
assertNull(parsed.rows().getFirst().getStatus());
|
||||
assertNull(parsed.rows().getFirst().getSyncStatus());
|
||||
assertNull(parsed.rows().getFirst().getSyncCountries());
|
||||
assertEquals("DE", parsed.rows().get(1).getCountry());
|
||||
|
||||
File invalid = directory.resolve("invalid.xlsx").toFile();
|
||||
try (Workbook workbook = new XSSFWorkbook();
|
||||
FileOutputStream output = new FileOutputStream(invalid)) {
|
||||
Sheet sheet = workbook.createSheet("错误表头");
|
||||
Sheet sheet = workbook.createSheet("invalid");
|
||||
Row header = sheet.createRow(0);
|
||||
List<String> headers = new ArrayList<>(PublishWorkbookService.SOURCE_HEADERS);
|
||||
headers.set(1, "Asin");
|
||||
@@ -79,30 +75,34 @@ class PublishWorkbookServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void writesOneSheetPerNormalizedCountryWithExactHeaders() throws Exception {
|
||||
void writesOnlyThePublishCountrySheetAndKeepsSyncColumns() throws Exception {
|
||||
Path directory = Files.createTempDirectory("publish-sheets-");
|
||||
try {
|
||||
File output = directory.resolve("result.xlsx").toFile();
|
||||
service.writeWorkbook(output, List.of(
|
||||
row("1", "B001", "UK", "19.99"),
|
||||
row("2", "B002", "德国", "not-a-number"),
|
||||
row("3", "B003", "GB", "20")));
|
||||
row("2", "B002", "FR", "not-a-number"),
|
||||
row("3", "B003", "GB", "20")), "DE");
|
||||
|
||||
try (FileInputStream input = new FileInputStream(output);
|
||||
Workbook workbook = new XSSFWorkbook(input)) {
|
||||
assertEquals(2, workbook.getNumberOfSheets());
|
||||
assertEquals(Set.of("英国", "德国"),
|
||||
Set.of(workbook.getSheetName(0), workbook.getSheetName(1)));
|
||||
Sheet uk = workbook.getSheet("英国");
|
||||
assertNotNull(uk);
|
||||
assertEquals(1, workbook.getNumberOfSheets());
|
||||
assertEquals("\u5fb7\u56fd", workbook.getSheetName(0));
|
||||
Sheet germany = workbook.getSheetAt(0);
|
||||
for (int index = 0; index < PublishWorkbookService.RESULT_HEADERS.size(); index++) {
|
||||
assertEquals(PublishWorkbookService.RESULT_HEADERS.get(index),
|
||||
uk.getRow(0).getCell(index).getStringCellValue());
|
||||
germany.getRow(0).getCell(index).getStringCellValue());
|
||||
}
|
||||
assertEquals(CellType.NUMERIC, germany.getRow(1).getCell(4).getCellType());
|
||||
assertEquals(19.99D, germany.getRow(1).getCell(4).getNumericCellValue(), 0.0001D);
|
||||
assertEquals("not-a-number", germany.getRow(2).getCell(4).getStringCellValue());
|
||||
for (int rowIndex = 1; rowIndex <= 3; rowIndex++) {
|
||||
assertEquals("\u5fb7\u56fd", germany.getRow(rowIndex).getCell(2).getStringCellValue());
|
||||
assertEquals("\u82f1\u56fd:\u6210\u529f\uff0c\u6cd5\u56fd:\u6210\u529f",
|
||||
germany.getRow(rowIndex).getCell(6).getStringCellValue());
|
||||
assertEquals("\u82f1\u56fd,\u6cd5\u56fd",
|
||||
germany.getRow(rowIndex).getCell(7).getStringCellValue());
|
||||
}
|
||||
assertEquals(CellType.NUMERIC, uk.getRow(1).getCell(4).getCellType());
|
||||
assertEquals(19.99D, uk.getRow(1).getCell(4).getNumericCellValue(), 0.0001D);
|
||||
assertEquals("not-a-number",
|
||||
workbook.getSheet("德国").getRow(1).getCell(4).getStringCellValue());
|
||||
}
|
||||
} finally {
|
||||
FileUtil.del(directory.toFile());
|
||||
@@ -115,7 +115,7 @@ class PublishWorkbookServiceTest {
|
||||
try {
|
||||
List<PublishWorkbookService.WorkbookInput> oneSuccess = List.of(
|
||||
new PublishWorkbookService.WorkbookInput(
|
||||
"郭亚庆.xlsx", "郭亚庆", List.of(row("1", "B001", "英国", "50"))));
|
||||
"shop.xlsx", "shop", "DE", List.of(row("1", "B001", "UK", "50"))));
|
||||
|
||||
PublishWorkbookService.PackagedResult single = service.packageTaskResult(
|
||||
directory.resolve("single").toFile(), "PUBLISH-1", 1, oneSuccess);
|
||||
@@ -155,9 +155,9 @@ class PublishWorkbookServiceTest {
|
||||
row.setCountry(country);
|
||||
row.setBrand("Brand");
|
||||
row.setPrice(price);
|
||||
row.setStatus("成功");
|
||||
row.setSyncStatus("成功");
|
||||
row.setSyncCountries("德国,法国");
|
||||
row.setStatus("success");
|
||||
row.setSyncStatus("\u82f1\u56fd:\u6210\u529f\uff0c\u6cd5\u56fd:\u6210\u529f");
|
||||
row.setSyncCountries("\u82f1\u56fd,\u6cd5\u56fd");
|
||||
return row;
|
||||
}
|
||||
}
|
||||
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package com.nanri.aiimage.modules.shopdatacrawl.controller;
|
||||
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
|
||||
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
||||
import com.nanri.aiimage.modules.permission.service.PermissionMenuService;
|
||||
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class AdminShopDataCrawlTaskControllerTest {
|
||||
|
||||
@Test
|
||||
void deletesResultForAuthenticatedAdminWithTaskPermissions() {
|
||||
ShopDataCrawlTaskService taskService = mock(ShopDataCrawlTaskService.class);
|
||||
AdminAuthSupport authSupport = mock(AdminAuthSupport.class);
|
||||
PermissionMenuService permissionService = mock(PermissionMenuService.class);
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
AdminShopDataCrawlTaskController controller = new AdminShopDataCrawlTaskController(
|
||||
taskService, authSupport, permissionService);
|
||||
AdminUserEntity operator = admin(8L);
|
||||
when(authSupport.requireAdmin(request)).thenReturn(operator);
|
||||
|
||||
controller.deleteHistory(101L, request);
|
||||
|
||||
verify(permissionService).requireShopDataCrawlTaskAccess(operator);
|
||||
verify(taskService).deleteAdminHistory(101L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsTrustedFlaskOperatorWhenLegacySessionHasNoJavaJwt() {
|
||||
ShopDataCrawlTaskService taskService = mock(ShopDataCrawlTaskService.class);
|
||||
AdminAuthSupport authSupport = mock(AdminAuthSupport.class);
|
||||
PermissionMenuService permissionService = mock(PermissionMenuService.class);
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
AdminShopDataCrawlTaskController controller = new AdminShopDataCrawlTaskController(
|
||||
taskService, authSupport, permissionService);
|
||||
ReflectionTestUtils.setField(controller, "internalToken", "shared-token");
|
||||
AdminUserEntity operator = admin(8L);
|
||||
when(authSupport.requireAdmin(request)).thenThrow(new BusinessException(401, "not logged in"));
|
||||
when(request.getHeader("X-Internal-Token")).thenReturn("shared-token");
|
||||
when(request.getParameter("operatorId")).thenReturn("8");
|
||||
when(permissionService.requireAdminOperator(8L)).thenReturn(operator);
|
||||
|
||||
controller.deleteHistory(101L, request);
|
||||
|
||||
verify(permissionService).requireAdminOperator(8L);
|
||||
verify(permissionService).requireShopDataCrawlTaskAccess(operator);
|
||||
verify(taskService).deleteAdminHistory(101L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAdminWithoutIndependentTaskDataPermission() {
|
||||
ShopDataCrawlTaskService taskService = mock(ShopDataCrawlTaskService.class);
|
||||
AdminAuthSupport authSupport = mock(AdminAuthSupport.class);
|
||||
PermissionMenuService permissionService = mock(PermissionMenuService.class);
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
AdminShopDataCrawlTaskController controller = new AdminShopDataCrawlTaskController(
|
||||
taskService, authSupport, permissionService);
|
||||
AdminUserEntity operator = admin(8L);
|
||||
BusinessException denied = new BusinessException(403, "no data permission");
|
||||
when(authSupport.requireAdmin(request)).thenReturn(operator);
|
||||
org.mockito.Mockito.doThrow(denied)
|
||||
.when(permissionService).requireShopDataCrawlTaskAccess(operator);
|
||||
|
||||
assertThatThrownBy(() -> controller.deleteHistory(101L, request)).isSameAs(denied);
|
||||
|
||||
verify(taskService, never()).deleteAdminHistory(101L);
|
||||
}
|
||||
|
||||
private AdminUserEntity admin(Long id) {
|
||||
AdminUserEntity operator = new AdminUserEntity();
|
||||
operator.setId(id);
|
||||
operator.setRole("admin");
|
||||
return operator;
|
||||
}
|
||||
}
|
||||
+26
-1
@@ -3,16 +3,22 @@ package com.nanri.aiimage.modules.shopdatacrawl.service;
|
||||
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.vo.ShopDataCrawlResultItemVo;
|
||||
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class ShopDataCrawlExcelAssemblyServiceTest {
|
||||
@TempDir Path tempDir;
|
||||
@@ -22,6 +28,8 @@ class ShopDataCrawlExcelAssemblyServiceTest {
|
||||
ShopDataCrawlRowDto row = new ShopDataCrawlRowDto();
|
||||
row.setDate("2026-07-25");
|
||||
row.setAsin("B012345678");
|
||||
row.setBrand("Example Brand");
|
||||
row.setCommodityImage("https://m.media-amazon.com/images/I/example.jpg");
|
||||
row.setInventorySales("11");
|
||||
row.setSalesRank("22");
|
||||
row.setPageViews("33");
|
||||
@@ -35,8 +43,11 @@ class ShopDataCrawlExcelAssemblyServiceTest {
|
||||
item.setSuccess(true);
|
||||
item.setCountryResults(List.of(country));
|
||||
|
||||
SimilarAsinImageEmbedder imageEmbedder = mock(SimilarAsinImageEmbedder.class);
|
||||
when(imageEmbedder.fetchAndResizeForCache(row.getCommodityImage()))
|
||||
.thenReturn(new SimilarAsinImageEmbedder.ResizedImage(jpegBytes(), 2, 2));
|
||||
File output = tempDir.resolve("result.xlsx").toFile();
|
||||
new ShopDataCrawlExcelAssemblyService().writeWorkbook(output, List.of(item));
|
||||
new ShopDataCrawlExcelAssemblyService(imageEmbedder).writeWorkbook(output, List.of(item));
|
||||
|
||||
try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(output))) {
|
||||
assertEquals(ShopDataCrawlExcelAssemblyService.SHEETS,
|
||||
@@ -50,7 +61,21 @@ class ShopDataCrawlExcelAssemblyServiceTest {
|
||||
}
|
||||
assertEquals("2026-07-25", workbook.getSheet("英国").getRow(1).getCell(0).getStringCellValue());
|
||||
assertEquals("B012345678", workbook.getSheet("英国").getRow(1).getCell(1).getStringCellValue());
|
||||
assertEquals("11", workbook.getSheet("英国").getRow(1).getCell(3).getStringCellValue());
|
||||
assertEquals("Example Brand", workbook.getSheet("英国").getRow(1)
|
||||
.getCell(ShopDataCrawlExcelAssemblyService.HEADERS.size() - 1).getStringCellValue());
|
||||
assertEquals(1, workbook.getAllPictures().size());
|
||||
assertEquals(1, workbook.getSheet("英国").getDrawingPatriarch().getShapes().size());
|
||||
assertEquals(80f, workbook.getSheet("英国").getRow(1).getHeightInPoints());
|
||||
assertEquals(18 * 256, workbook.getSheet("英国").getColumnWidth(2));
|
||||
assertEquals(0, workbook.getSheet("德国").getLastRowNum());
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] jpegBytes() throws Exception {
|
||||
BufferedImage image = new BufferedImage(2, 2, BufferedImage.TYPE_INT_RGB);
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
ImageIO.write(image, "jpg", output);
|
||||
return output.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -183,6 +183,18 @@ class ShopDataCrawlTaskServiceChunkTest {
|
||||
"task:" + task.getId() + ":owner:instance-a");
|
||||
}
|
||||
|
||||
@Test
|
||||
void preservesBrandFromAppClientThroughChunkMerge() {
|
||||
givenRunningTask(112L, 212L);
|
||||
ShopDataCrawlRowDto item = row("2026-07-25", "B001");
|
||||
item.setBrand("Example Brand");
|
||||
|
||||
service.submitResult(task.getId(), request(chunk(1, 1, "DE", item)));
|
||||
|
||||
assertEquals(1, result.getSuccess());
|
||||
assertTrue(task.getResultJson().contains("\"brand\":\"Example Brand\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void identicalResultChunkRetryIsIdempotent() {
|
||||
givenRunningTask(102L, 202L);
|
||||
@@ -528,6 +540,7 @@ class ShopDataCrawlTaskServiceChunkTest {
|
||||
ShopDataCrawlRowDto row = new ShopDataCrawlRowDto();
|
||||
row.setDate(date);
|
||||
row.setAsin(asin);
|
||||
row.setCommodityImage("https://m.media-amazon.com/images/I/" + asin + ".jpg");
|
||||
row.setInventorySales("10");
|
||||
row.setSalesRank("20");
|
||||
row.setPageViews("30");
|
||||
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
package com.nanri.aiimage.modules.shopdatacrawl.service;
|
||||
|
||||
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.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.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.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 java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ShopDataCrawlTaskServiceRetentionTest {
|
||||
|
||||
private static final String MODULE_TYPE = "SHOP_DATA_CRAWL";
|
||||
private static final Long USER_ID = 7L;
|
||||
|
||||
@BeforeAll
|
||||
static void initializeMybatisMetadata() {
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new Configuration(), "");
|
||||
TableInfoHelper.initTableInfo(assistant, FileResultEntity.class);
|
||||
}
|
||||
|
||||
@Mock private FileTaskMapper fileTaskMapper;
|
||||
@Mock private FileResultMapper fileResultMapper;
|
||||
@Mock private ShopDataCrawlResolveService resolveService;
|
||||
@Mock private ShopDataCrawlExcelAssemblyService excelAssemblyService;
|
||||
@Mock private ShopDataCrawlTaskCacheService cacheService;
|
||||
@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;
|
||||
@Spy private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
@InjectMocks private ShopDataCrawlTaskService service;
|
||||
|
||||
@Test
|
||||
void keepsNewestThreePerStableShopAndFallsBackToShopName() {
|
||||
LocalDateTime now = LocalDateTime.of(2026, 8, 5, 12, 0);
|
||||
FileResultEntity shopIdOldest = result(11L, 111L, USER_ID, "shop-1", "Renamed Shop",
|
||||
"result/shop-id-oldest.xlsx", 1, now.minusDays(2));
|
||||
FileResultEntity shopNameOldest = result(21L, 121L, USER_ID, " ", "Fallback Shop",
|
||||
"result/shop-name-oldest.xlsx", 1, now.minusDays(4));
|
||||
|
||||
List<FileResultEntity> rows = List.of(
|
||||
result(13L, 113L, USER_ID, "shop-1", "Current Name", "result/13.xlsx", 1, now.minusDays(1)),
|
||||
result(23L, 123L, USER_ID, null, "Fallback Shop", "result/23.xlsx", 1, now.minusDays(2)),
|
||||
shopIdOldest,
|
||||
result(14L, 114L, USER_ID, "shop-1", "Current Name", "result/14.xlsx", 1, now),
|
||||
result(24L, 124L, USER_ID, null, "Fallback Shop", "result/24.xlsx", 1, now),
|
||||
result(12L, 112L, USER_ID, "shop-1", "Old Name", "result/12.xlsx", 1, now.minusDays(2)),
|
||||
shopNameOldest,
|
||||
result(22L, 122L, USER_ID, null, "Fallback Shop", "result/22.xlsx", 1, now.minusDays(3)),
|
||||
result(1L, 101L, USER_ID, "shop-1", "Current Name", "result/failed.xlsx", 0, now.minusDays(9)),
|
||||
result(2L, 102L, USER_ID, "shop-1", "Current Name", null, 1, now.minusDays(9)),
|
||||
result(3L, 103L, 99L, "shop-1", "Current Name", "result/other-user.xlsx", 1, now.minusDays(9)),
|
||||
result(4L, 104L, USER_ID, "shop-2", "Current Name", "result/other-shop.xlsx", 1, now.minusDays(9)));
|
||||
|
||||
when(fileResultMapper.selectList(any())).thenReturn(rows);
|
||||
when(fileResultMapper.selectById(11L)).thenReturn(shopIdOldest);
|
||||
when(fileResultMapper.selectById(21L)).thenReturn(shopNameOldest);
|
||||
when(fileTaskMapper.selectById(111L)).thenReturn(terminalTask(111L));
|
||||
when(fileTaskMapper.selectById(121L)).thenReturn(terminalTask(121L));
|
||||
when(taskDistributedLockService.acquire(MODULE_TYPE, 111L))
|
||||
.thenReturn(mock(TaskDistributedLockService.LockHandle.class));
|
||||
when(taskDistributedLockService.acquire(MODULE_TYPE, 121L))
|
||||
.thenReturn(mock(TaskDistributedLockService.LockHandle.class));
|
||||
when(cacheService.getTaskCacheBatch(any())).thenReturn(Map.of());
|
||||
when(fileResultMapper.selectCount(any())).thenReturn(0L);
|
||||
|
||||
service.pruneCompletedHistoryForShop(USER_ID, "shop-id:shop-1");
|
||||
service.pruneCompletedHistoryForShop(USER_ID, "shop-name:Fallback Shop");
|
||||
|
||||
verify(fileResultMapper).deleteById(11L);
|
||||
verify(fileResultMapper).deleteById(21L);
|
||||
verify(taskFileJobService).deleteResultJobs(111L, MODULE_TYPE, 11L);
|
||||
verify(taskFileJobService).deleteResultJobs(121L, MODULE_TYPE, 21L);
|
||||
verify(taskResultItemService).deleteResultItem(111L, MODULE_TYPE, 11L);
|
||||
verify(taskResultItemService).deleteResultItem(121L, MODULE_TYPE, 21L);
|
||||
verify(ossStorageService).deleteObject("result/shop-id-oldest.xlsx");
|
||||
verify(ossStorageService).deleteObject("result/shop-name-oldest.xlsx");
|
||||
verify(fileResultMapper, never()).deleteById(1L);
|
||||
verify(fileResultMapper, never()).deleteById(2L);
|
||||
verify(fileResultMapper, never()).deleteById(3L);
|
||||
verify(fileResultMapper, never()).deleteById(4L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotDeleteOldFileWhileOwningTaskIsStillRunning() {
|
||||
LocalDateTime now = LocalDateTime.of(2026, 8, 5, 12, 0);
|
||||
FileResultEntity oldest = result(31L, 131L, USER_ID, "shop-running", "Running Shop",
|
||||
"result/running-oldest.xlsx", 1, now.minusDays(3));
|
||||
when(fileResultMapper.selectList(any())).thenReturn(List.of(
|
||||
result(34L, 134L, USER_ID, "shop-running", "Running Shop", "result/34.xlsx", 1, now),
|
||||
result(33L, 133L, USER_ID, "shop-running", "Running Shop", "result/33.xlsx", 1, now.minusDays(1)),
|
||||
result(32L, 132L, USER_ID, "shop-running", "Running Shop", "result/32.xlsx", 1, now.minusDays(2)),
|
||||
oldest));
|
||||
when(fileResultMapper.selectById(31L)).thenReturn(oldest);
|
||||
when(fileTaskMapper.selectById(131L)).thenReturn(task(131L, "RUNNING"));
|
||||
when(taskDistributedLockService.acquire(MODULE_TYPE, 131L))
|
||||
.thenReturn(mock(TaskDistributedLockService.LockHandle.class));
|
||||
|
||||
service.pruneCompletedHistoryForShop(USER_ID, "shop-id:shop-running");
|
||||
|
||||
verify(fileResultMapper, never()).deleteById(31L);
|
||||
verify(taskFileJobService, never()).deleteResultJobs(131L, MODULE_TYPE, 31L);
|
||||
verify(ossStorageService, never()).deleteObject("result/running-oldest.xlsx");
|
||||
}
|
||||
|
||||
private FileResultEntity result(Long id, Long taskId, Long userId, String shopId, String shopName,
|
||||
String resultFileUrl, int success, LocalDateTime createdAt) {
|
||||
FileResultEntity row = new FileResultEntity();
|
||||
row.setId(id);
|
||||
row.setTaskId(taskId);
|
||||
row.setModuleType(MODULE_TYPE);
|
||||
row.setUserId(userId);
|
||||
row.setSourceFileUrl(shopId);
|
||||
row.setSourceFilename(shopName);
|
||||
row.setResultFileUrl(resultFileUrl);
|
||||
row.setSuccess(success);
|
||||
row.setCreatedAt(createdAt);
|
||||
return row;
|
||||
}
|
||||
|
||||
private FileTaskEntity terminalTask(Long id) {
|
||||
return task(id, "SUCCESS");
|
||||
}
|
||||
|
||||
private FileTaskEntity task(Long id, String status) {
|
||||
FileTaskEntity task = new FileTaskEntity();
|
||||
task.setId(id);
|
||||
task.setModuleType(MODULE_TYPE);
|
||||
task.setStatus(status);
|
||||
return task;
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package com.nanri.aiimage.modules.shopkey.service;
|
||||
|
||||
import com.nanri.aiimage.modules.shopkey.mapper.ShopManageMapper;
|
||||
import com.nanri.aiimage.modules.shopkey.mapper.SkipPriceAsinMapper;
|
||||
import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinCreateRequest;
|
||||
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupEntity;
|
||||
import com.nanri.aiimage.modules.shopkey.model.entity.SkipPriceAsinEntity;
|
||||
import com.nanri.aiimage.modules.shopkey.model.vo.SkipPriceAsinItemVo;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotSame;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class SkipPriceAsinServiceTest {
|
||||
|
||||
@Mock
|
||||
private SkipPriceAsinMapper skipPriceAsinMapper;
|
||||
@Mock
|
||||
private ShopManageMapper shopManageMapper;
|
||||
@Mock
|
||||
private ShopManageGroupService shopManageGroupService;
|
||||
|
||||
@InjectMocks
|
||||
private SkipPriceAsinService service;
|
||||
|
||||
@Test
|
||||
void createInsertsNewRowWhenGroupAndShopAlreadyExist() {
|
||||
ShopManageGroupEntity group = new ShopManageGroupEntity();
|
||||
group.setId(10L);
|
||||
group.setGroupName("group-a");
|
||||
when(shopManageGroupService.getAccessibleById(10L, 7L, true)).thenReturn(group);
|
||||
|
||||
SkipPriceAsinEntity existing = new SkipPriceAsinEntity();
|
||||
existing.setId(100L);
|
||||
existing.setGroupId(10L);
|
||||
existing.setShopName("shop-a");
|
||||
existing.setAsinDe("OLD-ASIN");
|
||||
lenient().when(skipPriceAsinMapper.selectOne(any())).thenReturn(existing);
|
||||
|
||||
AtomicReference<SkipPriceAsinEntity> inserted = new AtomicReference<>();
|
||||
when(skipPriceAsinMapper.insert(any(SkipPriceAsinEntity.class))).thenAnswer(invocation -> {
|
||||
SkipPriceAsinEntity entity = invocation.getArgument(0);
|
||||
entity.setId(101L);
|
||||
inserted.set(entity);
|
||||
return 1;
|
||||
});
|
||||
when(skipPriceAsinMapper.selectById(101L)).thenAnswer(invocation -> inserted.get());
|
||||
|
||||
SkipPriceAsinCreateRequest request = new SkipPriceAsinCreateRequest();
|
||||
request.setGroupId(10L);
|
||||
request.setShopName("shop-a");
|
||||
request.setCountries(List.of("DE"));
|
||||
request.setAsinMappings(Map.of("DE", "NEW-ASIN"));
|
||||
request.setMinimumPriceMappings(Map.of("DE", new BigDecimal("19.99")));
|
||||
|
||||
SkipPriceAsinItemVo result = service.create(request, 7L, true);
|
||||
|
||||
ArgumentCaptor<SkipPriceAsinEntity> captor = ArgumentCaptor.forClass(SkipPriceAsinEntity.class);
|
||||
verify(skipPriceAsinMapper).insert(captor.capture());
|
||||
verify(skipPriceAsinMapper, never()).selectOne(any());
|
||||
verify(skipPriceAsinMapper, never()).updateById(any(SkipPriceAsinEntity.class));
|
||||
assertNotSame(existing, captor.getValue());
|
||||
assertEquals("OLD-ASIN", existing.getAsinDe());
|
||||
assertEquals(101L, result.getId());
|
||||
assertEquals("NEW-ASIN", result.getAsinDe());
|
||||
assertEquals(new BigDecimal("19.99"), result.getMinimumPriceDe());
|
||||
}
|
||||
}
|
||||
+57
@@ -1,6 +1,7 @@
|
||||
package com.nanri.aiimage.modules.similarasin.client;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
|
||||
@@ -180,4 +181,60 @@ class SimilarAsinCozeClientTest {
|
||||
assertTrue(json.contains("\"price\":10"));
|
||||
assertTrue(json.contains("\"price\":80"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void buildParametersIncludesCategorySwitch() throws Exception {
|
||||
SimilarAsinResultRowDto row = new SimilarAsinResultRowDto();
|
||||
row.setAsin("B0CATEGORY1");
|
||||
row.setTitle("Category test");
|
||||
SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null);
|
||||
|
||||
Method method = SimilarAsinCozeClient.class.getDeclaredMethod(
|
||||
"buildParameters", List.class, String.class, String.class, boolean.class, boolean.class);
|
||||
method.setAccessible(true);
|
||||
Map<String, Object> parameters = (Map<String, Object>) method.invoke(client, List.of(row), "", "", false, true);
|
||||
|
||||
assertEquals(Boolean.TRUE, parameters.get("category_switch"));
|
||||
assertEquals(Boolean.FALSE, parameters.get("img_switch"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void imageOnlyWorkflowOutputIsExtractedAndMergedByAsin() throws Exception {
|
||||
SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null);
|
||||
String imageData = """
|
||||
{"data":[{
|
||||
"asin":"B0BQNHDP2F",
|
||||
"main_url":"https://example.com/main.jpg",
|
||||
"puzzle_img1":"https://example.com/puzzle-1.jpg",
|
||||
"puzzle_img2":"https://example.com/puzzle-2.jpg"
|
||||
}]}
|
||||
""";
|
||||
String workflowOutput = objectMapper.writeValueAsString(Map.of(
|
||||
"node_status", "{}",
|
||||
"Output", imageData));
|
||||
var historyResponse = objectMapper.createObjectNode();
|
||||
historyResponse.put("code", 0);
|
||||
historyResponse.putArray("data")
|
||||
.addObject()
|
||||
.put("execute_status", "Success")
|
||||
.put("output", workflowOutput);
|
||||
Method extract = SimilarAsinCozeClient.class.getDeclaredMethod("extractResultDataText", JsonNode.class);
|
||||
extract.setAccessible(true);
|
||||
String dataText = (String) extract.invoke(client, historyResponse);
|
||||
|
||||
SimilarAsinResultRowDto source = new SimilarAsinResultRowDto();
|
||||
source.setAsin("B0BQNHDP2F");
|
||||
List<SimilarAsinResultRowDto> merged = client.mergeRowsFromDataText(List.of(source), dataText);
|
||||
|
||||
assertFalse(dataText.isBlank());
|
||||
assertEquals(1, merged.size());
|
||||
assertEquals("https://example.com/main.jpg", merged.getFirst().getMainUrl());
|
||||
assertEquals("https://example.com/puzzle-1.jpg", merged.getFirst().getPuzzleImg1());
|
||||
assertEquals("https://example.com/puzzle-2.jpg", merged.getFirst().getPuzzleImg2());
|
||||
|
||||
Method resolvedCount = SimilarAsinCozeClient.class.getDeclaredMethod("resolvedCount", List.class);
|
||||
resolvedCount.setAccessible(true);
|
||||
assertEquals(1, resolvedCount.invoke(client, merged));
|
||||
}
|
||||
}
|
||||
|
||||
+53
@@ -1,9 +1,11 @@
|
||||
package com.nanri.aiimage.modules.similarasin.service;
|
||||
|
||||
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
|
||||
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParsedRowVo;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
@@ -60,6 +62,57 @@ class SimilarAsinTaskServiceTest {
|
||||
assertFalse(SimilarAsinTaskService.isTerminalFileBuildProgress("SUCCESS", 2, 3));
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedResultRowsWithBlankIsConformEnableCategoryRetry() {
|
||||
SimilarAsinParsedRowVo blankCategory = new SimilarAsinParsedRowVo();
|
||||
blankCategory.setValues(new LinkedHashMap<>());
|
||||
blankCategory.getValues().put("status", "FAILED");
|
||||
blankCategory.getValues().put("is_conform", "");
|
||||
|
||||
assertTrue(SimilarAsinTaskService.shouldEnableCategorySwitchForRetry(
|
||||
List.of("id", "asin", "country", "is_conform", "status"),
|
||||
List.of(blankCategory),
|
||||
true));
|
||||
|
||||
assertFalse(SimilarAsinTaskService.shouldEnableCategorySwitchForRetry(
|
||||
List.of("id", "asin", "country", "is_conform", "status"),
|
||||
List.of(blankCategory),
|
||||
false));
|
||||
|
||||
blankCategory.getValues().put("is_conform", "符合");
|
||||
assertFalse(SimilarAsinTaskService.shouldEnableCategorySwitchForRetry(
|
||||
List.of("id", "asin", "country", "is_conform", "status"),
|
||||
List.of(blankCategory),
|
||||
true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void firstPassImageResultWorkbookKeepsAllRowsForSecondParse() {
|
||||
SimilarAsinParsedRowVo row = new SimilarAsinParsedRowVo();
|
||||
row.setValues(new LinkedHashMap<>());
|
||||
row.getValues().put("状态", "成功");
|
||||
row.getValues().put("是否有货", "");
|
||||
row.getValues().put("相似度", "");
|
||||
row.getValues().put("是否符合类目", "");
|
||||
row.getValues().put("不符合理由", "");
|
||||
row.getValues().put("产品类目", "");
|
||||
|
||||
assertTrue(SimilarAsinTaskService.isFirstPassResultWorkbook(
|
||||
List.of("id", "asin", "国家", "是否有货", "相似度", "是否符合类目", "不符合理由", "产品类目", "状态", "主图", "阿里巴巴图片1", "阿里巴巴图片2"),
|
||||
List.of(row)));
|
||||
|
||||
row.getValues().put("是否符合类目", "符合");
|
||||
assertFalse(SimilarAsinTaskService.isFirstPassResultWorkbook(
|
||||
List.of("id", "asin", "国家", "是否有货", "相似度", "是否符合类目", "不符合理由", "产品类目", "状态", "主图", "阿里巴巴图片1", "阿里巴巴图片2"),
|
||||
List.of(row)));
|
||||
|
||||
row.getValues().put("是否符合类目", "");
|
||||
row.getValues().put("状态", "失败");
|
||||
assertFalse(SimilarAsinTaskService.isFirstPassResultWorkbook(
|
||||
List.of("id", "asin", "国家", "是否有货", "相似度", "是否符合类目", "不符合理由", "产品类目", "状态", "主图", "阿里巴巴图片1", "阿里巴巴图片2"),
|
||||
List.of(row)));
|
||||
}
|
||||
|
||||
private int staticIntField(String name) throws Exception {
|
||||
Field field = SimilarAsinTaskService.class.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
|
||||
+59
@@ -2,6 +2,7 @@ package com.nanri.aiimage.modules.similarasin.util;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@@ -11,7 +12,9 @@ import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipFile;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class ExcelCellImageWriterTest {
|
||||
@@ -42,6 +45,48 @@ class ExcelCellImageWriterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void streamsRegisteredImagePathIntoWorkbookMedia() throws Exception {
|
||||
Path dir = Files.createTempDirectory("excel-cell-image-path-test-");
|
||||
Path xlsx = dir.resolve("result.xlsx");
|
||||
Path image = dir.resolve("thumb.jpeg");
|
||||
byte[] imageBytes = new byte[]{7, 8, 9, 10};
|
||||
writeMinimalWorkbook(xlsx);
|
||||
Files.write(image, imageBytes);
|
||||
|
||||
ExcelCellImageWriter.Session session = ExcelCellImageWriter.createSession();
|
||||
session.registerImage(1, 9, image);
|
||||
|
||||
ExcelCellImageWriter.patchXlsxFile(xlsx.toFile(), session);
|
||||
|
||||
try (ZipFile zip = new ZipFile(xlsx.toFile())) {
|
||||
assertArrayEquals(imageBytes,
|
||||
zip.getInputStream(zip.getEntry("xl/media/excelcellimage1.jpeg")).readAllBytes());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void patchesTenThousandImageCellsWithinLinearTimeBudget() {
|
||||
assertTimeoutPreemptively(Duration.ofSeconds(10), () -> {
|
||||
Path dir = Files.createTempDirectory("excel-cell-image-linear-test-");
|
||||
Path xlsx = dir.resolve("result.xlsx");
|
||||
writeWorkbookWithImageCells(xlsx, 10_000);
|
||||
|
||||
ExcelCellImageWriter.Session session = ExcelCellImageWriter.createSession();
|
||||
for (int row = 1; row <= 10_000; row++) {
|
||||
session.registerImage(row, 9, new byte[]{1});
|
||||
}
|
||||
|
||||
ExcelCellImageWriter.patchXlsxFile(xlsx.toFile(), session);
|
||||
|
||||
try (ZipFile zip = new ZipFile(xlsx.toFile())) {
|
||||
String sheet = read(zip, "xl/worksheets/sheet1.xml");
|
||||
assertEquals(10_000, maxVm(sheet));
|
||||
assertTrue(sheet.contains("<c r=\"J10001\" t=\"e\" vm=\"10000\"><v>#VALUE!</v></c>"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static int maxVm(String sheet) {
|
||||
Matcher matcher = Pattern.compile("vm=\"(\\d+)\"").matcher(sheet);
|
||||
int max = -1;
|
||||
@@ -99,6 +144,20 @@ class ExcelCellImageWriterTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static void writeWorkbookWithImageCells(Path xlsx, int imageCount) throws Exception {
|
||||
try (ZipOutputStream out = new ZipOutputStream(Files.newOutputStream(xlsx))) {
|
||||
write(out, "[Content_Types].xml", "<Types></Types>");
|
||||
write(out, "xl/_rels/workbook.xml.rels", "<Relationships></Relationships>");
|
||||
StringBuilder sheet = new StringBuilder("<worksheet><sheetData>");
|
||||
for (int row = 2; row <= imageCount + 1; row++) {
|
||||
sheet.append("<row r=\"").append(row).append("\"><c r=\"J").append(row)
|
||||
.append("\" t=\"inlineStr\"><is><t>image</t></is></c></row>");
|
||||
}
|
||||
sheet.append("</sheetData></worksheet>");
|
||||
write(out, "xl/worksheets/sheet1.xml", sheet.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private static void write(ZipOutputStream out, String name, String content) throws Exception {
|
||||
out.putNextEntry(new ZipEntry(name));
|
||||
out.write(content.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
+222
-1
@@ -1,7 +1,15 @@
|
||||
package com.nanri.aiimage.modules.similarasin.util;
|
||||
|
||||
import com.nanri.aiimage.config.OssProperties;
|
||||
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Protocol;
|
||||
import okhttp3.Response;
|
||||
import okhttp3.ResponseBody;
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
@@ -10,24 +18,39 @@ import java.awt.Graphics2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Field;
|
||||
import java.net.InetAddress;
|
||||
import java.net.UnknownHostException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
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.assertTimeoutPreemptively;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
|
||||
class SimilarAsinImageEmbedderTest {
|
||||
|
||||
// properties=null 时构造函数走 DEFAULT_DOWNLOAD_TIMEOUT_SECONDS / DEFAULT_DOWNLOAD_POOL_SIZE 兜底。
|
||||
private final SimilarAsinImageEmbedder embedder = new SimilarAsinImageEmbedder(null, createOssStorageService());
|
||||
|
||||
@AfterEach
|
||||
void shutDownEmbedder() {
|
||||
embedder.shutdown();
|
||||
}
|
||||
|
||||
private static OssStorageService createOssStorageService() {
|
||||
OssProperties properties = new OssProperties();
|
||||
properties.setEndpoint("https://oss.aishufu.top");
|
||||
@@ -40,6 +63,133 @@ class SimilarAsinImageEmbedderTest {
|
||||
return new OssStorageService(properties);
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultsImageDownloadPoolToEight() {
|
||||
assertEquals(8, new SimilarAsinProperties().getImageDownloadPoolSize());
|
||||
assertEquals(8, embedder.downloadPoolSize());
|
||||
}
|
||||
|
||||
@Test
|
||||
void writesFetchedThumbnailToPersistentCacheForNextEmbedderInstance() throws Exception {
|
||||
Path cacheDir = Files.createTempDirectory("similar-asin-image-cache-test-");
|
||||
String url = "https://images.example.com/product.jpg";
|
||||
byte[] sourceImage = createJpegBytes();
|
||||
AtomicInteger firstNetworkCalls = new AtomicInteger();
|
||||
AtomicInteger secondNetworkCalls = new AtomicInteger();
|
||||
SimilarAsinImageEmbedder first = new SimilarAsinImageEmbedder(
|
||||
properties(1, 5, cacheDir), createOssStorageService());
|
||||
SimilarAsinImageEmbedder second = new SimilarAsinImageEmbedder(
|
||||
properties(1, 5, cacheDir), createOssStorageService());
|
||||
try {
|
||||
replaceHttpClient(first, respondingClient(firstNetworkCalls, sourceImage));
|
||||
SimilarAsinImageEmbedder.ResizedImage fetched = first.fetchAndResizeForCache(url);
|
||||
|
||||
assertNotNull(fetched);
|
||||
assertEquals(1, firstNetworkCalls.get());
|
||||
try (var cachedFiles = Files.walk(cacheDir)) {
|
||||
assertEquals(1L, cachedFiles.filter(Files::isRegularFile).count());
|
||||
}
|
||||
|
||||
replaceHttpClient(second, new OkHttpClient.Builder()
|
||||
.addInterceptor(chain -> {
|
||||
secondNetworkCalls.incrementAndGet();
|
||||
throw new AssertionError("persistent cache miss triggered a second network request");
|
||||
})
|
||||
.build());
|
||||
SimilarAsinImageEmbedder.ResizedImage cached = second.fetchAndResizeForCache(url);
|
||||
|
||||
assertNotNull(cached);
|
||||
assertArrayEquals(fetched.bytes(), cached.bytes());
|
||||
assertEquals(fetched.width(), cached.width());
|
||||
assertEquals(fetched.height(), cached.height());
|
||||
assertEquals(0, secondNetworkCalls.get());
|
||||
} finally {
|
||||
first.shutdown();
|
||||
second.shutdown();
|
||||
deleteRecursively(cacheDir);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void diskPrefetchDeadlineCancelsInFlightDownload() throws Exception {
|
||||
SimilarAsinImageEmbedder deadlineEmbedder = new SimilarAsinImageEmbedder(
|
||||
properties(1, 1, null), createOssStorageService());
|
||||
CountDownLatch downloadStarted = new CountDownLatch(1);
|
||||
CountDownLatch cancellationObserved = new CountDownLatch(1);
|
||||
replaceHttpClient(deadlineEmbedder, new OkHttpClient.Builder()
|
||||
.addInterceptor(chain -> {
|
||||
downloadStarted.countDown();
|
||||
try {
|
||||
new CountDownLatch(1).await();
|
||||
throw new AssertionError("blocking download unexpectedly completed");
|
||||
} catch (InterruptedException ex) {
|
||||
Thread.currentThread().interrupt();
|
||||
cancellationObserved.countDown();
|
||||
throw new IOException("cancelled", ex);
|
||||
}
|
||||
})
|
||||
.build());
|
||||
Path spoolDir = Files.createTempDirectory("similar-asin-prefetch-deadline-test-");
|
||||
try (SimilarAsinImageEmbedder.ImageSpool spool = new SimilarAsinImageEmbedder.ImageSpool(spoolDir)) {
|
||||
assertTimeoutPreemptively(Duration.ofSeconds(3), () -> deadlineEmbedder.prefetchToDisk(
|
||||
List.of("https://images.example.com/slow.jpg"), spool));
|
||||
|
||||
assertTrue(downloadStarted.await(100, TimeUnit.MILLISECONDS));
|
||||
assertTrue(cancellationObserved.await(1, TimeUnit.SECONDS),
|
||||
"deadline should interrupt the active image download");
|
||||
assertEquals(0, spool.size());
|
||||
} finally {
|
||||
deadlineEmbedder.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedDiskPrefetchIsNotDownloadedAgainWhileEmbedding() throws Exception {
|
||||
SimilarAsinImageEmbedder failedEmbedder = new SimilarAsinImageEmbedder(
|
||||
properties(1, 5, null), createOssStorageService());
|
||||
AtomicInteger networkCalls = new AtomicInteger();
|
||||
replaceHttpClient(failedEmbedder, failingClient(networkCalls));
|
||||
String url = "https://images.example.com/missing.jpg";
|
||||
Path spoolDir = Files.createTempDirectory("similar-asin-prefetch-failed-test-");
|
||||
try (SimilarAsinImageEmbedder.ImageSpool spool = new SimilarAsinImageEmbedder.ImageSpool(spoolDir);
|
||||
XSSFWorkbook workbook = new XSSFWorkbook()) {
|
||||
failedEmbedder.prefetchToDisk(List.of(url), spool);
|
||||
int callsAfterPrefetch = networkCalls.get();
|
||||
var row = workbook.createSheet().createRow(1);
|
||||
|
||||
SimilarAsinImageEmbedder.ImageDim result = failedEmbedder.embedAsExcelCellImage(
|
||||
1, 9, url, row, new HashMap<>(), ExcelCellImageWriter.createSession(), spool);
|
||||
|
||||
assertEquals(SimilarAsinImageEmbedder.DOWNLOAD_MAX_RETRY + 1, callsAfterPrefetch);
|
||||
assertEquals(callsAfterPrefetch, networkCalls.get());
|
||||
assertNull(result);
|
||||
assertEquals(url, row.getCell(9).getStringCellValue());
|
||||
} finally {
|
||||
failedEmbedder.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void imageSpoolWritesThumbnailAndDeletesTaskDirectoryOnClose() throws Exception {
|
||||
Path directory = Files.createTempDirectory("similar-asin-image-spool-test-");
|
||||
byte[] bytes = new byte[]{1, 3, 5, 7};
|
||||
SimilarAsinImageEmbedder.ImageSpool spool = new SimilarAsinImageEmbedder.ImageSpool(directory);
|
||||
try {
|
||||
SimilarAsinImageEmbedder.SpoolImage image = spool.put(
|
||||
"https://example.com/image.jpg",
|
||||
new SimilarAsinImageEmbedder.ResizedImage(bytes, 120, 80));
|
||||
|
||||
assertEquals(1, spool.size());
|
||||
assertEquals(120, image.width());
|
||||
assertEquals(80, image.height());
|
||||
assertArrayEquals(bytes, Files.readAllBytes(image.path()));
|
||||
} finally {
|
||||
spool.close();
|
||||
}
|
||||
|
||||
assertFalse(Files.exists(directory));
|
||||
}
|
||||
|
||||
@Test
|
||||
void normalizesLegacyMinioUrlBeforeHttpsValidation() {
|
||||
String normalized = embedder.normalizeAndValidateDownloadUrl(
|
||||
@@ -247,4 +397,75 @@ class SimilarAsinImageEmbedderTest {
|
||||
assertTrue(summary.contains("IOException: outer"));
|
||||
assertTrue(summary.contains("cause=TimeoutException: inner"));
|
||||
}
|
||||
|
||||
private static SimilarAsinProperties properties(int poolSize, int prefetchTimeoutSeconds, Path cacheDir) {
|
||||
SimilarAsinProperties properties = new SimilarAsinProperties();
|
||||
properties.setImageDownloadPoolSize(poolSize);
|
||||
properties.setImagePrefetchTimeoutSeconds(prefetchTimeoutSeconds);
|
||||
properties.setImageLocalCacheDir(cacheDir == null ? "" : cacheDir.toString());
|
||||
return properties;
|
||||
}
|
||||
|
||||
private static OkHttpClient respondingClient(AtomicInteger calls, byte[] imageBytes) {
|
||||
return new OkHttpClient.Builder()
|
||||
.addInterceptor(chain -> {
|
||||
calls.incrementAndGet();
|
||||
return response(chain.request(), 200, "OK", imageBytes);
|
||||
})
|
||||
.build();
|
||||
}
|
||||
|
||||
private static OkHttpClient failingClient(AtomicInteger calls) {
|
||||
return new OkHttpClient.Builder()
|
||||
.addInterceptor(chain -> {
|
||||
calls.incrementAndGet();
|
||||
return response(chain.request(), 503, "Unavailable", new byte[0]);
|
||||
})
|
||||
.build();
|
||||
}
|
||||
|
||||
private static Response response(okhttp3.Request request,
|
||||
int status,
|
||||
String message,
|
||||
byte[] body) {
|
||||
return new Response.Builder()
|
||||
.request(request)
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(status)
|
||||
.message(message)
|
||||
.body(ResponseBody.create(body, MediaType.get("image/jpeg")))
|
||||
.build();
|
||||
}
|
||||
|
||||
private static void replaceHttpClient(SimilarAsinImageEmbedder target,
|
||||
OkHttpClient httpClient) throws ReflectiveOperationException {
|
||||
Field field = SimilarAsinImageEmbedder.class.getDeclaredField("httpClient");
|
||||
field.setAccessible(true);
|
||||
field.set(target, httpClient);
|
||||
}
|
||||
|
||||
private static byte[] createJpegBytes() throws IOException {
|
||||
BufferedImage image = new BufferedImage(64, 48, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D graphics = image.createGraphics();
|
||||
try {
|
||||
graphics.setColor(new Color(0x24, 0x68, 0xAC));
|
||||
graphics.fillRect(0, 0, image.getWidth(), image.getHeight());
|
||||
} finally {
|
||||
graphics.dispose();
|
||||
}
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
ImageIO.write(image, "jpg", output);
|
||||
return output.toByteArray();
|
||||
}
|
||||
|
||||
private static void deleteRecursively(Path directory) throws IOException {
|
||||
if (!Files.exists(directory)) {
|
||||
return;
|
||||
}
|
||||
try (var paths = Files.walk(directory)) {
|
||||
for (Path path : paths.sorted(java.util.Comparator.reverseOrder()).toList()) {
|
||||
Files.deleteIfExists(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.nanri.aiimage.modules.task.mapper.TaskFileJobMapper;
|
||||
import com.nanri.aiimage.modules.task.model.dto.TaskFileJobDispatchEvent;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class TaskFileJobServiceTest {
|
||||
|
||||
@Mock private TaskFileJobMapper taskFileJobMapper;
|
||||
@Mock private ApplicationEventPublisher applicationEventPublisher;
|
||||
|
||||
@BeforeAll
|
||||
static void initializeTableInfo() {
|
||||
TableInfoHelper.initTableInfo(
|
||||
new MapperBuilderAssistant(new MybatisConfiguration(), ""),
|
||||
TaskFileJobEntity.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void stuckJobIsRequeuedAndRetryCountIsIncremented() {
|
||||
TaskFileJobEntity running = runningJob(101L, 3, LocalDateTime.now().minusHours(1));
|
||||
TaskFileJobEntity pending = runningJob(101L, 4, LocalDateTime.now());
|
||||
pending.setStatus("PENDING");
|
||||
when(taskFileJobMapper.selectList(any())).thenReturn(List.of(running));
|
||||
when(taskFileJobMapper.update(isNull(), any(LambdaUpdateWrapper.class))).thenReturn(1);
|
||||
when(taskFileJobMapper.selectById(101L)).thenReturn(pending);
|
||||
TaskFileJobService service = new TaskFileJobService(taskFileJobMapper, applicationEventPublisher);
|
||||
|
||||
TaskFileJobService.StuckJobResetResult result = service.resetStuckRunningJobsDetailed(30, 20);
|
||||
|
||||
assertEquals(1, result.resetCount());
|
||||
assertTrue(result.exhaustedJobs().isEmpty());
|
||||
verify(applicationEventPublisher).publishEvent(any(TaskFileJobDispatchEvent.class));
|
||||
assertUpdateContains(4, running.getUpdatedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void stuckJobAtLastRetryWaitsForDurableFailureFinalization() {
|
||||
TaskFileJobEntity running = runningJob(102L, 4, LocalDateTime.now().minusHours(1));
|
||||
TaskFileJobEntity failed = runningJob(102L, TaskFileJobService.MAX_RETRY_COUNT, LocalDateTime.now());
|
||||
failed.setStatus("FAILED");
|
||||
/*
|
||||
failed.setErrorMessage("文件生成任务运行超时,已达到最大重试次数");
|
||||
*/
|
||||
failed.setErrorMessage("result file job timeout");
|
||||
when(taskFileJobMapper.selectList(any())).thenReturn(List.of(running));
|
||||
when(taskFileJobMapper.update(isNull(), any(LambdaUpdateWrapper.class))).thenReturn(1);
|
||||
when(taskFileJobMapper.selectById(102L)).thenReturn(failed);
|
||||
TaskFileJobService service = new TaskFileJobService(taskFileJobMapper, applicationEventPublisher);
|
||||
|
||||
TaskFileJobService.StuckJobResetResult result = service.resetStuckRunningJobsDetailed(30, 20);
|
||||
|
||||
assertEquals(0, result.resetCount());
|
||||
assertEquals(List.of(failed), result.exhaustedJobs());
|
||||
verify(applicationEventPublisher, never()).publishEvent(any());
|
||||
assertUpdateContains(TaskFileJobService.MAX_RETRY_COUNT, running.getUpdatedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pendingFailureFinalizationIsReturnedAgainWithoutRequeueing() {
|
||||
TaskFileJobEntity pending = runningJob(106L, TaskFileJobService.MAX_RETRY_COUNT, LocalDateTime.now());
|
||||
pending.setStatus("FAILED");
|
||||
when(taskFileJobMapper.selectList(any())).thenReturn(List.of(pending));
|
||||
TaskFileJobService service = new TaskFileJobService(taskFileJobMapper, applicationEventPublisher);
|
||||
|
||||
TaskFileJobService.StuckJobResetResult result = service.resetStuckRunningJobsDetailed(30, 20);
|
||||
|
||||
assertEquals(0, result.resetCount());
|
||||
assertEquals(List.of(pending), result.exhaustedJobs());
|
||||
verify(taskFileJobMapper, never()).update(any(), any());
|
||||
verify(applicationEventPublisher, never()).publishEvent(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void queuedClaimActivationUsesUpdatedAtAsFencingToken() {
|
||||
TaskFileJobEntity claim = runningJob(107L, 1, LocalDateTime.now().minusMinutes(1));
|
||||
when(taskFileJobMapper.update(isNull(), any(LambdaUpdateWrapper.class))).thenReturn(1);
|
||||
TaskFileJobService service = new TaskFileJobService(taskFileJobMapper, applicationEventPublisher);
|
||||
|
||||
assertTrue(service.activateRunningClaim(claim));
|
||||
|
||||
ArgumentCaptor<LambdaUpdateWrapper<TaskFileJobEntity>> update = updateCaptor();
|
||||
verify(taskFileJobMapper).update(isNull(), update.capture());
|
||||
assertTrue(update.getValue().getSqlSegment().contains("updated_at"));
|
||||
assertTrue(update.getValue().getParamNameValuePairs().containsValue(claim.getUpdatedAt()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void heartbeatRacePreventsStuckJobReset() {
|
||||
TaskFileJobEntity running = runningJob(103L, 4, LocalDateTime.now().minusHours(1));
|
||||
when(taskFileJobMapper.selectList(any())).thenReturn(List.of(running));
|
||||
when(taskFileJobMapper.update(isNull(), any(LambdaUpdateWrapper.class))).thenReturn(0);
|
||||
TaskFileJobService service = new TaskFileJobService(taskFileJobMapper, applicationEventPublisher);
|
||||
|
||||
TaskFileJobService.StuckJobResetResult result = service.resetStuckRunningJobsDetailed(30, 20);
|
||||
|
||||
assertEquals(0, result.resetCount());
|
||||
assertTrue(result.exhaustedJobs().isEmpty());
|
||||
verify(taskFileJobMapper, never()).selectById(any());
|
||||
verify(applicationEventPublisher, never()).publishEvent(any());
|
||||
assertUpdateContains(TaskFileJobService.MAX_RETRY_COUNT, running.getUpdatedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void exhaustedJobCannotBeClaimedOrRequeued() {
|
||||
when(taskFileJobMapper.update(isNull(), any(LambdaUpdateWrapper.class))).thenReturn(0);
|
||||
TaskFileJobService service = new TaskFileJobService(taskFileJobMapper, applicationEventPublisher);
|
||||
|
||||
assertFalse(service.markRunning(104L));
|
||||
assertFalse(service.requeue(104L, "retry"));
|
||||
|
||||
ArgumentCaptor<LambdaUpdateWrapper<TaskFileJobEntity>> updates = updateCaptor();
|
||||
verify(taskFileJobMapper, org.mockito.Mockito.times(2)).update(isNull(), updates.capture());
|
||||
for (LambdaUpdateWrapper<TaskFileJobEntity> update : updates.getAllValues()) {
|
||||
assertTrue(update.getSqlSegment().contains("retry_count"));
|
||||
assertTrue(update.getParamNameValuePairs().containsValue(TaskFileJobService.MAX_RETRY_COUNT));
|
||||
}
|
||||
verify(taskFileJobMapper, never()).selectById(any());
|
||||
verify(applicationEventPublisher, never()).publishEvent(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void markFailedUsesCurrentRetryCountWithCompareAndSet() {
|
||||
TaskFileJobEntity stale = runningJob(105L, 0, LocalDateTime.now().minusMinutes(5));
|
||||
TaskFileJobEntity current = runningJob(105L, 4, LocalDateTime.now());
|
||||
when(taskFileJobMapper.selectById(105L)).thenReturn(current);
|
||||
when(taskFileJobMapper.update(isNull(), any(LambdaUpdateWrapper.class))).thenReturn(1);
|
||||
TaskFileJobService service = new TaskFileJobService(taskFileJobMapper, applicationEventPublisher);
|
||||
|
||||
service.markFailed(stale, "failed");
|
||||
|
||||
ArgumentCaptor<LambdaUpdateWrapper<TaskFileJobEntity>> update = updateCaptor();
|
||||
verify(taskFileJobMapper).update(isNull(), update.capture());
|
||||
assertTrue(update.getValue().getSqlSegment().contains("retry_count"));
|
||||
assertTrue(update.getValue().getParamNameValuePairs().containsValue(4));
|
||||
assertTrue(update.getValue().getParamNameValuePairs().containsValue(TaskFileJobService.MAX_RETRY_COUNT));
|
||||
}
|
||||
|
||||
@Test
|
||||
void successWriteIsFencedToRunningJob() {
|
||||
TaskFileJobEntity job = runningJob(108L, 1, LocalDateTime.now());
|
||||
TaskFileJobService service = new TaskFileJobService(taskFileJobMapper, applicationEventPublisher);
|
||||
|
||||
service.markSuccess(job, "result.xlsx");
|
||||
|
||||
ArgumentCaptor<LambdaUpdateWrapper<TaskFileJobEntity>> update = updateCaptor();
|
||||
verify(taskFileJobMapper).update(isNull(), update.capture());
|
||||
assertTrue(update.getValue().getSqlSegment().contains("status"));
|
||||
assertTrue(update.getValue().getParamNameValuePairs().containsValue("RUNNING"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void similarAsinHeartbeatOnlyTouchesStaleRunningAssembleJobs() {
|
||||
TaskFileJobService service = new TaskFileJobService(taskFileJobMapper, applicationEventPublisher);
|
||||
|
||||
service.touchRunningAssembleJobsIfStale(20553L, "SIMILAR_ASIN", 60000L);
|
||||
|
||||
ArgumentCaptor<LambdaUpdateWrapper<TaskFileJobEntity>> update = updateCaptor();
|
||||
verify(taskFileJobMapper).update(isNull(), update.capture());
|
||||
assertTrue(update.getValue().getSqlSegment().contains("task_id"));
|
||||
assertTrue(update.getValue().getParamNameValuePairs().containsValue("SIMILAR_ASIN"));
|
||||
assertTrue(update.getValue().getParamNameValuePairs().containsValue("ASSEMBLE_RESULT"));
|
||||
assertTrue(update.getValue().getParamNameValuePairs().containsValue("RUNNING"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void retryExhaustedOnlyMeansFailedTerminalJob() {
|
||||
TaskFileJobEntity success = runningJob(109L, TaskFileJobService.MAX_RETRY_COUNT, LocalDateTime.now());
|
||||
success.setStatus("SUCCESS");
|
||||
TaskFileJobEntity failed = runningJob(110L, TaskFileJobService.MAX_RETRY_COUNT, LocalDateTime.now());
|
||||
failed.setStatus("FAILED");
|
||||
when(taskFileJobMapper.selectById(109L)).thenReturn(success);
|
||||
when(taskFileJobMapper.selectById(110L)).thenReturn(failed);
|
||||
TaskFileJobService service = new TaskFileJobService(taskFileJobMapper, applicationEventPublisher);
|
||||
|
||||
assertFalse(service.isRetryExhausted(109L));
|
||||
assertTrue(service.isRetryExhausted(110L));
|
||||
}
|
||||
|
||||
private void assertUpdateContains(int nextRetry, LocalDateTime updatedAt) {
|
||||
ArgumentCaptor<LambdaUpdateWrapper<TaskFileJobEntity>> update = updateCaptor();
|
||||
verify(taskFileJobMapper).update(isNull(), update.capture());
|
||||
assertTrue(update.getValue().getSqlSegment().contains("updated_at"));
|
||||
assertTrue(update.getValue().getParamNameValuePairs().containsValue(updatedAt));
|
||||
assertTrue(update.getValue().getParamNameValuePairs().containsValue(nextRetry));
|
||||
}
|
||||
|
||||
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||
private static ArgumentCaptor<LambdaUpdateWrapper<TaskFileJobEntity>> updateCaptor() {
|
||||
return ArgumentCaptor.forClass((Class) LambdaUpdateWrapper.class);
|
||||
}
|
||||
|
||||
private static TaskFileJobEntity runningJob(Long id, int retryCount, LocalDateTime updatedAt) {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
job.setId(id);
|
||||
job.setTaskId(20553L);
|
||||
job.setResultId(23110L);
|
||||
job.setModuleType("SIMILAR_ASIN");
|
||||
job.setStatus("RUNNING");
|
||||
job.setRetryCount(retryCount);
|
||||
job.setUpdatedAt(updatedAt);
|
||||
return job;
|
||||
}
|
||||
}
|
||||
+26
@@ -8,6 +8,7 @@ import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskCacheService;
|
||||
import com.nanri.aiimage.modules.brand.mapper.BrandCrawlTaskMapper;
|
||||
import com.nanri.aiimage.modules.brand.service.BrandTaskProgressCacheService;
|
||||
import com.nanri.aiimage.modules.collectdata.service.CollectDataService;
|
||||
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandTaskCacheService;
|
||||
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskCacheService;
|
||||
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskCacheService;
|
||||
@@ -67,8 +68,10 @@ class TaskHeartbeatServiceTest {
|
||||
@Mock private AppearancePatentTaskCacheService appearancePatentTaskCacheService;
|
||||
@Mock private SimilarAsinTaskCacheService similarAsinTaskCacheService;
|
||||
@Mock private SimilarAsinProperties similarAsinProperties;
|
||||
@Mock private TaskFileJobService taskFileJobService;
|
||||
@Mock private DeleteBrandTaskCacheService deleteBrandTaskCacheService;
|
||||
@Mock private BrandTaskProgressCacheService brandTaskProgressCacheService;
|
||||
@Mock private CollectDataService collectDataService;
|
||||
|
||||
@InjectMocks private TaskHeartbeatService service;
|
||||
|
||||
@@ -124,6 +127,28 @@ class TaskHeartbeatServiceTest {
|
||||
verify(shopDataCrawlTaskCacheService).saveTaskCache(task);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void collectDataHeartbeatForwardsProcessedKeywordProgress() {
|
||||
long taskId = 21016L;
|
||||
FileTaskEntity task = new FileTaskEntity();
|
||||
task.setId(taskId);
|
||||
task.setModuleType("COLLECT_DATA");
|
||||
task.setStatus("RUNNING");
|
||||
TaskHeartbeatRequest request = new TaskHeartbeatRequest();
|
||||
request.setCurrent(4);
|
||||
request.setTotal(19);
|
||||
|
||||
when(fileTaskMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(task);
|
||||
when(brandCrawlTaskMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null);
|
||||
when(fileTaskMapper.update(isNull(), any(LambdaUpdateWrapper.class))).thenReturn(1);
|
||||
|
||||
TaskHeartbeatVo result = service.heartbeat(taskId, request);
|
||||
|
||||
assertTrue(result.isAlive());
|
||||
verify(collectDataService).updateProgress(taskId, request);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void similarAsinHeartbeatUsesRedisWithoutRefreshingRecentDatabaseCheckpoint() {
|
||||
@@ -142,6 +167,7 @@ class TaskHeartbeatServiceTest {
|
||||
assertTrue(result.isAlive());
|
||||
assertEquals("SIMILAR_ASIN", result.getModuleType());
|
||||
verify(similarAsinTaskCacheService).touchTaskHeartbeat(taskId);
|
||||
verify(taskFileJobService).touchRunningAssembleJobsIfStale(taskId, "SIMILAR_ASIN", 120000L);
|
||||
verify(fileTaskMapper, never()).update(isNull(), any(LambdaUpdateWrapper.class));
|
||||
}
|
||||
}
|
||||
|
||||
+93
-4
@@ -17,6 +17,10 @@ import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
|
||||
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InOrder;
|
||||
@@ -24,9 +28,13 @@ import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.inOrder;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
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.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -72,7 +80,7 @@ class TaskResultFileJobWorkerTest {
|
||||
result.setResultFileUrl("result/withdraw/20140.xlsx");
|
||||
TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
|
||||
|
||||
when(taskFileJobService.markRunning(jobId)).thenReturn(true);
|
||||
allowClaim(job);
|
||||
when(taskDistributedLockService.acquire("WITHDRAW", taskId, TaskDistributedLockService.DEFAULT_WAIT_MILLIS))
|
||||
.thenReturn(lock);
|
||||
when(fileResultMapper.selectById(resultId)).thenReturn(result);
|
||||
@@ -102,7 +110,7 @@ class TaskResultFileJobWorkerTest {
|
||||
result.setResultFileUrl("result/publish/20141.xlsx");
|
||||
TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
|
||||
|
||||
when(taskFileJobService.markRunning(jobId)).thenReturn(true);
|
||||
allowClaim(job);
|
||||
when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
|
||||
when(taskDistributedLockService.acquire(
|
||||
PublishTaskService.MODULE_TYPE,
|
||||
@@ -150,7 +158,7 @@ class TaskResultFileJobWorkerTest {
|
||||
TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
|
||||
|
||||
when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
|
||||
when(taskFileJobService.markRunning(jobId)).thenReturn(true);
|
||||
allowClaim(job);
|
||||
when(taskDistributedLockService.acquire("SHOP_DATA_CRAWL", taskId,
|
||||
TaskDistributedLockService.DEFAULT_WAIT_MILLIS)).thenReturn(lock);
|
||||
when(fileResultMapper.selectById(resultId)).thenReturn(result);
|
||||
@@ -173,7 +181,7 @@ class TaskResultFileJobWorkerTest {
|
||||
job.setScopeKey("task:20144:owner:instance-a");
|
||||
TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
|
||||
when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
|
||||
when(taskFileJobService.markRunning(job.getId())).thenReturn(true);
|
||||
allowClaim(job);
|
||||
when(taskDistributedLockService.acquire("SHOP_DATA_CRAWL", job.getTaskId(),
|
||||
TaskDistributedLockService.DEFAULT_WAIT_MILLIS)).thenReturn(lock);
|
||||
doThrow(new IllegalStateException("upload failed"))
|
||||
@@ -184,5 +192,86 @@ class TaskResultFileJobWorkerTest {
|
||||
|
||||
verify(taskFileJobService).markFailed(job, "upload failed");
|
||||
verify(shopDataCrawlTaskService).handleResultFileJobFailure(job, "upload failed");
|
||||
verify(taskFileJobService).markFailureFinalized(job.getId(), "upload failed");
|
||||
}
|
||||
|
||||
@Test
|
||||
void stuckSimilarAsinJobAtRetryLimitFailsOwningTask() {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
job.setId(13858L);
|
||||
job.setTaskId(20553L);
|
||||
job.setResultId(23110L);
|
||||
job.setModuleType("SIMILAR_ASIN");
|
||||
job.setRetryCount(TaskFileJobService.MAX_RETRY_COUNT);
|
||||
job.setErrorMessage("文件生成任务运行超时,已达到最大重试次数");
|
||||
TaskFileJobService.StuckJobResetResult resetResult =
|
||||
new TaskFileJobService.StuckJobResetResult(0, List.of(job));
|
||||
when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(resetResult);
|
||||
|
||||
worker.resetStuckJobs();
|
||||
|
||||
verify(similarAsinTaskService).handleResultFileJobFailure(job, job.getErrorMessage());
|
||||
verify(taskFileJobService).markFailureFinalized(job.getId(), job.getErrorMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void stuckJobFailureCallbackDoesNotBlockRemainingJobs() {
|
||||
TaskFileJobEntity first = exhaustedSimilarAsinJob(13858L, 20553L);
|
||||
TaskFileJobEntity second = exhaustedSimilarAsinJob(13859L, 20554L);
|
||||
TaskFileJobService.StuckJobResetResult resetResult =
|
||||
new TaskFileJobService.StuckJobResetResult(0, List.of(first, second));
|
||||
when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(resetResult);
|
||||
doThrow(new IllegalStateException("owner mismatch"))
|
||||
.doNothing()
|
||||
.when(similarAsinTaskService)
|
||||
.handleResultFileJobFailure(any(), anyString());
|
||||
|
||||
worker.resetStuckJobs();
|
||||
|
||||
verify(similarAsinTaskService).handleResultFileJobFailure(first, first.getErrorMessage());
|
||||
verify(similarAsinTaskService).handleResultFileJobFailure(second, second.getErrorMessage());
|
||||
verify(taskFileJobService, never()).markFailureFinalized(first.getId(), first.getErrorMessage());
|
||||
verify(taskFileJobService).markFailureFinalized(second.getId(), second.getErrorMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pendingFailureCallbackIsRetriedOnNextScan() {
|
||||
TaskFileJobEntity job = exhaustedSimilarAsinJob(13860L, 20555L);
|
||||
TaskFileJobService.StuckJobResetResult resetResult =
|
||||
new TaskFileJobService.StuckJobResetResult(0, List.of(job));
|
||||
when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0))
|
||||
.thenReturn(resetResult, resetResult);
|
||||
doThrow(new IllegalStateException("temporary database failure"))
|
||||
.doNothing()
|
||||
.when(similarAsinTaskService)
|
||||
.handleResultFileJobFailure(job, job.getErrorMessage());
|
||||
|
||||
worker.resetStuckJobs();
|
||||
worker.resetStuckJobs();
|
||||
|
||||
verify(similarAsinTaskService, times(2))
|
||||
.handleResultFileJobFailure(job, job.getErrorMessage());
|
||||
verify(taskFileJobService).markFailureFinalized(job.getId(), job.getErrorMessage());
|
||||
}
|
||||
|
||||
private void allowClaim(TaskFileJobEntity job) {
|
||||
TaskFileJobEntity claim = new TaskFileJobEntity();
|
||||
claim.setId(job.getId());
|
||||
claim.setTaskId(job.getTaskId());
|
||||
claim.setModuleType(job.getModuleType());
|
||||
claim.setStatus("RUNNING");
|
||||
claim.setUpdatedAt(LocalDateTime.now());
|
||||
when(taskFileJobService.claimRunning(job.getId())).thenReturn(claim);
|
||||
when(taskFileJobService.activateRunningClaim(claim)).thenReturn(true);
|
||||
}
|
||||
|
||||
private static TaskFileJobEntity exhaustedSimilarAsinJob(long jobId, long taskId) {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
job.setId(jobId);
|
||||
job.setTaskId(taskId);
|
||||
job.setModuleType("SIMILAR_ASIN");
|
||||
job.setRetryCount(TaskFileJobService.MAX_RETRY_COUNT);
|
||||
job.setErrorMessage("文件生成任务运行超时,已达到最大重试次数");
|
||||
return job;
|
||||
}
|
||||
}
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package com.nanri.aiimage.modules.ziniao.controller;
|
||||
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.ziniao.model.cache.ZiniaoShopIndexRefreshCursorDto;
|
||||
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
|
||||
import com.nanri.aiimage.modules.ziniao.service.ZiniaoAuthService;
|
||||
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopIndexRefreshService;
|
||||
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopIndexService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
class ZiniaoAuthControllerTest {
|
||||
|
||||
@Test
|
||||
void manualIndexRefreshEndpointReturnsCompletedCursor() throws Exception {
|
||||
ZiniaoAuthService authService = mock(ZiniaoAuthService.class);
|
||||
ZiniaoShopIndexService indexService = mock(ZiniaoShopIndexService.class);
|
||||
ZiniaoShopIndexRefreshService refreshService = mock(ZiniaoShopIndexRefreshService.class);
|
||||
AdminAuthSupport adminAuthSupport = mock(AdminAuthSupport.class);
|
||||
ZiniaoShopIndexRefreshCursorDto cursor = new ZiniaoShopIndexRefreshCursorDto();
|
||||
cursor.setStatus("SUCCESS");
|
||||
cursor.setLastProcessedApiKeyCount(3);
|
||||
when(refreshService.refreshShopIndexManually()).thenReturn(cursor);
|
||||
ZiniaoAuthController controller = new ZiniaoAuthController(authService, indexService, refreshService, adminAuthSupport);
|
||||
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
|
||||
|
||||
mockMvc.perform(post("/api/ziniao/index-refresh")
|
||||
.header(HttpHeaders.AUTHORIZATION, "Bearer admin-token"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
.andExpect(jsonPath("$.data.status").value("SUCCESS"))
|
||||
.andExpect(jsonPath("$.data.lastProcessedApiKeyCount").value(3));
|
||||
verify(adminAuthSupport).requireAdmin(org.mockito.ArgumentMatchers.any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void manualIndexRefreshRequiresAdministrator() {
|
||||
ZiniaoAuthService authService = mock(ZiniaoAuthService.class);
|
||||
ZiniaoShopIndexService indexService = mock(ZiniaoShopIndexService.class);
|
||||
ZiniaoShopIndexRefreshService refreshService = mock(ZiniaoShopIndexRefreshService.class);
|
||||
AdminAuthSupport adminAuthSupport = mock(AdminAuthSupport.class);
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
BusinessException authFailure = new BusinessException(403, "需要管理员权限");
|
||||
when(adminAuthSupport.requireAdmin(request)).thenThrow(authFailure);
|
||||
ZiniaoAuthController controller = new ZiniaoAuthController(authService, indexService, refreshService, adminAuthSupport);
|
||||
|
||||
assertThatThrownBy(() -> controller.refreshShopIndex(request, null)).isSameAs(authFailure);
|
||||
verifyNoInteractions(refreshService);
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package com.nanri.aiimage.modules.ziniao.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.nanri.aiimage.modules.shopkey.mapper.ShopKeyMapper;
|
||||
import com.nanri.aiimage.modules.shopkey.model.entity.ShopKeyEntity;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class ZiniaoApiKeyProviderTest {
|
||||
|
||||
@BeforeAll
|
||||
static void initializeMybatisMetadata() {
|
||||
TableInfoHelper.initTableInfo(
|
||||
new MapperBuilderAssistant(new MybatisConfiguration(), "ziniao-api-key-provider-test"),
|
||||
ShopKeyEntity.class
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateNormalizedTokensShareOneRefreshAccountAndAllRecordIds() {
|
||||
ShopKeyMapper mapper = mock(ShopKeyMapper.class);
|
||||
ShopKeyEntity latest = shopKey(12L, " Bearer duplicate-key ", "最新账号");
|
||||
ShopKeyEntity older = shopKey(8L, "duplicate-key", "旧账号");
|
||||
when(mapper.selectList(any())).thenReturn(List.of(latest, older));
|
||||
|
||||
ZiniaoApiKeyProvider provider = new ZiniaoApiKeyProvider(mapper);
|
||||
|
||||
List<ZiniaoApiKeyProvider.ApiKeyAccount> accounts = provider.listApiKeyAccounts();
|
||||
|
||||
assertEquals(1, accounts.size());
|
||||
assertEquals("duplicate-key", accounts.getFirst().apiKey());
|
||||
assertEquals("最新账号", accounts.getFirst().accountName());
|
||||
assertEquals(List.of(12L, 8L), accounts.getFirst().shopKeyIds());
|
||||
}
|
||||
|
||||
@Test
|
||||
void whitelistResultUpdatesEveryRecordForTheNormalizedToken() {
|
||||
ShopKeyMapper mapper = mock(ShopKeyMapper.class);
|
||||
ZiniaoApiKeyProvider provider = new ZiniaoApiKeyProvider(mapper);
|
||||
ZiniaoApiKeyProvider.ApiKeyAccount account = new ZiniaoApiKeyProvider.ApiKeyAccount(
|
||||
"duplicate-key",
|
||||
"账号",
|
||||
List.of(12L, 8L)
|
||||
);
|
||||
|
||||
provider.markIpWhitelistBlocked(account, "当前服务器 IP 未加入紫鸟白名单");
|
||||
|
||||
verify(mapper).update(isNull(), any(Wrapper.class));
|
||||
}
|
||||
|
||||
private ShopKeyEntity shopKey(long id, String token, String accountName) {
|
||||
ShopKeyEntity entity = new ShopKeyEntity();
|
||||
entity.setId(id);
|
||||
entity.setZiniaoToken(token);
|
||||
entity.setZiniaoAccountName(accountName);
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.nanri.aiimage.modules.ziniao.service;
|
||||
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.common.service.DistributedJobLockService;
|
||||
import com.nanri.aiimage.modules.ziniao.model.cache.ZiniaoShopIndexRefreshCursorDto;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class ZiniaoShopIndexRefreshServiceTest {
|
||||
|
||||
@Test
|
||||
void manualRefreshRunsUnderDistributedLockAndReturnsCursor() {
|
||||
ZiniaoShopIndexService indexService = mock(ZiniaoShopIndexService.class);
|
||||
DistributedJobLockService lockService = mock(DistributedJobLockService.class);
|
||||
DistributedJobLockService.LockHandle lockHandle = mock(DistributedJobLockService.LockHandle.class);
|
||||
ZiniaoShopIndexRefreshCursorDto cursor = new ZiniaoShopIndexRefreshCursorDto();
|
||||
cursor.setStatus("SUCCESS");
|
||||
when(lockService.tryLock("ziniao:shop-index-refresh", Duration.ofMinutes(30))).thenReturn(lockHandle);
|
||||
when(indexService.getRefreshCursor()).thenReturn(cursor);
|
||||
ZiniaoShopIndexRefreshService service = new ZiniaoShopIndexRefreshService(indexService, lockService);
|
||||
|
||||
ZiniaoShopIndexRefreshCursorDto result = service.refreshShopIndexManually();
|
||||
|
||||
assertThat(result).isSameAs(cursor);
|
||||
verify(indexService).refreshAllShopIndex();
|
||||
verify(lockHandle).close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void manualRefreshRejectsConcurrentExecution() {
|
||||
ZiniaoShopIndexService indexService = mock(ZiniaoShopIndexService.class);
|
||||
DistributedJobLockService lockService = mock(DistributedJobLockService.class);
|
||||
when(lockService.tryLock("ziniao:shop-index-refresh", Duration.ofMinutes(30))).thenReturn(null);
|
||||
ZiniaoShopIndexRefreshService service = new ZiniaoShopIndexRefreshService(indexService, lockService);
|
||||
|
||||
assertThatThrownBy(service::refreshShopIndexManually)
|
||||
.isInstanceOf(BusinessException.class)
|
||||
.hasMessageContaining("正在刷新");
|
||||
verify(indexService, never()).refreshAllShopIndex();
|
||||
}
|
||||
}
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
package com.nanri.aiimage.modules.ziniao.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.config.ZiniaoProperties;
|
||||
import com.nanri.aiimage.modules.ziniao.memory.service.ZiniaoMemoryStoreService;
|
||||
import com.nanri.aiimage.modules.ziniao.memory.service.ZiniaoTransientCacheService;
|
||||
import com.nanri.aiimage.modules.ziniao.model.cache.ZiniaoShopIndexEntryDto;
|
||||
import com.nanri.aiimage.modules.ziniao.model.cache.ZiniaoShopIndexRefreshCursorDto;
|
||||
import com.nanri.aiimage.modules.ziniao.model.cache.ZiniaoShopCacheDto;
|
||||
import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoStaffItemVo;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
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.argThat;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ZiniaoShopIndexServiceTest {
|
||||
|
||||
@Mock
|
||||
private ZiniaoMemoryStoreService ziniaoMemoryStoreService;
|
||||
@Mock
|
||||
private ZiniaoTransientCacheService ziniaoTransientCacheService;
|
||||
@Mock
|
||||
private ZiniaoApiKeyProvider ziniaoApiKeyProvider;
|
||||
@Mock
|
||||
private ZiniaoAuthService ziniaoAuthService;
|
||||
|
||||
private ZiniaoShopIndexService service;
|
||||
private ZiniaoProperties properties;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
properties = new ZiniaoProperties();
|
||||
properties.setShopIndexEntryTtlHours(12);
|
||||
properties.setShopIndexRefreshBatchSize(100);
|
||||
service = new ZiniaoShopIndexService(
|
||||
ziniaoMemoryStoreService,
|
||||
ziniaoTransientCacheService,
|
||||
ziniaoApiKeyProvider,
|
||||
ziniaoAuthService,
|
||||
properties,
|
||||
new ObjectMapper()
|
||||
);
|
||||
when(ziniaoTransientCacheService.get(
|
||||
"SHOP_INDEX_REFRESH_CURSOR",
|
||||
"global",
|
||||
ZiniaoShopIndexRefreshCursorDto.class
|
||||
)).thenReturn(Optional.empty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void companyWhitelistFailureSkipsCurrentApiKeyAndRefreshesNextApiKey() {
|
||||
stubIpWhitelistDetection();
|
||||
ZiniaoApiKeyProvider.ApiKeyAccount blocked = new ZiniaoApiKeyProvider.ApiKeyAccount("blocked-key", "blocked-account");
|
||||
ZiniaoApiKeyProvider.ApiKeyAccount allowed = new ZiniaoApiKeyProvider.ApiKeyAccount("allowed-key", "allowed-account");
|
||||
when(ziniaoApiKeyProvider.listApiKeyAccounts()).thenReturn(List.of(blocked, allowed));
|
||||
when(ziniaoAuthService.resolveCompanyIdForIndex("blocked-key"))
|
||||
.thenThrow(new BusinessException("当前服务器 IP 未加入紫鸟白名单"));
|
||||
when(ziniaoAuthService.resolveCompanyIdForIndex("allowed-key")).thenReturn(2L);
|
||||
when(ziniaoAuthService.getOrLoadStaffForIndex("allowed-key", 2L)).thenReturn(List.of(staff(22L)));
|
||||
when(ziniaoAuthService.getOrLoadUserStoresForIndex("allowed-key", 2L, 22L))
|
||||
.thenReturn(List.of(shop("shop-2", "店铺B")));
|
||||
|
||||
service.refreshShopIndex();
|
||||
|
||||
verify(ziniaoMemoryStoreService).put(
|
||||
eq(ZiniaoMemoryStoreService.CACHE_TYPE_SHOP_INDEX_ENTRY),
|
||||
eq("s:shop-2"),
|
||||
argThat(value -> value instanceof ZiniaoShopIndexEntryDto entry
|
||||
&& "shop-2".equals(entry.getShopId())),
|
||||
any(Duration.class)
|
||||
);
|
||||
verify(ziniaoMemoryStoreService).put(
|
||||
eq(ZiniaoMemoryStoreService.CACHE_TYPE_SHOP_INDEX_ENTRY),
|
||||
eq("n:店铺B"),
|
||||
any(ZiniaoShopIndexEntryDto.class),
|
||||
any(Duration.class)
|
||||
);
|
||||
verify(ziniaoMemoryStoreService, never()).listAliveEntitiesByType(any(), anyInt());
|
||||
verify(ziniaoApiKeyProvider).markIpWhitelistBlocked(
|
||||
eq(blocked),
|
||||
eq("当前服务器 IP 未加入紫鸟白名单")
|
||||
);
|
||||
verify(ziniaoApiKeyProvider).markIpWhitelistAllowed(allowed);
|
||||
|
||||
ZiniaoShopIndexRefreshCursorDto cursor = capturedCursor();
|
||||
assertEquals("SUCCESS", cursor.getStatus());
|
||||
assertEquals(Integer.valueOf(1), cursor.getLastProcessedApiKeyCount());
|
||||
assertTrue(cursor.getMessage().contains("IP 白名单: 1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void whitelistFailureAfterPartialApiKeyScanDiscardsPartialEntriesAndContinues() {
|
||||
stubIpWhitelistDetection();
|
||||
ZiniaoApiKeyProvider.ApiKeyAccount partiallyBlocked = new ZiniaoApiKeyProvider.ApiKeyAccount("partial-key", "partial-account");
|
||||
ZiniaoApiKeyProvider.ApiKeyAccount allowed = new ZiniaoApiKeyProvider.ApiKeyAccount("allowed-key", "allowed-account");
|
||||
when(ziniaoApiKeyProvider.listApiKeyAccounts()).thenReturn(List.of(partiallyBlocked, allowed));
|
||||
when(ziniaoAuthService.resolveCompanyIdForIndex("partial-key")).thenReturn(1L);
|
||||
when(ziniaoAuthService.getOrLoadStaffForIndex("partial-key", 1L))
|
||||
.thenReturn(List.of(staff(11L), staff(12L)));
|
||||
when(ziniaoAuthService.getOrLoadUserStoresForIndex("partial-key", 1L, 11L))
|
||||
.thenReturn(List.of(shop("partial-shop", "半成品店铺")));
|
||||
when(ziniaoAuthService.getOrLoadUserStoresForIndex("partial-key", 1L, 12L))
|
||||
.thenThrow(new BusinessException("当前服务器 IP 未加入紫鸟白名单"));
|
||||
when(ziniaoAuthService.resolveCompanyIdForIndex("allowed-key")).thenReturn(2L);
|
||||
when(ziniaoAuthService.getOrLoadStaffForIndex("allowed-key", 2L)).thenReturn(List.of(staff(22L)));
|
||||
when(ziniaoAuthService.getOrLoadUserStoresForIndex("allowed-key", 2L, 22L))
|
||||
.thenReturn(List.of(shop("allowed-shop", "正常店铺")));
|
||||
|
||||
service.refreshShopIndex();
|
||||
|
||||
verify(ziniaoMemoryStoreService, never()).put(
|
||||
eq(ZiniaoMemoryStoreService.CACHE_TYPE_SHOP_INDEX_ENTRY),
|
||||
eq("s:partial-shop"),
|
||||
any(),
|
||||
any(Duration.class)
|
||||
);
|
||||
verify(ziniaoMemoryStoreService, never()).put(
|
||||
eq(ZiniaoMemoryStoreService.CACHE_TYPE_SHOP_INDEX_ENTRY),
|
||||
eq("n:半成品店铺"),
|
||||
any(),
|
||||
any(Duration.class)
|
||||
);
|
||||
verify(ziniaoMemoryStoreService).put(
|
||||
eq(ZiniaoMemoryStoreService.CACHE_TYPE_SHOP_INDEX_ENTRY),
|
||||
eq("s:allowed-shop"),
|
||||
any(ZiniaoShopIndexEntryDto.class),
|
||||
any(Duration.class)
|
||||
);
|
||||
verify(ziniaoMemoryStoreService, never()).listAliveEntitiesByType(any(), anyInt());
|
||||
verify(ziniaoApiKeyProvider).markIpWhitelistBlocked(
|
||||
eq(partiallyBlocked),
|
||||
eq("当前服务器 IP 未加入紫鸟白名单")
|
||||
);
|
||||
verify(ziniaoApiKeyProvider).markIpWhitelistAllowed(allowed);
|
||||
verify(ziniaoApiKeyProvider, never()).markIpWhitelistAllowed(partiallyBlocked);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonWhitelistCompanyFailureDoesNotOverwriteWhitelistStatus() {
|
||||
ZiniaoApiKeyProvider.ApiKeyAccount failed = new ZiniaoApiKeyProvider.ApiKeyAccount("failed-key", "failed-account");
|
||||
when(ziniaoApiKeyProvider.listApiKeyAccounts()).thenReturn(List.of(failed));
|
||||
when(ziniaoAuthService.resolveCompanyIdForIndex("failed-key"))
|
||||
.thenThrow(new BusinessException("紫鸟接口临时不可用"));
|
||||
when(ziniaoAuthService.isIpWhitelistError(any(BusinessException.class))).thenReturn(false);
|
||||
|
||||
service.refreshShopIndex();
|
||||
|
||||
verify(ziniaoApiKeyProvider, never()).markIpWhitelistBlocked(any(), any());
|
||||
verify(ziniaoApiKeyProvider, never()).markIpWhitelistAllowed(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void whitelistStatusWriteFailureDoesNotStopNextApiKey() {
|
||||
stubIpWhitelistDetection();
|
||||
ZiniaoApiKeyProvider.ApiKeyAccount blocked = new ZiniaoApiKeyProvider.ApiKeyAccount("blocked-key", "blocked-account");
|
||||
ZiniaoApiKeyProvider.ApiKeyAccount allowed = new ZiniaoApiKeyProvider.ApiKeyAccount("allowed-key", "allowed-account");
|
||||
when(ziniaoApiKeyProvider.listApiKeyAccounts()).thenReturn(List.of(blocked, allowed));
|
||||
when(ziniaoAuthService.resolveCompanyIdForIndex("blocked-key"))
|
||||
.thenThrow(new BusinessException("当前服务器 IP 未加入紫鸟白名单"));
|
||||
when(ziniaoAuthService.resolveCompanyIdForIndex("allowed-key")).thenReturn(2L);
|
||||
when(ziniaoAuthService.getOrLoadStaffForIndex("allowed-key", 2L)).thenReturn(List.of());
|
||||
doThrow(new IllegalStateException("数据库暂时不可用"))
|
||||
.when(ziniaoApiKeyProvider)
|
||||
.markIpWhitelistBlocked(blocked, "当前服务器 IP 未加入紫鸟白名单");
|
||||
|
||||
service.refreshShopIndex();
|
||||
|
||||
verify(ziniaoAuthService).resolveCompanyIdForIndex("allowed-key");
|
||||
verify(ziniaoApiKeyProvider).markIpWhitelistAllowed(allowed);
|
||||
}
|
||||
|
||||
@Test
|
||||
void fullRefreshIgnoresScheduledBatchLimit() {
|
||||
properties.setShopIndexRefreshBatchSize(1);
|
||||
ZiniaoApiKeyProvider.ApiKeyAccount first = new ZiniaoApiKeyProvider.ApiKeyAccount("first-key", "first-account");
|
||||
ZiniaoApiKeyProvider.ApiKeyAccount second = new ZiniaoApiKeyProvider.ApiKeyAccount("second-key", "second-account");
|
||||
when(ziniaoApiKeyProvider.listApiKeyAccounts()).thenReturn(List.of(first, second));
|
||||
when(ziniaoAuthService.resolveCompanyIdForIndex("first-key")).thenReturn(1L);
|
||||
when(ziniaoAuthService.resolveCompanyIdForIndex("second-key")).thenReturn(2L);
|
||||
when(ziniaoAuthService.getOrLoadStaffForIndex("first-key", 1L)).thenReturn(List.of());
|
||||
when(ziniaoAuthService.getOrLoadStaffForIndex("second-key", 2L)).thenReturn(List.of());
|
||||
|
||||
service.refreshAllShopIndex();
|
||||
|
||||
verify(ziniaoAuthService).resolveCompanyIdForIndex("first-key");
|
||||
verify(ziniaoAuthService).resolveCompanyIdForIndex("second-key");
|
||||
ZiniaoShopIndexRefreshCursorDto cursor = capturedCursor();
|
||||
assertEquals(Integer.valueOf(2), cursor.getLastProcessedApiKeyCount());
|
||||
assertEquals(Integer.valueOf(0), cursor.getNextApiKeyOffset());
|
||||
}
|
||||
|
||||
private ZiniaoShopIndexRefreshCursorDto capturedCursor() {
|
||||
ArgumentCaptor<Object> captor = ArgumentCaptor.forClass(Object.class);
|
||||
verify(ziniaoTransientCacheService, times(2)).put(
|
||||
eq("SHOP_INDEX_REFRESH_CURSOR"),
|
||||
eq("global"),
|
||||
captor.capture(),
|
||||
any(Duration.class)
|
||||
);
|
||||
return (ZiniaoShopIndexRefreshCursorDto) captor.getAllValues().getLast();
|
||||
}
|
||||
|
||||
private void stubIpWhitelistDetection() {
|
||||
when(ziniaoAuthService.isIpWhitelistError(any(BusinessException.class)))
|
||||
.thenAnswer(invocation -> invocation.<BusinessException>getArgument(0).getMessage().contains("白名单"));
|
||||
}
|
||||
|
||||
private ZiniaoStaffItemVo staff(long userId) {
|
||||
ZiniaoStaffItemVo staff = new ZiniaoStaffItemVo();
|
||||
staff.setUserId(userId);
|
||||
return staff;
|
||||
}
|
||||
|
||||
private ZiniaoShopCacheDto shop(String shopId, String shopName) {
|
||||
ZiniaoShopCacheDto shop = new ZiniaoShopCacheDto();
|
||||
shop.setShopId(shopId);
|
||||
shop.setShopName(shopName);
|
||||
shop.setPlatform("亚马逊");
|
||||
return shop;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user