feat: 外观专利改直连 LLM(DeepSeek/Gemini)、无效ASIN菜单更名 BRAND_DB、紫鸟店铺索引场景化
Build Backend JAR / build (push) Has been cancelled
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:
+7
-7
@@ -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
|
||||
|
||||
+193
@@ -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 = "Apple,Apple";
|
||||
} 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("产品描述:普通数据线");
|
||||
}
|
||||
}
|
||||
+3
@@ -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;
|
||||
|
||||
+84
-2
@@ -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("白名单"));
|
||||
|
||||
Reference in New Issue
Block a user