新需求更新 同步更新

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
@@ -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;
}
}