diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/queryasin/service/QueryAsinResolveServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/queryasin/service/QueryAsinResolveServiceTest.java
new file mode 100644
index 00000000..ce20de68
--- /dev/null
+++ b/backend-java/src/test/java/com/nanri/aiimage/modules/queryasin/service/QueryAsinResolveServiceTest.java
@@ -0,0 +1,233 @@
+package com.nanri.aiimage.modules.queryasin.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.common.model.vo.ProductRiskCandidateVo;
+import com.nanri.aiimage.common.model.vo.ProductRiskMatchShopsVo;
+import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCandidateAddRequest;
+import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskMatchShopsRequest;
+import com.nanri.aiimage.modules.queryasin.mapper.QueryAsinShopCandidateMapper;
+import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCountryAsinsDto;
+import com.nanri.aiimage.modules.queryasin.model.entity.QueryAsinShopCandidateEntity;
+import com.nanri.aiimage.modules.shopkey.mapper.QueryAsinMapper;
+import com.nanri.aiimage.modules.shopkey.model.entity.QueryAsinEntity;
+import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoShopMatchResultVo;
+import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopIndexService;
+import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.transaction.PlatformTransactionManager;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+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.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+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;
+
+/**
+ * 查询 ASIN 工具的备选店铺/国家清单解析(2026-09 审查 G4:该模块此前零测试文件)。
+ *
+ *
重点覆盖"创建任务时会整体推给 Python 的国家 ASIN 清单"这一下游契约:
+ * 国家顺序固定(DE/UK/FR/IT/ES)、空国家不出现、同国家内去重。
+ */
+class QueryAsinResolveServiceTest {
+
+ @BeforeAll
+ static void initializeMybatisMetadata() {
+ MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
+ TableInfoHelper.initTableInfo(assistant, QueryAsinShopCandidateEntity.class);
+ TableInfoHelper.initTableInfo(assistant, QueryAsinEntity.class);
+ }
+
+ private final QueryAsinShopCandidateMapper candidateMapper = mock(QueryAsinShopCandidateMapper.class);
+ private final ZiniaoShopSwitchService shopSwitchService = mock(ZiniaoShopSwitchService.class);
+ private final QueryAsinMapper queryAsinMapper = mock(QueryAsinMapper.class);
+ private final PlatformTransactionManager transactionManager = mock(PlatformTransactionManager.class);
+
+ private QueryAsinResolveService service;
+
+ @BeforeEach
+ void setUp() {
+ service = new QueryAsinResolveService(candidateMapper, shopSwitchService, queryAsinMapper, transactionManager);
+ when(shopSwitchService.normalizeShopName(anyString())).thenAnswer(invocation ->
+ ((String) invocation.getArgument(0)).trim());
+ }
+
+ private static ProductRiskCandidateAddRequest addRequest(Long userId, String shopName) {
+ ProductRiskCandidateAddRequest request = new ProductRiskCandidateAddRequest();
+ request.setUserId(userId);
+ request.setShopName(shopName);
+ return request;
+ }
+
+ private static ZiniaoShopMatchResultVo matched(String shopName) {
+ ZiniaoShopMatchResultVo vo = new ZiniaoShopMatchResultVo();
+ vo.setMatched(true);
+ vo.setShopName(shopName);
+ vo.setMatchStatus(ZiniaoShopIndexService.MATCH_STATUS_MATCHED);
+ return vo;
+ }
+
+ @Test
+ void listCandidatesRejectsInvalidUser() {
+ assertThrows(BusinessException.class, () -> service.listCandidates(null));
+ assertThrows(BusinessException.class, () -> service.listCandidates(0L));
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void listCandidatesMapsRowsToViews() {
+ QueryAsinShopCandidateEntity row = new QueryAsinShopCandidateEntity();
+ row.setId(12L);
+ row.setUserId(23L);
+ row.setShopName("查询店铺");
+ row.setCreatedAt(LocalDateTime.now());
+ when(candidateMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(row));
+
+ List views = service.listCandidates(23L);
+
+ assertEquals(1, views.size());
+ assertEquals(12L, views.getFirst().getId());
+ assertEquals("查询店铺", views.getFirst().getShopName());
+ }
+
+ @Test
+ void addCandidateRejectsBlankShopName() {
+ when(shopSwitchService.normalizeShopName(" ")).thenReturn("");
+
+ assertThrows(BusinessException.class, () -> service.addCandidate(addRequest(23L, " ")));
+ verify(candidateMapper, never()).insert(any(QueryAsinShopCandidateEntity.class));
+ }
+
+ @Test
+ void addCandidateRejectsWhenIndexMisses() {
+ ZiniaoShopMatchResultVo miss = new ZiniaoShopMatchResultVo();
+ miss.setMatched(false);
+ when(shopSwitchService.findIndexedStoreByName("未收录", false)).thenReturn(miss);
+
+ assertThrows(BusinessException.class, () -> service.addCandidate(addRequest(23L, "未收录")));
+ verify(candidateMapper, never()).insert(any(QueryAsinShopCandidateEntity.class));
+ }
+
+ @Test
+ void addCandidateRejectsConflictStatus() {
+ ZiniaoShopMatchResultVo conflict = matched("同名店铺");
+ conflict.setMatchStatus(ZiniaoShopIndexService.MATCH_STATUS_CONFLICT);
+ when(shopSwitchService.findIndexedStoreByName("同名店铺", false)).thenReturn(conflict);
+
+ assertThrows(BusinessException.class, () -> service.addCandidate(addRequest(23L, "同名店铺")));
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void addCandidateIsIdempotentForExistingShop() {
+ when(shopSwitchService.findIndexedStoreByName("已存在", false)).thenReturn(matched("已存在"));
+ QueryAsinShopCandidateEntity existing = new QueryAsinShopCandidateEntity();
+ existing.setId(56L);
+ existing.setUserId(23L);
+ existing.setShopName("已存在");
+ existing.setCreatedAt(LocalDateTime.now());
+ when(candidateMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(existing);
+
+ ProductRiskCandidateVo vo = service.addCandidate(addRequest(23L, "已存在"));
+
+ assertEquals(56L, vo.getId());
+ verify(candidateMapper, never()).insert(any(QueryAsinShopCandidateEntity.class));
+ }
+
+ @Test
+ void deleteCandidateRejectsAnotherUsersRecordWithSameMessage() {
+ QueryAsinShopCandidateEntity other = new QueryAsinShopCandidateEntity();
+ other.setId(4L);
+ other.setUserId(88L);
+ when(candidateMapper.selectById(4L)).thenReturn(other);
+
+ BusinessException exception = assertThrows(BusinessException.class,
+ () -> service.deleteCandidate(23L, 4L));
+
+ assertEquals("记录不存在", exception.getMessage());
+ verify(candidateMapper, never()).deleteById(any(Long.class));
+ }
+
+ @Test
+ void loadQueryAsinsForShopRejectsInvalidUser() {
+ assertThrows(BusinessException.class, () -> service.loadQueryAsinsForShop(0L, "店铺"));
+ }
+
+ @Test
+ void loadQueryAsinsForShopReturnsEmptyForBlankShopName() {
+ assertTrue(service.loadQueryAsinsForShop(23L, " ").isEmpty());
+ verify(queryAsinMapper, never()).selectList(any());
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void loadQueryAsinsForShopGroupsByCountryInFixedOrderAndDeduplicates() {
+ QueryAsinEntity row = new QueryAsinEntity();
+ row.setId(1L);
+ row.setShopName("查询店铺");
+ row.setAsinDe("B01");
+ row.setAsinUk("B02");
+ row.setAsinEs("B03");
+ QueryAsinEntity duplicate = new QueryAsinEntity();
+ duplicate.setId(2L);
+ duplicate.setShopName("查询店铺");
+ duplicate.setAsinDe("B01");
+ duplicate.setAsinUk("B04");
+ when(queryAsinMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(row, duplicate));
+
+ List groups = service.loadQueryAsinsForShop(23L, "查询店铺");
+
+ assertEquals(3, groups.size(), "空国家不出现");
+ assertEquals(List.of("DE", "UK", "ES"), groups.stream().map(QueryAsinCountryAsinsDto::getCountry).toList(),
+ "国家顺序固定为 DE/UK/FR/IT/ES 的子序列");
+ assertEquals(List.of("B01"), groups.get(0).getAsins(), "同国家内去重");
+ assertEquals(List.of("B02", "B04"), groups.get(1).getAsins());
+ }
+
+ @Test
+ void matchShopsRejectsEmptyAfterNormalization() {
+ ProductRiskMatchShopsRequest request = new ProductRiskMatchShopsRequest();
+ request.setUserId(23L);
+ request.setShopNames(List.of(" ", ""));
+
+ assertThrows(BusinessException.class, () -> service.matchShops(request));
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void matchShopsDeduplicatesKeepingFirstOccurrenceOrder() {
+ ProductRiskMatchShopsRequest request = new ProductRiskMatchShopsRequest();
+ request.setUserId(23L);
+ request.setShopNames(List.of("A", " B ", "A"));
+ when(queryAsinMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of());
+ when(shopSwitchService.findIndexedStoreByName(anyString(), anyBoolean())).thenAnswer(invocation ->
+ matched((String) invocation.getArgument(0)));
+
+ ProductRiskMatchShopsVo vo = service.matchShops(request);
+
+ assertEquals(2, vo.getItems().size());
+ assertEquals("A", vo.getItems().get(0).getShopName());
+ assertEquals("B", vo.getItems().get(1).getShopName());
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void countCandidatesTreatsNullCountAsZero() {
+ when(candidateMapper.selectCount(any(LambdaQueryWrapper.class))).thenReturn(null);
+
+ assertEquals(0L, service.countCandidates(23L));
+ }
+}
diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/withdraw/service/WithdrawResolveServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/withdraw/service/WithdrawResolveServiceTest.java
new file mode 100644
index 00000000..789fae92
--- /dev/null
+++ b/backend-java/src/test/java/com/nanri/aiimage/modules/withdraw/service/WithdrawResolveServiceTest.java
@@ -0,0 +1,225 @@
+package com.nanri.aiimage.modules.withdraw.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.common.model.vo.ProductRiskCandidateVo;
+import com.nanri.aiimage.common.model.vo.ProductRiskMatchShopsVo;
+import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCandidateAddRequest;
+import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskMatchShopsRequest;
+import com.nanri.aiimage.modules.withdraw.mapper.WithdrawShopCandidateMapper;
+import com.nanri.aiimage.modules.withdraw.model.entity.WithdrawShopCandidateEntity;
+import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoShopMatchResultVo;
+import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopIndexService;
+import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.BeforeAll;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+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;
+
+/**
+ * 取款工具的备选店铺解析(2026-09 审查 G4:该模块此前零测试文件)。
+ *
+ * 覆盖要点:用户校验、索引未命中/同名冲突拒绝、重复添加幂等(不重复插入)、
+ * 他人记录不可删(与"不存在"同文案,避免探测)、按店铺名批量删的归一化与去重。
+ */
+class WithdrawResolveServiceTest {
+
+ @BeforeAll
+ static void initializeMybatisMetadata() {
+ TableInfoHelper.initTableInfo(
+ new MapperBuilderAssistant(new MybatisConfiguration(), ""),
+ WithdrawShopCandidateEntity.class);
+ }
+
+ private final WithdrawShopCandidateMapper candidateMapper = mock(WithdrawShopCandidateMapper.class);
+ private final ZiniaoShopSwitchService shopSwitchService = mock(ZiniaoShopSwitchService.class);
+
+ private WithdrawResolveService service;
+
+ @BeforeEach
+ void setUp() {
+ service = new WithdrawResolveService(candidateMapper, shopSwitchService);
+ when(shopSwitchService.normalizeShopName(anyString())).thenAnswer(invocation ->
+ ((String) invocation.getArgument(0)).trim());
+ }
+
+ private static ProductRiskCandidateAddRequest addRequest(Long userId, String shopName) {
+ ProductRiskCandidateAddRequest request = new ProductRiskCandidateAddRequest();
+ request.setUserId(userId);
+ request.setShopName(shopName);
+ return request;
+ }
+
+ private static ZiniaoShopMatchResultVo matched(String shopName) {
+ ZiniaoShopMatchResultVo vo = new ZiniaoShopMatchResultVo();
+ vo.setMatched(true);
+ vo.setShopName(shopName);
+ vo.setMatchStatus(ZiniaoShopIndexService.MATCH_STATUS_MATCHED);
+ return vo;
+ }
+
+ @Test
+ void listCandidatesRejectsInvalidUser() {
+ assertThrows(BusinessException.class, () -> service.listCandidates(null));
+ assertThrows(BusinessException.class, () -> service.listCandidates(0L));
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void listCandidatesMapsRowsToViews() {
+ WithdrawShopCandidateEntity row = new WithdrawShopCandidateEntity();
+ row.setId(9L);
+ row.setUserId(23L);
+ row.setShopName("取款店铺");
+ row.setCreatedAt(LocalDateTime.now());
+ when(candidateMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(row));
+
+ List views = service.listCandidates(23L);
+
+ assertEquals(1, views.size());
+ assertEquals(9L, views.getFirst().getId());
+ assertEquals("取款店铺", views.getFirst().getShopName());
+ }
+
+ @Test
+ void addCandidateRejectsBlankShopName() {
+ when(shopSwitchService.normalizeShopName(" ")).thenReturn("");
+
+ assertThrows(BusinessException.class, () -> service.addCandidate(addRequest(23L, " ")));
+ verify(candidateMapper, never()).insert(any(WithdrawShopCandidateEntity.class));
+ }
+
+ @Test
+ void addCandidateRejectsWhenIndexMissesWithoutMessage() {
+ ZiniaoShopMatchResultVo miss = new ZiniaoShopMatchResultVo();
+ miss.setMatched(false);
+ when(shopSwitchService.findIndexedStoreByName("未收录", false)).thenReturn(miss);
+
+ BusinessException exception = assertThrows(BusinessException.class,
+ () -> service.addCandidate(addRequest(23L, "未收录")));
+
+ assertEquals("店铺索引未命中,无法加入备选区", exception.getMessage(), "无提示时用默认文案");
+ }
+
+ @Test
+ void addCandidateRejectsConflictStatus() {
+ ZiniaoShopMatchResultVo conflict = matched("同名店铺");
+ conflict.setMatchStatus(ZiniaoShopIndexService.MATCH_STATUS_CONFLICT);
+ conflict.setMatchMessage("存在多个同名店铺,请人工确认");
+ when(shopSwitchService.findIndexedStoreByName("同名店铺", false)).thenReturn(conflict);
+
+ assertThrows(BusinessException.class, () -> service.addCandidate(addRequest(23L, "同名店铺")));
+ verify(candidateMapper, never()).insert(any(WithdrawShopCandidateEntity.class));
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void addCandidateIsIdempotentForExistingShop() {
+ when(shopSwitchService.findIndexedStoreByName("已存在", false)).thenReturn(matched("已存在"));
+ WithdrawShopCandidateEntity existing = new WithdrawShopCandidateEntity();
+ existing.setId(41L);
+ existing.setUserId(23L);
+ existing.setShopName("已存在");
+ existing.setCreatedAt(LocalDateTime.now());
+ when(candidateMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(existing);
+
+ ProductRiskCandidateVo vo = service.addCandidate(addRequest(23L, " 已存在 "));
+
+ assertEquals(41L, vo.getId());
+ verify(candidateMapper, never()).insert(any(WithdrawShopCandidateEntity.class));
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void addCandidateInsertsWhenAbsent() {
+ when(shopSwitchService.findIndexedStoreByName("新店铺", false)).thenReturn(matched("新店铺"));
+ when(candidateMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null);
+ when(candidateMapper.insert(any(WithdrawShopCandidateEntity.class))).thenAnswer(invocation -> {
+ WithdrawShopCandidateEntity entity = invocation.getArgument(0);
+ entity.setId(77L);
+ return 1;
+ });
+
+ ProductRiskCandidateVo vo = service.addCandidate(addRequest(23L, "新店铺"));
+
+ assertEquals(77L, vo.getId());
+ assertEquals("新店铺", vo.getShopName());
+ }
+
+ @Test
+ void deleteCandidateRejectsAnotherUsersRecordWithSameMessage() {
+ WithdrawShopCandidateEntity other = new WithdrawShopCandidateEntity();
+ other.setId(3L);
+ other.setUserId(99L);
+ when(candidateMapper.selectById(3L)).thenReturn(other);
+
+ BusinessException exception = assertThrows(BusinessException.class,
+ () -> service.deleteCandidate(23L, 3L));
+
+ assertEquals("记录不存在", exception.getMessage());
+ verify(candidateMapper, never()).deleteById(any(Long.class));
+ }
+
+ @Test
+ void deleteCandidatesByShopNamesSkipsBlankAndNoOpsWhenEmpty() {
+ service.deleteCandidatesByShopNames(23L, List.of(" ", ""));
+
+ verify(candidateMapper, never()).delete(any());
+ }
+
+ @Test
+ void deleteCandidatesByShopNamesNormalizesAndUsesInClause() {
+ when(candidateMapper.delete(any(LambdaQueryWrapper.class))).thenReturn(2);
+
+ service.deleteCandidatesByShopNames(23L, List.of(" A ", "A", "B"));
+
+ verify(candidateMapper).delete(any(LambdaQueryWrapper.class));
+ }
+
+ @Test
+ void matchShopsRejectsEmptyAfterNormalization() {
+ ProductRiskMatchShopsRequest request = new ProductRiskMatchShopsRequest();
+ request.setUserId(23L);
+ request.setShopNames(List.of(" "));
+
+ assertThrows(BusinessException.class, () -> service.matchShops(request));
+ }
+
+ @Test
+ void matchShopsDeduplicatesKeepingFirstOccurrenceOrder() {
+ ProductRiskMatchShopsRequest request = new ProductRiskMatchShopsRequest();
+ request.setUserId(23L);
+ request.setShopNames(List.of(" B ", "A", "B"));
+ when(shopSwitchService.findIndexedStoreByName(anyString(), anyBoolean())).thenAnswer(invocation ->
+ matched((String) invocation.getArgument(0)));
+
+ ProductRiskMatchShopsVo vo = service.matchShops(request);
+
+ assertEquals(2, vo.getItems().size());
+ assertEquals("B", vo.getItems().get(0).getShopName());
+ assertEquals("A", vo.getItems().get(1).getShopName());
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void countCandidatesTreatsNullCountAsZero() {
+ when(candidateMapper.selectCount(any(LambdaQueryWrapper.class))).thenReturn(null);
+
+ assertEquals(0L, service.countCandidates(23L));
+ }
+}