diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinLlmClient.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinLlmClient.java new file mode 100644 index 00000000..a0aad228 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinLlmClient.java @@ -0,0 +1,398 @@ +package com.nanri.aiimage.modules.similarasin.client; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanri.aiimage.config.HttpClientPool; +import com.nanri.aiimage.config.SimilarAsinProperties; +import com.nanri.aiimage.metrics.ExternalCallMetricsRecorder; +import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.StreamUtils; +import org.springframework.web.client.RestClient; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * 货源查询直连 LLM 客户端:调用 OpenAI 兼容 /v1/chat/completions, + * 替代原 Coze 工作流(similarity_asin -> similarity_image -> LLM_chat)链路, + * 减少一次外部平台中转。链路对齐点见 SimilarAsinLlmService。 + */ +@Component +@Slf4j +public class SimilarAsinLlmClient { + + private static final MediaType APPLICATION_JSON_UTF8 = new MediaType("application", "json", StandardCharsets.UTF_8); + + private final SimilarAsinProperties properties; + private final ObjectMapper objectMapper; + private final ExternalCallMetricsRecorder externalCallMetrics; + + private volatile RestClient sharedRestClient; + + public SimilarAsinLlmClient(SimilarAsinProperties properties, + ObjectMapper objectMapper, + ExternalCallMetricsRecorder externalCallMetrics) { + this.properties = properties; + this.objectMapper = objectMapper; + this.externalCallMetrics = externalCallMetrics; + } + + /** 文本对话,json_object 输出。 */ + public String invokeChat(String model, String system, String userText, String apiKey) { + return invokeChat(model, system, userText, List.of(), apiKey, "json_object"); + } + + /** 多模态对话(图片 URL 列表),json_object 输出。 */ + public String invokeChatWithImages(String model, String system, String userText, List images, String apiKey) { + return invokeChat(model, system, userText, images, apiKey, "json_object"); + } + + public String invokeChat(String model, + String system, + String userText, + List images, + String apiKey, + String responseFormat) { + String resolvedKey = resolveApiKey(apiKey); + int attempts = Math.max(1, properties.getLlmRetryTimes()); + Exception lastFailure = null; + for (int attempt = 1; attempt <= attempts; attempt++) { + try { + return invokeChatOnce(model, system, userText, images, resolvedKey, responseFormat); + } catch (Exception ex) { + lastFailure = ex; + if (attempt >= attempts) { + break; + } + log.warn("[similar-asin][llm] retryable failure attempt={} model={} err={}", + attempt, model, failureMessage(ex)); + sleepBeforeRetry(attempt); + } + } + throw lastFailure == null + ? new IllegalStateException("LLM call failed") + : lastFailure instanceof RuntimeException runtimeFailure + ? runtimeFailure + : new IllegalStateException(lastFailure.getMessage(), lastFailure); + } + + /** 前端 api_key 为空时兜底走服务端配置(工作流里 api_key 为必填项)。 */ + public String resolveApiKey(String apiKey) { + if (apiKey != null && !apiKey.isBlank()) { + return apiKey.trim(); + } + return normalize(properties.getLlmApiKey()); + } + + public boolean hasApiKey(String apiKey) { + return !resolveApiKey(apiKey).isBlank(); + } + + private String invokeChatOnce(String model, + String system, + String userText, + List images, + String apiKey, + String responseFormat) { + Map body = buildChatBody(model, system, userText, images, responseFormat); + log.debug("[similar-asin][llm] request model={} url={} body={}", + model, joinUrl(properties.getLlmHost(), "/v1/chat/completions"), + writeJson(maskChatBody(body))); + RestClient.RequestBodySpec request = restClient().post() + .uri(joinUrl(properties.getLlmHost(), "/v1/chat/completions")) + .headers(headers -> { + headers.setBearerAuth(stripBearer(apiKey)); + headers.setContentType(APPLICATION_JSON_UTF8); + headers.set(HttpHeaders.ACCEPT_CHARSET, StandardCharsets.UTF_8.name()); + }); + request.body(body); + String responseText = request.exchange((clientRequest, clientResponse) -> { + byte[] responseBytes = StreamUtils.copyToByteArray(clientResponse.getBody()); + String responseBody = responseBytes.length == 0 ? "" : new String(responseBytes, StandardCharsets.UTF_8); + if (!clientResponse.getStatusCode().is2xxSuccessful()) { + throw new IllegalStateException("LLM http " + clientResponse.getStatusCode().value() + + ": " + abbreviate(responseBody, 500)); + } + log.debug("[similar-asin][llm] response model={} status={} body={}", + model, clientResponse.getStatusCode(), abbreviate(responseBody, 2000)); + return responseBody; + }); + JsonNode root = parseJsonOrThrow(responseText); + JsonNode errorNode = root.path("error"); + if (!errorNode.isMissingNode() && !errorNode.isNull()) { + String message = text(errorNode.path("message")); + throw new IllegalStateException(firstNonBlank(message, "LLM error")); + } + JsonNode contentNode = root.path("choices").path(0).path("message").path("content"); + String content = text(contentNode); + if (content == null || content.isBlank()) { + throw new IllegalStateException("LLM empty response"); + } + return content; + } + + private Map buildChatBody(String model, + String system, + String userText, + List images, + String responseFormat) { + Map body = new LinkedHashMap<>(); + body.put("model", model); + body.put("stream", false); + body.put("max_tokens", Math.max(1, properties.getLlmMaxTokens())); + body.put("temperature", 0); + Map responseFormatObj = new LinkedHashMap<>(); + responseFormatObj.put("type", responseFormat); + body.put("response_format", responseFormatObj); + + List> messages = new ArrayList<>(2); + Map systemMessage = new LinkedHashMap<>(); + systemMessage.put("role", "system"); + systemMessage.put("content", system); + messages.add(systemMessage); + + Map userMessage = new LinkedHashMap<>(); + userMessage.put("role", "user"); + if (images == null || images.isEmpty()) { + userMessage.put("content", userText); + } else { + List content = new ArrayList<>(); + Map textPart = new LinkedHashMap<>(); + textPart.put("type", "text"); + textPart.put("text", userText); + content.add(textPart); + for (String url : images) { + if (normalize(url).isBlank()) { + continue; + } + Map imagePart = new LinkedHashMap<>(); + imagePart.put("type", "image_url"); + Map imageUrl = new LinkedHashMap<>(); + imageUrl.put("url", url); + imagePart.put("image_url", imageUrl); + content.add(imagePart); + } + userMessage.put("content", content); + } + messages.add(userMessage); + body.put("messages", messages); + return body; + } + + @SuppressWarnings("unchecked") + private Map maskChatBody(Map body) { + Map masked = new LinkedHashMap<>(body); + Object messagesObj = masked.get("messages"); + if (messagesObj instanceof List messages) { + List maskedMessages = new ArrayList<>(messages.size()); + for (Object messageObj : messages) { + if (messageObj instanceof Map message) { + Map maskedMessage = new LinkedHashMap<>((Map) message); + Object contentObj = maskedMessage.get("content"); + if (contentObj instanceof String contentText && contentText.length() > 80) { + maskedMessage.put("content", contentText.substring(0, 40) + "...[len=" + contentText.length() + "]"); + } + maskedMessages.add(maskedMessage); + } else { + maskedMessages.add(messageObj); + } + } + masked.put("messages", maskedMessages); + } + return masked; + } + + /** + * LLM 输出的 JSON 解包:剥离 ```json 包裹、多重字符串转义后返回 JsonNode; + * 失败时抛 IllegalStateException。 + */ + public JsonNode parseJsonContent(String content) { + Object current = content; + while (current instanceof String value) { + String cleaned = value.trim(); + if (cleaned.startsWith("```json")) { + cleaned = cleaned.substring(7).replaceAll("```$", "").trim(); + } + Object parsed = parseLenient(cleaned); + if (parsed == null) { + Object fixed = parseLenient(cleaned.replace("\n", "\\n").replace("\r", "\\r")); + if (fixed == null) { + break; + } + current = fixed; + } else { + if (parsed.equals(value)) { + break; + } + current = parsed; + } + } + if (current instanceof Map map) { + try { + return objectMapper.valueToTree(map); + } catch (Exception ignored) { + // fall through + } + } + String raw = content == null ? "" : content.trim(); + try { + return objectMapper.readTree(raw); + } catch (Exception ex) { + throw new IllegalStateException("LLM 输出不是合法 JSON: " + abbreviate(raw, 300), ex); + } + } + + private Object parseLenient(String value) { + String normalized = normalize(value); + if (!(normalized.startsWith("{") || normalized.startsWith("["))) { + return null; + } + try { + return objectMapper.readValue(normalized, Object.class); + } catch (Exception ignored) { + return null; + } + } + + private JsonNode parseJsonOrThrow(String value) { + try { + return objectMapper.readTree(value); + } catch (Exception ex) { + throw new IllegalStateException("LLM response is not valid JSON", ex); + } + } + + private RestClient restClient() { + RestClient client = sharedRestClient; + if (client != null) { + return client; + } + synchronized (this) { + if (sharedRestClient == null) { + RestClient.Builder builder = RestClient.builder() + .requestFactory(HttpClientPool.requestFactory(properties.getLlmReadTimeoutMillis())); + if (externalCallMetrics != null) { + builder.requestInterceptor(externalCallMetrics.interceptor("llm")); + } + sharedRestClient = builder.build(); + } + return sharedRestClient; + } + } + + private void sleepBeforeRetry(int attemptIndex) { + try { + Thread.sleep(Math.max(1, attemptIndex) * 1500L); + } catch (InterruptedException interruptedException) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("LLM retry interrupted", interruptedException); + } + } + + private String writeJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (Exception ex) { + throw new IllegalStateException("Failed to serialize LLM payload", ex); + } + } + + private String abbreviate(String value, int maxLength) { + String normalized = value == null ? "" : value.trim(); + if (normalized.length() <= maxLength) { + return normalized; + } + return normalized.substring(0, Math.max(0, maxLength - 3)) + "..."; + } + + private String failureMessage(Exception ex) { + String message = ex == null ? null : ex.getMessage(); + if (message == null || message.isBlank()) { + return "LLM call failed"; + } + return message; + } + + private String stripBearer(String token) { + String normalized = token == null ? "" : token.trim(); + return normalized.regionMatches(true, 0, "Bearer ", 0, 7) ? normalized.substring(7).trim() : normalized; + } + + private String joinUrl(String baseUrl, String path) { + String base = baseUrl == null ? "" : baseUrl.trim(); + String suffix = path == null ? "" : path.trim(); + if (base.endsWith("/") && suffix.startsWith("/")) { + return base + suffix.substring(1); + } + if (!base.endsWith("/") && !suffix.startsWith("/")) { + return base + "/" + suffix; + } + return base + suffix; + } + + private String firstNonBlank(String preferred, String fallback) { + return preferred == null || preferred.isBlank() ? fallback : preferred.trim(); + } + + private String normalize(String value) { + return value == null ? "" : value.replace(String.valueOf((char) 0xFEFF), "").replace((char) 0x3000, ' ').trim(); + } + + private String text(JsonNode node) { + return node == null || node.isNull() ? null : node.asText(); + } + + /** 兼容对比:similarity 百分比文本统一规整为 "NN%" 形式。 */ + public static String normalizePercent(String value) { + String normalized = value == null ? "" : value.trim(); + if (normalized.isBlank() || "null".equalsIgnoreCase(normalized)) { + return ""; + } + if (normalized.endsWith("%")) { + return normalized; + } + try { + Double.parseDouble(normalized); + return normalized + "%"; + } catch (NumberFormatException ex) { + return normalized; + } + } + + public static boolean similarityHitsStock(String similarity) { + return parsePercent(similarity) >= 90; + } + + public static double parsePercent(String value) { + String normalized = value == null ? "" : value.trim(); + if (normalized.endsWith("%")) { + normalized = normalized.substring(0, normalized.length() - 1).trim(); + } + try { + return Double.parseDouble(normalized); + } catch (NumberFormatException ex) { + return -1; + } + } + + public static boolean isStockAvailable(String value) { + String normalized = value == null ? "" : normalizeStatic(value); + return normalized.equals("有货") || normalized.contains("有货"); + } + + public static boolean isConformYes(String value) { + return "符合".equals(normalizeStatic(value)); + } + + private static String normalizeStatic(String value) { + return value.replace(String.valueOf((char) 0xFEFF), "").replace((char) 0x3000, ' ').trim(); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinLlmService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinLlmService.java new file mode 100644 index 00000000..fe6bf2b1 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinLlmService.java @@ -0,0 +1,669 @@ +package com.nanri.aiimage.modules.similarasin.service; + +import com.fasterxml.jackson.databind.JsonNode; +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 lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +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.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Semaphore; + +/** + * 货源查询直连 LLM 编排服务:复刻 Coze 工作流 similarity_asin + * (含 similarity_image、LLM_chat 子工作流)的完整语义,去掉 Coze 中转。 + * + * 链路对齐点(按工作流节点): + * 1. 图片准备 batch(103226,无条件执行):alibaba 前 8 张拼图1、8~16 张拼图2 + * (puzzle_image 插件,失败降级为空串),主图下载转存 MinIO supply_images + * (upload_file 插件,失败即行失败); + * 2. category_switch=false:不调 LLM,输出行仅 asin + 三图,主图空时 status=不存在 + * (127486/1857081); + * 3. category_switch=true:一级匹配(141783)→ 二级匹配(1548215)→ 三级候选查询 + * (1288150),任一匹配为"无"则违规候选为空; + * 4. 合规检查(1570640,总是执行):is_conform/reason/category; + * 5. 不符合或 alibaba 空或 img_switch=false:只回填合规结果,status=成功(139255/1398750); + * 6. 否则图片对比循环(181250,最多 2 个候选):主图(原始url) vs 拼图 → LLM + * (LLM_chat,重试 3 次)→ is_stock=有货 则停;候选全失败时保持 status=失败(初始 data 语义)。 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class SimilarAsinLlmService { + + private static final String STATUS_SUCCESS = "成功"; + private static final String STATUS_FAILED = "失败"; + private static final String STATUS_NOT_EXISTS = "不存在"; + private static final String NO_MATCH = "无"; + + /** + * 一级/二级类目匹配系统提示词(工作流 109409/1544733 原文,ID 字段移除: + * LLM 会编造 slug ID,Java 按名称回查类目表取真实数字 ID)。 + */ + private static final String SYSTEM_CATEGORY = + "# Role\n你是一个电商数据专家,擅长根据商品的标题(Title)和属性(Attributes)进行精准的类目归类。\n\n" + + "# Task\n请分析用户提供的\"商品标题\"和\"商品属性\",从给定的\"备选类目列表\"中筛选出最匹配的一个类目。\n\n" + + "# Rules\n1. **语义匹配**:不仅要考虑关键词匹配,还要考虑商品的实际用途、材质和适用人群。\n" + + "2. **属性优先**:如果标题模糊,请重点参考属性中的关键信息(如:材质、功能、品牌)。\n" + + "3. **唯一输出**:只输出 JSON 格式的结果,不要包含任何解释、开场白或修饰词。\n" + + "4. **回退机制**:\n" + + " - 如果能匹配到类目,输出:{\"name\": \"类目名称\"}\n" + + " - 如果没有任何类目符合商品特征,输出:{\"name\": \"无\"}\n\n" + + "# Constraint\n严禁伪造类目名称。输出必须严格遵循 JSON 语法。"; + + /** 合规检查系统提示词(工作流 1380531 原文)。 */ + private static final String SYSTEM_CONFORM = + "# 角色与任务\n\n你是一个专业的亚马逊电商产品合规审核助手。请根据用户提供的产品文本信息(包括 ASIN、SKU、产品标题、产品属性等)以及\"用户指定的违规类目\",判断该产品是否符合上架要求。\n\n" + + "# 判断逻辑(请严格按以下优先级顺序执行)\n\n" + + "1. **优先匹配指定类目(特殊备注拦截逻辑)**:\n" + + "首先根据产品标题、SKU及属性等文本信息推断产品真实类目,并与用户输入的\"违规类目\"(一级/二级/三级)进行比对。此拦截逻辑适用于**任何层级**(包括一级、二级类目):\n\n" + + "* **泛化拦截(带特殊备注)**:如果某一层级的类目名称中带有包含性的特殊备注(如\"(包含所有...)\"、\"(所有...都不行)\"等),则**该带有备注的层级及其包含的所有底层产品**均视为违规。\n" + + "*示例 1(一级拦截):一级类目\"食品(所有食品都不行)\",二级\"饮料\",三级\"可乐\"。只要产品属于\"食品\"大类,一律判定为\"不符合\"。*\n" + + "*示例 2(二级拦截):一级类目\"食品\",二级\"饮料(包含所有饮料类产品)\",三级\"可乐\"。则所有\"饮料\"均判定为\"不符合\"(如雪碧、果汁都不行),但属于食品大类下的\"零食\"或\"糖果\"不受影响。*\n" + + "* **精准拦截(无特殊备注)**:如果所有层级均无此类特殊备注,则**仅有最底层(如三级类目)明确指定的具体产品**才判定为违规。\n" + + "*示例 3(精准拦截):一级类目\"食品\",二级\"饮料\",三级\"可乐\"。则只有\"可乐\"判定为\"不符合\",同属饮料的\"雪碧\"则判定为符合。*\n" + + "如果命中以上规则,直接判定为\"不符合\",并将匹配到的带备注的类目层级名称或具体的三级违规类目作为理由。\n\n" + + "2. **结合平台政策判定(重点关注高危及需资质产品)**:\n" + + "如果产品不在上述用户指定的违规范围内,请基于亚马逊官方政策判断。若该产品属于以下三类之一,强制判定为\"不符合\":\n\n" + + "* **医疗器械类产品**\n" + + "* **需要特殊资质/认证材料类产品**(如FDA认证、儿童CPC认证等强管控产品)\n" + + "* **高危类产品**(如易燃易爆、管制刀具、危险化学品等)\n" + + "如果是,请在理由中写明触发的具体亚马逊限制类目或原因。\n\n" + + "3. **最终合规判定**:\n" + + "如果上述两步的检查均未发现违规(既不属于用户指定的违规范围,也不属于亚马逊限制的上述三类产品),则判定为\"符合\"。\n\n" + + "# 输出限制\n\n" + + "请**严格且仅以**下方的 JSON 格式输出结果。**绝对禁止**输出任何前言、后语、解释性文字或 Markdown 代码块标记(如 ```json 等),只需纯 JSON 文本形式输出。\n\n" + + "# 预期的 JSON 输出格式\n\n" + + "{\n" + + "\"asin\": \"<提取并保持用户输入的ASIN不变,若用户未提供ASIN但提供了SKU,则填入SKU>\",\n" + + "\"is_conform\": \"<填写'符合'或'不符合'>\",\n" + + "\"reason\": \"<如果'不符合',请填入具体理由(如匹配到的用户带有备注的类目层级、或精准匹配的三级类目、或亚马逊违规原因);如果'符合',不需要理由,请直接填写 '无'>\",\n" + + "\"category\": \"<根据文本信息推断出的产品所属类目,必须输出完整的三级类目层级结构,使用'->'连接,示例:一级类目->二级类目->三级类目(如:食品->饮料->可乐),必须为中文>\"\n" + + "}"; + + /** 图片相似度对比系统提示词(工作流 199497 原文)。 */ + private static final String SYSTEM_IMAGE_COMPARE = + "# Role\n你是一位专业的电商图像对比与同款库存状态判定专家。\n\n" + + "# Task\n请根据提供的【图1】(主图)、【图2】(对比图/同款候选图)以及用户提供的【产品信息/类目/特殊过滤要求】,精准识别并对比两张图片中**商品销售主体**的视觉相似度,从而精确判定商品是否为同款有货。\n\n" + + "# Core Rules(主体锚定与干扰过滤)\n在进行比对前,必须首先依据【产品信息/类目】明确**本次比对的唯一售卖主体**,并强制执行以下过滤规则:\n" + + "1. **强制忽略展示道具与填充物**:\n" + + " - 严禁将用于展示功能的非售卖物品计入比对(例如:收纳包内的手表/充电器/数据线、手机壳内的手机机身、鞋包内的填充物/鞋撑、穿戴在模特身上的其他非标衣物等)。\n" + + " - 比对时仅聚焦于**商品外壳/本体本身**(如收纳包本身的内外壳材质、凹槽形状、拉链、缝线、包边)。\n" + + "2. **强制忽略营销文案与后期水印**:\n" + + " - 严禁将图片后期添加的促销文字、价格标签(如8.50、9.00)、卖点描述(如\"牛津布面料\")、尺寸标注、防盗水印等计入\"文字与Logo\"维度的差异。\n" + + " - 仅比对**商品本体上自带出厂印刷/压印/刺绣的品牌Logo或固定图案**。\n" + + "3. **强制排除拍摄环境与展示形态差异**:\n" + + " - 排除背景(纯白底、木纹、布景)、光影明暗、拍摄角度的差异。\n" + + " - 若商品为同款,但一张为\"开盖展示内部\",另一张为\"闭合展示外观\"或\"附带可拆卸挂扣\",应基于可见的主体结构特征进行同款一致性判定,不得因未展示部位直接判为完全不同。\n\n" + + "# Guidelines(多维度同款对比标准)\n必须针对**商品销售主体本身**从以下八个核心维度进行独立比对:\n" + + "1. **形状与轮廓**:商品主体的几何外形、长宽比例、边缘弧度、立体轮廓是否一致。\n" + + "2. **款式与结构**:商品的版型剪裁、开合方式(如拉链走向、卡扣结构)、内部功能槽位划分是否一致。\n" + + "3. **颜色与色调**:主体面料颜色、拉链布与拉链齿颜色、缝线颜色等核心配色是否一致(允许合理的光影深浅色差)。\n" + + "4. **材质与纹理**:表面材质(如EVA硬壳、牛津布纹理、PU皮革纹、金属质感等)是否为同种材质。\n" + + "5. **图案与印花**:商品主体本身固有的纹路(如表面的凹凸波浪纹理、装饰线条)是否一致。\n" + + "6. **本体文字与Logo**:商品主体表面自带的品牌Logo、压纹是否一致(忽略海报文案)。\n" + + "7. **细节与辅料**:拉链头款式、拉链走线边缘、挂绳/登山扣挂耳设计、包边工艺等微观细节是否吻合。\n" + + "8. **整体版型与做工**:排除道具与背景干扰后,商品展现出的同款货源一致性。\n\n" + + "# 判定与评分逻辑\n" + + "1. **同款判定(有货,相似度 ≥90%)**:\n" + + " - 当图1与图2中的**商品销售主体**在款式结构、主体材质、轮廓造型、核心细节(如独特的凹凸纹理、拉链配色)上完全一致,确认为同一款货源/产品时,整体相似度判定为 **90% - 100%**,状态输出为\"有货\"。\n" + + " - 若主体完全一致,仅因光影、角度或是否挂着可拆卸配件等有微小差别,可在 90%~98% 之间评定。\n" + + "2. **非同款判定(没有货,相似度 <90%)**:\n" + + " - 只要商品主体在核心结构(如圆形变方形)、材质(如硬壳变软布)、关键版型或功能凹槽设计上存在实质性不同,即属于不同款,相似度必须判定为 **<90%**,状态输出为\"没有货\"。\n\n" + + "# 字段输出规则\n" + + "* `asin`:直接提取并保持用户输入的 ASIN 不变。\n" + + "* `is_stock`:仅允许输出\"有货\"或\"没有货\"。\n" + + "* `similarity`:输出基于商品主体计算出的整体百分比数值(例如:\"95%\"、\"92%\"、\"30%\")。\n" + + "* `status`:仅允许输出\"成功\"。\n" + + "* `is_conform`:仅允许输出\"符合\"。\n" + + "* `category`:直接提取并保持用户输入的产品类目不变输出。\n\n" + + "# 输出格式约束\n" + + "你必须直接输出原始 JSON 文本,**严禁**使用 Markdown 代码块标记(如 ```json 和 ```),严禁输出任何额外解释说明。\n\n" + + "{\n" + + "\"asin\": \"保持输入的ASIN不变输出\",\n" + + "\"is_stock\": \"有货 或 没有货\",\n" + + "\"similarity\": \"95%\",\n" + + "\"status\": \"成功\",\n" + + "\"is_conform\": \"符合\",\n" + + "\"category\": \"保持输入的产品类目不变输出\"\n" + + "}"; + + private static final String USER_TEXT_CATEGORY = "产品标题:%s\n产品属性:%s\n类目:%s"; + private static final String USER_TEXT_CONFORM = + "标题:%s\nSKU:%s\n违规类目产品:一级类目:%s ,二级类目:%s,三级类目:%s"; + private static final String USER_TEXT_COMPARE = "ASIN:%s\n所属类目:%s\n产品信息:%s"; + + private final SimilarAsinLlmClient llmClient; + private final SimilarAsinProperties properties; + private final OssProperties ossProperties; + private final ProductCategoryService productCategoryService; + private final PuzzleImageMerger puzzleImageMerger; + private final OssStorageService ossStorageService; + + private volatile ExecutorService rowExecutor; + private volatile HttpClient downloadHttpClient; + + /** 测试注入点:替换图片下载 HttpClient,避免单测发起真实网络请求。 */ + void setDownloadHttpClientForTest(HttpClient client) { + this.downloadHttpClient = client; + } + + /** + * 批内行级直连检测:每行独立跑完整链路,结果按入参顺序返回。 + * 单行内部流程失败时整行标记失败(status=失败 + error/reason),不中断其他行。 + */ + public List inspectRows(List rows, + String prompt, + String apiKey, + boolean imgSwitch, + boolean categorySwitch) { + if (rows == null || rows.isEmpty()) { + return List.of(); + } + if (!llmClient.hasApiKey(apiKey)) { + log.warn("[similar-asin][llm] llm api key not configured, keep raw rows size={}", rows.size()); + return rows.stream().map(this::copyRow).toList(); + } + Semaphore concurrency = new Semaphore(Math.max(1, properties.getLlmRowConcurrency())); + ExecutorService executor = rowExecutor(); + List> futures = new ArrayList<>(rows.size()); + for (SimilarAsinResultRowDto row : rows) { + futures.add(CompletableFuture.supplyAsync(() -> { + try { + concurrency.acquire(); + try { + return inspectRow(row, prompt, apiKey, imgSwitch, categorySwitch); + } finally { + concurrency.release(); + } + } catch (InterruptedException interruptedException) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("LLM row inspect interrupted", interruptedException); + } + }, executor)); + } + List merged = new ArrayList<>(rows.size()); + for (int i = 0; i < futures.size(); i++) { + try { + merged.add(futures.get(i).join()); + } catch (Exception ex) { + log.warn("[similar-asin][llm] row failed index={} asin={} err={}", + i, rows.get(i).getAsin(), failureMessage(ex)); + merged.add(markFailed(copyRow(rows.get(i)), failureMessage(ex))); + } + } + return merged; + } + + private SimilarAsinResultRowDto inspectRow(SimilarAsinResultRowDto source, + String prompt, + String apiKey, + boolean imgSwitch, + boolean categorySwitch) { + SimilarAsinResultRowDto row = copyRow(source); + try { + inspectRowInternal(row, prompt, apiKey, imgSwitch, categorySwitch); + return row; + } catch (Exception ex) { + log.warn("[similar-asin][llm] row failed asin={} title={} err={}", + row.getAsin(), abbreviate(row.getTitle(), 120), failureMessage(ex)); + return markFailed(row, failureMessage(ex)); + } + } + + private void inspectRowInternal(SimilarAsinResultRowDto row, + String prompt, + String apiKey, + boolean imgSwitch, + boolean categorySwitch) throws Exception { + // --- 1. 图片准备(工作流 103226:无条件执行)--- + prepareImages(row, apiKey); + + if (!categorySwitch) { + // category_switch=false:只出图,不调用 LLM(工作流 127486/1857081)。 + if (normalize(row.getMainUrl()).isBlank()) { + row.setStatus(STATUS_NOT_EXISTS); + } + return; + } + + // --- 2. 类目匹配:一级 → 二级 → 三级违规候选(141783/1548215/1288150)--- + CategoryLevel first = matchCategoryLevel(row, null, apiKey); + List thirdCandidates; + CategoryLevel second; + if (isNoMatch(first)) { + second = CategoryLevel.none(); + thirdCandidates = List.of(); + } else { + second = matchCategoryLevel(row, first, apiKey); + if (isNoMatch(second)) { + thirdCandidates = List.of(); + } else { + thirdCandidates = childrenOf(second.id()); + } + } + + // --- 3. 合规检查(1570640:总是执行)--- + boolean conform = checkConform(row, first, second, thirdCandidates, prompt, apiKey); + + // --- 4. 不符合 / 无 alibaba / img_switch=false:只回填合规结果(139255 true 分支 → 1398750)--- + if (!conform || row.getAlibaba().isEmpty() || !imgSwitch) { + row.setStatus(STATUS_SUCCESS); + return; + } + + // --- 5. 图片对比循环(181250:主图 vs 拼图,候选最多 2 个)--- + compareLoop(row, apiKey); + } + + /** + * 图片准备:拼图1(alibaba 前 8 张)、拼图2(8~16 张)拼接后上传 MinIO + * supply_images;主图下载转存 MinIO。拼图失败降级为空(对齐插件 dataOnErr + * 空串),主图转存失败抛异常(对齐插件 processType 1 终止)。 + */ + private void prepareImages(SimilarAsinResultRowDto row, String apiKey) { + List alibaba = row.getAlibaba(); + List firstHalf = new ArrayList<>(); + List secondHalf = new ArrayList<>(); + for (int i = 0; i < alibaba.size(); i++) { + SimilarAsinResultRowDto.AlibabaItem item = alibaba.get(i); + if (item == null || normalize(item.getUrl()).isBlank()) { + continue; + } + if (i < 8) { + firstHalf.add(item); + } else if (i < 16) { + secondHalf.add(item); + } else { + break; + } + } + String puzzle1 = uploadMergedPuzzle(firstHalf, apiKey); + if (puzzle1 != null) { + row.setPuzzleImg1(puzzle1); + } + String puzzle2 = uploadMergedPuzzle(secondHalf, apiKey); + if (puzzle2 != null) { + row.setPuzzleImg2(puzzle2); + } + + String primaryUrl = row.getUrl(); + if (normalize(primaryUrl).isBlank()) { + // 主图缺省:main_url 留空,后续 status=不存在(对齐 1857081)。 + row.setMainUrl(""); + return; + } + String stored = uploadExternalUrlToSupplyImages(primaryUrl); + if (stored == null) { + throw new IllegalStateException("主图转存失败: " + abbreviate(primaryUrl, 160)); + } + row.setMainUrl(stored); + } + + private String uploadMergedPuzzle(List items, String apiKey) { + if (items.isEmpty()) { + return null; + } + byte[] merged = puzzleImageMerger.merge(items, null); + if (merged == null) { + return null; + } + try { + return uploadBytesToSupplyImages(merged); + } catch (Exception ex) { + log.warn("[similar-asin][llm] puzzle upload failed items={} err={}", items.size(), failureMessage(ex)); + return null; + } + } + + private CategoryLevel matchCategoryLevel(SimilarAsinResultRowDto row, + CategoryLevel parent, + String apiKey) { + List candidates = parent == null + ? childrenOf(null) + : childrenOf(parent.id()); + String userText = String.format(USER_TEXT_CATEGORY, + firstNonBlank(row.getTitle(), row.getAsin()), + normalize(row.getSku()), + formatCandidates(candidates)); + String content = llmClient.invokeChat(properties.getLlmCategoryModel(), SYSTEM_CATEGORY, userText, apiKey); + JsonNode root = llmClient.parseJsonContent(content); + String name = textOrEmpty(root, "name"); + if (isNoMatch(new CategoryLevel("", name))) { + return CategoryLevel.none(); + } + // LLM 只输出名称,ID 按名称在候选列表内回查(防 LLM 编造不存在的 ID)。 + CategoryLevel matched = findByName(candidates, name); + if (matched == null) { + log.warn("[similar-asin][llm] category name not found in candidates name={} candidates={}", + name, candidates.size()); + return CategoryLevel.none(); + } + return matched; + } + + /** 名称精确回查候选列表(含"无"),找不到返回 null。 */ + private CategoryLevel findByName(List candidates, String name) { + String normalized = normalize(name); + if (normalized.isBlank()) { + return null; + } + for (CategoryLevel candidate : candidates) { + if (normalize(candidate.name()).equals(normalized)) { + return candidate; + } + } + return null; + } + + private boolean checkConform(SimilarAsinResultRowDto row, + CategoryLevel first, + CategoryLevel second, + List thirdCandidates, + String prompt, + String apiKey) { + String userText = String.format(USER_TEXT_CONFORM, + firstNonBlank(row.getTitle(), row.getAsin()), + normalize(row.getSku()), + first.name(), + second.name(), + formatCandidates(thirdCandidates)); + String system = prompt == null || prompt.isBlank() ? SYSTEM_CONFORM : SYSTEM_CONFORM + "\n\n额外要求:" + prompt.trim(); + String content = llmClient.invokeChat(properties.getLlmConformModel(), system, userText, apiKey); + JsonNode root = llmClient.parseJsonContent(content); + String isConform = textOrEmpty(root, "is_conform"); + row.setIsConform(isConform); + row.setReason(firstNonBlank(textOrEmpty(root, "reason"), "")); + row.setCategory(firstNonBlank(textOrEmpty(root, "category"), "")); + return SimilarAsinLlmClient.isConformYes(isConform); + } + + /** + * 图片对比循环:候选为 puzzle1/puzzle2(非空),主图用原始 url(对齐工作流 + * 191275 的 data.url)。is_stock=有货 则停;候选全部失败时保持 status=失败 + * (对齐初始 data 语义 1976304)。 + */ + private void compareLoop(SimilarAsinResultRowDto row, String apiKey) { + String mainUrl = row.getUrl(); + if (normalize(mainUrl).isBlank()) { + // 主图缺省:无法对比,保持初始失败语义。 + row.setStatus(STATUS_FAILED); + return; + } + List candidates = new ArrayList<>(2); + if (!normalize(row.getPuzzleImg1()).isBlank()) { + candidates.add(row.getPuzzleImg1()); + } + if (!normalize(row.getPuzzleImg2()).isBlank()) { + candidates.add(row.getPuzzleImg2()); + } + if (candidates.isEmpty()) { + row.setStatus(STATUS_FAILED); + return; + } + row.setStatus(STATUS_FAILED); + for (String candidate : candidates) { + try { + compareImage(row, mainUrl, candidate, apiKey); + } catch (Exception ex) { + log.warn("[similar-asin][llm] compare failed asin={} candidate={} err={}", + row.getAsin(), abbreviate(candidate, 160), failureMessage(ex)); + continue; + } + if (SimilarAsinLlmClient.isStockAvailable(row.getIsStock())) { + break; + } + } + } + + private void compareImage(SimilarAsinResultRowDto row, String mainUrl, String compareUrl, String apiKey) { + String userText = String.format(USER_TEXT_COMPARE, + normalize(row.getAsin()), + normalize(row.getCategory()), + firstNonBlank(row.getTitle(), row.getAsin())); + String content = llmClient.invokeChatWithImages(properties.getLlmImageCompareModel(), + SYSTEM_IMAGE_COMPARE, userText, List.of(mainUrl, compareUrl), apiKey); + JsonNode root = llmClient.parseJsonContent(content); + String asin = textOrEmpty(root, "asin"); + String isStock = textOrEmpty(root, "is_stock"); + String similarity = textOrEmpty(root, "similarity"); + String status = textOrEmpty(root, "status"); + String isConform = textOrEmpty(root, "is_conform"); + String category = textOrEmpty(root, "category"); + if (!asin.isBlank()) { + row.setAsin(asin); + } + row.setIsStock(isStock); + row.setSimilarity(SimilarAsinLlmClient.normalizePercent(similarity)); + row.setStatus(firstNonBlank(status, STATUS_SUCCESS)); + if (!isConform.isBlank()) { + row.setIsConform(isConform); + } + if (!category.isBlank()) { + row.setCategory(category); + } + } + + private List childrenOf(String parentId) { + Long parsedId = null; + if (parentId != null && !parentId.isBlank()) { + try { + parsedId = Long.parseLong(parentId.trim()); + } catch (NumberFormatException ex) { + log.warn("[similar-asin][llm] category parent id not a number id={}", parentId); + return List.of(); + } + } + ProductCategoryListVo vo = productCategoryService.children(parsedId, 1, 100); + List result = new ArrayList<>(); + if (vo == null || vo.getItems() == null) { + return result; + } + for (ProductCategoryItemVo item : vo.getItems()) { + if (item == null || item.getId() == null) { + continue; + } + result.add(new CategoryLevel(String.valueOf(item.getId()), firstNonBlank(item.getName(), ""))); + } + return result; + } + + private String formatCandidates(List candidates) { + StringBuilder sb = new StringBuilder(); + for (CategoryLevel candidate : candidates) { + if (candidate.name().isBlank()) { + continue; + } + if (sb.length() > 0) { + sb.append(","); + } + sb.append(candidate.name()).append("(ID:").append(candidate.id()).append(")"); + } + return sb.toString(); + } + + private boolean isNoMatch(CategoryLevel level) { + return level == null || level.name().isBlank() || NO_MATCH.equals(level.name().trim()); + } + + /** 上传字节到 MinIO supply_images 前缀,返回公网 URL。 */ + private String uploadBytesToSupplyImages(byte[] bytes) { + String objectKey = "supply_images/" + UUID.randomUUID() + ".jpg"; + ossStorageService.uploadBytes(ossProperties.getBucket(), objectKey, bytes, "image/jpeg"); + return ossStorageService.getPublicUrl(objectKey); + } + + /** 下载外部图片并转存到 MinIO supply_images,失败返回 null。 */ + private String uploadExternalUrlToSupplyImages(String url) { + byte[] bytes = downloadBytes(url); + if (bytes == null) { + return null; + } + String objectKey = "supply_images/" + UUID.randomUUID() + ".jpg"; + ossStorageService.uploadBytes(ossProperties.getBucket(), objectKey, bytes, "image/jpeg"); + return ossStorageService.getPublicUrl(objectKey); + } + + private byte[] downloadBytes(String url) { + String trimmed = url == null ? "" : url.trim(); + if (trimmed.isBlank()) { + return null; + } + int timeoutSeconds = Math.max(1, properties.getLlmImageDownloadTimeoutSeconds()); + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(trimmed)) + .timeout(Duration.ofSeconds(timeoutSeconds)) + .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)") + .header("Referer", "https://www.coze.cn") + .GET() + .build(); + HttpResponse response = downloadHttpClient().send(request, HttpResponse.BodyHandlers.ofByteArray()); + if (response.statusCode() == 200 && response.body() != null && response.body().length > 0) { + return response.body(); + } + log.warn("[similar-asin][llm] main download http {} url={}", response.statusCode(), abbreviate(trimmed, 160)); + return null; + } catch (Exception ex) { + log.warn("[similar-asin][llm] main download fail url={} err={}", abbreviate(trimmed, 160), failureMessage(ex)); + return null; + } + } + + private HttpClient downloadHttpClient() { + HttpClient client = downloadHttpClient; + if (client != null) { + return client; + } + synchronized (this) { + if (downloadHttpClient == null) { + downloadHttpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .version(HttpClient.Version.HTTP_1_1) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + } + return downloadHttpClient; + } + } + + private ExecutorService rowExecutor() { + ExecutorService executor = rowExecutor; + if (executor != null) { + return executor; + } + synchronized (this) { + if (rowExecutor == null) { + rowExecutor = Executors.newThreadPerTaskExecutor( + Thread.ofVirtual().name("similar-asin-llm-row-", 0).factory()); + } + return rowExecutor; + } + } + + private SimilarAsinResultRowDto copyRow(SimilarAsinResultRowDto source) { + SimilarAsinResultRowDto row = new SimilarAsinResultRowDto(); + row.setSourceFileKey(source.getSourceFileKey()); + row.setSourceFilename(source.getSourceFilename()); + row.setRowToken(source.getRowToken()); + row.setGroupKey(source.getGroupKey()); + row.setId(source.getId()); + row.setAsin(source.getAsin()); + row.setCountry(source.getCountry()); + row.setSku(source.getSku()); + row.setPrice(source.getPrice()); + row.setUrls(source.getUrls()); + row.setAlibaba(source.getAlibaba()); + row.setTitle(source.getTitle()); + row.setUrl(source.getUrl()); + row.setError(source.getError()); + row.setDone(source.getDone()); + row.setStatus(source.getStatus()); + row.setIsConform(source.getIsConform()); + row.setReason(source.getReason()); + row.setCategory(source.getCategory()); + row.setTitleRisk(source.getTitleRisk()); + row.setAppearanceRisk(source.getAppearanceRisk()); + row.setPatentRisk(source.getPatentRisk()); + row.setConclusion(source.getConclusion()); + row.setIsStock(source.getIsStock()); + row.setSimilarity(source.getSimilarity()); + row.setTitleReason(source.getTitleReason()); + row.setAppearanceReason(source.getAppearanceReason()); + row.setPatentReason(source.getPatentReason()); + row.setMainUrl(source.getMainUrl()); + row.setPuzzleImg1(source.getPuzzleImg1()); + row.setPuzzleImg2(source.getPuzzleImg2()); + return row; + } + + private SimilarAsinResultRowDto markFailed(SimilarAsinResultRowDto row, String failureMessage) { + String message = failureMessage == null || failureMessage.isBlank() ? "货源查询失败" : failureMessage; + if (normalize(row.getError()).isBlank()) { + row.setError(message); + } + if (normalize(row.getReason()).isBlank()) { + row.setReason(message); + } + if (normalize(row.getStatus()).isBlank()) { + row.setStatus(STATUS_FAILED); + } + return row; + } + + private String textOrEmpty(JsonNode node, String field) { + if (node == null || node.isMissingNode() || node.isNull()) { + return ""; + } + JsonNode value = node.get(field); + return value == null || value.isNull() ? "" : normalize(value.asText("")); + } + + private String failureMessage(Exception ex) { + String message = ex == null ? null : ex.getMessage(); + if (message == null || message.isBlank()) { + return ex == null ? "LLM call failed" : ex.getClass().getSimpleName(); + } + return message; + } + + private String abbreviate(String value, int maxLength) { + String normalized = value == null ? "" : value.trim(); + if (normalized.length() <= maxLength) { + return normalized; + } + return normalized.substring(0, Math.max(0, maxLength - 3)) + "..."; + } + + private String firstNonBlank(String preferred, String fallback) { + return preferred == null || preferred.isBlank() ? fallback : preferred.trim(); + } + + private String normalize(String value) { + return value == null ? "" : value.replace(String.valueOf((char) 0xFEFF), "").replace((char) 0x3000, ' ').trim(); + } + + private record CategoryLevel(String id, String name) { + static CategoryLevel none() { + return new CategoryLevel("", NO_MATCH); + } + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java index 86abb21a..a2df65a2 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java @@ -353,6 +353,7 @@ public class SimilarAsinTaskService { private final SimilarAsinFilterConditionMapper filterConditionMapper; private final ObjectMapper objectMapper; private final SimilarAsinCozeClient cozeClient; + private final SimilarAsinLlmService similarAsinLlmService; private final SimilarAsinTaskCacheService taskCacheService; private final SimilarAsinProperties properties; private final TaskFileJobService taskFileJobService; @@ -2333,6 +2334,11 @@ public class SimilarAsinTaskService { return; } try (lockHandle) { + if (properties.isDirectLlmEnabled()) { + // 直连模式:轮询器退化为"兜底调度器",把还挂着 PENDING 的任务 + // 重新调度一次批量提交(submitLlmBatch 同步直连),新任务本就走直连。 + schedulePendingLlmBatches(); + } List states = listOwnedPendingCozeStates(); if (states == null || states.isEmpty()) { return; @@ -2353,6 +2359,50 @@ public class SimilarAsinTaskService { } } + /** + * 直连模式兜底调度:把已封口(提交完成)但仍有 PENDING 状态的任务重新调度一次 + * 批量提交,新批次走 submitLlmBatch 同步直连,由提交路径落 DONE 缓冲/merge。 + */ + private void schedulePendingLlmBatches() { + List states = listOwnedPendingCozeStates(); + if (states == null || states.isEmpty()) { + return; + } + Set taskIds = new LinkedHashSet<>(); + for (TaskScopeStateEntity state : states) { + if (state != null && state.getTaskId() != null) { + taskIds.add(state.getTaskId()); + } + } + log.info("[similar-asin] direct-llm poll fallback scheduling pending tasks count={}", + taskIds.size()); + for (Long taskId : taskIds) { + cozeTaskExecutor.execute(() -> { + TaskDistributedLockService.LockHandle taskLockHandle = acquireTaskLock(taskId, 0L); + if (taskLockHandle == null) { + return; + } + try (taskLockHandle) { + FileTaskEntity task = fileTaskMapper.selectById(taskId); + if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || STATUS_SUCCESS.equals(task.getStatus())) { + return; + } + FileResultEntity result = findOrCreateResultRecordForAssembly(task, allRowCount(task)); + TaskFileJobEntity job = taskFileJobService.enqueueAssembleResult( + task.getId(), MODULE_TYPE, result.getId(), buildTaskOwnerScopeKey(task)); + if (job == null || "SUCCESS".equals(job.getStatus())) { + return; + } + List chunks = taskChunkMapper.selectList(new LambdaQueryWrapper() + .eq(TaskChunkEntity::getTaskId, task.getId()) + .eq(TaskChunkEntity::getModuleType, MODULE_TYPE) + .orderByAsc(TaskChunkEntity::getChunkIndex)); + submitCozeBatches(task, result, job, chunks, loadAllRowsByBaseId(task)); + } + }); + } + } + private void pollPendingCozeStatesForTask(Long taskId, List stateIds) { if (taskId == null || stateIds == null || stateIds.isEmpty()) { return; @@ -2719,6 +2769,10 @@ public class SimilarAsinTaskService { return COZE_STATUS_SUBMITTED.equals(existing.getCozeStatus()) || COZE_STATUS_RUNNING.equals(existing.getCozeStatus()); } + if (properties.isDirectLlmEnabled()) { + return submitLlmBatch(task, result, job, batchRows, batchScopeKey, batchScopeHash, + batchIndex, batchTotal, prompt, apiKey, imgSwitch, categorySwitch, allRowsByBaseId); + } SimilarAsinCozeClient.CozeCredentialRef credential = cozeClient.nextCredential(); try { SimilarAsinCozeClient.CozeSubmitResponse submit = submitCozeWorkflowThrottled( @@ -2779,6 +2833,73 @@ public class SimilarAsinTaskService { } } + /** + * 直连 LLM 模式(directLlmEnabled=true)下的批提交:跳过 Coze 中转, + * 由 SimilarAsinLlmService 逐行跑完整链路(拼图/合规/对比),成功后按 + * 原 Coze 同步 immediate DONE 结果路径集成:scope 去重 → 缓冲或立即 merge。 + * 行级失败信息经空结果检测保留,与 Coze 同步提交失败行为对齐。 + */ + private boolean submitLlmBatch(FileTaskEntity task, + FileResultEntity result, + TaskFileJobEntity job, + List batchRows, + String batchScopeKey, + String batchScopeHash, + int batchIndex, + int batchTotal, + String prompt, + String apiKey, + boolean imgSwitch, + boolean categorySwitch, + Map> allRowsByBaseId) { + List llmRows; + try { + llmRows = similarAsinLlmService.inspectRows(batchRows, prompt, apiKey, imgSwitch, categorySwitch); + } catch (Exception ex) { + String message = firstNonBlank(ex.getMessage(), "LLM submit failed"); + log.warn("[similar-asin] llm submit failed taskId={} jobId={} rows={} batch={}/{} err={}", + task.getId(), job.getId(), batchRows.size(), batchIndex, batchTotal, message); + mergeCozeRowsIntoChunk(task, + null, + null, + cozeClient.markRowsFailed(batchRows, message), + allRowsByBaseId); + return false; + } + if (llmRows == null || llmRows.isEmpty()) { + String message = "LLM submit returned empty result rows"; + log.warn("[similar-asin] llm submit empty taskId={} jobId={} rows={} batch={}/{}", + task.getId(), job.getId(), batchRows.size(), batchIndex, batchTotal); + mergeCozeRowsIntoChunk(task, + null, + null, + cozeClient.markRowsFailed(batchRows, message), + allRowsByBaseId); + return false; + } + String emptyResultMessage = emptyCozeResultMessage(llmRows, batchRows.size()); + if (!emptyResultMessage.isBlank()) { + mergeCozeRowsIntoChunk(task, + null, + null, + cozeClient.markRowsFailed(batchRows, emptyResultMessage), + allRowsByBaseId); + return false; + } + // 落一条 DONE state 承载缓冲 pointer(对齐 Coze 同步 immediate 路径); + // 缓冲失败/关闭时回退立即 merge,结果不丢失。 + if (isCozeResultBufferEnabled()) { + TaskScopeStateEntity doneState = persistImmediateCozeDoneState(task, result, job, batchRows, + batchScopeKey, batchScopeHash, batchIndex, batchTotal, "llm-direct"); + if (doneState != null) { + bufferCozeRowsOrMerge(doneState, readCozeBatchContext(doneState), llmRows, task, allRowsByBaseId); + return false; + } + } + mergeCozeRowsIntoChunk(task, null, null, llmRows, allRowsByBaseId); + return false; + } + private void savePendingCozeBatchState(FileTaskEntity task, FileResultEntity result, TaskFileJobEntity job, @@ -2918,6 +3039,13 @@ public class SimilarAsinTaskService { } return; } + // 直连模式:不再轮询 Coze,直接把存量批次重跑一遍直连 LLM(submitLlmBatch 内部 + // 同步落 DONE 缓冲/merge 并触发 finalize),把历史遗留 PENDING 状态清掉。 + if (properties.isDirectLlmEnabled()) { + if (submitLlmBatchForPendingState(state)) { + return; + } + } if (!(COZE_STATUS_SUBMITTED.equals(state.getCozeStatus()) || COZE_STATUS_RUNNING.equals(state.getCozeStatus()))) { return; } @@ -3031,6 +3159,48 @@ public class SimilarAsinTaskService { } } + /** + * 直连模式下清存量 PENDING 状态:把该 state 的批次载荷重跑一遍直连 LLM, + * 结果落 DONE 缓冲/merge,由 maybeFinalizeCozeJob 触发收尾,最后把 state 置为终态。 + * 重跑失败时先试 split/retry 路径(复用 Coze 分类器与重试语义),仍失败则标记失败。 + * 返回 true 表示本批次已被本轮处理完(调用方直接 return,不再走 Coze 轮询)。 + */ + private boolean submitLlmBatchForPendingState(TaskScopeStateEntity state) { + if (state == null || state.getId() == null || state.getTaskId() == null) { + return false; + } + FileTaskEntity task = taskForPoll(state.getTaskId()); + if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || STATUS_SUCCESS.equals(task.getStatus())) { + return false; + } + List batchRows = readCozeBatchRows(state); + if (batchRows == null || batchRows.isEmpty()) { + markCozeStateTerminal(state, COZE_STATUS_FAILED, "Coze 批次载荷缺失"); + maybeFinalizeCozeJob(state.getTaskId(), readCozeBatchContext(state)); + return true; + } + CozeBatchContext context = readCozeBatchContext(state); + String prompt = readAiPrompt(task); + String apiKey = readApiKey(task); + boolean imgSwitch = readImgSwitch(task); + boolean categorySwitch = readCategorySwitch(task); + boolean submitted = submitLlmBatch(task, null, taskFileJobService.findById( + context == null ? null : context.jobId()), + batchRows, state.getScopeKey(), state.getScopeHash(), + context == null ? 1 : context.batchIndex(), + context == null ? 1 : context.batchTotal(), + prompt, apiKey, imgSwitch, categorySwitch, + allRowsByBaseIdForPoll(task)); + if (submitted) { + return true; + } + // 直连重跑未真正提交(提交异常已被 submitLlmBatch 内部消化为失败 merge): + // 直接把 state 置为终态并触发收尾,避免 PENDING 永远挂着。 + markCozeStateTerminal(state, COZE_STATUS_FAILED, "直连模式重跑批次失败"); + maybeFinalizeCozeJob(state.getTaskId(), context); + return true; + } + private void finalizeTimedOutCozeStatesForTask(Long taskId) { if (taskId == null || taskId <= 0) { return; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/PuzzleImageMerger.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/PuzzleImageMerger.java new file mode 100644 index 00000000..a72a2b99 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/PuzzleImageMerger.java @@ -0,0 +1,310 @@ +package com.nanri.aiimage.modules.similarasin.util; + +import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import javax.imageio.IIOImage; +import javax.imageio.ImageIO; +import javax.imageio.ImageWriteParam; +import javax.imageio.ImageWriter; +import javax.imageio.stream.ImageOutputStream; +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.Font; +import java.awt.FontMetrics; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.math.BigDecimal; +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.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/** + * 详情页图片拼接:复刻 Coze 插件 image_pinjie 的行为。 + * 横版(Orientation=2):alibaba 图片按 4 列网格铺到 2560px 宽画布, + * 每格下方叠加白色价格条(红色粗体、两位小数),整图 JPEG(95) 输出。 + * 图片下载失败时保留原图(URL 回退),与 Coze 插件语义对齐。 + */ +@Component +@Slf4j +public class PuzzleImageMerger { + + private static final int CANVAS_WIDTH = 2560; + private static final int COLS = 4; + private static final float JPEG_QUALITY = 0.95f; + private static final int FALLBACK_FONT_SIZE = 40; + + private final com.nanri.aiimage.config.SimilarAsinProperties properties; + private volatile HttpClient sharedHttpClient; + + public PuzzleImageMerger(com.nanri.aiimage.config.SimilarAsinProperties properties) { + this.properties = properties; + } + + /** + * 拼接一张横版网格图。 + * + * @param items 带 price 的图片项(alibaba 列表);为空时返回 null + * @param sourceRow 原始行(用于缺 url 时兜底 row.getUrl()) + * @return 拼接后的 JPEG 字节,全部图片下载失败时返回 null + */ + public byte[] merge(List items, SimilarAsinResultRowDto sourceRow) { + List urls = new ArrayList<>(); + List prices = new ArrayList<>(); + for (SimilarAsinResultRowDto.AlibabaItem item : items) { + String url = item == null ? "" : item.getUrl(); + if (url == null || url.isBlank()) { + continue; + } + urls.add(url); + Object rawPrice = item == null ? null : item.getRawPrice(); + prices.add(parsePrice(rawPrice)); + } + if (urls.isEmpty() && sourceRow != null) { + List fallbackUrls = sourceRow.getUrls(); + if (fallbackUrls != null && !fallbackUrls.isEmpty()) { + urls.addAll(fallbackUrls); + for (int i = 0; i < fallbackUrls.size(); i++) { + prices.add(parsePrice(sourceRow.getPrice())); + } + } + } + if (urls.isEmpty()) { + return null; + } + return merge(urls, prices); + } + + public byte[] merge(List urls, List prices) { + if (urls == null || urls.isEmpty()) { + return null; + } + List rawImages = new ArrayList<>(urls.size()); + List rawPrices = new ArrayList<>(urls.size()); + List failedUrls = new ArrayList<>(); + for (int i = 0; i < urls.size(); i++) { + String url = urls.get(i); + byte[] raw = downloadImage(url); + if (raw == null) { + failedUrls.add(url); + continue; + } + rawImages.add(raw); + rawPrices.add(i < prices.size() ? prices.get(i) : null); + } + if (rawImages.isEmpty()) { + if (!failedUrls.isEmpty()) { + log.warn("[similar-asin][puzzle] all images download failed urls={}", failedUrls.size()); + } + return null; + } + + BufferedImage[] decoded = new BufferedImage[rawImages.size()]; + boolean anyFailed = false; + for (int i = 0; i < rawImages.size(); i++) { + BufferedImage image = decode(rawImages.get(i)); + if (image == null) { + anyFailed = true; + continue; + } + decoded[i] = image; + } + if (countNonNull(decoded) == 0) { + return null; + } + + boolean hasPrice = rawPrices.stream().anyMatch(price -> price != null); + int cellW = CANVAS_WIDTH / COLS; + BufferedImage firstImage = firstNonNull(decoded); + double aspectRatio = firstImage == null ? 16.0 / 9.0 : (double) firstImage.getHeight() / firstImage.getWidth(); + int imageRegionHeight = (int) Math.floor(cellW * aspectRatio); + int textRegionHeight = hasPrice ? (int) Math.floor(cellW * 0.22) : 0; + int cellH = imageRegionHeight + textRegionHeight; + int rows = Math.max(1, (int) Math.ceil((double) countNonNull(decoded) / COLS)); + int canvasHeight = rows * cellH; + + BufferedImage canvas = new BufferedImage(CANVAS_WIDTH, canvasHeight, BufferedImage.TYPE_INT_RGB); + Graphics2D g = canvas.createGraphics(); + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g.setColor(Color.WHITE); + g.fillRect(0, 0, CANVAS_WIDTH, canvasHeight); + + int drawnIndex = 0; + for (int i = 0; i < decoded.length; i++) { + BufferedImage image = decoded[i]; + if (image == null) { + continue; + } + int row = drawnIndex / COLS; + int col = drawnIndex % COLS; + int left = col * cellW; + int top = row * cellH; + g.drawImage(image, left, top, cellW, imageRegionHeight, null); + BigDecimal price = rawPrices.get(i); + if (hasPrice && price != null) { + drawPrice(g, price, left, top + imageRegionHeight, cellW, textRegionHeight); + } + drawnIndex++; + } + g.dispose(); + + if (anyFailed) { + log.warn("[similar-asin][puzzle] some images failed, used {}/{}", countNonNull(decoded), decoded.length); + } + return encodeJpeg(canvas); + } + + private void drawPrice(Graphics2D g, BigDecimal price, int left, int top, int cellW, int textRegionHeight) { + int fontSize = Math.max(FALLBACK_FONT_SIZE, (int) Math.floor(cellW * 0.15)); + Font font = new Font(Font.SANS_SERIF, Font.BOLD, fontSize); + g.setFont(font); + g.setColor(Color.WHITE); + g.fillRect(left, top, cellW, textRegionHeight); + String priceText = price.setScale(2, BigDecimal.ROUND_HALF_UP).toPlainString(); + FontMetrics metrics = g.getFontMetrics(font); + int textWidth = metrics.stringWidth(priceText); + int x = left + (cellW - textWidth) / 2; + int baseline = top + (textRegionHeight - metrics.getHeight()) / 2 + metrics.getAscent(); + g.setColor(new Color(0xFF4500)); + g.setStroke(new BasicStroke(1.0f)); + g.drawString(priceText, x, baseline); + } + + private BigDecimal parsePrice(Object raw) { + if (raw == null) { + return null; + } + if (raw instanceof Number number) { + return new BigDecimal(number.toString()); + } + String text = String.valueOf(raw).trim(); + if (text.isBlank()) { + return null; + } + try { + return new BigDecimal(text); + } catch (NumberFormatException ex) { + return null; + } + } + + private byte[] downloadImage(String url) { + String trimmed = url == null ? "" : url.trim(); + if (trimmed.isBlank()) { + return null; + } + int timeoutSeconds = Math.max(1, properties.getLlmImageDownloadTimeoutSeconds()); + int attempts = 2; + for (int attempt = 1; attempt <= attempts; attempt++) { + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(trimmed)) + .timeout(Duration.ofSeconds(timeoutSeconds)) + .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)") + .header("Referer", "https://www.coze.cn") + .GET() + .build(); + HttpResponse response = httpClient().send(request, HttpResponse.BodyHandlers.ofByteArray()); + if (response.statusCode() == 200 && response.body() != null && response.body().length > 0) { + return response.body(); + } + log.warn("[similar-asin][puzzle] download http {} url={} attempt={}/{}", + response.statusCode(), abbreviate(trimmed, 160), attempt, attempts); + } catch (Exception ex) { + log.warn("[similar-asin][puzzle] download fail url={} attempt={}/{} err={}", + abbreviate(trimmed, 160), attempt, attempts, ex.getMessage()); + } + } + return null; + } + + private HttpClient httpClient() { + HttpClient client = sharedHttpClient; + if (client != null) { + return client; + } + synchronized (this) { + if (sharedHttpClient == null) { + sharedHttpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .version(HttpClient.Version.HTTP_1_1) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + } + return sharedHttpClient; + } + } + + private BufferedImage decode(byte[] raw) { + try (InputStream stream = new ByteArrayInputStream(raw)) { + return ImageIO.read(stream); + } catch (IOException ex) { + return null; + } + } + + private byte[] encodeJpeg(BufferedImage image) { + Iterator writers = ImageIO.getImageWritersByFormatName("jpg"); + if (!writers.hasNext()) { + return null; + } + ImageWriter writer = writers.next(); + ImageWriteParam param = writer.getDefaultWriteParam(); + param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); + param.setCompressionQuality(JPEG_QUALITY); + try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageOutputStream ios = ImageIO.createImageOutputStream(baos)) { + writer.setOutput(ios); + writer.write(null, new IIOImage(image, null, null), param); + writer.dispose(); + return baos.toByteArray(); + } catch (IOException ex) { + log.warn("[similar-asin][puzzle] encode jpeg failed err={}", ex.getMessage()); + return null; + } finally { + if (writer != null) { + writer.dispose(); + } + } + } + + private static int countNonNull(Object[] array) { + int count = 0; + for (Object value : array) { + if (value != null) { + count++; + } + } + return count; + } + + private static BufferedImage firstNonNull(BufferedImage[] array) { + for (BufferedImage image : array) { + if (image != null) { + return image; + } + } + return null; + } + + private static String abbreviate(String value, int maxLength) { + String normalized = value == null ? "" : value.trim(); + if (normalized.length() <= maxLength) { + return normalized; + } + return normalized.substring(0, Math.max(0, maxLength - 3)) + "..."; + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/LlmGatewayTlsProbe.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/LlmGatewayTlsProbe.java new file mode 100644 index 00000000..acb483bc --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/LlmGatewayTlsProbe.java @@ -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 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()); + } + } + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinLlmLocalVerify.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinLlmLocalVerify.java new file mode 100644 index 00000000..f8ef995d --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinLlmLocalVerify.java @@ -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 [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 [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 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 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 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 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 loadCategories(ObjectMapper objectMapper) throws Exception { + com.fasterxml.jackson.databind.JsonNode root = objectMapper.readTree( + SimilarAsinLlmLocalVerify.class.getResourceAsStream("/categories.json")); + List 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 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) + "..."; + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinLlmServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinLlmServiceTest.java new file mode 100644 index 00000000..85ad1801 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinLlmServiceTest.java @@ -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.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 { + 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> 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 sslSession() { + return java.util.Optional.empty(); + } + } + + + private SimilarAsinLlmClient llmClient(String apiKey, Map 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 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 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 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 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()); + } +}