feat: SimilarASIN 直连 LLM 通道(Gemini/DeepSeek 多模型)替代 Coze 工作流
- SimilarAsinLlmClient/Service:分类、符合性、图片比对三阶段直连 LLM - PuzzleImageMerger:拼图合并用于图片比对 - direct-llm-enabled 开关 + llm-host/api-key/模型/超时/并发配置(默认开) - LlmGatewayTlsProbe、SimilarAsinLlmLocalVerify 为本地验证工具
This commit is contained in:
+85
@@ -0,0 +1,85 @@
|
||||
package com.nanri.aiimage.modules.similarasin.service;
|
||||
|
||||
import javax.net.ssl.SNIHostName;
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.SSLParameters;
|
||||
import javax.net.ssl.SSLSocket;
|
||||
import javax.net.ssl.SSLSocketFactory;
|
||||
import java.net.InetAddress;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
public class LlmGatewayTlsProbe {
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
String host = "ai.t8star.org";
|
||||
String apiKey = args[0];
|
||||
|
||||
System.out.println("[probe] java=" + System.getProperty("java.version")
|
||||
+ " tls=" + System.getProperty("java.vm.name"));
|
||||
for (InetAddress a : InetAddress.getAllByName(host)) {
|
||||
System.out.println("[probe] dns " + a);
|
||||
}
|
||||
|
||||
rawHandshake(host, null, "default");
|
||||
rawHandshake(host, "TLSv1.2", "tls12-only");
|
||||
|
||||
httpClientCall(host, apiKey, null, "jdk-http-default");
|
||||
httpClientCall(host, apiKey, "TLSv1.2", "jdk-http-tls12");
|
||||
}
|
||||
|
||||
private static void rawHandshake(String host, String protocol, String label) throws Exception {
|
||||
SSLSocketFactory factory = (SSLSocketFactory) SSLSocketFactory.getDefault();
|
||||
try (SSLSocket socket = (SSLSocket) factory.createSocket(host, 443)) {
|
||||
socket.setSoTimeout(15000);
|
||||
SSLParameters params = socket.getSSLParameters();
|
||||
if (protocol != null) {
|
||||
params.setProtocols(new String[]{protocol});
|
||||
}
|
||||
params.setServerNames(List.of(new SNIHostName(host)));
|
||||
socket.setSSLParameters(params);
|
||||
socket.startHandshake();
|
||||
System.out.println("[probe] raw[" + label + "] OK proto=" + socket.getSession().getProtocol()
|
||||
+ " cipher=" + socket.getSession().getCipherSuite());
|
||||
} catch (Exception ex) {
|
||||
System.out.println("[probe] raw[" + label + "] FAIL " + ex.getClass().getSimpleName() + ": " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static void httpClientCall(String host, String apiKey, String protocol, String label) throws Exception {
|
||||
HttpClient.Builder builder = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
.version(HttpClient.Version.HTTP_1_1);
|
||||
if (protocol != null) {
|
||||
SSLContext context = SSLContext.getInstance("TLS");
|
||||
context.init(null, null, null);
|
||||
builder.sslContext(context);
|
||||
}
|
||||
HttpClient client = builder.build();
|
||||
try {
|
||||
String body = "{\"model\":\"gemini-3.5-flash-lite\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}],\"max_tokens\":10}";
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create("https://" + host + "/v1/chat/completions"))
|
||||
.timeout(Duration.ofSeconds(30))
|
||||
.header("Authorization", "Bearer " + apiKey)
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||
.build();
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
String text = response.body();
|
||||
System.out.println("[probe] http[" + label + "] status=" + response.statusCode()
|
||||
+ " body=" + text.substring(0, Math.min(160, text.length())));
|
||||
} catch (Exception ex) {
|
||||
System.out.println("[probe] http[" + label + "] FAIL " + ex.getClass().getSimpleName() + ": " + ex.getMessage());
|
||||
Throwable cause = ex;
|
||||
while (cause.getCause() != null) {
|
||||
cause = cause.getCause();
|
||||
System.out.println("[probe] cause " + cause.getClass().getSimpleName() + ": " + cause.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
package com.nanri.aiimage.modules.similarasin.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.config.OssProperties;
|
||||
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
|
||||
import com.nanri.aiimage.modules.productcategory.mapper.ProductCategoryMapper;
|
||||
import com.nanri.aiimage.modules.productcategory.service.ProductCategoryService;
|
||||
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinLlmClient;
|
||||
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
|
||||
import com.nanri.aiimage.modules.similarasin.util.PuzzleImageMerger;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 本地验证入口:用生产真实批次数据 + 生产 LLM 网关跑 SimilarAsinLlmService 完整链路。
|
||||
* 用法:mvn compile test-compile 后执行
|
||||
* java -cp target/classes;target/test-classes;$(cat cp.txt) com.nanri.aiimage.modules.similarasin.service.SimilarAsinLlmLocalVerify <data.json> <apiKey> [imgSwitch] [categorySwitch]
|
||||
*/
|
||||
public class SimilarAsinLlmLocalVerify {
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
System.setOut(new java.io.PrintStream(new java.io.FileOutputStream(java.io.FileDescriptor.out), true, "UTF-8"));
|
||||
System.setErr(new java.io.PrintStream(new java.io.FileOutputStream(java.io.FileDescriptor.err), true, "UTF-8"));
|
||||
if (args.length < 2) {
|
||||
System.err.println("usage: SimilarAsinLlmLocalVerify <data.json> <apiKey> [imgSwitch] [categorySwitch]");
|
||||
System.exit(1);
|
||||
}
|
||||
String dataFile = args[0];
|
||||
String apiKey = args[1];
|
||||
boolean imgSwitch = args.length > 2 && Boolean.parseBoolean(args[2]);
|
||||
boolean categorySwitch = args.length > 3 && Boolean.parseBoolean(args[3]);
|
||||
|
||||
ObjectMapper objectMapper = new ObjectMapper();
|
||||
objectMapper.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||
JsonNode root = objectMapper.readTree(new File(dataFile));
|
||||
List<SimilarAsinResultRowDto> rows = new ArrayList<>();
|
||||
if (root.isArray()) {
|
||||
for (JsonNode node : root) {
|
||||
rows.add(fromJson(objectMapper, node));
|
||||
}
|
||||
} else {
|
||||
rows.add(fromJson(objectMapper, root));
|
||||
}
|
||||
System.out.println("[verify] loaded rows=" + rows.size() + " imgSwitch=" + imgSwitch
|
||||
+ " categorySwitch=" + categorySwitch);
|
||||
if (imgSwitch && categorySwitch) {
|
||||
System.out.println("[verify] raw first row alibaba[0].url="
|
||||
+ (rows.isEmpty() || rows.get(0).getAlibaba().isEmpty() ? "null"
|
||||
: rows.get(0).getAlibaba().get(0).getUrl()));
|
||||
}
|
||||
|
||||
SimilarAsinProperties props = new SimilarAsinProperties();
|
||||
props.setLlmApiKey(apiKey);
|
||||
props.setLlmRowConcurrency(2);
|
||||
props.setLlmImageDownloadTimeoutSeconds(10);
|
||||
|
||||
SimilarAsinLlmClient client = new SimilarAsinLlmClient(props, objectMapper, null);
|
||||
OssProperties ossProps = new OssProperties();
|
||||
ossProps.setEndpoint("https://oss.aishufu.top");
|
||||
ossProps.setPublicEndpoint("https://oss.aishufu.top");
|
||||
ossProps.setBucket("nanri-ai-images");
|
||||
ossProps.setAccessKeyId("appuser");
|
||||
ossProps.setAccessKeySecret("AppUser@2024SecureKey");
|
||||
OssStorageService oss = new OssStorageService(ossProps);
|
||||
PuzzleImageMerger merger = new PuzzleImageMerger(props);
|
||||
|
||||
// 生产真实类目数据(导出自 biz_product_category),spy 类目服务按 parentId 过滤。
|
||||
List<com.nanri.aiimage.modules.productcategory.model.entity.ProductCategoryEntity> categories = loadCategories(objectMapper);
|
||||
ProductCategoryService categoryService = Mockito.spy(new ProductCategoryService(
|
||||
Mockito.mock(com.nanri.aiimage.modules.productcategory.mapper.ProductCategoryMapper.class)));
|
||||
Mockito.doAnswer(invocation -> {
|
||||
Long parentId = invocation.getArgument(0);
|
||||
List<com.nanri.aiimage.modules.productcategory.model.vo.ProductCategoryItemVo> items = categories.stream()
|
||||
.filter(c -> parentId == null ? c.getParentId() == null : parentId.equals(c.getParentId()))
|
||||
.map(c -> {
|
||||
com.nanri.aiimage.modules.productcategory.model.vo.ProductCategoryItemVo item =
|
||||
new com.nanri.aiimage.modules.productcategory.model.vo.ProductCategoryItemVo();
|
||||
item.setId(c.getId());
|
||||
item.setParentId(c.getParentId());
|
||||
item.setName(c.getName());
|
||||
item.setCategoryKey(c.getCategoryKey());
|
||||
return item;
|
||||
})
|
||||
.toList();
|
||||
com.nanri.aiimage.modules.productcategory.model.vo.ProductCategoryListVo vo =
|
||||
new com.nanri.aiimage.modules.productcategory.model.vo.ProductCategoryListVo();
|
||||
vo.setItems(items);
|
||||
vo.setTree(List.of());
|
||||
vo.setTotal((long) items.size());
|
||||
vo.setPage(1L);
|
||||
vo.setPageSize((long) Math.max(1, items.size()));
|
||||
vo.setHasMore(false);
|
||||
return vo;
|
||||
}).when(categoryService).children(Mockito.any(), Mockito.anyLong(), Mockito.anyLong());
|
||||
|
||||
SimilarAsinLlmService service = new SimilarAsinLlmService(
|
||||
client, props, ossProps, categoryService, merger, oss);
|
||||
|
||||
long start = System.currentTimeMillis();
|
||||
List<SimilarAsinResultRowDto> result = service.inspectRows(rows, null, apiKey, imgSwitch, categorySwitch);
|
||||
long elapsed = System.currentTimeMillis() - start;
|
||||
System.out.println("[verify] done rows=" + result.size() + " elapsedMs=" + elapsed);
|
||||
for (SimilarAsinResultRowDto row : result) {
|
||||
System.out.println(String.format(
|
||||
"asin=%s | status=%s | isConform=%s | category=%s | reason=%s | isStock=%s | similarity=%s | mainUrl=%s | puzzle1=%s | puzzle2=%s",
|
||||
row.getAsin(), row.getStatus(), row.getIsConform(), row.getCategory(),
|
||||
row.getReason(), row.getIsStock(), row.getSimilarity(),
|
||||
shorten(row.getMainUrl()), shorten(row.getPuzzleImg1()), shorten(row.getPuzzleImg2())));
|
||||
}
|
||||
}
|
||||
|
||||
private static List<com.nanri.aiimage.modules.productcategory.model.entity.ProductCategoryEntity> loadCategories(ObjectMapper objectMapper) throws Exception {
|
||||
com.fasterxml.jackson.databind.JsonNode root = objectMapper.readTree(
|
||||
SimilarAsinLlmLocalVerify.class.getResourceAsStream("/categories.json"));
|
||||
List<com.nanri.aiimage.modules.productcategory.model.entity.ProductCategoryEntity> list = new ArrayList<>();
|
||||
for (com.fasterxml.jackson.databind.JsonNode node : root) {
|
||||
com.nanri.aiimage.modules.productcategory.model.entity.ProductCategoryEntity entity =
|
||||
new com.nanri.aiimage.modules.productcategory.model.entity.ProductCategoryEntity();
|
||||
entity.setId(node.get("id").asLong());
|
||||
if (!node.get("parent_id").isNull()) {
|
||||
entity.setParentId(node.get("parent_id").asLong());
|
||||
}
|
||||
entity.setName(node.get("name").asText());
|
||||
entity.setCategoryKey(node.get("category_key").asText());
|
||||
entity.setSortOrder(node.get("sort_order").isNull() ? null : node.get("sort_order").asInt());
|
||||
entity.setDescription(node.get("description").isNull() ? null : node.get("description").asText());
|
||||
entity.setIsBuiltin(node.get("is_builtin") != null && node.get("is_builtin").asBoolean());
|
||||
list.add(entity);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private static SimilarAsinResultRowDto fromJson(ObjectMapper objectMapper, JsonNode node) throws Exception {
|
||||
SimilarAsinResultRowDto row = new SimilarAsinResultRowDto();
|
||||
row.setAsin(text(node, "asin"));
|
||||
row.setTitle(text(node, "title"));
|
||||
row.setSku(text(node, "sku"));
|
||||
row.setCountry(text(node, "country"));
|
||||
row.setUrl(text(node, "url"));
|
||||
JsonNode alibaba = node.get("alibaba");
|
||||
if (alibaba != null && alibaba.isArray()) {
|
||||
List<SimilarAsinResultRowDto.AlibabaItem> items = new ArrayList<>();
|
||||
for (JsonNode item : alibaba) {
|
||||
items.add(objectMapper.treeToValue(item, SimilarAsinResultRowDto.AlibabaItem.class));
|
||||
}
|
||||
row.setAlibaba(items);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private static String text(JsonNode node, String field) {
|
||||
JsonNode value = node.get(field);
|
||||
return value == null || value.isNull() ? null : value.asText();
|
||||
}
|
||||
|
||||
private static String shorten(String value) {
|
||||
if (value == null) {
|
||||
return "null";
|
||||
}
|
||||
return value.length() <= 70 ? value : value.substring(0, 70) + "...";
|
||||
}
|
||||
}
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
package com.nanri.aiimage.modules.similarasin.service;
|
||||
|
||||
import com.nanri.aiimage.config.OssProperties;
|
||||
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
|
||||
import com.nanri.aiimage.modules.productcategory.model.vo.ProductCategoryItemVo;
|
||||
import com.nanri.aiimage.modules.productcategory.model.vo.ProductCategoryListVo;
|
||||
import com.nanri.aiimage.modules.productcategory.service.ProductCategoryService;
|
||||
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinLlmClient;
|
||||
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
|
||||
import com.nanri.aiimage.modules.similarasin.util.PuzzleImageMerger;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import org.mockito.Mockito;
|
||||
|
||||
class SimilarAsinLlmServiceTest {
|
||||
|
||||
private static byte[] jpegBytes() {
|
||||
try {
|
||||
BufferedImage image = new BufferedImage(10, 10, BufferedImage.TYPE_INT_RGB);
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
ImageIO.write(image, "jpg", baos);
|
||||
return baos.toByteArray();
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private SimilarAsinProperties properties() {
|
||||
SimilarAsinProperties props = new SimilarAsinProperties();
|
||||
props.setLlmApiKey("test-key");
|
||||
return props;
|
||||
}
|
||||
|
||||
private SimilarAsinLlmService service(SimilarAsinLlmClient llmClient) {
|
||||
OssStorageService ossStorage = mock(OssStorageService.class);
|
||||
when(ossStorage.getPublicUrl(anyString())).thenAnswer(invocation -> "https://oss.aishufu.top/nanri-ai-images/" + invocation.getArgument(0));
|
||||
PuzzleImageMerger merger = mock(PuzzleImageMerger.class);
|
||||
when(merger.merge(anyList(), Mockito.<SimilarAsinResultRowDto>any())).thenReturn(jpegBytes());
|
||||
ProductCategoryService categoryService = mock(ProductCategoryService.class);
|
||||
when(categoryService.children(any(), anyLong(), anyLong()))
|
||||
.thenAnswer(invocation -> {
|
||||
Long parentId = invocation.getArgument(0);
|
||||
if (parentId == null) {
|
||||
return categoryPage("类目A", 1L);
|
||||
}
|
||||
if (parentId == 1L) {
|
||||
return categoryPage("类目B", 2L);
|
||||
}
|
||||
if (parentId == 2L) {
|
||||
return categoryPage("类目C", 3L);
|
||||
}
|
||||
return emptyCategoryPage();
|
||||
});
|
||||
SimilarAsinLlmService svc = new SimilarAsinLlmService(
|
||||
llmClient,
|
||||
properties(),
|
||||
mock(OssProperties.class),
|
||||
categoryService,
|
||||
merger,
|
||||
ossStorage);
|
||||
svc.setDownloadHttpClientForTest(mockHttpClient());
|
||||
return svc;
|
||||
}
|
||||
|
||||
private static ProductCategoryListVo categoryPage(String name, long id) {
|
||||
ProductCategoryItemVo item = new ProductCategoryItemVo();
|
||||
item.setId(id);
|
||||
item.setName(name);
|
||||
ProductCategoryListVo vo = new ProductCategoryListVo();
|
||||
vo.setItems(List.of(item));
|
||||
vo.setTotal(1L);
|
||||
vo.setPage(1L);
|
||||
vo.setPageSize(1L);
|
||||
vo.setHasMore(false);
|
||||
return vo;
|
||||
}
|
||||
|
||||
private static ProductCategoryListVo emptyCategoryPage() {
|
||||
ProductCategoryListVo vo = new ProductCategoryListVo();
|
||||
vo.setItems(List.of());
|
||||
vo.setTotal(0L);
|
||||
vo.setPage(1L);
|
||||
vo.setPageSize(1L);
|
||||
vo.setHasMore(false);
|
||||
return vo;
|
||||
}
|
||||
|
||||
private static HttpClient mockHttpClient() {
|
||||
HttpClient client = mock(HttpClient.class);
|
||||
try {
|
||||
when(client.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)))
|
||||
.thenAnswer(invocation -> {
|
||||
byte[] body = jpegBytes();
|
||||
HttpRequest request = invocation.getArgument(0);
|
||||
return new TestHttpResponse(200, body, request);
|
||||
});
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException(ex);
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
private static final class TestHttpResponse implements HttpResponse<byte[]> {
|
||||
private final int statusCode;
|
||||
private final byte[] body;
|
||||
private final HttpRequest request;
|
||||
|
||||
TestHttpResponse(int statusCode, byte[] body, HttpRequest request) {
|
||||
this.statusCode = statusCode;
|
||||
this.body = body;
|
||||
this.request = request;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int statusCode() {
|
||||
return statusCode;
|
||||
}
|
||||
|
||||
@Override
|
||||
public HttpRequest request() {
|
||||
return request;
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.Optional<HttpResponse<byte[]>> previousResponse() {
|
||||
return java.util.Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.net.http.HttpHeaders headers() {
|
||||
return java.net.http.HttpHeaders.of(Map.of(), (a, b) -> true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] body() {
|
||||
return body;
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.net.URI uri() {
|
||||
return java.net.URI.create("http://test");
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.net.http.HttpClient.Version version() {
|
||||
return HttpClient.Version.HTTP_1_1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.Optional<javax.net.ssl.SSLSession> sslSession() {
|
||||
return java.util.Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private SimilarAsinLlmClient llmClient(String apiKey, Map<String, String> responses) {
|
||||
SimilarAsinLlmClient client = mock(SimilarAsinLlmClient.class);
|
||||
when(client.resolveApiKey(anyString())).thenReturn(apiKey == null ? "" : apiKey);
|
||||
when(client.hasApiKey(anyString())).thenReturn(apiKey != null && !apiKey.isBlank());
|
||||
when(client.invokeChat(anyString(), anyString(), anyString(), anyString()))
|
||||
.thenAnswer(invocation -> {
|
||||
String response = responses.get("chat");
|
||||
if (response == null) {
|
||||
throw new IllegalStateException("unexpected chat call");
|
||||
}
|
||||
return response;
|
||||
});
|
||||
when(client.invokeChatWithImages(anyString(), anyString(), anyString(), any(), anyString()))
|
||||
.thenAnswer(invocation -> {
|
||||
String response = responses.get("images");
|
||||
if (response == null) {
|
||||
throw new IllegalStateException("unexpected images call");
|
||||
}
|
||||
return response;
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
private static final com.fasterxml.jackson.databind.ObjectMapper OBJECT_MAPPER =
|
||||
new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
|
||||
/** 类目匹配(按序号返回不同类目名)与合规检查(后续)返回不同 JSON。 */
|
||||
private SimilarAsinLlmClient llmClientStaged(String apiKey, List<String> categoryJsons,
|
||||
String conformJson, String imagesJson) {
|
||||
SimilarAsinLlmClient client = mock(SimilarAsinLlmClient.class);
|
||||
int[] categoryIndex = {0};
|
||||
when(client.resolveApiKey(anyString())).thenReturn(apiKey);
|
||||
when(client.hasApiKey(anyString())).thenReturn(apiKey != null && !apiKey.isBlank());
|
||||
when(client.invokeChat(anyString(), anyString(), anyString(), anyString()))
|
||||
.thenAnswer(invocation -> {
|
||||
if (categoryIndex[0] < categoryJsons.size()) {
|
||||
return categoryJsons.get(categoryIndex[0]++);
|
||||
}
|
||||
return conformJson;
|
||||
});
|
||||
when(client.invokeChatWithImages(anyString(), anyString(), anyString(), any(), anyString()))
|
||||
.thenAnswer(invocation -> imagesJson);
|
||||
when(client.parseJsonContent(anyString()))
|
||||
.thenAnswer(invocation -> parseJson(invocation.getArgument(0)));
|
||||
return client;
|
||||
}
|
||||
|
||||
private static com.fasterxml.jackson.databind.JsonNode parseJson(String content) {
|
||||
try {
|
||||
return OBJECT_MAPPER.readTree(content);
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void noApiKeyKeepsRawRows() {
|
||||
SimilarAsinLlmService service = service(llmClient("", Map.of()));
|
||||
SimilarAsinResultRowDto row = new SimilarAsinResultRowDto();
|
||||
row.setAsin("B0TEST");
|
||||
List<SimilarAsinResultRowDto> result = service.inspectRows(List.of(row), "", "", true, true);
|
||||
assertEquals(1, result.size());
|
||||
assertEquals("B0TEST", result.get(0).getAsin());
|
||||
assertNull(result.get(0).getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void categorySwitchOffOnlyPreparesImagesAndMarksNotExistsWhenNoMainUrl() {
|
||||
SimilarAsinLlmService service = service(llmClient("k", Map.of()));
|
||||
SimilarAsinResultRowDto row = new SimilarAsinResultRowDto();
|
||||
row.setAsin("B0TEST");
|
||||
row.setTitle("Test product");
|
||||
List<SimilarAsinResultRowDto> result = service.inspectRows(List.of(row), "", "k", false, false);
|
||||
assertEquals(1, result.size());
|
||||
assertEquals("不存在", result.get(0).getStatus());
|
||||
assertNull(result.get(0).getIsConform());
|
||||
assertNull(result.get(0).getPuzzleImg1());
|
||||
}
|
||||
|
||||
@Test
|
||||
void imageCompareStopsOnStockAndFillsFields() {
|
||||
// 前 2 次 chat:一级/二级类目匹配返回名称(Java 按名回查候选取真实 ID);第 3 次:合规检查。
|
||||
SimilarAsinLlmClient client = llmClientStaged("k", List.of("{\"name\":\"类目A\"}", "{\"name\":\"类目B\"}"),
|
||||
"{\"asin\":\"B0TEST\",\"is_conform\":\"符合\",\"reason\":\"无\",\"category\":\"类目A->类目B->类目C\"}",
|
||||
"{\"asin\":\"B0TEST\",\"is_stock\":\"有货\",\"similarity\":\"95%\",\"status\":\"成功\",\"is_conform\":\"符合\",\"category\":\"类目A->类目B->类目C\"}");
|
||||
SimilarAsinLlmService service = service(client);
|
||||
|
||||
SimilarAsinResultRowDto row = new SimilarAsinResultRowDto();
|
||||
row.setAsin("B0TEST");
|
||||
row.setTitle("Test product");
|
||||
row.setUrl("https://m.media-amazon.com/images/I/main.jpg");
|
||||
SimilarAsinResultRowDto.AlibabaItem item = new SimilarAsinResultRowDto.AlibabaItem();
|
||||
item.setUrl("https://cbu01.alicdn.com/img/1.jpg");
|
||||
row.setAlibaba(List.of(item));
|
||||
|
||||
// 一级/二级都匹配(按名回查 ID),三级候选可用,合规符合 → 图片对比,有货即停。
|
||||
List<SimilarAsinResultRowDto> result = service.inspectRows(List.of(row), "", "k", true, true);
|
||||
assertEquals(1, result.size());
|
||||
SimilarAsinResultRowDto out = result.get(0);
|
||||
assertEquals("成功", out.getStatus());
|
||||
assertEquals("有货", out.getIsStock());
|
||||
assertEquals("95%", out.getSimilarity());
|
||||
assertEquals("符合", out.getIsConform());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user