新需求更新 同步更新

This commit is contained in:
supernijia
2026-08-06 01:11:54 +08:00
parent 9048bbb7f8
commit 28e7fce11c
112 changed files with 7739 additions and 637 deletions
@@ -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));
@@ -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));
}
}
@@ -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);
}
}
}
@@ -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;
}
}
@@ -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")))
@@ -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
@@ -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());
}
}
@@ -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());
}
}
@@ -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();
}
}
@@ -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));
}
}
@@ -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;
}
}
@@ -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();
}
}
@@ -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;
@@ -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;
}
}
@@ -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;
}
}
@@ -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();
}
}
@@ -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");
@@ -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;
}
}
@@ -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());
}
}
@@ -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));
}
}
@@ -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);
@@ -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));
@@ -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);
}
}
}
}
@@ -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;
}
}
@@ -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));
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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();
}
}
@@ -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;
}
}