feat: SimilarASIN 直连 LLM 通道(Gemini/DeepSeek 多模型)替代 Coze 工作流

- SimilarAsinLlmClient/Service:分类、符合性、图片比对三阶段直连 LLM
- PuzzleImageMerger:拼图合并用于图片比对
- direct-llm-enabled 开关 + llm-host/api-key/模型/超时/并发配置(默认开)
- LlmGatewayTlsProbe、SimilarAsinLlmLocalVerify 为本地验证工具
This commit is contained in:
2026-08-31 17:02:53 +08:00
parent 7f4737c800
commit 14a41e64f1
7 changed files with 2078 additions and 0 deletions
@@ -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());
}
}
}
}
@@ -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) + "...";
}
}
@@ -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());
}
}