feat: 外观专利改直连 LLM(DeepSeek/Gemini)、无效ASIN菜单更名 BRAND_DB、紫鸟店铺索引场景化
Build Backend JAR / build (push) Has been cancelled

- AppearancePatent:Coze 工作流切换为 llm-host 直连模型(title/appearance 双模型、行并发、重试),配置键迁移并保留旧环境变量兜底
- InvalidAsinDataMapper 补充按值+品牌唯一键查询
- ZiniaoShopIndexService 新增店铺分类/索引场景
- V98:无效ASIN菜单 rename 补充 BRAND_DB;docs 架构优化规划
- 配套单测更新
This commit is contained in:
2026-08-31 17:02:59 +08:00
parent 14a41e64f1
commit c8cd851b2f
20 changed files with 1291 additions and 2338 deletions
@@ -26,21 +26,21 @@ class AppearancePatentCozeClientTest {
);
@Test
void markRowsFailedLeavesUserFacingResultBlankWhenAsyncPollTimeout() {
void markRowsFailedLeavesUserFacingResultFilledWithReviewMessage() {
AppearancePatentResultRowDto row = new AppearancePatentResultRowDto();
row.setId("1");
List<AppearancePatentResultRowDto> failedRows =
client.markRowsFailed(List.of(row), "Coze \u5f02\u6b65\u5de5\u4f5c\u6d41\u8f6e\u8be2\u8d85\u65f6");
client.markRowsFailed(List.of(row), "LLM \u68c0\u6d4b\u5931\u8d25");
assertThat(failedRows).hasSize(1);
AppearancePatentResultRowDto failed = failedRows.get(0);
assertThat(failed.getError()).isEqualTo("Coze \u5f02\u6b65\u5de5\u4f5c\u6d41\u8f6e\u8be2\u8d85\u65f6");
assertThat(failed.getError()).isEqualTo("LLM \u68c0\u6d4b\u5931\u8d25");
assertThat(failed.getStatus()).isEqualTo("FAILED");
assertThat(failed.getTitleRisk()).isNull();
assertThat(failed.getAppearanceRisk()).isNull();
assertThat(failed.getPatentRisk()).isNull();
assertThat(failed.getConclusion()).isNull();
assertThat(failed.getTitleRisk()).isEqualTo("\u68c0\u6d4b\u5931\u8d25\uff1aLLM \u68c0\u6d4b\u5931\u8d25");
assertThat(failed.getAppearanceRisk()).isEqualTo("\u68c0\u6d4b\u5931\u8d25\uff1aLLM \u68c0\u6d4b\u5931\u8d25");
assertThat(failed.getPatentRisk()).isEqualTo("\u68c0\u6d4b\u5931\u8d25\uff1aLLM \u68c0\u6d4b\u5931\u8d25");
assertThat(failed.getConclusion()).isEqualTo("LLM \u68c0\u6d4b\u5931\u8d25");
}
@Test
@@ -0,0 +1,193 @@
package com.nanri.aiimage.modules.appearancepatent.client;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.config.AppearancePatentProperties;
import com.nanri.aiimage.config.BrandCheckProperties;
import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentResultRowDto;
import com.nanri.aiimage.modules.brand.client.BrandCheckClient;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
class AppearancePatentLlmClientHttpTest {
private HttpServer server;
private final ObjectMapper objectMapper = new ObjectMapper();
private final Map<String, AtomicInteger> callCounts = new ConcurrentHashMap<>();
private final List<String> capturedBodies = new ArrayList<>();
private AppearancePatentCozeClient client;
@BeforeEach
void setUp() throws IOException {
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/v1/chat/completions", this::handleChat);
server.start();
AppearancePatentProperties properties = new AppearancePatentProperties();
properties.setLlmHost("http://127.0.0.1:" + server.getAddress().getPort());
properties.setLlmRetryTimes(3);
client = new AppearancePatentCozeClient(
properties,
objectMapper,
null,
new BrandCheckClient(new BrandCheckProperties(), null)
);
}
@AfterEach
void tearDown() {
server.stop(0);
}
private void handleChat(HttpExchange exchange) throws IOException {
String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
capturedBodies.add(body);
try {
@SuppressWarnings("unchecked")
Map<String, Object> request = objectMapper.readValue(body, Map.class);
String model = String.valueOf(request.get("model"));
callCounts.computeIfAbsent(model, ignored -> new AtomicInteger()).incrementAndGet();
String content;
if (model.contains("deepseek")) {
// 商标提取:按标题内容返回品牌词或"无"
String messages = String.valueOf(request.get("messages"));
if (messages.contains("Apple")) {
content = "AppleApple";
} else {
content = "";
}
} else {
// 外观检测:返回 JSON(带 ```json 包裹与换行,模拟脏输出)
if (messagesBodyContains(exchange, "原创个性杯")) {
content = "```json\n{\"appearance_status\": \"侵权\", \"appearance_reason\": \"【视觉拆解】:特殊造型\\n【判定依据】:高度相似知名设计\"}\n```";
} else {
content = "{\"appearance_status\": \"无侵权\", \"appearance_reason\": \"【视觉拆解】:普通直筒杯。\\n【对比评估】:行业通用基础形状。\\n【判定依据】:无侵权。\"}";
}
}
String response = objectMapper.writeValueAsString(Map.of(
"model", model,
"choices", List.of(Map.of("message", Map.of("content", content)))
));
byte[] bytes = response.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
exchange.sendResponseHeaders(200, bytes.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(bytes);
}
} catch (Exception ex) {
byte[] bytes = ("{\"error\":{\"message\":\"" + ex.getMessage() + "\"}}").getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(500, bytes.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(bytes);
}
}
}
private boolean messagesBodyContains(HttpExchange exchange, String text) {
return capturedBodies.stream().anyMatch(b -> b.contains(text));
}
private AppearancePatentResultRowDto row(String id, String asin, String title, String sku, String url) {
AppearancePatentResultRowDto row = new AppearancePatentResultRowDto();
row.setId(id);
row.setAsin(asin);
row.setTitle(title);
row.setSku(sku);
row.setUrl(url);
return row;
}
@Test
void inspectRowRunsBothModelsAndParsesJsonAppearance() {
AppearancePatentResultRowDto row = row("1", "B001", "Apple Magic Case", "AC-1", "https://img.example.com/1.jpg");
List<AppearancePatentResultRowDto> rows = client.inspectRows(List.of(row), null, "test-key");
assertThat(rows).hasSize(1);
AppearancePatentResultRowDto result = rows.get(0);
assertThat(result.getAsin()).isEqualTo("B001");
assertThat(result.getAppearanceRisk()).isEqualTo("无侵权");
assertThat(result.getAppearanceReason()).contains("视觉拆解");
assertThat(result.getTitleReason()).contains("Apple");
assertThat(callCounts.get("deepseek-v4-flash")).hasValue(1);
assertThat(callCounts.get("gemini-3.7-flash")).hasValue(1);
}
@Test
void appearanceJsonWithCodeFenceAndEscapedNewlineIsUnwrapped() {
AppearancePatentResultRowDto row = row("2", "B002", "原创个性杯", "CUP-9", "https://img.example.com/2.jpg");
List<AppearancePatentResultRowDto> rows = client.inspectRows(List.of(row), null, "test-key");
AppearancePatentResultRowDto result = rows.get(0);
assertThat(result.getAppearanceRisk()).isEqualTo("侵权");
assertThat(result.getAppearanceReason()).contains("高度相似知名设计");
assertThat(result.getConclusion()).isEqualTo("侵权");
}
@Test
void titleNoneSkipsBrandCheckAndMarksNoInfringement() {
AppearancePatentResultRowDto row = row("3", "B003", "普通收纳盒", "", "https://img.example.com/3.jpg");
List<AppearancePatentResultRowDto> rows = client.inspectRows(List.of(row), null, "test-key");
AppearancePatentResultRowDto result = rows.get(0);
assertThat(result.getTitleRisk()).isEqualTo("无侵权");
assertThat(result.getAppearanceRisk()).isEqualTo("无侵权");
assertThat(result.getConclusion()).isEqualTo("无侵权");
}
@Test
void missingUrlFallsBackToAppearanceAnomalyWithoutLlmCall() {
AppearancePatentResultRowDto row = row("4", "B004", "测试商品", "", "");
List<AppearancePatentResultRowDto> rows = client.inspectRows(List.of(row), null, "test-key");
AppearancePatentResultRowDto result = rows.get(0);
assertThat(result.getAppearanceRisk()).isEqualTo("外观识别异常");
assertThat(callCounts.get("deepseek-v4-flash")).isNull();
assertThat(callCounts.get("gemini-3.7-flash")).isNull();
}
@Test
void missingApiKeyKeepsRowsUntouched() {
AppearancePatentResultRowDto row = row("5", "B005", "测试", "", "https://img.example.com/5.jpg");
List<AppearancePatentResultRowDto> rows = client.inspectRows(List.of(row), null, "");
assertThat(rows).hasSize(1);
assertThat(rows.get(0).getAppearanceRisk()).isNull();
assertThat(callCounts).isEmpty();
}
@Test
void appearanceRequestCarriesImageUrlAndJsonResponseFormat() {
row("6", "B006", "普通数据线", "", "https://img.example.com/6.jpg");
client.inspectRows(List.of(row("6", "B006", "普通数据线", "", "https://img.example.com/6.jpg")), null, "test-key");
String appearanceBody = capturedBodies.stream()
.filter(b -> b.contains("gemini-3.7-flash"))
.findFirst()
.orElseThrow();
assertThat(appearanceBody).contains("https://img.example.com/6.jpg");
assertThat(appearanceBody).contains("\"type\":\"image_url\"");
assertThat(appearanceBody).contains("\"type\":\"json_object\"");
assertThat(appearanceBody).contains("产品描述:普通数据线");
}
}
@@ -2,6 +2,7 @@ package com.nanri.aiimage.modules.shopkey.service;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.common.security.ShopCredentialCryptoService;
import com.nanri.aiimage.modules.shopkey.mapper.ShopCredentialCheckMapper;
import com.nanri.aiimage.modules.shopkey.mapper.ShopManageMapper;
import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageEntity;
import org.junit.jupiter.api.Test;
@@ -25,6 +26,8 @@ class ShopManageServiceTest {
private ShopManageGroupService shopManageGroupService;
@Mock
private ShopCredentialCryptoService shopCredentialCryptoService;
@Mock
private ShopCredentialCheckMapper shopCredentialCheckMapper;
@InjectMocks
private ShopManageService service;
@@ -17,6 +17,8 @@ import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
@@ -82,6 +84,8 @@ class ZiniaoShopIndexServiceTest {
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")));
when(ziniaoMemoryStoreService.listAliveEntitiesByType(
ZiniaoMemoryStoreService.CACHE_TYPE_SHOP_INDEX_ENTRY, 10000)).thenReturn(List.of());
service.refreshShopIndex();
@@ -98,7 +102,6 @@ class ZiniaoShopIndexServiceTest {
any(ZiniaoShopIndexEntryDto.class),
any(Duration.class)
);
verify(ziniaoMemoryStoreService, never()).listAliveEntitiesByType(any(), anyInt());
verify(ziniaoApiKeyProvider).markIpWhitelistBlocked(
eq(blocked),
eq("当前服务器 IP 未加入紫鸟白名单")
@@ -149,7 +152,6 @@ class ZiniaoShopIndexServiceTest {
any(ZiniaoShopIndexEntryDto.class),
any(Duration.class)
);
verify(ziniaoMemoryStoreService, never()).listAliveEntitiesByType(any(), anyInt());
verify(ziniaoApiKeyProvider).markIpWhitelistBlocked(
eq(partiallyBlocked),
eq("当前服务器 IP 未加入紫鸟白名单")
@@ -158,6 +160,76 @@ class ZiniaoShopIndexServiceTest {
verify(ziniaoApiKeyProvider, never()).markIpWhitelistAllowed(partiallyBlocked);
}
@Test
void whitelistBlockedApiKeyMarksItsActiveIndexRowsAsBypassEligible() throws Exception {
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")));
ZiniaoShopIndexEntryDto blockedDto = new ZiniaoShopIndexEntryDto();
blockedDto.setNormalizedShopName("blocked-shop");
blockedDto.setStatus("ACTIVE");
blockedDto.setApiKeyHash(sha256("blocked-key"));
blockedDto.setLastRefreshedAt(1000L);
ZiniaoMemoryStoreEntity blockedRow = new ZiniaoMemoryStoreEntity();
blockedRow.setId(1L);
blockedRow.setCacheType("SHOP_INDEX_ENTRY");
blockedRow.setCacheKey("s:blocked-shop");
blockedRow.setPayloadJson(new ObjectMapper().writeValueAsString(blockedDto));
blockedRow.setExpiresAt(LocalDateTime.now().plusHours(12));
ZiniaoShopIndexEntryDto allowedDto = new ZiniaoShopIndexEntryDto();
allowedDto.setNormalizedShopName("allowed-shop");
allowedDto.setStatus("ACTIVE");
allowedDto.setApiKeyHash(sha256("allowed-key"));
allowedDto.setLastRefreshedAt(1000L);
ZiniaoMemoryStoreEntity allowedRow = new ZiniaoMemoryStoreEntity();
allowedRow.setId(2L);
allowedRow.setCacheType("SHOP_INDEX_ENTRY");
allowedRow.setCacheKey("s:allowed-shop");
allowedRow.setPayloadJson(new ObjectMapper().writeValueAsString(allowedDto));
allowedRow.setExpiresAt(LocalDateTime.now().plusHours(12));
when(ziniaoMemoryStoreService.listAliveEntitiesByType("SHOP_INDEX_ENTRY", 10000))
.thenReturn(List.of(blockedRow, allowedRow));
service.refreshShopIndex();
ArgumentCaptor<List<ZiniaoMemoryStoreEntity>> captor = ArgumentCaptor.forClass(List.class);
verify(ziniaoMemoryStoreService).updateStaleMarks(captor.capture());
assertEquals(1, captor.getValue().size());
ZiniaoMemoryStoreEntity updated = captor.getValue().get(0);
assertEquals(1L, updated.getId());
var payload = new ObjectMapper().readTree(updated.getPayloadJson());
assertEquals("IP_WHITELIST", payload.get("refreshBlockedReason").asText());
assertEquals("ACTIVE", payload.get("status").asText());
assertTrue(payload.get("lastRefreshBlockedAt").asLong() >= 1000L,
"lastRefreshBlockedAt 应不小于行内 lastRefreshedAt,否则查询侧豁免条件不成立");
}
@Test
void laterWhitelistClearRemovesBlockedMarksFromIndexRows() throws Exception {
ZiniaoApiKeyProvider.ApiKeyAccount key = new ZiniaoApiKeyProvider.ApiKeyAccount("key", "acct");
when(ziniaoApiKeyProvider.listApiKeyAccounts()).thenReturn(List.of(key));
when(ziniaoAuthService.resolveCompanyIdForIndex("key")).thenReturn(1L);
when(ziniaoAuthService.getOrLoadStaffForIndex("key", 1L)).thenReturn(List.of(staff(11L)));
when(ziniaoAuthService.getOrLoadUserStoresForIndex("key", 1L, 11L))
.thenReturn(List.of(shop("s-1", "shop-1")));
when(ziniaoMemoryStoreService.listAliveEntitiesByType("SHOP_INDEX_ENTRY", 10000)).thenReturn(List.of());
service.refreshShopIndex();
verify(ziniaoMemoryStoreService, never()).updateStaleMarks(anyList());
}
@Test
void nonWhitelistCompanyFailureDoesNotOverwriteWhitelistStatus() {
ZiniaoApiKeyProvider.ApiKeyAccount failed = new ZiniaoApiKeyProvider.ApiKeyAccount("failed-key", "failed-account");
@@ -327,6 +399,16 @@ class ZiniaoShopIndexServiceTest {
return (ZiniaoShopIndexRefreshCursorDto) captor.getAllValues().getLast();
}
private String sha256(String value) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(value.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : hash) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
private void stubIpWhitelistDetection() {
when(ziniaoAuthService.isIpWhitelistError(any(BusinessException.class)))
.thenAnswer(invocation -> invocation.<BusinessException>getArgument(0).getMessage().contains("白名单"));