diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/AppearancePatentProperties.java b/backend-java/src/main/java/com/nanri/aiimage/config/AppearancePatentProperties.java index b6f77eac..2c779b03 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/AppearancePatentProperties.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/AppearancePatentProperties.java @@ -3,36 +3,39 @@ package com.nanri.aiimage.config; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; -import java.util.ArrayList; -import java.util.List; - @Data @ConfigurationProperties(prefix = "aiimage.appearance-patent") public class AppearancePatentProperties { - private String cozeBaseUrl = "https://api.coze.cn"; - private String cozeWorkflowPath = "/v1/workflow/run"; - private String cozeWorkflowHistoryPath = "/v1/workflows/{workflow_id}/run_histories/{execute_id}"; - private String cozeWorkflowId = "7632683471312355338"; - private String cozeToken = ""; - private List cozeCredentials = new ArrayList<>(); - private int cozeCredentialStripeSize = 5; - private int cozeBatchSize = 10; - private int cozeConnectTimeoutMillis = 10000; - private int cozeReadTimeoutMillis = 60000; - private int cozePollIntervalMillis = 30000; - private int cozePollTimeoutMillis = 600000; + + /** + * LLM API(OpenAI 兼容 /v1/chat/completions)地址 + */ + private String llmHost = "https://ai.t8star.org"; + /** + * 商标关键词提取模型 + */ + private String titleModel = "deepseek-v4-flash"; + /** + * 外观检测模型(视觉) + */ + private String appearanceModel = "gemini-3.7-flash"; + private int llmMaxTokens = 64000; + private int llmConnectTimeoutMillis = 10000; + private int llmReadTimeoutMillis = 180000; + private int llmBatchSize = 10; + /** + * 批内行级并发数,默认等于批量大小 + */ + private int llmRowConcurrency = 10; + /** + * 每行每个 LLM 请求的重试次数(含首次) + */ + private int llmRetryTimes = 3; private int staleTimeoutMinutes = 30; private String staleFinalizeCron = "0 */2 * * * *"; /** - * 末尾不足一批的数据等待该时长后强制提交 Coze。 + * 末尾不足一批的数据等待该时长后强制提交检测。 * Python 回传超时后会直接封口;这个配置处理 Python 慢回传但仍未超时的零头批次。 */ - private int cozeFlushPendingMinutes = 1; - - @Data - public static class CozeCredential { - private String name; - private String workflowId; - private String token; - } + private int flushPendingMinutes = 1; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/SimilarAsinProperties.java b/backend-java/src/main/java/com/nanri/aiimage/config/SimilarAsinProperties.java index ae46a6f3..186a7e19 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/SimilarAsinProperties.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/SimilarAsinProperties.java @@ -199,6 +199,38 @@ public class SimilarAsinProperties { */ private long cozeSubmitLockRetryDelayMillis = 500L; + /** + * 货源查询直连 LLM 模式开关(默认 true:新任务与存量 PENDING 批次都走直连 LLM, + * 不再经过 Coze)。false 时回退到原 Coze 工作流链路(轮询/重试状态机保留)。 + */ + private boolean directLlmEnabled = true; + + /** 直连 LLM 的 base url(OpenAI 兼容 /v1/chat/completions)。 */ + private String llmHost = "https://ai.t8star.org"; + + /** 直连 LLM 的 Bearer token;前端未传 api_key 时兜底使用。 */ + private String llmApiKey = ""; + + /** 类目匹配(一级/二级)使用的小模型。 */ + private String llmCategoryModel = "gemini-3.5-flash-lite"; + + /** 合规检查(is_conform/reason/category)使用的小模型。 */ + private String llmConformModel = "gemini-3.5-flash-lite"; + + /** 图片相似度对比(主图 vs 拼接图)使用的模型。 */ + private String llmImageCompareModel = "gemini-3.7-flash"; + + private int llmMaxTokens = 64000; + private int llmConnectTimeoutMillis = 10000; + private int llmReadTimeoutMillis = 180000; + private int llmRetryTimes = 3; + + /** 批内行级并发上限:每行最多 2 次图片对比 + 1 次合规 + 3 次类目匹配。 */ + private int llmRowConcurrency = 5; + + /** 拼接图/主图下载超时(秒),慢源图片较多时放大该值。 */ + private int llmImageDownloadTimeoutSeconds = 10; + @Data public static class CozeCredential { private String name; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClient.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClient.java index 30e8ac14..9e458949 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClient.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClient.java @@ -3,14 +3,14 @@ package com.nanri.aiimage.modules.appearancepatent.client; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.nanri.aiimage.config.AppearancePatentProperties; +import com.nanri.aiimage.config.HttpClientPool; +import com.nanri.aiimage.metrics.ExternalCallMetricsRecorder; import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentResultRowDto; import com.nanri.aiimage.modules.brand.client.BrandCheckClient; -import com.nanri.aiimage.modules.coze.service.CozeCredentialPoolService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; -import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.stereotype.Component; import org.springframework.util.StreamUtils; import org.springframework.web.client.RestClient; @@ -21,90 +21,126 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Semaphore; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +/** + * 外观专利检测:直连 LLM(OpenAI 兼容 /v1/chat/completions), + * 每行并发跑"商标关键词提取"与"外观侵权检测"两个请求,结果语义与原 Coze 工作流对齐。 + */ @Component @RequiredArgsConstructor @Slf4j public class AppearancePatentCozeClient { - private static final String MODULE_TYPE = "APPEARANCE_PATENT"; private static final MediaType APPLICATION_JSON_UTF8 = new MediaType("application", "json", StandardCharsets.UTF_8); private static final String INFRINGEMENT = "侵权"; private static final String NO_INFRINGEMENT = "无侵权"; private static final String BRAND_QUERY_FAILED = "商标查询失败"; - private static final String COZE_ASYNC_POLL_TIMEOUT_MESSAGE = "Coze 异步工作流轮询超时"; + private static final String APPEARANCE_ANOMALY = "外观识别异常"; + private static final String MISSING_ROW_DATA = "爬虫数据缺失"; + + /** 商标关键词提取系统提示词(与原 Coze 工作流一致) */ + private static final String TITLE_SYSTEM_PROMPT = + "你是品牌词提取工具。从用户输入的商品标题中,提取实际出现的品牌、商标、企业、平台名称及违规关键词。\n\n" + + "硬性约束:\n" + + "1. 只提取文本中明确出现的词,严禁推测、联想或补充文本中不存在的内容。\n" + + "2. 仅提取品牌/商标名本体,剔除型号、规格、参数。\n" + + "3. 违规关键词仅限文本中实际出现的绝对化用语、违禁词。\n" + + "4. 去重,保留原文形式。\n\n" + + "### 4. \uD83C\uDFAF 输出格式要求\n" + + "* **拼接符号**:多个关键词之间使用中文逗号 `,` 拼接。\n" + + "* **兜底输出**:如果文本中确实没有任何商标、品牌或违规词,直接输出 `\"无\"`。\n" + + "* **零干扰输出**:**绝对不要**包含任何引言、解释、前缀、多余的空格或标点符号。"; + + /** 外观专利检测系统提示词(与原 Coze 工作流一致) */ + private static final String APPEARANCE_SYSTEM_PROMPT = + "# 角色定义\n" + + "你是一位极其严谨的跨境电商知识产权(IP)律师兼视觉侵权鉴定专家。你的任务是基于用户提供的产品图片和描述,评估该产品在亚马逊等平台销售时的**外观设计(Design Patent)侵权风险**。\n\n" + + "---\n\n" + + "# \uD83D\uDD75\uFE0F 核心判定规则(防幻觉指南)\n" + + "你无法实时查询专利数据库,因此必须基于**“视觉独创性 vs 行业通用性”**的逻辑进行客观推理。请严格遵循以下标准:\n\n" + + "### 1. 判定为【侵权】(高风险)的标准:\n" + + "* **高度相似/傍大牌**:产品的整体形状、特殊轮廓、标志性细节与某个知名品牌(如 Apple, Dyson, Lego, Crocs, Stanley 等)的经典私模产品高度一致。\n" + + "* **极强独创性**:包含非基础几何形状的奇特造型、极其特殊的装饰性纹理或非传统的结构组合(这类高辨识度设计大概率已申请外观专利)。\n\n" + + "### 2. 判定为【无侵权】(低/无风险)的标准:\n" + + "* **公知/通用设计(公模)**:产品属于行业内极其基础、烂大街的通用形状。例如:标准的正方体收纳盒、普通的直筒水杯、毫无装饰的常规数据线。\n" + + "* **纯功能性外观**:外观完全由其功能决定,没有任何多余的装饰性设计。\n\n" + + "### 3. 严格排除项:\n" + + "* 必须**完全忽略**产品上的 Logo、文字商标、图案印花(商标维度的侵权不在本次评估范围内)。只聚焦于“产品的物理轮廓与立体造型”。\n\n" + + "---\n\n" + + "# JSON 输出格式\n" + + "{\n" + + " \"appearance_status\": \"[侵权/无侵权]\",\n" + + " \"appearance_reason\": \"【视觉拆解】:客观描述产品的核心外观形状和特殊设计点。\n" + + "【对比评估】:分析该设计是属于行业通用基础形状,还是模仿了特定的知名产品特征。\n" + + "【判定依据】:基于上述分析,给出最终判定理由。\"\n" + + "}"; + + private static final Pattern STATUS_REGEX = + Pattern.compile("\"appearance_status\"\\s*:\\s*\"([^\"]+)\""); + private static final Pattern STATUS_ESCAPED_REGEX = + Pattern.compile("\\\\\"appearance_status\\\\\"\\s*:\\s*\\\\\"([^\\\\\"]+)\\\\\""); + private static final Pattern REASON_REGEX = + Pattern.compile("\"appearance_reason\"\\s*:\\s*\"([\\s\\S]+?)\"\\s*[}\\n]"); + private static final Pattern REASON_ESCAPED_REGEX = + Pattern.compile("\\\\\"appearance_reason\\\\\"\\s*:\\s*\\\\\"([\\s\\S]+?)\\\\\"\\s*[}\\n]"); private final AppearancePatentProperties properties; private final ObjectMapper objectMapper; - private final CozeCredentialPoolService cozeCredentialPoolService; + private final ExternalCallMetricsRecorder externalCallMetrics; private final BrandCheckClient brandCheckClient; - private final AtomicLong credentialCursor = new AtomicLong(); - public List inspect(List rows, String prompt, String apiKey) { - return inspect(rows, prompt, apiKey, null); - } + private volatile RestClient sharedRestClient; + private volatile ExecutorService rowExecutor; - public List inspect(List rows, String prompt, String apiKey, String patentToken) { + /** + * 批内行级并发检测:每行并行调用商标提取与外观检测两个 LLM 请求, + * 结果按行合并后返回(顺序与入参一致)。 + */ + public List inspectRows(List rows, + String prompt, + String apiKey) { if (rows == null || rows.isEmpty()) { return List.of(); } - if (!hasConfiguredCredential()) { - log.warn("[appearance-patent] coze token not configured, keep raw rows size={}", rows.size()); + if (apiKey == null || apiKey.isBlank()) { + log.warn("[appearance-patent] llm api key not configured, keep raw rows size={}", rows.size()); return rows.stream().map(this::copy).toList(); } - try { - return inspectWithFallback(rows, prompt, apiKey, patentToken); - } catch (Exception ex) { - String failureMessage = failureMessage(ex); - log.warn("[appearance-patent] coze batch failed size={} err={}", rows.size(), failureMessage); - return rows.stream().map(this::copy).map(row -> markFailed(row, failureMessage)).toList(); + Semaphore concurrency = new Semaphore(Math.max(1, properties.getLlmRowConcurrency())); + ExecutorService executor = rowExecutor(); + List> futures = new ArrayList<>(rows.size()); + for (AppearancePatentResultRowDto row : rows) { + futures.add(CompletableFuture.supplyAsync(() -> { + try { + concurrency.acquire(); + try { + return inspectRow(row, prompt, apiKey); + } finally { + concurrency.release(); + } + } catch (InterruptedException interruptedException) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("LLM row inspect interrupted"); + } + }, executor)); } - } - - public CozeSubmitResponse submitWorkflow(List rows, String prompt, String apiKey) throws Exception { - return submitWorkflow(rows, prompt, apiKey, null, nextCredential()); - } - - public CozeSubmitResponse submitWorkflow(List rows, - String prompt, - String apiKey, - String patentToken, - CozeCredentialRef credential) throws Exception { - CozeCredentialRef resolvedCredential = resolveCredential(credential); - JsonNode submitRoot = objectMapper.readTree(postWorkflow(rows, prompt, apiKey, patentToken, resolvedCredential)); - ensureSuccess(submitRoot); - return new CozeSubmitResponse( - extractExecuteId(submitRoot), - extractResultDataText(submitRoot), - writeJson(submitRoot), - resolvedCredential.name() - ); - } - - public CozePollResponse pollWorkflow(String executeId) throws Exception { - return pollWorkflow(executeId, null); - } - - public CozePollResponse pollWorkflow(String executeId, CozeCredentialRef credential) throws Exception { - CozeCredentialRef resolvedCredential = resolveCredential(credential); - JsonNode pollRoot = objectMapper.readTree(getWorkflowHistory(executeId, resolvedCredential)); - ensureSuccess(pollRoot); - String status = normalize(resolveWorkflowStatus(pollRoot)).toUpperCase(Locale.ROOT); - String dataText = extractResultDataText(pollRoot); - String outputText = dataText.isBlank() ? extractWorkflowOutputText(pollRoot) : ""; - String failureMessage = isFailedWorkflowStatus(status) - ? firstNonBlank(resolveFailureMessage(pollRoot), "Coze async workflow failed") - : ""; - return new CozePollResponse(executeId, status, dataText, outputText, failureMessage, writeJson(pollRoot), - resolvedCredential.name()); - } - - public List mergeRowsFromDataText(List rows, String dataText) throws Exception { - if (dataText == null || dataText.isBlank()) { - return rows == null ? List.of() : rows.stream().map(this::copy).toList(); + 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("[appearance-patent] llm row failed index={} asin={} err={}", + i, rows.get(i).getAsin(), failureMessage(ex)); + merged.add(markFailed(copy(rows.get(i)), failureMessage(ex))); + } } - return mergeRows(rows, parseResults(wrapDataPayload(dataText))); + return merged; } public List markRowsFailed(List rows, String failureMessage) { @@ -114,336 +150,333 @@ public class AppearancePatentCozeClient { return rows.stream().map(this::copy).map(row -> markFailed(row, failureMessage)).toList(); } - private List inspectWithFallback(List rows, String prompt, String apiKey, String patentToken) { - try { - if (rows.size() == 1) { - return inspectSingleRowWithRetry(rows, prompt, apiKey, patentToken); - } - InspectAttempt attempt = inspectOnce(rows, prompt, apiKey, patentToken); - if (attempt.resolvedCount() < rows.size()) { - throw new PartialCozeResultException(attempt.resolvedCount(), rows.size(), attempt.rawResultCount()); - } - return attempt.mergedRows(); - } catch (Exception ex) { - if (shouldSplitBatch(rows, ex)) { - int middle = rows.size() / 2; - log.warn("[appearance-patent] coze batch fallback split size={} left={} right={} err={}", - rows.size(), middle, rows.size() - middle, failureMessage(ex)); - List merged = new ArrayList<>(rows.size()); - merged.addAll(inspectPartitionWithFailureFallback(rows.subList(0, middle), prompt, apiKey, patentToken)); - merged.addAll(inspectPartitionWithFailureFallback(rows.subList(middle, rows.size()), prompt, apiKey, patentToken)); - return merged; - } - throw propagate(ex); + public List mergeRowsFromDataText(List rows, String dataText) throws Exception { + if (dataText == null || dataText.isBlank()) { + return rows == null ? List.of() : rows.stream().map(this::copy).toList(); } + return mergeRows(rows, parseResults(wrapDataPayload(dataText))); } - private List inspectPartitionWithFailureFallback(List rows, String prompt, String apiKey, String patentToken) { - try { - return inspectWithFallback(rows, prompt, apiKey, patentToken); - } catch (Exception ex) { - String failureMessage = failureMessage(ex); - log.warn("[appearance-patent] coze partition failed size={} err={}", rows.size(), failureMessage); - return rows.stream().map(this::copy).map(row -> markFailed(row, failureMessage)).toList(); + /** + * 单行检测:与原 Coze 工作流语义对齐。 + * 任一 LLM 失败或数据缺失时走工作流"默认值"分支:appearance=外观识别异常、 + * title 保留原始标题、title_reason/appearance_reason 填充对应错误信息。 + */ + private AppearancePatentResultRowDto inspectRow(AppearancePatentResultRowDto row, String prompt, String apiKey) { + AppearancePatentResultRowDto resultRow = copy(row); + String rawTitle = normalize(row.getTitle()); + if (rawTitle.isBlank() || normalize(row.getUrl()).isBlank()) { + return applyRowFallback(resultRow, rawTitle, + firstNonBlank(rawTitle, MISSING_ROW_DATA), ""); } + String titleKeywords = ""; + String titleError = ""; + boolean titleFailed = false; + try { + titleKeywords = invokeTitle(row, apiKey); + } catch (Exception ex) { + titleFailed = true; + titleError = firstNonBlank(ex.getMessage(), "商标识别失败"); + log.warn("[appearance-patent] title llm failed asin={} title={} err={}", + row.getAsin(), abbreviate(row.getTitle(), 120), failureMessage(ex)); + } + String appearanceStatus = ""; + String appearanceReason = ""; + boolean appearanceFailed = false; + try { + AppearanceJudgement judgement = invokeAppearance(row, prompt, apiKey); + appearanceStatus = judgement.status(); + appearanceReason = judgement.reason(); + } catch (Exception ex) { + appearanceFailed = true; + appearanceReason = firstNonBlank(ex.getMessage(), "外观识别失败"); + log.warn("[appearance-patent] appearance llm failed asin={} title={} url={} err={}", + row.getAsin(), abbreviate(row.getTitle(), 120), abbreviate(row.getUrl(), 120), failureMessage(ex)); + } + if (titleFailed || appearanceFailed) { + String titleReason = titleFailed ? titleError : firstNonBlank(rawTitle, MISSING_ROW_DATA); + return applyRowFallback(resultRow, rawTitle, titleReason, appearanceFailed ? appearanceReason : ""); + } + CozeResult result = new CozeResult( + row.getGroupKey(), + row.getId(), + row.getAsin(), + row.getCountry(), + titleKeywords, + appearanceStatus, + "", + "", + "", + titleKeywords, + appearanceReason, + "", + ""); + applyResult(resultRow, result); + return resultRow; } - private List inspectSingleRowWithRetry(List rows, String prompt, String apiKey, String patentToken) throws Exception { - AppearancePatentResultRowDto row = rows.getFirst(); - PartialCozeResultException lastFailure = null; - for (int attemptIndex = 1; attemptIndex <= 3; attemptIndex++) { + private AppearancePatentResultRowDto applyRowFallback(AppearancePatentResultRowDto row, + String title, + String titleReason, + String appearanceReason) { + CozeResult result = new CozeResult( + row.getGroupKey(), + row.getId(), + row.getAsin(), + row.getCountry(), + title, + APPEARANCE_ANOMALY, + "", + "", + "", + titleReason, + appearanceReason, + "", + ""); + applyResult(row, result); + return row; + } + + private String invokeTitle(AppearancePatentResultRowDto row, String apiKey) throws Exception { + String userText = joinNonBlank(row.getTitle(), sanitizeSku(row.getSku()), ","); + String content = invokeChat(properties.getTitleModel(), TITLE_SYSTEM_PROMPT, userText, + List.of(), apiKey, "text"); + return cleanTitle(content); + } + + private AppearanceJudgement invokeAppearance(AppearancePatentResultRowDto row, String prompt, String apiKey) throws Exception { + String userText = "产品描述:" + nonBlank(row.getTitle(), "") + nonBlank(row.getSku(), ""); + String system = APPEARANCE_SYSTEM_PROMPT; + if (prompt != null && !prompt.isBlank()) { + system = APPEARANCE_SYSTEM_PROMPT + "\n\n额外要求:" + prompt.trim(); + } + String content = invokeChat(properties.getAppearanceModel(), system, userText, + List.of(row.getUrl()), apiKey, "json_object"); + return parseAppearanceJudgement(content); + } + + private String invokeChat(String model, + String system, + String userText, + List images, + String apiKey, + String responseFormat) throws Exception { + int attempts = Math.max(1, properties.getLlmRetryTimes()); + Exception lastFailure = null; + for (int attempt = 1; attempt <= attempts; attempt++) { try { - InspectAttempt attempt = inspectOnce(rows, prompt, apiKey, patentToken); - if (attempt.resolvedCount() == rows.size()) { - return attempt.mergedRows(); - } - log.warn("[appearance-patent] coze single unresolved attempt={} rowId={} asin={} country={} title={} url={} raw={}", - attemptIndex, - row.getId(), - row.getAsin(), - row.getCountry(), - abbreviate(row.getTitle(), 120), - abbreviate(row.getUrl(), 120), - abbreviate(attempt.raw(), 500)); - lastFailure = new PartialCozeResultException(attempt.resolvedCount(), rows.size(), attempt.rawResultCount()); + return invokeChatOnce(model, system, userText, images, apiKey, responseFormat); } catch (Exception ex) { - if (attemptIndex >= 3 || !isRetryableBatchFailure(ex)) { - throw ex; + lastFailure = ex; + if (attempt >= attempts) { + break; } - log.warn("[appearance-patent] coze single retryable failure attempt={} rowId={} asin={} country={} err={}", - attemptIndex, - row.getId(), - row.getAsin(), - row.getCountry(), - failureMessage(ex)); - } - if (attemptIndex < 3) { - sleepBeforeRetry(attemptIndex); + log.warn("[appearance-patent] llm retryable failure attempt={} model={} err={}", + attempt, model, failureMessage(ex)); + sleepBeforeRetry(attempt); } } - throw lastFailure == null ? new PartialCozeResultException(0, rows.size(), 0) : lastFailure; + throw lastFailure == null ? new IllegalStateException("LLM call failed") : lastFailure; } - private InspectAttempt inspectOnce(List rows, String prompt, String apiKey, String patentToken) throws Exception { - String raw = runWorkflowAsyncAndWait(rows, prompt, apiKey, patentToken); - List results = parseResults(raw); - if (rows.size() > 1 && !results.isEmpty() && results.stream().noneMatch(this::hasIdentity)) { - throw new PartialCozeResultException(0, rows.size(), results.size()); - } - List merged = mergeRows(rows, results); - return new InspectAttempt(raw, merged, resolvedCount(merged), results.size()); - } - - private String runWorkflowAsyncAndWait(List rows, String prompt, String apiKey, String patentToken) throws Exception { - CozeCredentialRef credential = nextCredential(); - JsonNode submitRoot = objectMapper.readTree(postWorkflow(rows, prompt, apiKey, patentToken, credential)); - ensureSuccess(submitRoot); - - String immediateData = extractResultDataText(submitRoot); - if (!immediateData.isBlank()) { - return wrapDataPayload(immediateData); - } - - String executeId = extractExecuteId(submitRoot); - if (executeId == null || executeId.isBlank()) { - throw new IllegalStateException("Coze async execute_id missing"); - } - - long deadline = System.currentTimeMillis() + Math.max(10000, properties.getCozePollTimeoutMillis()); - while (System.currentTimeMillis() < deadline) { - ensureNotInterrupted(); - JsonNode pollRoot = objectMapper.readTree(getWorkflowHistory(executeId, credential)); - ensureSuccess(pollRoot); - - String dataText = extractResultDataText(pollRoot); - if (!dataText.isBlank()) { - return wrapDataPayload(dataText); - } - - String status = normalize(resolveWorkflowStatus(pollRoot)).toUpperCase(Locale.ROOT); - if (isFailedWorkflowStatus(status)) { - throw new IllegalStateException(firstNonBlank(resolveFailureMessage(pollRoot), "Coze async workflow failed")); - } - if (isSuccessfulWorkflowStatus(status)) { - String outputText = extractWorkflowOutputText(pollRoot); - if (!outputText.isBlank()) { - return wrapDataPayload(outputText); - } - throw new IllegalStateException("Coze async workflow completed without output"); - } - sleepQuietly(Math.max(200, properties.getCozePollIntervalMillis())); - } - - throw new IllegalStateException("Coze async workflow poll timeout"); - } - - private String postWorkflow(List rows, - String prompt, - String apiKey, - String patentToken, - CozeCredentialRef credential) { - Map parameters = buildParameters(rows, prompt, apiKey, patentToken); - Map body = new LinkedHashMap<>(); - body.put("workflow_id", credential.workflowId()); - body.put("parameters", parameters); - body.put("is_async", Boolean.TRUE); - log.info("[appearance-patent] coze request credential={} url={} body={}", - credential.name(), - joinUrl(properties.getCozeBaseUrl(), properties.getCozeWorkflowPath()), - writeJson(maskCozeRequestBody(body))); - + private String invokeChatOnce(String model, + String system, + String userText, + List images, + String apiKey, + String responseFormat) { + Map body = buildChatBody(model, system, userText, images, responseFormat); + log.info("[appearance-patent] llm request model={} url={} body={}", + model, joinUrl(properties.getLlmHost(), "/v1/chat/completions"), + writeJson(maskChatBody(body))); RestClient.RequestBodySpec request = restClient().post() - .uri(joinUrl(properties.getCozeBaseUrl(), properties.getCozeWorkflowPath())) + .uri(joinUrl(properties.getLlmHost(), "/v1/chat/completions")) .headers(headers -> { - headers.setBearerAuth(stripBearer(credential.token())); + headers.setBearerAuth(stripBearer(apiKey)); headers.setContentType(APPLICATION_JSON_UTF8); headers.set(HttpHeaders.ACCEPT_CHARSET, StandardCharsets.UTF_8.name()); }); request.body(body); - return request.exchange((clientRequest, clientResponse) -> { + String responseText = request.exchange((clientRequest, clientResponse) -> { byte[] responseBytes = StreamUtils.copyToByteArray(clientResponse.getBody()); - String responseText = responseBytes.length == 0 ? "" : new String(responseBytes, StandardCharsets.UTF_8); - log.info("[appearance-patent] coze submit response status={} body={}", - clientResponse.getStatusCode(), - responseText); - return responseText; - }); - } - - private String getWorkflowHistory(String executeId, CozeCredentialRef credential) { - String path = properties.getCozeWorkflowHistoryPath() - .replace("{workflow_id}", credential.workflowId()) - .replace("{execute_id}", executeId); - return restClient().get() - .uri(joinUrl(properties.getCozeBaseUrl(), path)) - .headers(headers -> { - headers.setBearerAuth(stripBearer(credential.token())); - headers.setContentType(APPLICATION_JSON_UTF8); - headers.set(HttpHeaders.ACCEPT_CHARSET, StandardCharsets.UTF_8.name()); - }) - .exchange((clientRequest, clientResponse) -> { - byte[] responseBytes = StreamUtils.copyToByteArray(clientResponse.getBody()); - String responseText = responseBytes.length == 0 ? "" : new String(responseBytes, StandardCharsets.UTF_8); - log.info("[appearance-patent] coze history response credential={} executeId={} status={} body={}", - credential.name(), executeId, - clientResponse.getStatusCode(), - responseText); - return responseText; - }); - } - - public CozeCredentialRef nextCredential() { - List pooledCredentials = cozeCredentialPoolService.listEnabled(MODULE_TYPE); - CozeCredentialPoolService.CozeCredential pooledCredential = - cozeCredentialPoolService.chooseRoundRobin(MODULE_TYPE, pooledCredentials, properties.getCozeCredentialStripeSize()); - if (pooledCredential != null) { - return new CozeCredentialRef(pooledCredential.name(), pooledCredential.workflowId(), pooledCredential.token(), - pooledCredential.maxConcurrent()); - } - List credentials = configuredCredentials(); - int stripeSize = Math.max(1, properties.getCozeCredentialStripeSize()); - long cursor = Math.max(0L, credentialCursor.getAndIncrement()); - int index = (int) ((cursor / stripeSize) % credentials.size()); - return credentials.get(index); - } - - public CozeCredentialRef credentialByName(String name) { - if (name == null || name.isBlank()) { - return nextCredential(); - } - String normalizedName = normalize(name); - for (CozeCredentialRef credential : configuredCredentials()) { - if (normalize(credential.name()).equals(normalizedName)) { - return credential; + 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.info("[appearance-patent] 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")); } - return nextCredential(); - } - - public boolean hasConfiguredCredential() { - return !configuredCredentials().isEmpty(); - } - - public int configuredCredentialCount() { - return configuredCredentials().size(); - } - - private CozeCredentialRef resolveCredential(CozeCredentialRef credential) { - return credential == null ? nextCredential() : credential; - } - - private List configuredCredentials() { - List credentials = new ArrayList<>(); - for (CozeCredentialPoolService.CozeCredential credential : cozeCredentialPoolService.listEnabled(MODULE_TYPE)) { - credentials.add(new CozeCredentialRef(credential.name(), credential.workflowId(), credential.token(), - credential.maxConcurrent())); + 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"); } - if (!credentials.isEmpty()) { - return credentials; - } - if (properties.getCozeCredentials() != null) { - int index = 1; - for (AppearancePatentProperties.CozeCredential credential : properties.getCozeCredentials()) { - if (credential == null - || normalize(credential.getWorkflowId()).isBlank() - || normalize(credential.getToken()).isBlank()) { + 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; } - String name = firstNonBlank(credential.getName(), "credential-" + index); - credentials.add(new CozeCredentialRef(name, credential.getWorkflowId(), credential.getToken(), Integer.MAX_VALUE)); - index++; + 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); } - if (credentials.isEmpty() - && properties.getCozeWorkflowId() != null && !properties.getCozeWorkflowId().isBlank() - && properties.getCozeToken() != null && !properties.getCozeToken().isBlank()) { - credentials.add(new CozeCredentialRef("default", properties.getCozeWorkflowId(), properties.getCozeToken(), Integer.MAX_VALUE)); - } - return credentials; - } - - private Map buildParameters(List rows, String prompt, String apiKey, String patentToken) { - List groupKeys = rows.stream().map(row -> nonBlank(row.getGroupKey(), rowKey(row))).toList(); - List rowIds = rows.stream().map(row -> nonBlank(row.getId(), "")).toList(); - List asins = rows.stream().map(row -> nonBlank(row.getAsin(), "")).toList(); - List countries = rows.stream().map(row -> nonBlank(row.getCountry(), "")).toList(); - List skus = rows.stream().map(row -> sanitizeSku(row.getSku())).toList(); - List titles = rows.stream().map(row -> nonBlank(row.getTitle(), row.getAsin())).toList(); - List urls = rows.stream().map(row -> nonBlank(row.getUrl(), "")).toList(); - - Map parameters = new LinkedHashMap<>(); - parameters.put("title_list", titles); - parameters.put("url_list", urls); - parameters.put("items", buildItemObjects(rows, groupKeys, rowIds, asins, countries, skus, titles, urls)); - // 前端没填 prompt 就一律不向 Coze 透传该字段,避免无关默认提示词污染工作流。 - if (prompt != null && !prompt.isBlank()) { - parameters.put("prompt", prompt); - } - if (apiKey != null && !apiKey.isBlank()) { - parameters.put("api_key", apiKey.trim()); - } - if (patentToken != null && !patentToken.isBlank()) { - parameters.put("patent_token", patentToken.trim()); - } - return parameters; + messages.add(userMessage); + body.put("messages", messages); + return body; } @SuppressWarnings("unchecked") - private Map maskCozeRequestBody(Map body) { + private Map maskChatBody(Map body) { Map masked = new LinkedHashMap<>(body); - Object parametersObj = masked.get("parameters"); - if (parametersObj instanceof Map parameters) { - Map maskedParameters = new LinkedHashMap<>((Map) parameters); - Object apiKey = maskedParameters.get("api_key"); - if (apiKey instanceof String apiKeyText && !apiKeyText.isBlank()) { - maskedParameters.put("api_key", maskSecret(apiKeyText)); + 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); + } } - Object patentToken = maskedParameters.get("patent_token"); - if (patentToken instanceof String patentTokenText && !patentTokenText.isBlank()) { - maskedParameters.put("patent_token", maskSecret(patentTokenText)); - } - masked.put("parameters", maskedParameters); + masked.put("messages", maskedMessages); } return masked; } - private String maskSecret(String secret) { - String normalized = secret == null ? "" : secret.trim(); - if (normalized.isBlank()) { - return ""; + /** + * 外观 LLM 输出的 JSON 解包:循环剥离字符串包裹(```json 标记、换行转义修复), + * 失败时用正则兜底提取 appearance_status / appearance_reason。 + */ + private AppearanceJudgement parseAppearanceJudgement(String content) { + String status = ""; + String reason = ""; + 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 (normalized.length() <= 10) { - return "***"; + if (current instanceof Map map) { + status = asText(map.get("appearance_status")); + reason = asText(map.get("appearance_reason")); } - return normalized.substring(0, 6) + "***" + normalized.substring(normalized.length() - 4); + if (status == null || status.isBlank() || reason == null || reason.isBlank()) { + String raw = String.valueOf(content); + if (status == null || status.isBlank()) { + String extracted = extractByRegex(raw, STATUS_REGEX, STATUS_ESCAPED_REGEX); + if (extracted != null) { + status = extracted; + } + } + if (reason == null || reason.isBlank()) { + String extracted = extractByRegex(raw, REASON_REGEX, REASON_ESCAPED_REGEX); + if (extracted != null) { + reason = extracted.replace("\\n", "\n").replace("\\\"", "\"").replace("\\r", ""); + } + } + } + String finalStatus = status == null || status.isBlank() ? NO_INFRINGEMENT : status; + return new AppearanceJudgement(finalStatus, reason == null ? "" : reason); } - private List> buildItemObjects(List rows, - List groupKeys, - List rowIds, - List asins, - List countries, - List skus, - List titles, - List urls) { - List> items = new ArrayList<>(rows.size()); - for (int i = 0; i < rows.size(); i++) { - Map item = new LinkedHashMap<>(); - item.put("group_key", groupKeys.get(i)); - item.put("row_id", rowIds.get(i)); - item.put("asin", asins.get(i)); - item.put("country", countries.get(i)); - item.put("sku", skus.get(i)); - item.put("title", titles.get(i)); - item.put("url", urls.get(i)); - items.add(item); + private String extractByRegex(String raw, Pattern plainPattern, Pattern escapedPattern) { + Matcher matcher = plainPattern.matcher(raw); + if (matcher.find()) { + return matcher.group(1); + } + matcher = escapedPattern.matcher(raw); + if (matcher.find()) { + return matcher.group(1); + } + return null; + } + + 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); } - return items; } private List parseResults(String raw) throws Exception { JsonNode root = objectMapper.readTree(raw); - ensureSuccess(root); String dataText = extractResultDataText(root); if (dataText.isBlank()) { return List.of(); @@ -582,7 +615,7 @@ public class AppearancePatentCozeClient { || !normalize(result.asin()).isBlank(); } - private void applyResult(AppearancePatentResultRowDto row, CozeResult result) { + void applyResult(AppearancePatentResultRowDto row, CozeResult result) { if (row == null || result == null) { return; } @@ -658,11 +691,62 @@ public class AppearancePatentCozeClient { return normalize(value).contains(BRAND_QUERY_FAILED); } + private String extractResultDataText(JsonNode root) { + if (root == null || root.isMissingNode() || root.isNull()) { + return ""; + } + JsonNode dataNode = root.path("data"); + if (dataNode.isTextual()) { + return dataNode.asText(""); + } + return ""; + } + + private String wrapDataPayload(String dataText) { + Map payload = new LinkedHashMap<>(); + payload.put("code", 0); + payload.put("data", dataText); + return writeJson(payload); + } + + private String writeJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (Exception ex) { + throw new IllegalStateException("Failed to serialize LLM payload", ex); + } + } + private RestClient restClient() { - SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); - requestFactory.setConnectTimeout(properties.getCozeConnectTimeoutMillis()); - requestFactory.setReadTimeout(properties.getCozeReadTimeoutMillis()); - return RestClient.builder().requestFactory(requestFactory).build(); + 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 ExecutorService rowExecutor() { + ExecutorService executor = rowExecutor; + if (executor != null) { + return executor; + } + synchronized (this) { + if (rowExecutor == null) { + rowExecutor = Executors.newThreadPerTaskExecutor( + Thread.ofVirtual().name("appearance-llm-row-", 0).factory()); + } + return rowExecutor; + } } private AppearancePatentResultRowDto copy(AppearancePatentResultRowDto source) { @@ -694,21 +778,9 @@ public class AppearancePatentCozeClient { } private AppearancePatentResultRowDto markFailed(AppearancePatentResultRowDto row, String failureMessage) { - if (isCozeAsyncPollTimeout(failureMessage)) { - row.setError(failureMessage); - if (row.getStatus() == null || row.getStatus().isBlank()) { - row.setStatus("FAILED"); - row.setFailureSyntheticStatus(true); - } - row.setTitleRisk(null); - row.setAppearanceRisk(null); - row.setPatentRisk(null); - row.setConclusion(null); - return row; - } String reviewMessage = failureMessage == null || failureMessage.isBlank() - ? "Coze 检测失败" - : "Coze 检测失败:" + failureMessage; + ? "检测失败" + : "检测失败:" + failureMessage; if (row.getError() == null || row.getError().isBlank()) { row.setError(failureMessage); } @@ -733,324 +805,6 @@ public class AppearancePatentCozeClient { return row; } - private boolean isCozeAsyncPollTimeout(String failureMessage) { - String normalized = normalize(failureMessage).toLowerCase(Locale.ROOT); - return normalized.contains(COZE_ASYNC_POLL_TIMEOUT_MESSAGE.toLowerCase(Locale.ROOT)) - || normalized.contains("coze async workflow poll timeout"); - } - - private boolean shouldSplitBatch(List rows, Exception ex) { - return rows != null && rows.size() > 1 && isRetryableBatchFailure(ex); - } - - private boolean isRetryableBatchFailure(Exception ex) { - if (ex instanceof PartialCozeResultException) { - return true; - } - String message = ex == null ? "" : nonBlank(ex.getMessage(), ""); - return message.contains("Workflow node execution limit exceeded") - || message.contains("Read timed out") - || message.contains("Connection reset") - || message.contains("I/O error on POST request") - || message.toLowerCase(Locale.ROOT).contains("timeout"); - } - - private int resolvedCount(List rows) { - int resolved = 0; - for (AppearancePatentResultRowDto row : rows) { - if (hasResolvedCozeFields(row)) { - resolved++; - } - } - return resolved; - } - - private boolean hasResolvedCozeFields(AppearancePatentResultRowDto row) { - if (row == null) { - return false; - } - return !normalize(row.getTitleRisk()).isBlank() - || !normalize(row.getAppearanceRisk()).isBlank() - || !normalize(row.getPatentRisk()).isBlank() - || !normalize(row.getConclusion()).isBlank(); - } - - private void ensureSuccess(JsonNode root) { - if (root.path("code").asInt(-1) != 0) { - throw new IllegalStateException(root.path("msg").asText("Coze response code is not 0")); - } - } - - private String extractResultDataText(JsonNode root) { - if (root == null || root.isMissingNode() || root.isNull()) { - return ""; - } - - JsonNode dataNode = root.path("data"); - if (dataNode.isTextual()) { - String value = dataNode.asText(""); - if (looksLikeResultDataPayload(value)) { - return value; - } - return discoverEmbeddedData(parseJsonOrMissing(value)); - } - if (dataNode.isObject()) { - String nested = text(firstNonNull(dataNode.get("data"), firstNonNull(dataNode.get("output"), dataNode.get("result")))); - if (nested != null && !nested.isBlank() && looksLikeResultDataPayload(nested)) { - return nested; - } - JsonNode outputs = firstNonNull(dataNode.get("outputs"), dataNode.get("details")); - String discovered = discoverEmbeddedData(outputs); - if (!discovered.isBlank()) { - return discovered; - } - } - - return discoverEmbeddedData(root); - } - - private String extractWorkflowOutputText(JsonNode root) { - JsonNode outputNode = findFirstField(root, "output"); - if (outputNode == null || outputNode.isNull() || outputNode.isMissingNode()) { - return ""; - } - if (!outputNode.isTextual()) { - String discovered = discoverEmbeddedData(outputNode); - return discovered.isBlank() && isResultDataPayload(outputNode) ? outputNode.toString() : discovered; - } - String output = normalize(outputNode.asText("")); - if (output.isBlank()) { - return ""; - } - if (looksLikeResultDataPayload(output)) { - return output; - } - JsonNode parsedOutput = parseJsonOrMissing(output); - String nestedOutput = text(firstNonNull(parsedOutput.get("Output"), parsedOutput.get("output"))); - if (nestedOutput != null && !nestedOutput.isBlank() && looksLikeResultDataPayload(nestedOutput)) { - return nestedOutput; - } - return discoverEmbeddedData(parsedOutput); - } - - private String discoverEmbeddedData(JsonNode node) { - if (node == null || node.isNull() || node.isMissingNode()) { - return ""; - } - if (node.isTextual()) { - String value = node.asText(""); - if (looksLikeResultDataPayload(value)) { - return value; - } - return discoverEmbeddedData(parseJsonOrMissing(value)); - } - if (node.isArray()) { - for (JsonNode child : node) { - String discovered = discoverEmbeddedData(child); - if (!discovered.isBlank()) { - return discovered; - } - } - return ""; - } - if (node.isObject()) { - if (isResultDataPayload(node)) { - return node.toString(); - } - for (java.util.Iterator> it = node.fields(); it.hasNext(); ) { - Map.Entry entry = it.next(); - String discovered = discoverEmbeddedData(entry.getValue()); - if (!discovered.isBlank()) { - return discovered; - } - } - } - return ""; - } - - private boolean looksLikeResultDataPayload(String value) { - JsonNode parsed = parseJsonOrMissing(value); - return isResultDataPayload(parsed); - } - - private boolean isResultDataPayload(JsonNode node) { - if (node == null || node.isMissingNode() || node.isNull()) { - return false; - } - JsonNode array = node.path("data"); - if (!array.isArray()) { - return false; - } - for (JsonNode item : array) { - JsonNode itemNode = resultItemNode(item); - if (hasResultFields(item) || hasResultFields(itemNode)) { - return true; - } - } - return false; - } - - private boolean hasResultFields(JsonNode item) { - return item != null - && (item.has("appearance") - || item.has("appearance_risk") - || item.has("patent") - || item.has("patent ") - || item.has("patent_risk") - || item.has("result") - || item.has("conclusion") - || item.has("title_reason") - || item.has("titleReason") - || item.has("appearance_reason") - || item.has("appearanceReason") - || item.has("patent_reason") - || item.has("patentReason") - || item.has("patent reason") - || item.has("score") - || item.has("Score")); - } - - private boolean isSuccessfulWorkflowStatus(String status) { - String normalized = normalize(status).toUpperCase(Locale.ROOT); - return normalized.equals("SUCCESS") - || normalized.equals("SUCCEEDED") - || normalized.equals("COMPLETED") - || normalized.equals("COMPLETE") - || normalized.equals("DONE"); - } - - private boolean isFailedWorkflowStatus(String status) { - String normalized = normalize(status).toUpperCase(Locale.ROOT); - return normalized.contains("FAIL") - || normalized.contains("ERROR") - || normalized.contains("CANCEL"); - } - - private JsonNode parseJsonOrMissing(String value) { - String normalized = normalize(value); - if (!(normalized.startsWith("{") || normalized.startsWith("["))) { - return objectMapper.missingNode(); - } - try { - return objectMapper.readTree(normalized); - } catch (Exception ignored) { - return objectMapper.missingNode(); - } - } - - private String resolveWorkflowStatus(JsonNode root) { - JsonNode dataNode = root.path("data"); - JsonNode statusNode = firstNonNull( - firstNonNull(dataNode.get("status"), dataNode.get("execute_status")), - firstNonNull(root.get("status"), root.get("execute_status"))); - String status = text(statusNode); - if (status != null && !status.isBlank()) { - return status; - } - return findTextByFieldName(root, "execute_status", "status"); - } - - private String resolveFailureMessage(JsonNode root) { - JsonNode dataNode = root.path("data"); - String message = text(firstNonNull(dataNode.get("error_message"), firstNonNull(dataNode.get("msg"), root.get("msg")))); - if (message != null && !message.isBlank()) { - return message; - } - return firstNonBlank(findTextByFieldName(root, "error_message", "error", "msg"), ""); - } - - private String extractExecuteId(JsonNode root) { - return findTextByFieldName(root, "execute_id", "executeId"); - } - - private JsonNode findFirstField(JsonNode node, String name) { - if (node == null || node.isMissingNode() || node.isNull() || name == null || name.isBlank()) { - return objectMapper.missingNode(); - } - if (node.isTextual()) { - return findFirstField(parseJsonOrMissing(node.asText("")), name); - } - if (node.isArray()) { - for (JsonNode child : node) { - JsonNode found = findFirstField(child, name); - if (found != null && !found.isMissingNode() && !found.isNull()) { - return found; - } - } - return objectMapper.missingNode(); - } - if (node.isObject()) { - JsonNode direct = node.get(name); - if (direct != null && !direct.isMissingNode() && !direct.isNull()) { - return direct; - } - for (java.util.Iterator> it = node.fields(); it.hasNext(); ) { - Map.Entry entry = it.next(); - JsonNode found = findFirstField(entry.getValue(), name); - if (found != null && !found.isMissingNode() && !found.isNull()) { - return found; - } - } - } - return objectMapper.missingNode(); - } - - private String findTextByFieldName(JsonNode node, String... names) { - if (node == null || node.isMissingNode() || node.isNull()) { - return ""; - } - if (node.isTextual()) { - return findTextByFieldName(parseJsonOrMissing(node.asText("")), names); - } - if (node.isArray()) { - for (JsonNode child : node) { - String found = findTextByFieldName(child, names); - if (!found.isBlank()) { - return found; - } - } - return ""; - } - if (node.isObject()) { - for (String name : names) { - String value = text(node.get(name)); - if (value != null && !value.isBlank()) { - return value; - } - } - JsonNode dataNode = node.get("data"); - if (dataNode != null && dataNode.isTextual()) { - String found = findTextByFieldName(parseJsonOrMissing(dataNode.asText("")), names); - if (!found.isBlank()) { - return found; - } - } - for (java.util.Iterator> it = node.fields(); it.hasNext(); ) { - Map.Entry entry = it.next(); - String found = findTextByFieldName(entry.getValue(), names); - if (!found.isBlank()) { - return found; - } - } - } - return ""; - } - - private String wrapDataPayload(String dataText) { - Map payload = new LinkedHashMap<>(); - payload.put("code", 0); - payload.put("data", dataText); - return writeJson(payload); - } - - private String writeJson(Object value) { - try { - return objectMapper.writeValueAsString(value); - } catch (Exception ex) { - throw new IllegalStateException("Failed to serialize Coze payload", ex); - } - } - private String abbreviate(String value, int maxLength) { String normalized = value == null ? "" : value.trim(); if (normalized.length() <= maxLength) { @@ -1060,9 +814,7 @@ public class AppearancePatentCozeClient { } private void sleepBeforeRetry(int attemptIndex) { - long delayMillis = Math.max(1, attemptIndex) * 1500L; - ensureNotInterrupted(); - sleepQuietly(delayMillis); + sleepQuietly(Math.max(1, attemptIndex) * 1500L); } private void sleepQuietly(long delayMillis) { @@ -1070,33 +822,29 @@ public class AppearancePatentCozeClient { Thread.sleep(delayMillis); } catch (InterruptedException interruptedException) { Thread.currentThread().interrupt(); - throw new IllegalStateException("Coze workflow interrupted", interruptedException); + throw new IllegalStateException("LLM retry interrupted", interruptedException); } } - private void ensureNotInterrupted() { - if (Thread.currentThread().isInterrupted()) { - throw new IllegalStateException("Coze workflow interrupted"); + private String joinNonBlank(String first, String second, String separator) { + String normalizedFirst = normalize(first); + String normalizedSecond = normalize(second); + if (normalizedSecond.isBlank()) { + return normalizedFirst; } - } - - private RuntimeException propagate(Exception ex) { - if (ex instanceof RuntimeException runtimeException) { - return runtimeException; + if (normalizedFirst.isBlank()) { + return normalizedSecond; } - return new IllegalStateException(nonBlank(ex.getMessage(), "Coze call failed"), ex); + return normalizedFirst + separator + normalizedSecond; } - private JsonNode firstNonNull(JsonNode left, JsonNode right) { - return left == null || left.isNull() ? right : left; + private String cleanTitle(String value) { + String normalized = value == null ? "" : value.replaceAll("[\\r\\n]+", "").replaceAll("\\s+", " ").trim(); + return normalized; } - private String text(JsonNode node) { - return node == null || node.isNull() ? null : node.asText(); - } - - private String nonBlank(String value, String fallback) { - return value == null || value.isBlank() ? fallback : value; + private String asText(Object value) { + return value == null ? null : String.valueOf(value); } private String sanitizeSku(String value) { @@ -1125,6 +873,10 @@ public class AppearancePatentCozeClient { || lower.contains(" capacity:"); } + private String nonBlank(String value, String fallback) { + return value == null || value.isBlank() ? fallback : value; + } + private String firstNonBlank(String preferred, String fallback) { return preferred == null || preferred.isBlank() ? fallback : preferred.trim(); } @@ -1133,32 +885,16 @@ public class AppearancePatentCozeClient { return value == null ? "" : value.replace(String.valueOf((char) 0xFEFF), "").replace((char) 0x3000, ' ').trim(); } - private String rowKey(AppearancePatentResultRowDto row) { - if (row == null) { - return ""; - } - return rowKey(row.getId(), row.getAsin(), row.getCountry()); - } - private String rowKey(String rowId, String asin, String country) { return normalize(rowId) + "::" + normalize(asin).toUpperCase(Locale.ROOT) + "::" + normalize(country); } private String failureMessage(Exception ex) { - if (ex instanceof PartialCozeResultException partial) { - return "Coze result incomplete(" + partial.resolvedCount() + "/" + partial.expectedCount() + ")"; - } String message = ex == null ? null : ex.getMessage(); if (message == null || message.isBlank()) { - return "Coze call failed"; + return "LLM call failed"; } - if (message.contains("Workflow node execution limit exceeded")) { - return "Coze workflow node execution limit exceeded"; - } - if (message.contains("Read timed out")) { - return "Coze call timed out"; - } - return "Coze call failed: " + message; + return message; } private String stripBearer(String token) { @@ -1178,6 +914,14 @@ public class AppearancePatentCozeClient { return base + suffix; } + private JsonNode firstNonNull(JsonNode left, JsonNode right) { + return left == null || left.isNull() ? right : left; + } + + private String text(JsonNode node) { + return node == null || node.isNull() ? null : node.asText(); + } + private record CozeResult( String groupKey, String rowId, @@ -1195,87 +939,9 @@ public class AppearancePatentCozeClient { ) { } - private record InspectAttempt( - String raw, - List mergedRows, - int resolvedCount, - int rawResultCount - ) { - } - - public record CozeSubmitResponse( - String executeId, - String immediateData, - String rawResponse, - String credentialName - ) { - } - - public record CozePollResponse( - String executeId, + private record AppearanceJudgement( String status, - String dataText, - String outputText, - String failureMessage, - String rawResponse, - String credentialName + String reason ) { - public boolean hasPayload() { - return dataText != null && !dataText.isBlank() || outputText != null && !outputText.isBlank(); - } - - public String resolvedPayloadText() { - return dataText != null && !dataText.isBlank() ? dataText : outputText; - } - - public boolean isFailed() { - String normalized = status == null ? "" : status.trim().toUpperCase(Locale.ROOT); - return normalized.contains("FAIL") || normalized.contains("ERROR") || normalized.contains("CANCEL"); - } - - public boolean isFinished() { - String normalized = status == null ? "" : status.trim().toUpperCase(Locale.ROOT); - return isFailed() - || normalized.contains("SUCCESS") - || normalized.contains("SUCCEED") - || normalized.contains("FINISH") - || normalized.contains("DONE") - || normalized.contains("COMPLET"); - } - } - - public record CozeCredentialRef( - String name, - String workflowId, - String token, - int maxConcurrent - ) { - } - - private static final class PartialCozeResultException extends RuntimeException { - - private final int resolvedCount; - private final int expectedCount; - private final int rawResultCount; - - private PartialCozeResultException(int resolvedCount, int expectedCount, int rawResultCount) { - super("partial-result resolved=" + resolvedCount + "/" + expectedCount + " raw=" + rawResultCount); - this.resolvedCount = resolvedCount; - this.expectedCount = expectedCount; - this.rawResultCount = rawResultCount; - } - - private int resolvedCount() { - return resolvedCount; - } - - private int expectedCount() { - return expectedCount; - } - - @SuppressWarnings("unused") - private int rawResultCount() { - return rawResultCount; - } } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/controller/AppearancePatentController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/controller/AppearancePatentController.java index 0ac7c5c7..bc666dca 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/controller/AppearancePatentController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/controller/AppearancePatentController.java @@ -35,7 +35,7 @@ import java.nio.charset.StandardCharsets; @RestController @RequiredArgsConstructor @RequestMapping("/api/appearance-patent") -@Tag(name = "外观专利检测", description = "外观专利检测任务接口。前端上传 Excel 后由 Java 解析并创建任务;Python 回传商品数据;Java 负责攒批调用 Coze、补齐子行、生成最终 xlsx 并上传 OSS。") +@Tag(name = "外观专利检测", description = "外观专利检测任务接口。前端上传 Excel 后由 Java 解析并创建任务;Python 回传商品数据;Java 负责攒批调用 LLM 检测、补齐子行、生成最终 xlsx 并上传 OSS。") public class AppearancePatentController { private final AppearancePatentTaskService service; @@ -110,7 +110,7 @@ public class AppearancePatentController { } @PostMapping("/tasks/{taskId}/result") - @Operation(summary = "提交 Python 回传结果", description = "Python 回传商品数据接口。items 可以是一条或多条;Java 先原样保存回传数据,再内部攒够 10 条调用 Coze。done=true 表示 Python 已完成全部回传,Java 会强制处理剩余不足 10 条的数据并生成最终 xlsx。") + @Operation(summary = "提交 Python 回传结果", description = "Python 回传商品数据接口。items 可以是一条或多条;Java 先原样保存回传数据,再内部攒够 10 条调用 LLM 检测。done=true 表示 Python 已完成全部回传,Java 会强制处理剩余不足 10 条的数据并生成最终 xlsx。") public ApiResponse result( @Parameter(description = "外观专利检测任务 ID。任务必须处于 RUNNING 状态。", required = true, example = "3938") @PathVariable Long taskId, diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentParseRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentParseRequest.java index dde77c23..a03912bc 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentParseRequest.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentParseRequest.java @@ -24,17 +24,17 @@ public class AppearancePatentParseRequest { @JsonProperty("ai_prompt") @JsonAlias({"aiPrompt", "prompt"}) - @Schema(description = "AI 提示词。非必填;为空时前端会使用默认提示词。后端会保存该提示词,并在调用 Coze workflow 时作为 prompt 参数传入。", example = "请排查这些亚马逊商品在英国及欧洲地区是否存在知识产权侵权风险。") + @Schema(description = "AI 提示词。非必填;为空时前端会使用默认提示词。后端会保存该提示词,并在调用 LLM 时作为附加要求传入。", example = "请排查这些亚马逊商品在英国及欧洲地区是否存在知识产权侵权风险。") private String aiPrompt; @JsonProperty("api_key") @JsonAlias({"apiKey"}) - @Schema(description = "传递给 Coze workflow parameters.api_key 的任务级密钥。") + @Schema(description = "调用 LLM API 的任务级密钥。") @NotBlank(message = "密钥不能为空") private String apiKey; @JsonProperty("patent_token") @JsonAlias({"patentToken"}) - @Schema(description = "传递给 Coze workflow parameters.patent_token 的专利汇令牌。非必填。") + @Schema(description = "专利汇令牌。非必填。") private String patentToken; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentParsedPayloadDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentParsedPayloadDto.java index 277b7303..6d4c27be 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentParsedPayloadDto.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentParsedPayloadDto.java @@ -14,10 +14,10 @@ public class AppearancePatentParsedPayloadDto { @Schema(description = "AI 提示词") private String aiPrompt; - @Schema(description = "传递给 Coze workflow parameters.api_key 的任务级密钥") + @Schema(description = "调用 LLM API 的任务级密钥") private String apiKey; - @Schema(description = "传递给 Coze workflow parameters.patent_token 的专利汇令牌") + @Schema(description = "专利汇令牌") private String patentToken; @Schema(description = "本次解析的源文件列表") diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentResultRowDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentResultRowDto.java index a2f01636..632a99cb 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentResultRowDto.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentResultRowDto.java @@ -29,7 +29,7 @@ public class AppearancePatentResultRowDto { @Schema(description = "站点或国家。来自 Excel 的国家列,例如英国、德国、法国。", example = "英国") private String country; - @Schema(description = "商品 SKU。Java 调用 Coze 时会放入 items[].sku。", example = "SKU-001") + @Schema(description = "商品 SKU。Java 调用 LLM 时会作为标题识别输入。", example = "SKU-001") @JsonAlias({"SKU", "sellerSku", "seller_sku", "merchantSku", "merchant_sku", "商品SKU", "商品 sku", "库存SKU"}) private String sku; @@ -41,7 +41,7 @@ public class AppearancePatentResultRowDto { @JsonAlias({"Price", "价格"}) private String price; - @Schema(description = "商品主图或待检测图片 URL。Java 调用 Coze 时会放入 url_list。", example = "https://webstatic.aiproxy.vip/output/20260425/103322/demo.jpg") + @Schema(description = "商品主图或待检测图片 URL。Java 调用 LLM 时会作为外观检测图片输入。", example = "https://webstatic.aiproxy.vip/output/20260425/103322/demo.jpg") @JsonAlias({ "imageUrl", "image_url", "imgUrl", "img_url", "pictureUrl", "picture_url", "mainImage", "main_image", "mainImageUrl", "main_image_url", @@ -51,24 +51,24 @@ public class AppearancePatentResultRowDto { }) private String url; - @Schema(description = "商品标题。Java 调用 Coze 时会放入 title_list;为空时会回退使用 ASIN。", example = "Women Floral Dress Summer Casual") + @Schema(description = "商品标题。Java 调用 LLM 时会作为标题识别输入;为空时会回退使用 ASIN。", example = "Women Floral Dress Summer Casual") @JsonAlias({"productTitle", "product_title", "itemTitle", "item_title", "商品标题", "商品名称", "标题"}) private String title; - @Schema(description = "单行错误信息。通常用于记录 Python 单行处理异常;Coze 失败时后端会尽量保留原始行,不强行写入风险结果。", example = "图片地址为空") + @Schema(description = "单行错误信息。通常用于记录 Python 单行处理异常;LLM 检测失败时后端会尽量保留原始行,不强行写入风险结果。", example = "图片地址为空") private String error; @Schema(description = "单行完成标记。当前主要使用请求体顶层 done 控制任务收尾,该字段仅作兼容。", example = "true") private Boolean done; @JsonAlias({"row_status", "rowStatus", "Status"}) - @Schema(description = "Coze 行处理状态", example = "success", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "行处理状态", example = "success", accessMode = Schema.AccessMode.READ_ONLY) private String status; - @Schema(description = "Java 调用 Coze 后生成的标题维度检测结果,对应最终 xlsx 的“标题维度(商标)”列。Python 回传请求中不要传该字段;即使传入,后端也会以 Java/Coze 处理结果为准。", example = "标题未发现明显商标侵权风险。", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "Java 调用 LLM 后生成的标题维度检测结果,对应最终 xlsx 的“标题维度(商标)”列。Python 回传请求中不要传该字段;即使传入,后端也会以 Java/LLM 处理结果为准。", example = "标题未发现明显商标侵权风险。", accessMode = Schema.AccessMode.READ_ONLY) private String titleRisk; - @Schema(description = "Java 调用 Coze 后生成的外观维度检测结果,对应最终 xlsx 的“外观维度(外观设计专利)”列。Python 回传请求中不要传该字段。", example = "未发现明显外观设计专利侵权风险。", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "Java 调用 LLM 后生成的外观维度检测结果,对应最终 xlsx 的“外观维度(外观设计专利)”列。Python 回传请求中不要传该字段。", example = "未发现明显外观设计专利侵权风险。", accessMode = Schema.AccessMode.READ_ONLY) private String appearanceRisk; @JsonAlias({"patent ", "patent"}) diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskCacheService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskCacheService.java index cded0514..bfb77d50 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskCacheService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskCacheService.java @@ -126,11 +126,51 @@ public class AppearancePatentTaskCacheService { try { stringRedisTemplate.delete(pendingRowsKey(taskId)); stringRedisTemplate.delete(heartbeatKey(taskId)); + stringRedisTemplate.delete(processedRowsKey(taskId)); } catch (Exception ex) { log.warn("[appearance-patent-cache] delete degraded taskId={} msg={}", taskId, ex.getMessage()); } } + /** + * 行级检测去重:Redis SETNX,防止 submitResult 与 stale 恢复双入口并发处理同一批。 + * 返回 true 表示首次标记(应执行检测),false 表示已处理过(跳过)。 + */ + public boolean markRowProcessed(Long taskId, String rowKey) { + if (taskId == null || taskId <= 0 || rowKey == null || rowKey.isBlank()) { + return false; + } + try { + Boolean first = stringRedisTemplate.opsForValue().setIfAbsent( + processedRowKey(taskId, rowKey), "1", Duration.ofHours(TTL_HOURS)); + return Boolean.TRUE.equals(first); + } catch (Exception ex) { + log.warn("[appearance-patent-cache] mark row processed degraded taskId={} rowKey={} msg={}", taskId, rowKey, ex.getMessage()); + // Redis 降级时放行,由持久化层的 hasResolvedCozeFields 判据兜底去重。 + return true; + } + } + + public boolean isRowProcessed(Long taskId, String rowKey) { + if (taskId == null || taskId <= 0 || rowKey == null || rowKey.isBlank()) { + return false; + } + try { + return Boolean.TRUE.equals(stringRedisTemplate.hasKey(processedRowKey(taskId, rowKey))); + } catch (Exception ex) { + log.warn("[appearance-patent-cache] check row processed degraded taskId={} rowKey={} msg={}", taskId, rowKey, ex.getMessage()); + return false; + } + } + + private String processedRowsKey(Long taskId) { + return "appearance-patent:task:processed-rows:" + taskId; + } + + private String processedRowKey(Long taskId, String rowKey) { + return processedRowsKey(taskId) + ":" + rowKey; + } + private String pendingRowsKey(Long taskId) { return "appearance-patent:task:pending-rows:" + taskId; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java index b3c67cef..014f0318 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java @@ -31,7 +31,6 @@ import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentParse import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentTaskBatchVo; import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentTaskDetailVo; import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentTaskItemVo; -import com.nanri.aiimage.modules.coze.service.CozeCredentialPoolService; import com.nanri.aiimage.modules.file.service.LocalFileStorageService; import com.nanri.aiimage.modules.file.service.oss.OssStorageService; import com.nanri.aiimage.modules.task.mapper.FileResultMapper; @@ -62,8 +61,6 @@ import org.apache.poi.xssf.streaming.SXSSFWorkbook; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.dao.DuplicateKeyException; -import org.springframework.core.task.TaskExecutor; -import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.annotation.Transactional; @@ -102,12 +99,6 @@ public class AppearancePatentTaskService { private static final String STATUS_RUNNING = "RUNNING"; private static final String STATUS_SUCCESS = "SUCCESS"; private static final String STATUS_FAILED = "FAILED"; - private static final String COZE_ASYNC_POLL_TIMEOUT_MESSAGE = "Coze 异步工作流轮询超时"; - private static final String COZE_EMPTY_RESULT_MESSAGE = "Coze returned empty result rows"; - private static final String COZE_STATUS_SUBMITTED = "SUBMITTED"; - private static final String COZE_STATUS_RUNNING = "RUNNING"; - private static final String COZE_STATUS_DONE = "DONE"; - private static final String COZE_STATUS_FAILED = "FAILED"; private static final String CONTENT_TYPE_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; private static final String CONTENT_TYPE_ZIP = "application/zip"; private static final int CHUNK_PAYLOAD_MERGE_RETRY_LIMIT = 3; @@ -116,12 +107,6 @@ public class AppearancePatentTaskService { private static final Duration TASK_LOCK_TTL = Duration.ofMinutes(5); private static final long TASK_LOCK_WAIT_MILLIS = 10000L; private static final long TASK_LOCK_RETRY_DELAY_MILLIS = 200L; - private static final Duration COZE_SUBMIT_LOCK_TTL = Duration.ofMinutes(2); - private static final long COZE_SUBMIT_LOCK_WAIT_MILLIS = 1000L; - private static final long COZE_SUBMIT_LOCK_RETRY_DELAY_MILLIS = 500L; - private static final long COZE_SUBMIT_MIN_INTERVAL_MILLIS = 30000L; - private static final long PENDING_COZE_RETRY_INTERVAL_MILLIS = 30000L; - private static final int MAX_COZE_SUBMIT_RETRY_COUNT = 5; private static final List RESULT_HEADERS = List.of( "id", "asin", @@ -153,10 +138,6 @@ public class AppearancePatentTaskService { private final DistributedJobLockService distributedJobLockService; private final TaskDistributedLockService taskDistributedLockService; private final InstanceMetadata instanceMetadata; - private final CozeCredentialPoolService cozeCredentialPoolService; - @Autowired - @Qualifier("cozeTaskExecutor") - private TaskExecutor cozeTaskExecutor; public AppearancePatentParseVo parseAndCreateTask(AppearancePatentParseRequest request) { long startedAt = System.nanoTime(); @@ -733,6 +714,152 @@ public class AppearancePatentTaskService { fileTaskMapper.updateById(task); } + /** + * 同步检测:按批次串行调用 LLM(批内行级并发由 client 负责), + * 结果直接合并回 chunk。批间去重用 Redis 行级标记防止 submitResult 与 stale 恢复双入口重复检测。 + */ + private void submitLlmBatches(FileTaskEntity task, + FileResultEntity result, + TaskFileJobEntity job, + List chunks, + Map> allRowsByBaseId) { + if (chunks == null || chunks.isEmpty()) { + return; + } + String prompt = readAiPrompt(task); + String apiKey = readApiKey(task); + int batchSize = Math.max(1, properties.getLlmBatchSize()); + List candidates = collectPendingLlmCandidates(chunks); + boolean flushRemainder = isResultSubmissionComplete(task.getId()); + if (!flushRemainder && !candidates.isEmpty()) { + LocalDateTime jobUpdatedAt = job.getUpdatedAt(); + long pendingFlushMillis = flushPendingMillis(); + if (jobUpdatedAt != null + && Duration.between(jobUpdatedAt, LocalDateTime.now()).toMillis() >= pendingFlushMillis) { + flushRemainder = true; + log.warn("[appearance-patent] 零头批次等待超时,强制提交 taskId={} jobId={} pendingRows={} batchSize={} jobUpdatedAt={} flushAfterMillis={}", + task.getId(), job.getId(), candidates.size(), batchSize, jobUpdatedAt, pendingFlushMillis); + } + } + int submitLimit = (candidates.size() / batchSize) * batchSize; + if (flushRemainder && submitLimit < candidates.size()) { + submitLimit = candidates.size(); + } + if (submitLimit <= 0) { + log.info("[appearance-patent] llm batch waiting for more rows taskId={} jobId={} pendingRows={} batchSize={} finalUpload={}", + task.getId(), job.getId(), candidates.size(), batchSize, flushRemainder); + return; + } + int batchTotal = Math.max(1, (submitLimit + batchSize - 1) / batchSize); + int batchIndex = 1; + for (int i = 0; i < submitLimit; i += batchSize) { + List batchRows = candidates.subList(i, Math.min(i + batchSize, submitLimit)); + taskFileJobService.touchRunning(job.getId()); + long startedAt = System.currentTimeMillis(); + List llmRows; + try { + llmRows = cozeClient.inspectRows(batchRows, prompt, apiKey); + } catch (Exception ex) { + String message = firstNonBlank(ex.getMessage(), "LLM 检测失败"); + log.warn("[appearance-patent] llm batch failed taskId={} jobId={} rows={} batch={}/{} err={}", + task.getId(), job.getId(), batchRows.size(), batchIndex, batchTotal, message); + llmRows = cozeClient.markRowsFailed(batchRows, message); + } + mergeLlmRowsIntoChunks(task, allRowsByBaseId, llmRows, batchRows); + log.info("[appearance-patent] llm batch done taskId={} jobId={} rows={} batch={}/{} costMs={}", + task.getId(), job.getId(), batchRows.size(), batchIndex, batchTotal, + System.currentTimeMillis() - startedAt); + batchIndex++; + } + } + + private void mergeLlmRowsIntoChunks(FileTaskEntity task, + Map> allRowsByBaseId, + List llmRows, + List batchRows) { + if (llmRows == null || llmRows.isEmpty()) { + return; + } + Map deduped = new LinkedHashMap<>(); + for (int i = 0; i < llmRows.size(); i++) { + AppearancePatentResultRowDto row = llmRows.get(i); + AppearancePatentResultRowDto sourceRow = i < batchRows.size() ? batchRows.get(i) : null; + String key = rowKey(row); + if (key.isBlank()) { + if (sourceRow != null) { + key = rowKey(sourceRow); + } + } + if (key.isBlank() || !deduped.containsKey(key)) { + deduped.put(key, row); + } + } + mergeCozeRowsIntoSubmittedChunks(task, new ArrayList<>(deduped.values()), allRowsByBaseId); + } + + /** + * 收集未检测的候选行(行级 Redis 去重 + 已解析字段过滤),与原 Coze 语义一致。 + */ + private List loadSubmittedChunks(Long taskId) { + if (taskId == null || taskId <= 0) { + return List.of(); + } + List chunks = taskChunkMapper.selectList(new LambdaQueryWrapper() + .eq(TaskChunkEntity::getTaskId, taskId) + .eq(TaskChunkEntity::getModuleType, MODULE_TYPE) + .orderByAsc(TaskChunkEntity::getScopeHash) + .orderByAsc(TaskChunkEntity::getChunkIndex)); + return chunks == null ? List.of() : chunks; + } + + + private List collectPendingLlmCandidates(List chunks) { + if (chunks == null || chunks.isEmpty()) { + return List.of(); + } + List candidates = new ArrayList<>(); + for (TaskChunkEntity chunk : chunks) { + Map persistedRows = readChunkRows(chunk); + if (persistedRows.isEmpty()) { + continue; + } + for (AppearancePatentResultRowDto row : pickGroupRepresentativesForCoze(persistedRows.values())) { + String key = rowKey(row); + if (key.isBlank() || hasResolvedCozeFields(row)) { + continue; + } + if (taskCacheService.isRowProcessed(chunk.getTaskId(), key)) { + continue; + } + taskCacheService.markRowProcessed(chunk.getTaskId(), key); + candidates.add(row); + } + } + return candidates; + } + + private int countLlmWorkUnits(List chunks, int batchSize) { + if (chunks == null || chunks.isEmpty()) { + return 0; + } + int total = 0; + for (TaskChunkEntity chunk : chunks) { + Map persistedRows = readChunkRows(chunk); + if (persistedRows.isEmpty()) { + continue; + } + int unresolved = pickGroupRepresentativesForCoze(persistedRows.values()).size(); + if (unresolved > 0) { + total += Math.max(1, (unresolved + batchSize - 1) / batchSize); + } + } + return total; + } + + private long flushPendingMillis() { + return Math.max(1, properties.getFlushPendingMinutes()) * 60_000L; + } + private void submitCozeForSubmittedChunk(SubmitContext context) { if (context == null || context.task() == null || context.task().getId() == null) { return; @@ -743,7 +870,7 @@ public class AppearancePatentTaskService { } List chunks = loadSubmittedChunks(task.getId()); if (chunks.isEmpty()) { - log.info("[appearance-patent] skip stale recovery coze submission because no submitted chunks remain taskId={}", + log.info("[appearance-patent] skip stale recovery llm submission because no submitted chunks remain taskId={}", task.getId()); return; } @@ -755,22 +882,20 @@ public class AppearancePatentTaskService { TaskFileJobEntity job = taskFileJobService.enqueueAssembleResult( task.getId(), MODULE_TYPE, result.getId(), buildTaskOwnerScopeKey(task)); if (job == null || "SUCCESS".equals(job.getStatus())) { - log.info("[appearance-patent] stale recovery skipped coze submission because result job unavailable taskId={} resultId={} jobStatus={}", + log.info("[appearance-patent] stale recovery skipped llm submission because result job unavailable taskId={} resultId={} jobStatus={}", task.getId(), result.getId(), job == null ? null : job.getStatus()); return; } log.info("[appearance-patent] stale recovery created assemble job taskId={} resultId={} jobId={} jobStatus={} chunkCount={}", task.getId(), result.getId(), job.getId(), job.getStatus(), chunks.size()); Map> allRowsByBaseId = loadAllRowsByBaseId(task); - boolean pendingCoze = submitCozeBatches(task, result, job, chunks, allRowsByBaseId); - saveCozePipelineProgress(task, job); - if (pendingCoze) { + submitLlmBatches(task, result, job, chunks, allRowsByBaseId); + if (isResultSubmissionComplete(task.getId())) { + taskFileJobService.requeue(job.getId(), "检测结果已回流,正在组装 xlsx"); + } else { taskFileJobService.touchRunning(job.getId()); - touchJavaSideTaskActivity(task.getId()); - } else if (isResultSubmissionComplete(task.getId())) { - maybeFinalizeCozeJobLocked(task.getId(), new CozeBatchContext( - job.getId(), result.getId(), null, null, 1, 1, currentInstanceId(), 0, null)); } + touchJavaSideTaskActivity(task.getId()); } private void scheduleCozePipelineForSubmittedChunk(SubmitContext context) { @@ -815,10 +940,9 @@ public class AppearancePatentTaskService { } Long taskId = task.getId(); boolean uploadComplete = isResultSubmissionComplete(taskId); - long pendingCozeStates = countPendingCozeStates(taskId); long activeAssembleJobs = taskFileJobService.countActiveAssembleJobs(taskId, MODULE_TYPE); - log.info("[appearance-patent] stale recovery probe taskId={} uploadComplete={} pendingCozeStates={} activeAssembleJobs={} persistedRows={}", - taskId, uploadComplete, pendingCozeStates, activeAssembleJobs, hasPersistedResultRows(taskId)); + log.info("[appearance-patent] stale recovery probe taskId={} uploadComplete={} activeAssembleJobs={} persistedRows={}", + taskId, uploadComplete, activeAssembleJobs, hasPersistedResultRows(taskId)); if (!hasPersistedResultRows(taskId)) { log.info("[appearance-patent] stale recovery aborted because no persisted rows taskId={}", taskId); return false; @@ -830,12 +954,12 @@ public class AppearancePatentTaskService { taskId); return false; } - int pendingRows = collectPendingCozeCandidates(task, loadSubmittedChunks(taskId)).size(); - log.warn("[appearance-patent] Python 回传心跳超时,已封口上传并继续 Coze/文件收尾 taskId={} pendingRows={} pendingCozeStates={} activeAssembleJobs={} forcedScopes={}", - taskId, pendingRows, pendingCozeStates, activeAssembleJobs, forcedScopes); + int pendingRows = collectPendingLlmCandidates(loadSubmittedChunks(taskId)).size(); + log.warn("[appearance-patent] Python 回传心跳超时,已封口上传并继续检测/文件收尾 taskId={} pendingRows={} activeAssembleJobs={} forcedScopes={}", + taskId, pendingRows, activeAssembleJobs, forcedScopes); } else { - log.warn("[appearance-patent] Python 超时恢复继续推进 Coze/文件收尾 taskId={} pendingCozeStates={} activeAssembleJobs={}", - taskId, pendingCozeStates, activeAssembleJobs); + log.warn("[appearance-patent] Python 超时恢复继续推进检测/文件收尾 taskId={} activeAssembleJobs={}", + taskId, activeAssembleJobs); } submitCozeForSubmittedChunk(new SubmitContext(task, null, null, 0, true, null)); touchJavaSideTaskActivity(taskId); @@ -964,61 +1088,6 @@ public class AppearancePatentTaskService { return template.execute(status -> action.get()); } - private List applyCozeInBatches(List items, FileTaskEntity task) { - return applyCozeInBatches(items, task, null); - } - - private List applyCozeInBatches(List items, - FileTaskEntity task, - Runnable progressHook) { - if (items == null || items.isEmpty()) { - return List.of(); - } - String prompt = readAiPrompt(task); - String apiKey = readApiKey(task); - String patentToken = readPatentToken(task); - int batchSize = Math.max(1, properties.getCozeBatchSize()); - List result = new ArrayList<>(); - for (int i = 0; i < items.size(); i += batchSize) { - result.addAll(cozeClient.inspect(items.subList(i, Math.min(i + batchSize, items.size())), prompt, apiKey, patentToken)); - if (progressHook != null) { - progressHook.run(); - } - } - return result; - } - - private void applyCozeToPersistedChunks(FileTaskEntity task, Runnable progressHook) { - if (task == null || task.getId() == null) { - return; - } - Map> allRowsByBaseId = loadAllRowsByBaseId(task); - List chunks = taskChunkMapper.selectList(new LambdaQueryWrapper() - .eq(TaskChunkEntity::getTaskId, task.getId()) - .eq(TaskChunkEntity::getModuleType, MODULE_TYPE) - .orderByAsc(TaskChunkEntity::getChunkIndex)); - log.info("[appearance-patent] async coze start taskId={} chunks={}", task.getId(), chunks.size()); - for (TaskChunkEntity chunk : chunks) { - Map persistedRows = readChunkRows(chunk); - if (persistedRows.isEmpty()) { - continue; - } - List unresolvedRows = pickGroupRepresentativesForCoze(persistedRows.values()); - if (unresolvedRows.isEmpty()) { - continue; - } - List cozeRows = applyCozeInBatches(unresolvedRows, task, progressHook); - Map mergedRows = new LinkedHashMap<>(); - for (AppearancePatentResultRowDto resultRow : cozeRows) { - for (AppearancePatentResultRowDto expandedRow : expandRows(List.of(resultRow), allRowsByBaseId)) { - mergedRows.put(rowKey(expandedRow), expandedRow); - } - } - mergeChunkPayload(task.getId(), chunk.getScopeHash(), chunk.getChunkIndex(), new ArrayList<>(mergedRows.values())); - log.info("[appearance-patent] async coze chunk merged taskId={} chunk={} unresolved={} merged={}", - task.getId(), chunk.getChunkIndex(), unresolvedRows.size(), mergedRows.size()); - } - } private void mergeChunkPayload(Long taskId, String scopeHash, Integer chunkIndex, List rows) { if (rows == null || rows.isEmpty()) { @@ -1306,14 +1375,6 @@ public class AppearancePatentTaskService { } } - private String readPatentToken(FileTaskEntity task) { - try { - return normalize(readParsedPayload(task).getPatentToken()); - } catch (Exception ignored) { - return ""; - } - } - private void finalizeTask(FileTaskEntity task, String error, int rowCount, boolean assembleWorkbook) { String finalError = error; FileResultEntity result = null; @@ -1390,8 +1451,7 @@ public class AppearancePatentTaskService { task.getId(), MODULE_TYPE, result.getId(), buildTaskOwnerScopeKey(task)); if (dispatchWhenIdle && job != null - && "RUNNING".equals(job.getStatus()) - && countPendingCozeStates(task.getId()) == 0) { + && "RUNNING".equals(job.getStatus())) { taskFileJobService.requeue(job.getId(), "Python upload finished, assembling xlsx"); } return job; @@ -1453,964 +1513,26 @@ public class AppearancePatentTaskService { .eq(TaskChunkEntity::getTaskId, task.getId()) .eq(TaskChunkEntity::getModuleType, MODULE_TYPE) .orderByAsc(TaskChunkEntity::getChunkIndex)); - int cozeWorkUnits = countCozeWorkUnits(chunks, Math.max(1, properties.getCozeBatchSize())); - int plannedCozeUnits = Math.max(cozeWorkUnits, countAllCozeStates(task.getId())); - int totalProgressUnits = Math.max(3, plannedCozeUnits + 3); - if (countPendingCozeStates(task.getId()) > 0) { - finalizeTimedOutCozeStatesForTask(task.getId()); - } - if (countPendingCozeStates(task.getId()) > 0) { - taskFileJobService.touchRunning(job.getId()); - touchJavaSideTaskActivity(task.getId()); - saveFileBuildProgress(task, job, totalProgressUnits, 1, "Coze 已提交,等待结果回流"); - return false; - } - saveFileBuildProgress(task, job, totalProgressUnits, 0, "正在提交 Coze"); + int llmWorkUnits = countLlmWorkUnits(chunks, Math.max(1, properties.getLlmBatchSize())); + int plannedLlmUnits = Math.max(1, llmWorkUnits); + int totalProgressUnits = Math.max(3, plannedLlmUnits + 3); + saveFileBuildProgress(task, job, totalProgressUnits, 0, "正在提交检测"); Map> allRowsByBaseId = loadAllRowsByBaseId(task); - boolean pendingCoze = submitCozeBatches(task, result, job, chunks, allRowsByBaseId); - if (pendingCoze) { - taskFileJobService.touchRunning(job.getId()); - touchJavaSideTaskActivity(task.getId()); - saveFileBuildProgress(task, job, totalProgressUnits, 1, "Coze 已提交,等待结果回流"); - return false; - } + submitLlmBatches(task, result, job, chunks, allRowsByBaseId); if (STATUS_RUNNING.equals(task.getStatus()) && !isResultSubmissionComplete(task.getId())) { taskFileJobService.touchRunning(job.getId()); touchJavaSideTaskActivity(task.getId()); - saveFileBuildProgress(task, job, totalProgressUnits, Math.max(1, plannedCozeUnits), "等待 Python 继续回传数据"); + saveFileBuildProgress(task, job, totalProgressUnits, Math.max(1, plannedLlmUnits), "等待 Python 继续回传数据"); return false; } completeCozeFileJob(task, result, job, totalProgressUnits); return true; } - @Scheduled(fixedDelayString = "${aiimage.appearance-patent.coze-poll-delay-ms:30000}") - public void pollPendingCozeJobs() { - List states = listOwnedPendingCozeStates(); - if (states == null || states.isEmpty()) { - return; - } - log.info("[appearance-patent] coze poll picked pending states count={}", states.size()); - for (TaskScopeStateEntity state : states) { - if (state == null || state.getId() == null || !isCozeStateOwnedByCurrentInstance(state)) { - continue; - } - Long stateId = state.getId(); - try { - cozeTaskExecutor.execute(() -> { - log.info("[appearance-patent] coze poll worker entered stateId={} taskId={} executeId={}", - stateId, state.getTaskId(), state.getCozeExecuteId()); - pollPendingCozeState(stateId); - }); - } catch (Exception ex) { - log.warn("[appearance-patent] coze poll dispatch failed stateId={} taskId={} executeId={} err={}", - stateId, state.getTaskId(), state.getCozeExecuteId(), - firstNonBlank(ex.getMessage(), ex.getClass().getSimpleName()), ex); - } - } - } - - private List listOwnedPendingCozeStates() { - Map merged = new LinkedHashMap<>(); - for (TaskScopeStateEntity state : queryOwnedPendingCozeStates(false, 50)) { - if (state != null && state.getId() != null) { - merged.put(state.getId(), state); - } - } - for (TaskScopeStateEntity state : queryOwnedPendingCozeStates(true, 50)) { - if (state != null && state.getId() != null) { - merged.putIfAbsent(state.getId(), state); - } - } - return new ArrayList<>(merged.values()); - } - - private List queryOwnedPendingCozeStates(boolean oldestFirst, int limit) { - LambdaQueryWrapper wrapper = new LambdaQueryWrapper() - .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE) - .in(TaskScopeStateEntity::getCozeStatus, List.of(COZE_STATUS_SUBMITTED, COZE_STATUS_RUNNING)) - .and(ownerWrapper -> ownerWrapper - .apply("JSON_UNQUOTE(JSON_EXTRACT(state_json, '$.ownerInstanceId')) IS NULL") - .or() - .apply("JSON_UNQUOTE(JSON_EXTRACT(state_json, '$.ownerInstanceId')) = ''") - .or() - .apply("JSON_UNQUOTE(JSON_EXTRACT(state_json, '$.ownerInstanceId')) = {0}", currentInstanceId())) - .last("limit " + Math.max(1, Math.min(limit, 100))); - if (oldestFirst) { - wrapper.orderByAsc(TaskScopeStateEntity::getUpdatedAt); - } else { - wrapper.orderByDesc(TaskScopeStateEntity::getUpdatedAt); - } - List states = taskScopeStateMapper.selectList(wrapper); - return states == null ? List.of() : states; - } - - private List loadSubmittedChunks(Long taskId) { - if (taskId == null || taskId <= 0) { - return List.of(); - } - List chunks = taskChunkMapper.selectList(new LambdaQueryWrapper() - .eq(TaskChunkEntity::getTaskId, taskId) - .eq(TaskChunkEntity::getModuleType, MODULE_TYPE) - .orderByAsc(TaskChunkEntity::getScopeHash) - .orderByAsc(TaskChunkEntity::getChunkIndex)); - return chunks == null ? List.of() : chunks; - } - - private List collectPendingCozeCandidates(FileTaskEntity task, List chunks) { - if (task == null || task.getId() == null || chunks == null || chunks.isEmpty()) { - return List.of(); - } - Set queuedRowKeys = new LinkedHashSet<>(loadSubmittedCozeRowKeys(task.getId())); - List candidates = new ArrayList<>(); - for (TaskChunkEntity chunk : chunks) { - Map persistedRows = readChunkRows(chunk); - if (persistedRows.isEmpty()) { - continue; - } - for (AppearancePatentResultRowDto row : pickGroupRepresentativesForCoze(persistedRows.values())) { - String key = rowKey(row); - if (key.isBlank() || !queuedRowKeys.add(key)) { - continue; - } - AppearancePatentResultRowDto persistedRow = persistedRows.get(key); - if (hasResolvedCozeFields(persistedRow)) { - continue; - } - candidates.add(new CozeCandidate(chunk.getScopeHash(), chunk.getChunkIndex(), row)); - } - } - return candidates; - } - - private Set loadSubmittedCozeRowKeys(Long taskId) { - Set keys = new LinkedHashSet<>(); - if (taskId == null || taskId <= 0) { - return keys; - } - List states = taskScopeStateMapper.selectList(new LambdaQueryWrapper() - .select(TaskScopeStateEntity::getId, TaskScopeStateEntity::getTaskId, TaskScopeStateEntity::getParsedPayloadJson) - .eq(TaskScopeStateEntity::getTaskId, taskId) - .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE) - .isNotNull(TaskScopeStateEntity::getCozeStatus)); - if (states == null || states.isEmpty()) { - return keys; - } - for (TaskScopeStateEntity state : states) { - for (AppearancePatentResultRowDto row : readCozeBatchRows(state)) { - String key = rowKey(row); - if (!key.isBlank()) { - keys.add(key); - } - } - } - return keys; - } - - private boolean submitCozeBatches(FileTaskEntity task, - FileResultEntity result, - TaskFileJobEntity job, - List chunks, - Map> allRowsByBaseId) { - if (chunks == null || chunks.isEmpty()) { - return countPendingCozeStates(task.getId()) > 0; - } - if (!cozeClient.hasConfiguredCredential()) { - log.warn("[appearance-patent] coze token not configured, skip async coze taskId={} jobId={}", - task.getId(), job.getId()); - return false; - } - String prompt = readAiPrompt(task); - String apiKey = readApiKey(task); - String patentToken = readPatentToken(task); - int batchSize = Math.max(1, properties.getCozeBatchSize()); - List candidates = collectPendingCozeCandidates(task, chunks); - boolean flushRemainder = isResultSubmissionComplete(task.getId()); - if (!flushRemainder && !candidates.isEmpty()) { - LocalDateTime jobUpdatedAt = job.getUpdatedAt(); - long pendingFlushMillis = cozeFlushPendingMillis(); - if (jobUpdatedAt != null - && Duration.between(jobUpdatedAt, LocalDateTime.now()).toMillis() >= pendingFlushMillis) { - flushRemainder = true; - log.warn("[appearance-patent] Coze 零头批次等待超时,强制提交 taskId={} jobId={} pendingRows={} batchSize={} jobUpdatedAt={} flushAfterMillis={}", - task.getId(), job.getId(), candidates.size(), batchSize, jobUpdatedAt, pendingFlushMillis); - } - } - int submitLimit = (candidates.size() / batchSize) * batchSize; - if (flushRemainder && submitLimit < candidates.size()) { - submitLimit = candidates.size(); - } - if (submitLimit <= 0) { - log.info("[appearance-patent] coze batch waiting for more rows taskId={} jobId={} pendingRows={} batchSize={} finalUpload={}", - task.getId(), job.getId(), candidates.size(), batchSize, flushRemainder); - return countPendingCozeStates(task.getId()) > 0; - } - boolean pending = false; - int batchTotal = Math.max(1, (submitLimit + batchSize - 1) / batchSize); - int batchIndex = 1; - for (int i = 0; i < submitLimit; i += batchSize) { - List batchCandidates = candidates.subList(i, Math.min(i + batchSize, submitLimit)); - List batchRows = batchCandidates.stream() - .map(CozeCandidate::row) - .toList(); - pending |= submitCozeBatch(task, result, job, batchRows, batchIndex, batchTotal, prompt, apiKey, patentToken, allRowsByBaseId); - batchIndex++; - } - return pending || countPendingCozeStates(task.getId()) > 0; - } - - private boolean submitCozeBatch(FileTaskEntity task, - FileResultEntity result, - TaskFileJobEntity job, - List batchRows, - int batchIndex, - int batchTotal, - String prompt, - String apiKey, - String patentToken, - Map> allRowsByBaseId) { - if (batchRows == null || batchRows.isEmpty()) { - return false; - } - taskFileJobService.touchRunning(job.getId()); - String batchScopeKey = buildCozeBatchScopeKey(task.getId(), batchRows); - String batchScopeHash = DigestUtil.sha256Hex(batchScopeKey); - TaskScopeStateEntity existing = taskScopeStateMapper.selectOne(new LambdaQueryWrapper() - .eq(TaskScopeStateEntity::getTaskId, task.getId()) - .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE) - .eq(TaskScopeStateEntity::getScopeHash, batchScopeHash) - .last("limit 1")); - if (existing != null) { - return COZE_STATUS_SUBMITTED.equals(existing.getCozeStatus()) - || COZE_STATUS_RUNNING.equals(existing.getCozeStatus()); - } - AppearancePatentCozeClient.CozeCredentialRef credential = cozeClient.nextCredential(); - try { - AppearancePatentCozeClient.CozeSubmitResponse submit = submitCozeWorkflowThrottled( - batchRows, prompt, apiKey, patentToken, credential, true); - if (submit.immediateData() != null && !submit.immediateData().isBlank()) { - List cozeRows = mergeUsableCozeRows(batchRows, submit.immediateData()); - mergeCozeRowsIntoSubmittedChunks(task, cozeRows, allRowsByBaseId); - return false; - } - if (submit.executeId() == null || submit.executeId().isBlank()) { - mergeCozeRowsIntoSubmittedChunks(task, - cozeClient.markRowsFailed(batchRows, "Coze async execute_id missing"), - allRowsByBaseId); - return false; - } - saveCozeBatchState(task, result, job, batchRows, batchScopeKey, batchScopeHash, - batchIndex, batchTotal, submit.executeId(), submit.credentialName()); - log.info("[appearance-patent] coze async submitted taskId={} jobId={} rows={} batch={}/{} credential={} executeId={}", - task.getId(), job.getId(), batchRows.size(), batchIndex, batchTotal, - submit.credentialName(), submit.executeId()); - return true; - } catch (Exception ex) { - String message = firstNonBlank(ex.getMessage(), "Coze submit failed"); - log.warn("[appearance-patent] coze async submit failed taskId={} jobId={} rows={} batch={}/{} err={}", - task.getId(), job.getId(), batchRows.size(), batchIndex, batchTotal, message); - if (isCozeThrottleLockTimeout(message)) { - savePendingCozeBatchState(task, result, job, batchRows, batchScopeKey, batchScopeHash, - batchIndex, batchTotal, message, credential.name()); - taskFileJobService.touchRunning(job.getId()); - touchJavaSideTaskActivity(task.getId()); - return true; - } - mergeCozeRowsIntoSubmittedChunks(task, cozeClient.markRowsFailed(batchRows, message), allRowsByBaseId); - return false; - } - } - - private boolean isCozeThrottleLockTimeout(String message) { - return normalize(message).toLowerCase(Locale.ROOT).contains("coze submit throttle lock timeout"); - } - - private void savePendingCozeBatchState(FileTaskEntity task, - FileResultEntity result, - TaskFileJobEntity job, - List batchRows, - String batchScopeKey, - String batchScopeHash, - int batchIndex, - int batchTotal, - String pendingReason, - String credentialName) { - LocalDateTime now = LocalDateTime.now(); - CozeBatchContext context = new CozeBatchContext( - job.getId(), - result.getId(), - null, - null, - batchIndex, - batchTotal, - currentInstanceId(), - 0, - credentialName - ); - String batchPayload = writeJson(batchRows, "serialize pending coze batch payload failed"); - String storedBatchPayload = storeSharedCozeBatchPayload(task.getId(), batchScopeHash, batchPayload); - TaskScopeStateEntity state = new TaskScopeStateEntity(); - state.setTaskId(task.getId()); - state.setModuleType(MODULE_TYPE); - state.setScopeKey(batchScopeKey); - state.setScopeHash(batchScopeHash); - state.setParsedPayloadJson(storedBatchPayload); - state.setStateJson(writeJson(context, "serialize pending coze batch context failed")); - state.setCozeStatus(COZE_STATUS_RUNNING); - state.setCozeSubmittedAt(now); - state.setCozeLastPolledAt(null); - state.setCozeAttemptCount(0); - state.setCozeError(firstNonBlank(pendingReason, "Coze submit queued by throttle")); - state.setChunkTotal(batchTotal); - state.setReceivedChunkCount(batchIndex); - state.setCompleted(0); - state.setCreatedAt(now); - state.setUpdatedAt(now); - try { - taskScopeStateMapper.insert(state); - touchJavaSideTaskActivity(task.getId()); - log.info("[appearance-patent] coze async submit queued by throttle taskId={} jobId={} rows={} batch={}/{}", - task.getId(), job.getId(), batchRows.size(), batchIndex, batchTotal); - } catch (DuplicateKeyException ex) { - transientPayloadStorageService.deletePayloadIfPresent(storedBatchPayload); - log.info("[appearance-patent] duplicate pending coze batch state ignored taskId={} scope={}", - task.getId(), batchScopeKey); - } - } - - private void saveCozeBatchState(FileTaskEntity task, - FileResultEntity result, - TaskFileJobEntity job, - List batchRows, - String batchScopeKey, - String batchScopeHash, - int batchIndex, - int batchTotal, - String executeId, - String credentialName) { - LocalDateTime now = LocalDateTime.now(); - CozeBatchContext context = new CozeBatchContext( - job.getId(), - result.getId(), - null, - null, - batchIndex, - batchTotal, - currentInstanceId(), - 0, - credentialName - ); - String batchPayload = writeJson(batchRows, "serialize coze batch payload failed"); - String storedBatchPayload = storeSharedCozeBatchPayload(task.getId(), batchScopeHash, batchPayload); - TaskScopeStateEntity state = new TaskScopeStateEntity(); - state.setTaskId(task.getId()); - state.setModuleType(MODULE_TYPE); - state.setScopeKey(batchScopeKey); - state.setScopeHash(batchScopeHash); - state.setParsedPayloadJson(storedBatchPayload); - state.setStateJson(writeJson(context, "serialize coze batch context failed")); - state.setCozeExecuteId(executeId); - state.setCozeStatus(COZE_STATUS_SUBMITTED); - state.setCozeSubmittedAt(now); - state.setCozeAttemptCount(0); - state.setChunkTotal(batchTotal); - state.setReceivedChunkCount(batchIndex); - state.setCompleted(0); - state.setCreatedAt(now); - state.setUpdatedAt(now); - try { - taskScopeStateMapper.insert(state); - touchJavaSideTaskActivity(task.getId()); - } catch (DuplicateKeyException ex) { - transientPayloadStorageService.deletePayloadIfPresent(storedBatchPayload); - log.info("[appearance-patent] duplicate coze batch state ignored taskId={} scope={}", - task.getId(), batchScopeKey); - } - } - - private void pollPendingCozeState(Long stateId) { - Long taskIdForLock = null; - TaskScopeStateEntity lockState = taskScopeStateMapper.selectById(stateId); - if (lockState != null) { - taskIdForLock = lockState.getTaskId(); - } - if (!isCozeStateOwnedByCurrentInstance(lockState)) { - log.info("[appearance-patent] coze poll skipped because owner is another instance taskId={} stateId={} owner={} current={}", - taskIdForLock, stateId, ownerFromCozeState(lockState), currentInstanceId()); - return; - } - TaskDistributedLockService.LockHandle lockHandle = acquireTaskLock(taskIdForLock, 0L); - if (lockHandle == null) { - if (taskIdForLock != null) { - log.info("[appearance-patent] coze poll skipped because task is locked taskId={} stateId={}", - taskIdForLock, stateId); - } - return; - } - try (lockHandle) { - pollPendingCozeStateLocked(stateId); - } - } - - private void pollPendingCozeStateLocked(Long stateId) { - try { - TaskScopeStateEntity state = taskScopeStateMapper.selectById(stateId); - if (state == null || state.getCozeExecuteId() == null || state.getCozeExecuteId().isBlank()) { - if (state != null && state.getCozeExecuteId() == null - && (COZE_STATUS_SUBMITTED.equals(state.getCozeStatus()) || COZE_STATUS_RUNNING.equals(state.getCozeStatus()))) { - retryPendingCozeSubmitState(state); - } - return; - } - if (!(COZE_STATUS_SUBMITTED.equals(state.getCozeStatus()) || COZE_STATUS_RUNNING.equals(state.getCozeStatus()))) { - return; - } - if (!tryClaimCozeStateForPoll(state)) { - log.info("[appearance-patent] coze poll skipped by claim guard taskId={} stateId={} executeId={} lastPolledAt={}", - state.getTaskId(), state.getId(), state.getCozeExecuteId(), state.getCozeLastPolledAt()); - return; - } - CozeBatchContext context = readCozeBatchContext(state); - if (context == null || context.jobId() == null || context.resultId() == null) { - log.warn("[appearance-patent] coze poll aborted because batch context is missing taskId={} stateId={} executeId={} stateJson={}", - state.getTaskId(), state.getId(), state.getCozeExecuteId(), abbreviate(state.getStateJson(), 300)); - markCozeStateTerminal(state, COZE_STATUS_FAILED, "Coze batch context missing"); - return; - } - if (!isOwnerCurrent(context.ownerInstanceId())) { - log.info("[appearance-patent] coze poll skipped after context refresh because owner is another instance taskId={} stateId={} owner={} current={}", - state.getTaskId(), state.getId(), context.ownerInstanceId(), currentInstanceId()); - return; - } - taskFileJobService.touchRunning(context.jobId()); - log.info("[appearance-patent] coze poll start taskId={} stateId={} executeId={} jobId={} chunk={} batch={}/{}", - state.getTaskId(), state.getId(), state.getCozeExecuteId(), - context.jobId(), context.chunkIndex(), context.batchIndex(), context.batchTotal()); - AppearancePatentCozeClient.CozePollResponse poll = cozeClient.pollWorkflow( - state.getCozeExecuteId(), - cozeClient.credentialByName(context.credentialName())); - if (!poll.hasPayload() && !poll.isFinished() && !isCozeStateTimedOut(state)) { - log.info("[appearance-patent] coze poll pending taskId={} stateId={} executeId={} status={}", - state.getTaskId(), state.getId(), state.getCozeExecuteId(), poll.status()); - updateCozeStateRunning(state, null); - return; - } - String failureMessage = poll.isFailed() - ? firstNonBlank(poll.failureMessage(), "Coze async workflow failed") - : ""; - if (!poll.hasPayload() && failureMessage.isBlank()) { - failureMessage = isCozeStateTimedOut(state) - ? "Coze 异步工作流轮询超时" - : "Coze async workflow completed without output"; - } - List batchRows = readCozeBatchRows(state); - if (batchRows.isEmpty() && failureMessage.isBlank()) { - failureMessage = "Coze batch payload missing"; - } - List cozeRows = List.of(); - if (failureMessage.isBlank()) { - try { - // 显式把 workflow status 传进 mergeUsableCozeRows: - // 业务侧 SUCCESS 但 payload 空时不再走 retry,而是直接 markFailed 落地。 - cozeRows = mergeUsableCozeRows(batchRows, poll.resolvedPayloadText(), poll.status()); - } catch (Exception ex) { - failureMessage = firstNonBlank(ex.getMessage(), COZE_EMPTY_RESULT_MESSAGE); - } - } - if (!failureMessage.isBlank() && splitRetryFailedCozeBatchState(state, context, batchRows, failureMessage)) { - return; - } - if (!failureMessage.isBlank() && retryFailedCozeBatchState(state, context, batchRows, failureMessage)) { - return; - } - if (!failureMessage.isBlank()) { - cozeRows = cozeClient.markRowsFailed(batchRows, failureMessage); - } - FileTaskEntity task = fileTaskMapper.selectById(state.getTaskId()); - if (task != null) { - Map> allRowsByBaseId = loadAllRowsByBaseId(task); - mergeCozeRowsIntoSubmittedChunks(task, cozeRows, allRowsByBaseId); - } - markCozeStateTerminal(state, - failureMessage.isBlank() ? COZE_STATUS_DONE : COZE_STATUS_FAILED, - failureMessage.isBlank() ? null : failureMessage); - if (task != null) { - TaskFileJobEntity progressJob = taskFileJobService.findAssembleJob(state.getTaskId(), MODULE_TYPE, context.resultId()); - saveCozePipelineProgress(task, progressJob); - } - log.info("[appearance-patent] coze poll completed taskId={} stateId={} executeId={} status={} batchRows={} mergedRows={} failure={}", - state.getTaskId(), state.getId(), state.getCozeExecuteId(), - failureMessage.isBlank() ? COZE_STATUS_DONE : COZE_STATUS_FAILED, - batchRows.size(), cozeRows.size(), firstNonBlank(failureMessage, "-")); - maybeFinalizeCozeJobLocked(state.getTaskId(), context); - } catch (Exception ex) { - TaskScopeStateEntity state = taskScopeStateMapper.selectById(stateId); - String message = firstNonBlank(ex.getMessage(), "Coze poll failed"); - if (state != null) { - CozeBatchContext context = readCozeBatchContext(state); - if (isCozeStateTimedOut(state) && context != null) { - List batchRows = readCozeBatchRows(state); - FileTaskEntity task = fileTaskMapper.selectById(state.getTaskId()); - if (task != null) { - Map> allRowsByBaseId = loadAllRowsByBaseId(task); - mergeCozeRowsIntoSubmittedChunks(task, - cozeClient.markRowsFailed(batchRows, message), - allRowsByBaseId); - } - markCozeStateTerminal(state, COZE_STATUS_FAILED, message); - maybeFinalizeCozeJobLocked(state.getTaskId(), context); - return; - } - log.warn("[appearance-patent] coze poll failed taskId={} stateId={} executeId={} err={}", - state.getTaskId(), state.getId(), state.getCozeExecuteId(), message); - updateCozeStateRunning(state, message); - return; - } - log.warn("[appearance-patent] coze poll crashed before state refresh stateId={} err={}", - stateId, message, ex); - } - } - - private void finalizeTimedOutCozeStatesForTask(Long taskId) { - if (taskId == null || taskId <= 0) { - return; - } - List states = taskScopeStateMapper.selectList(new LambdaQueryWrapper() - .eq(TaskScopeStateEntity::getTaskId, taskId) - .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE) - .in(TaskScopeStateEntity::getCozeStatus, List.of(COZE_STATUS_SUBMITTED, COZE_STATUS_RUNNING)) - .orderByAsc(TaskScopeStateEntity::getCozeSubmittedAt) - .last("limit 50")); - if (states == null || states.isEmpty()) { - return; - } - for (TaskScopeStateEntity state : states) { - if (state == null || state.getId() == null || !isCozeStateTimedOut(state)) { - continue; - } - CozeBatchContext context = readCozeBatchContext(state); - if (context == null || context.resultId() == null) { - markCozeStateTerminal(state, COZE_STATUS_FAILED, "Coze 批次上下文缺失"); - continue; - } - List batchRows = readCozeBatchRows(state); - FileTaskEntity task = fileTaskMapper.selectById(taskId); - if (task != null) { - Map> allRowsByBaseId = loadAllRowsByBaseId(task); - mergeCozeRowsIntoSubmittedChunks(task, - cozeClient.markRowsFailed(batchRows, "Coze 异步工作流轮询超时"), - allRowsByBaseId); - } - markCozeStateTerminal(state, COZE_STATUS_FAILED, "Coze 异步工作流轮询超时"); - maybeFinalizeCozeJobLocked(taskId, context); - log.warn("[appearance-patent] 文件任务超时兜底已将 Coze pending 批次置为失败 taskId={} stateId={} jobId={}", - taskId, state.getId(), context.jobId()); - } - } - - private boolean retryFailedCozeBatchState(TaskScopeStateEntity state, - CozeBatchContext context, - List batchRows, - String failureMessage) { - if (state == null || context == null || batchRows == null || batchRows.isEmpty()) { - return false; - } - if (!isRetryableCozeFailure(failureMessage) || cozeSubmitRetryCount(context) >= MAX_COZE_SUBMIT_RETRY_COUNT) { - return false; - } - FileTaskEntity task = fileTaskMapper.selectById(state.getTaskId()); - if (task == null || !MODULE_TYPE.equals(task.getModuleType())) { - return false; - } - try { - AppearancePatentCozeClient.CozeSubmitResponse submit = - submitCozeWorkflowThrottled(batchRows, readAiPrompt(task), readApiKey(task), readPatentToken(task), - cozeClient.credentialByName(context.credentialName()), false); - Map> allRowsByBaseId = loadAllRowsByBaseId(task); - if (submit.immediateData() != null && !submit.immediateData().isBlank()) { - List cozeRows = - mergeUsableCozeRows(batchRows, submit.immediateData()); - mergeCozeRowsIntoSubmittedChunks(task, cozeRows, allRowsByBaseId); - markCozeStateTerminal(state, COZE_STATUS_DONE, null); - maybeFinalizeCozeJobLocked(state.getTaskId(), context); - log.info("[appearance-patent] coze retry returned immediate result taskId={} stateId={} chunk={} batch={}/{}", - state.getTaskId(), state.getId(), context.chunkIndex(), context.batchIndex(), context.batchTotal()); - return true; - } - if (submit.executeId() == null || submit.executeId().isBlank()) { - return false; - } - LocalDateTime now = LocalDateTime.now(); - CozeBatchContext retryContext = withCozeSubmitRetryCount(context, cozeSubmitRetryCount(context) + 1); - int updated = taskScopeStateMapper.update(null, new LambdaUpdateWrapper() - .eq(TaskScopeStateEntity::getId, state.getId()) - .in(TaskScopeStateEntity::getCozeStatus, List.of(COZE_STATUS_SUBMITTED, COZE_STATUS_RUNNING)) - .set(TaskScopeStateEntity::getCozeExecuteId, submit.executeId()) - .set(TaskScopeStateEntity::getCozeStatus, COZE_STATUS_SUBMITTED) - .set(TaskScopeStateEntity::getCozeSubmittedAt, now) - .set(TaskScopeStateEntity::getCozeLastPolledAt, null) - .set(TaskScopeStateEntity::getCozeCompletedAt, null) - .set(TaskScopeStateEntity::getCozeAttemptCount, 0) - .set(TaskScopeStateEntity::getCozeError, "retry after failure: " + firstNonBlank(failureMessage, "unknown")) - .set(TaskScopeStateEntity::getStateJson, writeJson(retryContext, "serialize coze batch retry context failed")) - .set(TaskScopeStateEntity::getCompleted, 0) - .set(TaskScopeStateEntity::getUpdatedAt, now)); - if (updated > 0) { - taskFileJobService.touchRunning(context.jobId()); - touchJavaSideTaskActivity(state.getTaskId()); - log.info("[appearance-patent] coze retry submitted taskId={} stateId={} oldExecuteId={} newExecuteId={} chunk={} batch={}/{} retry={}/{} failure={}", - state.getTaskId(), state.getId(), state.getCozeExecuteId(), submit.executeId(), - context.chunkIndex(), context.batchIndex(), context.batchTotal(), - retryContext.submitRetryCount(), MAX_COZE_SUBMIT_RETRY_COUNT, failureMessage); - return true; - } - } catch (Exception ex) { - log.warn("[appearance-patent] coze retry submit failed taskId={} stateId={} executeId={} err={}", - state.getTaskId(), state.getId(), state.getCozeExecuteId(), firstNonBlank(ex.getMessage(), "Coze retry failed")); - } - return false; - } - - private AppearancePatentCozeClient.CozeSubmitResponse submitCozeWorkflowThrottled( - List rows, - String prompt, - String apiKey, - String patentToken, - AppearancePatentCozeClient.CozeCredentialRef credential, - boolean allowCredentialFallback) throws Exception { - int attempts = allowCredentialFallback ? Math.max(1, cozeClient.configuredCredentialCount()) : 1; - AppearancePatentCozeClient.CozeCredentialRef currentCredential = - credential == null ? cozeClient.nextCredential() : credential; - Exception lastFailure = null; - for (int i = 0; i < attempts; i++) { - DistributedJobLockService.LockHandle lockHandle = acquireCozeSubmitLock(currentCredential); - if (lockHandle == null) { - lastFailure = new IllegalStateException("Coze submit throttle lock timeout"); - currentCredential = cozeClient.nextCredential(); - continue; - } - CozeCredentialPoolService.BorrowedCredential borrowedCredential = - cozeCredentialPoolService.borrow(MODULE_TYPE, toPoolCredential(currentCredential)); - if (borrowedCredential == null) { - lockHandle.close(); - lastFailure = new IllegalStateException("Coze credential concurrency limit reached"); - currentCredential = cozeClient.nextCredential(); - continue; - } - try (lockHandle; borrowedCredential) { - return cozeClient.submitWorkflow(rows, prompt, apiKey, patentToken, currentCredential); - } finally { - sleepQuietly(COZE_SUBMIT_MIN_INTERVAL_MILLIS); - } - } - throw lastFailure == null ? new IllegalStateException("Coze submit failed") : lastFailure; - } - - private CozeCredentialPoolService.CozeCredential toPoolCredential(AppearancePatentCozeClient.CozeCredentialRef credential) { - if (credential == null) { - return null; - } - return new CozeCredentialPoolService.CozeCredential( - credential.name(), - credential.workflowId(), - credential.token(), - credential.maxConcurrent()); - } - - private DistributedJobLockService.LockHandle acquireCozeSubmitLock(AppearancePatentCozeClient.CozeCredentialRef credential) { - long deadline = System.currentTimeMillis() + COZE_SUBMIT_LOCK_WAIT_MILLIS; - String credentialName = credential == null ? "default" : firstNonBlank(credential.name(), "default"); - while (System.currentTimeMillis() <= deadline) { - DistributedJobLockService.LockHandle lockHandle = - distributedJobLockService.tryLock("appearance-patent:coze-submit:" + credentialName, COZE_SUBMIT_LOCK_TTL); - if (lockHandle != null) { - return lockHandle; - } - sleepQuietly(COZE_SUBMIT_LOCK_RETRY_DELAY_MILLIS); - } - return null; - } - - private void sleepQuietly(long millis) { - if (millis <= 0L) { - return; - } - try { - Thread.sleep(millis); - } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - } - } - - private boolean splitRetryFailedCozeBatchState(TaskScopeStateEntity state, - CozeBatchContext context, - List batchRows, - String failureMessage) { - if (state == null || context == null || batchRows == null || batchRows.size() <= 1) { - return false; - } - if (!shouldSplitCozeBatchForRetry(failureMessage) || cozeSubmitRetryCount(context) >= MAX_COZE_SUBMIT_RETRY_COUNT) { - return false; - } - FileTaskEntity task = fileTaskMapper.selectById(state.getTaskId()); - if (task == null || !MODULE_TYPE.equals(task.getModuleType())) { - return false; - } - int middle = Math.max(1, batchRows.size() / 2); - List> partitions = List.>of( - new ArrayList<>(batchRows.subList(0, middle)), - new ArrayList<>(batchRows.subList(middle, batchRows.size())) - ).stream().filter(rows -> rows != null && !rows.isEmpty()).toList(); - int retryCount = cozeSubmitRetryCount(context) + 1; - boolean submittedAny = false; - try { - Map> allRowsByBaseId = loadAllRowsByBaseId(task); - int partIndex = 1; - for (List partRows : partitions) { - AppearancePatentCozeClient.CozeSubmitResponse submit = - submitCozeWorkflowThrottled(partRows, readAiPrompt(task), readApiKey(task), readPatentToken(task), - cozeClient.credentialByName(context.credentialName()), false); - if (submit.immediateData() != null && !submit.immediateData().isBlank()) { - List cozeRows = - mergeUsableCozeRows(partRows, submit.immediateData()); - mergeCozeRowsIntoSubmittedChunks(task, cozeRows, allRowsByBaseId); - submittedAny = true; - } else if (submit.executeId() != null && !submit.executeId().isBlank()) { - saveSplitRetryCozeBatchState(state, context, partRows, partIndex, partitions.size(), retryCount, - submit.executeId(), submit.credentialName()); - submittedAny = true; - } - partIndex++; - } - if (submittedAny) { - markCozeStateTerminal(state, COZE_STATUS_DONE, "split retry submitted after failure: " + firstNonBlank(failureMessage, "unknown")); - taskFileJobService.touchRunning(context.jobId()); - touchJavaSideTaskActivity(state.getTaskId()); - maybeFinalizeCozeJobLocked(state.getTaskId(), context); - log.info("[appearance-patent] coze split retry submitted taskId={} stateId={} chunk={} batch={}/{} parts={} retry={}/{} failure={}", - state.getTaskId(), state.getId(), context.chunkIndex(), context.batchIndex(), context.batchTotal(), - partitions.size(), retryCount, MAX_COZE_SUBMIT_RETRY_COUNT, failureMessage); - return true; - } - } catch (Exception ex) { - log.warn("[appearance-patent] coze split retry submit failed taskId={} stateId={} executeId={} err={}", - state.getTaskId(), state.getId(), state.getCozeExecuteId(), firstNonBlank(ex.getMessage(), "Coze split retry failed")); - } - return false; - } - - private void saveSplitRetryCozeBatchState(TaskScopeStateEntity parent, - CozeBatchContext parentContext, - List batchRows, - int partIndex, - int partTotal, - int retryCount, - String executeId, - String credentialName) { - String scopeKey = parent.getScopeKey() + ":split:" + retryCount + ":" + partIndex; - String scopeHash = DigestUtil.sha256Hex(scopeKey); - CozeBatchContext context = new CozeBatchContext( - parentContext.jobId(), - parentContext.resultId(), - parentContext.chunkScopeHash(), - parentContext.chunkIndex(), - partIndex, - partTotal, - parentContext.ownerInstanceId(), - retryCount, - firstNonBlank(credentialName, parentContext.credentialName()) - ); - LocalDateTime now = LocalDateTime.now(); - String batchPayload = writeJson(batchRows, "serialize split coze batch payload failed"); - String storedBatchPayload = storeSharedCozeBatchPayload(parent.getTaskId(), scopeHash, batchPayload); - TaskScopeStateEntity state = new TaskScopeStateEntity(); - state.setTaskId(parent.getTaskId()); - state.setModuleType(MODULE_TYPE); - state.setScopeKey(scopeKey); - state.setScopeHash(scopeHash); - state.setParsedPayloadJson(storedBatchPayload); - state.setStateJson(writeJson(context, "serialize split coze batch context failed")); - state.setCozeExecuteId(executeId); - state.setCozeStatus(COZE_STATUS_SUBMITTED); - state.setCozeSubmittedAt(now); - state.setCozeAttemptCount(0); - state.setChunkTotal(partTotal); - state.setReceivedChunkCount(partIndex); - state.setCompleted(0); - state.setCreatedAt(now); - state.setUpdatedAt(now); - try { - taskScopeStateMapper.insert(state); - } catch (DuplicateKeyException ex) { - transientPayloadStorageService.deletePayloadIfPresent(storedBatchPayload); - log.info("[appearance-patent] duplicate split coze batch state ignored taskId={} scope={}", - parent.getTaskId(), scopeKey); - } - } - - private boolean shouldSplitCozeBatchForRetry(String failureMessage) { - String normalized = normalize(failureMessage).toLowerCase(Locale.ROOT); - return normalized.contains("timeout") - || normalized.contains("timed out") - || normalized.contains("without output") - || normalized.contains("empty result") - || normalized.contains("out of limit") - || normalized.contains("execution limit") - || normalized.contains("720712008") - || normalized.contains("720701002") - || normalized.contains("工作流节点执行超限") - || normalized.contains("调用超时"); - } - - private boolean isRetryableCozeFailure(String failureMessage) { - String normalized = normalize(failureMessage).toLowerCase(Locale.ROOT); - return normalized.contains("rate limit") - || normalized.contains("too many") - || normalized.contains("retry later") - || normalized.contains("timeout") - || normalized.contains("timed out") - || normalized.contains("without output") - || normalized.contains("empty result") - || normalized.contains("out of limit") - || normalized.contains("execution limit") - || normalized.contains("702093018") - || normalized.contains("720712008") - || normalized.contains("720701002") - || normalized.contains("plugin limit") - || normalized.contains("限流") - || normalized.contains("稍后重试") - || normalized.contains("工作流节点执行超限") - || normalized.contains("调用超时"); - } - - private void updateCozeStateRunning(TaskScopeStateEntity state, String error) { - int updated = taskScopeStateMapper.update(null, new LambdaUpdateWrapper() - .eq(TaskScopeStateEntity::getId, state.getId()) - .in(TaskScopeStateEntity::getCozeStatus, List.of(COZE_STATUS_SUBMITTED, COZE_STATUS_RUNNING)) - .set(TaskScopeStateEntity::getCozeStatus, COZE_STATUS_RUNNING) - .set(TaskScopeStateEntity::getCozeLastPolledAt, LocalDateTime.now()) - .set(TaskScopeStateEntity::getCozeAttemptCount, cozeAttemptCount(state) + 1) - .set(TaskScopeStateEntity::getCozeError, error) - .set(TaskScopeStateEntity::getUpdatedAt, LocalDateTime.now())); - if (updated > 0) { - touchJavaSideTaskActivity(state.getTaskId()); - } - } - - private boolean tryClaimCozeStateForPoll(TaskScopeStateEntity state) { - if (state == null || state.getId() == null) { - return false; - } - LocalDateTime now = LocalDateTime.now(); - long intervalMillis = Math.max(200L, properties.getCozePollIntervalMillis()); - if (state.getCozeLastPolledAt() != null - && Duration.between(state.getCozeLastPolledAt(), now).toMillis() < intervalMillis) { - return false; - } - return taskScopeStateMapper.update(null, new LambdaUpdateWrapper() - .eq(TaskScopeStateEntity::getId, state.getId()) - .in(TaskScopeStateEntity::getCozeStatus, List.of(COZE_STATUS_SUBMITTED, COZE_STATUS_RUNNING)) - .and(wrapper -> wrapper - .isNull(TaskScopeStateEntity::getCozeLastPolledAt) - .or() - .le(TaskScopeStateEntity::getCozeLastPolledAt, now.minus(Duration.ofMillis(intervalMillis)))) - .set(TaskScopeStateEntity::getCozeStatus, COZE_STATUS_RUNNING) - .set(TaskScopeStateEntity::getCozeLastPolledAt, now) - .set(TaskScopeStateEntity::getUpdatedAt, now)) > 0; - } - - private boolean tryClaimPendingCozeSubmitState(TaskScopeStateEntity state) { - if (state == null || state.getId() == null) { - return false; - } - LocalDateTime now = LocalDateTime.now(); - long intervalMillis = Math.max(1000L, PENDING_COZE_RETRY_INTERVAL_MILLIS); - return taskScopeStateMapper.update(null, new LambdaUpdateWrapper() - .eq(TaskScopeStateEntity::getId, state.getId()) - .in(TaskScopeStateEntity::getCozeStatus, List.of(COZE_STATUS_SUBMITTED, COZE_STATUS_RUNNING)) - .and(wrapper -> wrapper - .isNull(TaskScopeStateEntity::getCozeLastPolledAt) - .or() - .le(TaskScopeStateEntity::getCozeLastPolledAt, now.minus(Duration.ofMillis(intervalMillis)))) - .set(TaskScopeStateEntity::getCozeStatus, COZE_STATUS_RUNNING) - .set(TaskScopeStateEntity::getCozeLastPolledAt, now) - .set(TaskScopeStateEntity::getUpdatedAt, now)) > 0; - } - - private void markCozeStateTerminal(TaskScopeStateEntity state, String status, String error) { - taskScopeStateMapper.update(null, new LambdaUpdateWrapper() - .eq(TaskScopeStateEntity::getId, state.getId()) - .in(TaskScopeStateEntity::getCozeStatus, List.of(COZE_STATUS_SUBMITTED, COZE_STATUS_RUNNING)) - .set(TaskScopeStateEntity::getCozeStatus, status) - .set(TaskScopeStateEntity::getCozeCompletedAt, LocalDateTime.now()) - .set(TaskScopeStateEntity::getCozeLastPolledAt, LocalDateTime.now()) - .set(TaskScopeStateEntity::getCozeAttemptCount, cozeAttemptCount(state) + 1) - .set(TaskScopeStateEntity::getCozeError, error) - .set(TaskScopeStateEntity::getCompleted, 1) - .set(TaskScopeStateEntity::getUpdatedAt, LocalDateTime.now())); - } - - private void maybeFinalizeCozeJob(Long taskId, CozeBatchContext context) { - if (taskId == null || context == null || countPendingCozeStates(taskId) > 0) { - return; - } - if (!isOwnerCurrent(context.ownerInstanceId())) { - return; - } - TaskDistributedLockService.LockHandle taskLockHandle = acquireTaskLock(taskId, 0L); - if (taskLockHandle == null) { - return; - } - try (taskLockHandle) { - maybeFinalizeCozeJobLocked(taskId, context); - } - } - - private void maybeFinalizeCozeJobLocked(Long taskId, CozeBatchContext context) { - if (taskId == null || context == null || countPendingCozeStates(taskId) > 0) { - return; - } - DistributedJobLockService.LockHandle lockHandle = - distributedJobLockService.tryLock("appearance-patent:coze-finalize:" + taskId, Duration.ofMinutes(5)); - if (lockHandle == null) { - return; - } - try (lockHandle) { - if (countPendingCozeStates(taskId) > 0) { - return; - } - TaskFileJobEntity job = taskFileJobService.findAssembleJob(taskId, MODULE_TYPE, context.resultId()); - if (job == null || "SUCCESS".equals(job.getStatus())) { - return; - } - FileTaskEntity task = fileTaskMapper.selectById(taskId); - if (task != null && STATUS_RUNNING.equals(task.getStatus()) && !isResultSubmissionComplete(taskId)) { - taskFileJobService.touchRunning(job.getId()); - touchJavaSideTaskActivity(taskId); - return; - } - boolean requeued = taskFileJobService.requeue(job.getId(), "Coze 结果已回流,正在组装 xlsx"); - if (requeued) { - log.info("[appearance-patent] coze async results ready, result file job requeued taskId={} jobId={} resultId={}", - taskId, job.getId(), context.resultId()); - } - } catch (Exception ex) { - TaskFileJobEntity job = taskFileJobService.findAssembleJob(taskId, MODULE_TYPE, context.resultId()); - if (job != null) { - taskFileJobService.markFailed(job, firstNonBlank(ex.getMessage(), "appearance patent result file build failed")); - } - log.warn("[appearance-patent] coze async finalize failed taskId={} resultId={} err={}", - taskId, context.resultId(), ex.getMessage(), ex); - } - } - private void completeCozeFileJob(FileTaskEntity task, FileResultEntity result, TaskFileJobEntity job, int totalProgressUnits) { - if (countPendingCozeStates(task.getId()) > 0) { - throw new BusinessException("Coze 结果仍在处理中,暂不能生成结果文件"); - } int assembleProgress = Math.max(1, totalProgressUnits - 2); saveFileBuildProgress(task, job, totalProgressUnits, assembleProgress, "正在组装 xlsx"); assembleResultWorkbook(task, result); @@ -2430,14 +1552,6 @@ public class AppearancePatentTaskService { saveFileBuildProgress(task, job, totalProgressUnits, totalProgressUnits, "结果文件已生成"); } - private void mergeCozeRowsIntoChunk(FileTaskEntity task, - String chunkScopeHash, - Integer chunkIndex, - List cozeRows, - Map> allRowsByBaseId) { - mergeCozeRowsIntoSubmittedChunks(task, cozeRows, allRowsByBaseId, chunkScopeHash, chunkIndex); - } - private void mergeCozeRowsIntoSubmittedChunks(FileTaskEntity task, List cozeRows, Map> allRowsByBaseId) { @@ -2505,109 +1619,6 @@ public class AppearancePatentTaskService { return firstNonBlank(scopeHash, "") + ":" + (chunkIndex == null ? 0 : chunkIndex); } - private List readCozeBatchRows(TaskScopeStateEntity state) { - if (state == null || state.getParsedPayloadJson() == null || state.getParsedPayloadJson().isBlank()) { - return List.of(); - } - try { - String payloadJson = transientPayloadStorageService.resolvePayload( - state.getParsedPayloadJson(), "read appearance patent coze batch failed"); - JsonNode array = objectMapper.readTree(payloadJson); - if (!array.isArray()) { - return List.of(); - } - List rows = new ArrayList<>(); - for (JsonNode node : array) { - rows.add(objectMapper.treeToValue(node, AppearancePatentResultRowDto.class)); - } - return rows; - } catch (Exception ex) { - log.warn("[appearance-patent] read coze batch failed taskId={} stateId={} err={}", - state.getTaskId(), state.getId(), ex.getMessage()); - return List.of(); - } - } - - private CozeBatchContext readCozeBatchContext(TaskScopeStateEntity state) { - if (state == null || state.getStateJson() == null || state.getStateJson().isBlank()) { - return null; - } - try { - return objectMapper.readValue(state.getStateJson(), CozeBatchContext.class); - } catch (Exception ex) { - log.warn("[appearance-patent] read coze batch context failed taskId={} stateId={} err={}", - state.getTaskId(), state.getId(), ex.getMessage()); - return null; - } - } - - private int cozeSubmitRetryCount(CozeBatchContext context) { - return context == null || context.submitRetryCount() == null ? 0 : context.submitRetryCount(); - } - - private CozeBatchContext withCozeSubmitRetryCount(CozeBatchContext context, int submitRetryCount) { - return new CozeBatchContext( - context.jobId(), - context.resultId(), - context.chunkScopeHash(), - context.chunkIndex(), - context.batchIndex(), - context.batchTotal(), - context.ownerInstanceId(), - submitRetryCount, - context.credentialName() - ); - } - - private boolean isCozeStateTimedOut(TaskScopeStateEntity state) { - if (state == null || state.getCozeSubmittedAt() == null) { - return false; - } - long timeoutMillis = Math.max(10000L, properties.getCozePollTimeoutMillis()); - return Duration.between(state.getCozeSubmittedAt(), LocalDateTime.now()).toMillis() >= timeoutMillis; - } - - private long cozeFlushPendingMillis() { - return Math.max(1, properties.getCozeFlushPendingMinutes()) * 60_000L; - } - - private int cozeAttemptCount(TaskScopeStateEntity state) { - return state == null || state.getCozeAttemptCount() == null ? 0 : state.getCozeAttemptCount(); - } - - private int countPendingCozeStates(Long taskId) { - if (taskId == null || taskId <= 0) { - return 0; - } - Long count = taskScopeStateMapper.selectCount(new LambdaQueryWrapper() - .eq(TaskScopeStateEntity::getTaskId, taskId) - .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE) - .in(TaskScopeStateEntity::getCozeStatus, List.of(COZE_STATUS_SUBMITTED, COZE_STATUS_RUNNING))); - return count == null ? 0 : count.intValue(); - } - - private int countCompletedCozeStates(Long taskId) { - if (taskId == null || taskId <= 0) { - return 0; - } - Long count = taskScopeStateMapper.selectCount(new LambdaQueryWrapper() - .eq(TaskScopeStateEntity::getTaskId, taskId) - .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE) - .in(TaskScopeStateEntity::getCozeStatus, List.of(COZE_STATUS_DONE, COZE_STATUS_FAILED))); - return count == null ? 0 : count.intValue(); - } - - private int countAllCozeStates(Long taskId) { - if (taskId == null || taskId <= 0) { - return 0; - } - Long count = taskScopeStateMapper.selectCount(new LambdaQueryWrapper() - .eq(TaskScopeStateEntity::getTaskId, taskId) - .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE) - .isNotNull(TaskScopeStateEntity::getCozeStatus)); - return count == null ? 0 : count.intValue(); - } - private boolean isResultSubmissionComplete(Long taskId) { if (taskId == null || taskId <= 0) { return false; @@ -2632,151 +1643,11 @@ public class AppearancePatentTaskService { .set(FileTaskEntity::getUpdatedAt, LocalDateTime.now())); } - private String buildCozeBatchScopeKey(Long taskId, List batchRows) { - StringBuilder rowKeys = new StringBuilder(); - if (batchRows != null) { - for (AppearancePatentResultRowDto row : batchRows) { - String key = rowKey(row); - if (!key.isBlank()) { - if (!rowKeys.isEmpty()) { - rowKeys.append('|'); - } - rowKeys.append(key); - } - } - } - return "coze:task:" + taskId + ":rows:" + DigestUtil.sha256Hex(rowKeys.toString()); - } - private String buildTaskOwnerScopeKey(FileTaskEntity task) { Long taskId = task == null ? null : task.getId(); return "task:" + taskId + ":owner:" + firstNonBlank(ownerFromTask(task), currentInstanceId()); } - private void retryPendingCozeSubmitState(TaskScopeStateEntity state) { - if (state == null || state.getId() == null) { - return; - } - if (!tryClaimPendingCozeSubmitState(state)) { - log.info("[appearance-patent] coze submit retry skipped by claim guard taskId={} stateId={} lastPolledAt={}", - state.getTaskId(), state.getId(), state.getCozeLastPolledAt()); - return; - } - CozeBatchContext context = readCozeBatchContext(state); - if (context == null || context.jobId() == null || context.resultId() == null) { - markCozeStateTerminal(state, COZE_STATUS_FAILED, "Coze pending submit context missing"); - return; - } - List batchRows = readCozeBatchRows(state); - if (batchRows.isEmpty()) { - markCozeStateTerminal(state, COZE_STATUS_FAILED, "Coze pending submit payload missing"); - maybeFinalizeCozeJobLocked(state.getTaskId(), context); - return; - } - FileTaskEntity task = fileTaskMapper.selectById(state.getTaskId()); - if (task == null || !MODULE_TYPE.equals(task.getModuleType())) { - markCozeStateTerminal(state, COZE_STATUS_FAILED, "Coze pending submit task missing"); - return; - } - Map currentRows = loadPersistedResultRowsForResultRows( - task.getId(), batchRows); - List currentBatchRows = batchRows.stream() - .map(row -> findPersistedResultRow(row, currentRows)) - .filter(Objects::nonNull) - .toList(); - if (currentBatchRows.size() == batchRows.size() - && currentBatchRows.stream().allMatch(this::hasResolvedCozeFields)) { - markCozeStateTerminal(state, COZE_STATUS_DONE, "Coze rows already resolved by another batch"); - maybeFinalizeCozeJobLocked(state.getTaskId(), context); - log.info("[appearance-patent] coze pending submit skipped because rows already resolved taskId={} stateId={} jobId={} rows={}", - state.getTaskId(), state.getId(), context.jobId(), batchRows.size()); - return; - } - try { - taskFileJobService.touchRunning(context.jobId()); - AppearancePatentCozeClient.CozeSubmitResponse submit = - submitCozeWorkflowThrottled(batchRows, readAiPrompt(task), readApiKey(task), readPatentToken(task), - cozeClient.credentialByName(context.credentialName()), false); - Map> allRowsByBaseId = loadAllRowsByBaseId(task); - if (submit.immediateData() != null && !submit.immediateData().isBlank()) { - List cozeRows = - mergeUsableCozeRows(batchRows, submit.immediateData()); - mergeCozeRowsIntoSubmittedChunks(task, cozeRows, allRowsByBaseId); - markCozeStateTerminal(state, COZE_STATUS_DONE, null); - maybeFinalizeCozeJobLocked(state.getTaskId(), context); - return; - } - if (submit.executeId() == null || submit.executeId().isBlank()) { - markCozeStateTerminal(state, COZE_STATUS_FAILED, "Coze async execute_id missing"); - mergeCozeRowsIntoSubmittedChunks(task, - cozeClient.markRowsFailed(batchRows, "Coze async execute_id missing"), - allRowsByBaseId); - maybeFinalizeCozeJobLocked(state.getTaskId(), context); - return; - } - LocalDateTime now = LocalDateTime.now(); - taskScopeStateMapper.update(null, new LambdaUpdateWrapper() - .eq(TaskScopeStateEntity::getId, state.getId()) - .in(TaskScopeStateEntity::getCozeStatus, List.of(COZE_STATUS_SUBMITTED, COZE_STATUS_RUNNING)) - .set(TaskScopeStateEntity::getCozeExecuteId, submit.executeId()) - .set(TaskScopeStateEntity::getCozeStatus, COZE_STATUS_SUBMITTED) - .set(TaskScopeStateEntity::getCozeSubmittedAt, now) - .set(TaskScopeStateEntity::getCozeLastPolledAt, null) - .set(TaskScopeStateEntity::getCozeError, null) - .set(TaskScopeStateEntity::getUpdatedAt, now)); - touchJavaSideTaskActivity(state.getTaskId()); - log.info("[appearance-patent] coze pending submit retried taskId={} stateId={} jobId={} rows={} executeId={}", - state.getTaskId(), state.getId(), context.jobId(), batchRows.size(), submit.executeId()); - } catch (Exception ex) { - String message = firstNonBlank(ex.getMessage(), "Coze pending submit retry failed"); - log.warn("[appearance-patent] coze pending submit retry failed taskId={} stateId={} jobId={} rows={} err={}", - state.getTaskId(), state.getId(), context.jobId(), batchRows.size(), message); - if (isCozeThrottleLockTimeout(message)) { - if (isCozeStateTimedOut(state)) { - Map> allRowsByBaseId = loadAllRowsByBaseId(task); - String finalMessage = "等待 Coze 提交凭证超时:" + message; - mergeCozeRowsIntoSubmittedChunks(task, - cozeClient.markRowsFailed(batchRows, finalMessage), - allRowsByBaseId); - markCozeStateTerminal(state, COZE_STATUS_FAILED, finalMessage); - maybeFinalizeCozeJobLocked(state.getTaskId(), context); - log.warn("[appearance-patent] Coze pending 提交等待超时,已将批次置为失败 taskId={} stateId={} jobId={} rows={}", - state.getTaskId(), state.getId(), context.jobId(), batchRows.size()); - return; - } - keepPendingCozeSubmitState(state, message); - return; - } - int nextAttemptCount = cozeAttemptCount(state) + 1; - if (nextAttemptCount >= MAX_COZE_SUBMIT_RETRY_COUNT || isCozeStateTimedOut(state)) { - Map> allRowsByBaseId = loadAllRowsByBaseId(task); - String finalMessage = isCozeStateTimedOut(state) - ? message + "(超过 " + properties.getCozePollTimeoutMillis() + "ms)" - : message + ",已重试提交 " + nextAttemptCount + " 次"; - mergeCozeRowsIntoSubmittedChunks(task, - cozeClient.markRowsFailed(batchRows, finalMessage), - allRowsByBaseId); - markCozeStateTerminal(state, COZE_STATUS_FAILED, finalMessage); - maybeFinalizeCozeJobLocked(state.getTaskId(), context); - return; - } - updateCozeStateRunning(state, message); - } - } - - private void keepPendingCozeSubmitState(TaskScopeStateEntity state, String error) { - int updated = taskScopeStateMapper.update(null, new LambdaUpdateWrapper() - .eq(TaskScopeStateEntity::getId, state.getId()) - .in(TaskScopeStateEntity::getCozeStatus, List.of(COZE_STATUS_SUBMITTED, COZE_STATUS_RUNNING)) - .set(TaskScopeStateEntity::getCozeStatus, COZE_STATUS_RUNNING) - .set(TaskScopeStateEntity::getCozeExecuteId, null) - .set(TaskScopeStateEntity::getCozeError, error) - .set(TaskScopeStateEntity::getUpdatedAt, LocalDateTime.now())); - if (updated > 0) { - touchJavaSideTaskActivity(state.getTaskId()); - } - } - private String currentInstanceId() { String instanceId = instanceMetadata == null ? null : instanceMetadata.getInstanceId(); return firstNonBlank(instanceId, "unknown-instance"); @@ -2800,19 +1671,10 @@ public class AppearancePatentTaskService { currentInstanceId()); } - private boolean isCozeStateOwnedByCurrentInstance(TaskScopeStateEntity state) { - return state == null || isOwnerCurrent(ownerFromCozeState(state)); - } - private boolean isOwnerCurrent(String owner) { return owner == null || owner.isBlank() || Objects.equals(owner, currentInstanceId()); } - private String ownerFromCozeState(TaskScopeStateEntity state) { - CozeBatchContext context = readCozeBatchContext(state); - return context == null ? null : context.ownerInstanceId(); - } - private String ownerFromTask(FileTaskEntity task) { if (task == null || task.getResultJson() == null || task.getResultJson().isBlank()) { return null; @@ -2854,24 +1716,6 @@ public class AppearancePatentTaskService { return value.substring(0, normalizedMaxLength - 3) + "..."; } - private int countCozeWorkUnits(List chunks, int batchSize) { - if (chunks == null || chunks.isEmpty()) { - return 0; - } - int total = 0; - for (TaskChunkEntity chunk : chunks) { - Map persistedRows = readChunkRows(chunk); - if (persistedRows.isEmpty()) { - continue; - } - int unresolved = pickGroupRepresentativesForCoze(persistedRows.values()).size(); - if (unresolved > 0) { - total += Math.max(1, (unresolved + batchSize - 1) / batchSize); - } - } - return total; - } - private void saveFileBuildProgress(FileTaskEntity task, TaskFileJobEntity job, int total, @@ -2948,54 +1792,10 @@ public class AppearancePatentTaskService { } } - private void saveCozePipelineProgress(FileTaskEntity task, TaskFileJobEntity job) { - if (task == null || task.getId() == null || job == null || job.getId() == null) { - return; - } - int totalCoze = countAllCozeStates(task.getId()); - int completedCoze = countCompletedCozeStates(task.getId()); - int pendingCoze = countPendingCozeStates(task.getId()); - boolean uploadComplete = isResultSubmissionComplete(task.getId()); - if (totalCoze <= 0) { - int receivedChunks = countTaskChunks(task.getId()); - int uploadedProgress = Math.max(1, receivedChunks); - saveFileBuildProgress(task, job, Math.max(uploadedProgress + 1, 2), uploadedProgress, - "正在接收 Python 数据,累计 50 条后提交 Coze"); - return; - } - int total = Math.max(3, totalCoze + 3); - int completed = Math.max(0, Math.min(completedCoze, total - 1)); - String message; - if (pendingCoze > 0) { - message = "已提交 Coze " + totalCoze + " 批,已完成 " + completedCoze + " 批,等待结果回流"; - } else if (!uploadComplete) { - message = "Coze 已完成 " + completedCoze + " 批,等待 Python 继续回传数据"; - } else { - message = "Coze 结果已回流,正在组装 xlsx"; - completed = Math.max(completed, Math.min(total - 2, completedCoze)); - } - saveFileBuildProgress(task, job, total, completed, message); - } - - private int countTaskChunks(Long taskId) { - if (taskId == null || taskId <= 0) { - return 0; - } - Long count = taskChunkMapper.selectCount(new LambdaQueryWrapper() - .eq(TaskChunkEntity::getTaskId, taskId) - .eq(TaskChunkEntity::getModuleType, MODULE_TYPE)); - return count == null ? 0 : count.intValue(); - } - public void cleanupResultFileJob(TaskFileJobEntity job) { if (job == null || job.getTaskId() == null) { return; } - if (countPendingCozeStates(job.getTaskId()) > 0) { - log.warn("[appearance-patent] skip cleanup because coze is still pending taskId={} jobId={}", - job.getTaskId(), job.getId()); - return; - } deleteTransientTaskPayloads(job.getTaskId()); taskChunkMapper.delete(new LambdaQueryWrapper() .eq(TaskChunkEntity::getTaskId, job.getTaskId()) @@ -3503,58 +2303,6 @@ public class AppearancePatentTaskService { || !normalize(row.getTitleReason()).isBlank(); } - private List mergeUsableCozeRows(List batchRows, - String payloadText) throws Exception { - // 旧签名保留:未显式传 workflow status 的调用方默认按 "业务侧 SUCCESS" 对待 - // (submit immediateData 同步路径本身就意味着 coze workflow 业务侧已成功返回)。 - return mergeUsableCozeRows(batchRows, payloadText, null); - } - - /** - * 合并 Coze 回包: - *
    - *
  • workflow 业务侧 SUCCESS(status=SUCCESS 或 immediate 同步返回)但解析后无任何风险维度结果, - * 直接 {@link AppearancePatentCozeClient#markRowsFailed(List, String)} 落地,不再抛异常, - * 避免业务空结果被重复扔进 retry/split-retry 死循环。
  • - *
  • workflow 业务侧非 SUCCESS(poll status=FAILED/CANCELED 等系统侧失败)保持原行为, - * 抛 {@link IllegalStateException},让外层 retry/split-retry 链路处理。
  • - *
- */ - private List mergeUsableCozeRows(List batchRows, - String payloadText, - String workflowStatus) throws Exception { - List cozeRows = cozeClient.mergeRowsFromDataText(batchRows, payloadText); - if (isUnusableCozePayload(cozeRows)) { - if (isBusinessSuccessStatus(workflowStatus)) { - log.warn("[appearance-patent] coze business success but empty payload, markFailed batchRows={} status={}", - batchRows == null ? 0 : batchRows.size(), workflowStatus); - return cozeClient.markRowsFailed(batchRows, "Coze 业务返回空结果"); - } - throw new IllegalStateException(COZE_EMPTY_RESULT_MESSAGE); - } - return cozeRows; - } - - private boolean isBusinessSuccessStatus(String status) { - // submit 同步路径没有 status,但回了 data 字段就视为业务 SUCCESS。 - if (status == null || status.isBlank()) { - return true; - } - String normalized = status.trim().toUpperCase(Locale.ROOT); - return normalized.contains("SUCCESS"); - } - - private boolean isUnusableCozePayload(List cozeRows) { - if (cozeRows == null || cozeRows.isEmpty()) { - return true; - } - boolean hasUsableOutcome = cozeRows.stream().anyMatch(row -> hasResolvedCozeFields(row) || hasReasonFields(row)); - if (hasUsableOutcome) { - return false; - } - return cozeRows.stream().allMatch(row -> isFailedCozeStatusValue(row == null ? null : row.getStatus()) - || normalize(row == null ? null : row.getStatus()).isBlank()); - } private ParsedWorkbook parseWorkbook(File input, AppearancePatentSourceFileDto source) { DataFormatter formatter = new DataFormatter(); @@ -4301,9 +3049,6 @@ public class AppearancePatentTaskService { } private String userFacingCozeCellValue(AppearancePatentResultRowDto row, String value) { - if (row != null && isCozeAsyncPollTimeout(row.getError())) { - return ""; - } String normalizedValue = normalize(value); if (!normalizedValue.isBlank() && !isTechnicalCozeFailure(normalizedValue)) { return value; @@ -4312,9 +3057,6 @@ public class AppearancePatentTaskService { if (row != null && isTechnicalCozeFailure(row.getError())) { return firstNonBlank(row.getError(), ""); } - if (row != null && isFailedCozeStatusValue(row.getStatus())) { - return firstNonBlank(row.getError(), ""); - } return firstNonBlank(value, ""); } @@ -4322,9 +3064,6 @@ public class AppearancePatentTaskService { if (row == null) { return ""; } - if (isCozeAsyncPollTimeout(row.getError())) { - return ""; - } String conclusion = normalize(row.getConclusion()); if (!conclusion.isBlank() && !isTechnicalCozeFailure(conclusion)) { return row.getConclusion(); @@ -4333,17 +3072,9 @@ public class AppearancePatentTaskService { if (isTechnicalCozeFailure(row.getError())) { return firstNonBlank(row.getError(), ""); } - if (isFailedCozeStatusValue(row.getStatus())) { - return firstNonBlank(row.getError(), ""); - } return firstNonBlank(row.getConclusion(), ""); } - private boolean isCozeAsyncPollTimeout(String value) { - String normalized = normalize(value).toLowerCase(Locale.ROOT); - return normalized.contains(COZE_ASYNC_POLL_TIMEOUT_MESSAGE.toLowerCase(Locale.ROOT)) - || normalized.contains("coze async workflow poll timeout"); - } private String userFacingStatus(AppearancePatentResultRowDto row) { if (row == null) { @@ -4367,13 +3098,6 @@ public class AppearancePatentTaskService { || normalized.contains("timeout"); } - private boolean isFailedCozeStatusValue(String status) { - String normalized = normalize(status).toLowerCase(Locale.ROOT); - return normalized.contains("fail") - || normalized.contains("error") - || normalized.contains("cancel") - || normalized.contains("失败"); - } private String safeFileStem(String filename) { String name = filename == null || filename.isBlank() ? "appearance-patent" : filename; @@ -4426,22 +3150,6 @@ public class AppearancePatentTaskService { String error) { } - private record CozeBatchContext(Long jobId, - Long resultId, - String chunkScopeHash, - Integer chunkIndex, - Integer batchIndex, - Integer batchTotal, - String ownerInstanceId, - Integer submitRetryCount, - String credentialName) { - } - - private record CozeCandidate(String chunkScopeHash, - Integer chunkIndex, - AppearancePatentResultRowDto row) { - } - private static class SourceRowsBuilder { private final String sourceFileKey; private String sourceFilename; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/invalidasin/mapper/InvalidAsinDataMapper.java b/backend-java/src/main/java/com/nanri/aiimage/modules/invalidasin/mapper/InvalidAsinDataMapper.java index ff756f1c..06d77a2d 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/invalidasin/mapper/InvalidAsinDataMapper.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/invalidasin/mapper/InvalidAsinDataMapper.java @@ -11,14 +11,19 @@ import java.util.List; @Mapper public interface InvalidAsinDataMapper extends BaseMapper { - /** 批量 INSERT IGNORE:命中唯一键 (data_value, brand) 的重复行静默跳过,幂等。 */ + /** + * 批量 INSERT IGNORE:命中唯一键 (data_value, brand) 的重复行静默跳过,幂等。 + * created_at/updated_at 不显式写入:依赖列的 DEFAULT CURRENT_TIMESTAMP。 + * 显式传 null 会绕过默认值,在宽松 SQL 模式下落为 '0000-00-00 00:00:00', + * 随后在 NO_ZERO_DATE 模式(生产已启用)下读取即报 Zero date value prohibited。 + */ @Insert(""" """) diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopIndexService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopIndexService.java index 89bcd188..821a5b54 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopIndexService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopIndexService.java @@ -195,6 +195,7 @@ public class ZiniaoShopIndexService { int skippedApiKeyCount = 0; int whitelistSkippedApiKeyCount = 0; boolean completeCoverage = true; + List whitelistBlockedApiKeys = new ArrayList<>(); try { List allApiKeyAccounts = ziniaoApiKeyProvider.listApiKeyAccounts(); List apiKeyAccounts = selectApiKeyBatchByOffset( @@ -217,6 +218,7 @@ public class ZiniaoShopIndexService { completeCoverage = false; if (ziniaoAuthService.isIpWhitelistError(ex)) { whitelistSkippedApiKeyCount++; + whitelistBlockedApiKeys.add(apiKey); markIpWhitelistBlockedSafely(apiKeyAccount, ex.getMessage()); log.warn("[ziniao-index] skip apiKey reason=IP_WHITELIST accountName={} msg={}", companyName, ex.getMessage()); continue; @@ -233,6 +235,7 @@ public class ZiniaoShopIndexService { if (ziniaoAuthService.isIpWhitelistError(ex)) { skippedApiKeyCount++; whitelistSkippedApiKeyCount++; + whitelistBlockedApiKeys.add(apiKey); completeCoverage = false; markIpWhitelistBlockedSafely(apiKeyAccount, ex.getMessage()); log.warn("[ziniao-index] skip apiKey reason=IP_WHITELIST accountName={} companyId={} msg={}", @@ -252,6 +255,7 @@ public class ZiniaoShopIndexService { if (ziniaoAuthService.isIpWhitelistError(ex)) { skippedApiKeyCount++; whitelistSkippedApiKeyCount++; + whitelistBlockedApiKeys.add(apiKey); completeCoverage = false; markIpWhitelistBlockedSafely(apiKeyAccount, ex.getMessage()); log.warn("[ziniao-index] skip apiKey reason=IP_WHITELIST accountName={} companyId={} userId={} msg={}", @@ -380,6 +384,7 @@ public class ZiniaoShopIndexService { log.info("[ziniao-index] skip stale marking for partial refresh completedApiKeys={}/{} skippedApiKeys={} nextOffset={}", completedApiKeyCount, allApiKeyAccounts.size(), skippedApiKeyCount, nextOffset); } + syncWhitelistBlockedMarks(whitelistBlockedApiKeys, now); cursor.setStatus("SUCCESS"); List refreshMessages = new ArrayList<>(); @@ -583,6 +588,75 @@ public class ZiniaoShopIndexService { return tb >= ta ? b : a; } + /** + * 本轮因 IP 白名单被跳过的 apiKey,其名下的索引行保持 ACTIVE 且 lastRefreshedAt 不再更新, + * 20 分钟后会落入"已过保鲜期"。这里给这些行写入 refreshBlockedReason / lastRefreshBlockedAt, + * 让查询侧 {@link #shouldBypassFreshnessBecauseWhitelistFailure} 得以豁免保鲜期判断。 + * 仅写入本轮被阻断的键对应行;成功键的行一旦重新 put() 即为无阻断字段的新对象。 + */ + private void syncWhitelistBlockedMarks(List whitelistBlockedApiKeys, long now) { + if (whitelistBlockedApiKeys == null || whitelistBlockedApiKeys.isEmpty()) { + return; + } + Set blockedApiKeyHashes = new HashSet<>(); + for (String apiKey : whitelistBlockedApiKeys) { + blockedApiKeyHashes.add(buildApiKeyHash(apiKey)); + } + List entities = ziniaoMemoryStoreService.listAliveEntitiesByType( + ZiniaoMemoryStoreService.CACHE_TYPE_SHOP_INDEX_ENTRY, + SHOP_INDEX_LIST_LIMIT + ); + if (entities.isEmpty()) { + return; + } + List updates = new ArrayList<>(); + int changedCount = 0; + for (ZiniaoMemoryStoreEntity entity : entities) { + if (entity == null || entity.getId() == null || entity.getPayloadJson() == null) { + continue; + } + ZiniaoShopIndexEntryDto existingEntry; + try { + existingEntry = objectMapper.readValue(entity.getPayloadJson(), ZiniaoShopIndexEntryDto.class); + } catch (Exception ex) { + log.warn("[ziniao-index] skip corrupt shop_index row while marking whitelist-blocked cacheKey={}", entity.getCacheKey()); + continue; + } + if (existingEntry == null || !STATUS_ACTIVE.equals(existingEntry.getStatus()) + || existingEntry.getApiKeyHash() == null + || !blockedApiKeyHashes.contains(existingEntry.getApiKeyHash())) { + continue; + } + long lastRefreshed = existingEntry.getLastRefreshedAt() == null ? 0L : existingEntry.getLastRefreshedAt(); + boolean changed = !REFRESH_BLOCKED_REASON_IP_WHITELIST.equals(existingEntry.getRefreshBlockedReason()) + || existingEntry.getLastRefreshBlockedAt() == null + || existingEntry.getLastRefreshBlockedAt() < lastRefreshed; + if (!changed) { + continue; + } + existingEntry.setRefreshBlockedReason(REFRESH_BLOCKED_REASON_IP_WHITELIST); + existingEntry.setLastRefreshBlockedAt(Math.max(now, lastRefreshed)); + String newPayload; + try { + newPayload = objectMapper.writeValueAsString(existingEntry); + } catch (Exception ex) { + throw new BusinessException("写入紫鸟记忆存储失败", ex); + } + entity.setPayloadJson(newPayload); + entity.setExpiresAt(LocalDateTime.now().plus(resolveEntryTtl())); + entity.setUpdatedAt(LocalDateTime.now()); + updates.add(entity); + changedCount++; + } + if (!updates.isEmpty()) { + ziniaoMemoryStoreService.updateStaleMarks(updates); + } + if (changedCount > 0) { + log.info("[ziniao-index] marked whitelist-blocked shop_index rows count={} blockedApiKeys={}", + changedCount, whitelistBlockedApiKeys.size()); + } + } + private void markMissingEntriesAsStale(Set activeCacheKeys, long now) { List entities = ziniaoMemoryStoreService.listAliveEntitiesByType( ZiniaoMemoryStoreService.CACHE_TYPE_SHOP_INDEX_ENTRY, diff --git a/backend-java/src/main/resources/application.yml b/backend-java/src/main/resources/application.yml index e421a75d..802ea09e 100644 --- a/backend-java/src/main/resources/application.yml +++ b/backend-java/src/main/resources/application.yml @@ -225,16 +225,16 @@ aiimage: connect-timeout-millis: ${AIIMAGE_BRAND_CHECK_CONNECT_TIMEOUT_MILLIS:10000} read-timeout-millis: ${AIIMAGE_BRAND_CHECK_READ_TIMEOUT_MILLIS:60000} appearance-patent: - coze-base-url: ${AIIMAGE_APPEARANCE_PATENT_COZE_BASE_URL:https://api.coze.cn} - coze-workflow-path: ${AIIMAGE_APPEARANCE_PATENT_COZE_WORKFLOW_PATH:/v1/workflow/run} - coze-workflow-id: ${AIIMAGE_APPEARANCE_PATENT_COZE_WORKFLOW_ID:7639685157562089513} - coze-token: ${AIIMAGE_APPEARANCE_PATENT_COZE_TOKEN:} - coze-batch-size: ${AIIMAGE_APPEARANCE_PATENT_COZE_BATCH_SIZE:10} - coze-connect-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_CONNECT_TIMEOUT_MILLIS:10000} - coze-read-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_READ_TIMEOUT_MILLIS:60000} - coze-poll-interval-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_POLL_INTERVAL_MILLIS:30000} - coze-poll-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_COZE_POLL_TIMEOUT_MILLIS:600000} - coze-flush-pending-minutes: ${AIIMAGE_APPEARANCE_PATENT_COZE_FLUSH_PENDING_MINUTES:1} + llm-host: ${AIIMAGE_APPEARANCE_PATENT_LLM_HOST:https://ai.t8star.org} + title-model: ${AIIMAGE_APPEARANCE_PATENT_TITLE_MODEL:deepseek-v4-flash} + appearance-model: ${AIIMAGE_APPEARANCE_PATENT_APPEARANCE_MODEL:gemini-3.7-flash} + llm-max-tokens: ${AIIMAGE_APPEARANCE_PATENT_LLM_MAX_TOKENS:64000} + llm-connect-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_LLM_CONNECT_TIMEOUT_MILLIS:10000} + llm-read-timeout-millis: ${AIIMAGE_APPEARANCE_PATENT_LLM_READ_TIMEOUT_MILLIS:180000} + llm-batch-size: ${AIIMAGE_APPEARANCE_PATENT_LLM_BATCH_SIZE:10} + llm-row-concurrency: ${AIIMAGE_APPEARANCE_PATENT_LLM_ROW_CONCURRENCY:10} + llm-retry-times: ${AIIMAGE_APPEARANCE_PATENT_LLM_RETRY_TIMES:3} + flush-pending-minutes: ${AIIMAGE_APPEARANCE_PATENT_FLUSH_PENDING_MINUTES:${AIIMAGE_APPEARANCE_PATENT_COZE_FLUSH_PENDING_MINUTES:1}} stale-timeout-minutes: ${AIIMAGE_APPEARANCE_PATENT_STALE_TIMEOUT_MINUTES:30} stale-finalize-cron: ${AIIMAGE_APPEARANCE_PATENT_STALE_FINALIZE_CRON:0 */2 * * * *} similar-asin: @@ -266,6 +266,18 @@ aiimage: coze-include-legacy-api-key: ${AIIMAGE_SIMILAR_ASIN_COZE_INCLUDE_LEGACY_API_KEY:true} coze-use-legacy-item-field-order: ${AIIMAGE_SIMILAR_ASIN_COZE_USE_LEGACY_ITEM_ORDER:false} coze-result-buffer-enabled: ${AIIMAGE_SIMILAR_ASIN_COZE_RESULT_BUFFER_ENABLED:true} + direct-llm-enabled: ${AIIMAGE_SIMILAR_ASIN_DIRECT_LLM_ENABLED:true} + llm-host: ${AIIMAGE_SIMILAR_ASIN_LLM_HOST:https://ai.t8star.org} + llm-api-key: ${AIIMAGE_SIMILAR_ASIN_LLM_API_KEY:} + llm-category-model: ${AIIMAGE_SIMILAR_ASIN_LLM_CATEGORY_MODEL:gemini-3.5-flash-lite} + llm-conform-model: ${AIIMAGE_SIMILAR_ASIN_LLM_CONFORM_MODEL:gemini-3.5-flash-lite} + llm-image-compare-model: ${AIIMAGE_SIMILAR_ASIN_LLM_IMAGE_COMPARE_MODEL:gemini-3.7-flash} + llm-max-tokens: ${AIIMAGE_SIMILAR_ASIN_LLM_MAX_TOKENS:64000} + llm-connect-timeout-millis: ${AIIMAGE_SIMILAR_ASIN_LLM_CONNECT_TIMEOUT_MILLIS:10000} + llm-read-timeout-millis: ${AIIMAGE_SIMILAR_ASIN_LLM_READ_TIMEOUT_MILLIS:180000} + llm-retry-times: ${AIIMAGE_SIMILAR_ASIN_LLM_RETRY_TIMES:3} + llm-row-concurrency: ${AIIMAGE_SIMILAR_ASIN_LLM_ROW_CONCURRENCY:5} + llm-image-download-timeout-seconds: ${AIIMAGE_SIMILAR_ASIN_LLM_IMAGE_DOWNLOAD_TIMEOUT_SECONDS:10} collect-data: stale-timeout-minutes: ${AIIMAGE_COLLECT_DATA_STALE_TIMEOUT_MINUTES:30} stale-check-cron: ${AIIMAGE_COLLECT_DATA_STALE_CHECK_CRON:*/30 * * * * *} diff --git a/backend-java/src/main/resources/db/V98__rename_invalid_asin_menu_to_brand_db.sql b/backend-java/src/main/resources/db/V98__rename_invalid_asin_menu_to_brand_db.sql new file mode 100644 index 00000000..57b21c35 --- /dev/null +++ b/backend-java/src/main/resources/db/V98__rename_invalid_asin_menu_to_brand_db.sql @@ -0,0 +1,6 @@ +-- V98: 后台管理菜单「无效ASIN数据」重命名为「品牌数据库」 +-- 只更新 admin 菜单的 name 显示名,column_key / route_path 保持不变, +-- 所有权限校验、前端分组路由、面板加载逻辑均按 route_path 匹配,不受影响。 +-- 对应前端静态文案见 backend/static/admin.js 与 backend/web_source/admin.html。 +UPDATE `columns` SET `name` = '品牌数据库' +WHERE `menu_type` = 'admin' AND `column_key` = 'admin_invalid_asin_data'; diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClientTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClientTest.java index b6059cf1..c9a8699c 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClientTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClientTest.java @@ -26,21 +26,21 @@ class AppearancePatentCozeClientTest { ); @Test - void markRowsFailedLeavesUserFacingResultBlankWhenAsyncPollTimeout() { + void markRowsFailedLeavesUserFacingResultFilledWithReviewMessage() { AppearancePatentResultRowDto row = new AppearancePatentResultRowDto(); row.setId("1"); List failedRows = - client.markRowsFailed(List.of(row), "Coze \u5f02\u6b65\u5de5\u4f5c\u6d41\u8f6e\u8be2\u8d85\u65f6"); + client.markRowsFailed(List.of(row), "LLM \u68c0\u6d4b\u5931\u8d25"); assertThat(failedRows).hasSize(1); AppearancePatentResultRowDto failed = failedRows.get(0); - assertThat(failed.getError()).isEqualTo("Coze \u5f02\u6b65\u5de5\u4f5c\u6d41\u8f6e\u8be2\u8d85\u65f6"); + assertThat(failed.getError()).isEqualTo("LLM \u68c0\u6d4b\u5931\u8d25"); assertThat(failed.getStatus()).isEqualTo("FAILED"); - assertThat(failed.getTitleRisk()).isNull(); - assertThat(failed.getAppearanceRisk()).isNull(); - assertThat(failed.getPatentRisk()).isNull(); - assertThat(failed.getConclusion()).isNull(); + assertThat(failed.getTitleRisk()).isEqualTo("\u68c0\u6d4b\u5931\u8d25\uff1aLLM \u68c0\u6d4b\u5931\u8d25"); + assertThat(failed.getAppearanceRisk()).isEqualTo("\u68c0\u6d4b\u5931\u8d25\uff1aLLM \u68c0\u6d4b\u5931\u8d25"); + assertThat(failed.getPatentRisk()).isEqualTo("\u68c0\u6d4b\u5931\u8d25\uff1aLLM \u68c0\u6d4b\u5931\u8d25"); + assertThat(failed.getConclusion()).isEqualTo("LLM \u68c0\u6d4b\u5931\u8d25"); } @Test diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentLlmClientHttpTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentLlmClientHttpTest.java new file mode 100644 index 00000000..620f2b33 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentLlmClientHttpTest.java @@ -0,0 +1,193 @@ +package com.nanri.aiimage.modules.appearancepatent.client; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanri.aiimage.config.AppearancePatentProperties; +import com.nanri.aiimage.config.BrandCheckProperties; +import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentResultRowDto; +import com.nanri.aiimage.modules.brand.client.BrandCheckClient; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +class AppearancePatentLlmClientHttpTest { + + private HttpServer server; + private final ObjectMapper objectMapper = new ObjectMapper(); + private final Map callCounts = new ConcurrentHashMap<>(); + private final List capturedBodies = new ArrayList<>(); + + private AppearancePatentCozeClient client; + + @BeforeEach + void setUp() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/v1/chat/completions", this::handleChat); + server.start(); + + AppearancePatentProperties properties = new AppearancePatentProperties(); + properties.setLlmHost("http://127.0.0.1:" + server.getAddress().getPort()); + properties.setLlmRetryTimes(3); + client = new AppearancePatentCozeClient( + properties, + objectMapper, + null, + new BrandCheckClient(new BrandCheckProperties(), null) + ); + } + + @AfterEach + void tearDown() { + server.stop(0); + } + + private void handleChat(HttpExchange exchange) throws IOException { + String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + capturedBodies.add(body); + try { + @SuppressWarnings("unchecked") + Map request = objectMapper.readValue(body, Map.class); + String model = String.valueOf(request.get("model")); + callCounts.computeIfAbsent(model, ignored -> new AtomicInteger()).incrementAndGet(); + + String content; + if (model.contains("deepseek")) { + // 商标提取:按标题内容返回品牌词或"无" + String messages = String.valueOf(request.get("messages")); + if (messages.contains("Apple")) { + content = "Apple,Apple"; + } else { + content = "无"; + } + } else { + // 外观检测:返回 JSON(带 ```json 包裹与换行,模拟脏输出) + if (messagesBodyContains(exchange, "原创个性杯")) { + content = "```json\n{\"appearance_status\": \"侵权\", \"appearance_reason\": \"【视觉拆解】:特殊造型\\n【判定依据】:高度相似知名设计\"}\n```"; + } else { + content = "{\"appearance_status\": \"无侵权\", \"appearance_reason\": \"【视觉拆解】:普通直筒杯。\\n【对比评估】:行业通用基础形状。\\n【判定依据】:无侵权。\"}"; + } + } + String response = objectMapper.writeValueAsString(Map.of( + "model", model, + "choices", List.of(Map.of("message", Map.of("content", content))) + )); + byte[] bytes = response.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8"); + exchange.sendResponseHeaders(200, bytes.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(bytes); + } + } catch (Exception ex) { + byte[] bytes = ("{\"error\":{\"message\":\"" + ex.getMessage() + "\"}}").getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(500, bytes.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(bytes); + } + } + } + + private boolean messagesBodyContains(HttpExchange exchange, String text) { + return capturedBodies.stream().anyMatch(b -> b.contains(text)); + } + + private AppearancePatentResultRowDto row(String id, String asin, String title, String sku, String url) { + AppearancePatentResultRowDto row = new AppearancePatentResultRowDto(); + row.setId(id); + row.setAsin(asin); + row.setTitle(title); + row.setSku(sku); + row.setUrl(url); + return row; + } + + @Test + void inspectRowRunsBothModelsAndParsesJsonAppearance() { + AppearancePatentResultRowDto row = row("1", "B001", "Apple Magic Case", "AC-1", "https://img.example.com/1.jpg"); + + List rows = client.inspectRows(List.of(row), null, "test-key"); + + assertThat(rows).hasSize(1); + AppearancePatentResultRowDto result = rows.get(0); + assertThat(result.getAsin()).isEqualTo("B001"); + assertThat(result.getAppearanceRisk()).isEqualTo("无侵权"); + assertThat(result.getAppearanceReason()).contains("视觉拆解"); + assertThat(result.getTitleReason()).contains("Apple"); + assertThat(callCounts.get("deepseek-v4-flash")).hasValue(1); + assertThat(callCounts.get("gemini-3.7-flash")).hasValue(1); + } + + @Test + void appearanceJsonWithCodeFenceAndEscapedNewlineIsUnwrapped() { + AppearancePatentResultRowDto row = row("2", "B002", "原创个性杯", "CUP-9", "https://img.example.com/2.jpg"); + + List rows = client.inspectRows(List.of(row), null, "test-key"); + + AppearancePatentResultRowDto result = rows.get(0); + assertThat(result.getAppearanceRisk()).isEqualTo("侵权"); + assertThat(result.getAppearanceReason()).contains("高度相似知名设计"); + assertThat(result.getConclusion()).isEqualTo("侵权"); + } + + @Test + void titleNoneSkipsBrandCheckAndMarksNoInfringement() { + AppearancePatentResultRowDto row = row("3", "B003", "普通收纳盒", "", "https://img.example.com/3.jpg"); + + List rows = client.inspectRows(List.of(row), null, "test-key"); + + AppearancePatentResultRowDto result = rows.get(0); + assertThat(result.getTitleRisk()).isEqualTo("无侵权"); + assertThat(result.getAppearanceRisk()).isEqualTo("无侵权"); + assertThat(result.getConclusion()).isEqualTo("无侵权"); + } + + @Test + void missingUrlFallsBackToAppearanceAnomalyWithoutLlmCall() { + AppearancePatentResultRowDto row = row("4", "B004", "测试商品", "", ""); + + List rows = client.inspectRows(List.of(row), null, "test-key"); + + AppearancePatentResultRowDto result = rows.get(0); + assertThat(result.getAppearanceRisk()).isEqualTo("外观识别异常"); + assertThat(callCounts.get("deepseek-v4-flash")).isNull(); + assertThat(callCounts.get("gemini-3.7-flash")).isNull(); + } + + @Test + void missingApiKeyKeepsRowsUntouched() { + AppearancePatentResultRowDto row = row("5", "B005", "测试", "", "https://img.example.com/5.jpg"); + + List rows = client.inspectRows(List.of(row), null, ""); + + assertThat(rows).hasSize(1); + assertThat(rows.get(0).getAppearanceRisk()).isNull(); + assertThat(callCounts).isEmpty(); + } + + @Test + void appearanceRequestCarriesImageUrlAndJsonResponseFormat() { + row("6", "B006", "普通数据线", "", "https://img.example.com/6.jpg"); + client.inspectRows(List.of(row("6", "B006", "普通数据线", "", "https://img.example.com/6.jpg")), null, "test-key"); + + String appearanceBody = capturedBodies.stream() + .filter(b -> b.contains("gemini-3.7-flash")) + .findFirst() + .orElseThrow(); + assertThat(appearanceBody).contains("https://img.example.com/6.jpg"); + assertThat(appearanceBody).contains("\"type\":\"image_url\""); + assertThat(appearanceBody).contains("\"type\":\"json_object\""); + assertThat(appearanceBody).contains("产品描述:普通数据线"); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/shopkey/service/ShopManageServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/shopkey/service/ShopManageServiceTest.java index 6e6850b6..b34b5f64 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/shopkey/service/ShopManageServiceTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/shopkey/service/ShopManageServiceTest.java @@ -2,6 +2,7 @@ package com.nanri.aiimage.modules.shopkey.service; import com.nanri.aiimage.common.exception.BusinessException; import com.nanri.aiimage.common.security.ShopCredentialCryptoService; +import com.nanri.aiimage.modules.shopkey.mapper.ShopCredentialCheckMapper; import com.nanri.aiimage.modules.shopkey.mapper.ShopManageMapper; import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageEntity; import org.junit.jupiter.api.Test; @@ -25,6 +26,8 @@ class ShopManageServiceTest { private ShopManageGroupService shopManageGroupService; @Mock private ShopCredentialCryptoService shopCredentialCryptoService; + @Mock + private ShopCredentialCheckMapper shopCredentialCheckMapper; @InjectMocks private ShopManageService service; diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopIndexServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopIndexServiceTest.java index b1cd5fa0..5c0ae53b 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopIndexServiceTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopIndexServiceTest.java @@ -17,6 +17,8 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; import java.time.Duration; import java.time.LocalDateTime; import java.util.List; @@ -82,6 +84,8 @@ class ZiniaoShopIndexServiceTest { when(ziniaoAuthService.getOrLoadStaffForIndex("allowed-key", 2L)).thenReturn(List.of(staff(22L))); when(ziniaoAuthService.getOrLoadUserStoresForIndex("allowed-key", 2L, 22L)) .thenReturn(List.of(shop("shop-2", "店铺B"))); + when(ziniaoMemoryStoreService.listAliveEntitiesByType( + ZiniaoMemoryStoreService.CACHE_TYPE_SHOP_INDEX_ENTRY, 10000)).thenReturn(List.of()); service.refreshShopIndex(); @@ -98,7 +102,6 @@ class ZiniaoShopIndexServiceTest { any(ZiniaoShopIndexEntryDto.class), any(Duration.class) ); - verify(ziniaoMemoryStoreService, never()).listAliveEntitiesByType(any(), anyInt()); verify(ziniaoApiKeyProvider).markIpWhitelistBlocked( eq(blocked), eq("当前服务器 IP 未加入紫鸟白名单") @@ -149,7 +152,6 @@ class ZiniaoShopIndexServiceTest { any(ZiniaoShopIndexEntryDto.class), any(Duration.class) ); - verify(ziniaoMemoryStoreService, never()).listAliveEntitiesByType(any(), anyInt()); verify(ziniaoApiKeyProvider).markIpWhitelistBlocked( eq(partiallyBlocked), eq("当前服务器 IP 未加入紫鸟白名单") @@ -158,6 +160,76 @@ class ZiniaoShopIndexServiceTest { verify(ziniaoApiKeyProvider, never()).markIpWhitelistAllowed(partiallyBlocked); } + @Test + void whitelistBlockedApiKeyMarksItsActiveIndexRowsAsBypassEligible() throws Exception { + stubIpWhitelistDetection(); + ZiniaoApiKeyProvider.ApiKeyAccount blocked = new ZiniaoApiKeyProvider.ApiKeyAccount("blocked-key", "blocked-account"); + ZiniaoApiKeyProvider.ApiKeyAccount allowed = new ZiniaoApiKeyProvider.ApiKeyAccount("allowed-key", "allowed-account"); + when(ziniaoApiKeyProvider.listApiKeyAccounts()).thenReturn(List.of(blocked, allowed)); + when(ziniaoAuthService.resolveCompanyIdForIndex("blocked-key")) + .thenThrow(new BusinessException("当前服务器 IP 未加入紫鸟白名单")); + when(ziniaoAuthService.resolveCompanyIdForIndex("allowed-key")).thenReturn(2L); + when(ziniaoAuthService.getOrLoadStaffForIndex("allowed-key", 2L)).thenReturn(List.of(staff(22L))); + when(ziniaoAuthService.getOrLoadUserStoresForIndex("allowed-key", 2L, 22L)) + .thenReturn(List.of(shop("shop-2", "店铺B"))); + + ZiniaoShopIndexEntryDto blockedDto = new ZiniaoShopIndexEntryDto(); + blockedDto.setNormalizedShopName("blocked-shop"); + blockedDto.setStatus("ACTIVE"); + blockedDto.setApiKeyHash(sha256("blocked-key")); + blockedDto.setLastRefreshedAt(1000L); + ZiniaoMemoryStoreEntity blockedRow = new ZiniaoMemoryStoreEntity(); + blockedRow.setId(1L); + blockedRow.setCacheType("SHOP_INDEX_ENTRY"); + blockedRow.setCacheKey("s:blocked-shop"); + blockedRow.setPayloadJson(new ObjectMapper().writeValueAsString(blockedDto)); + blockedRow.setExpiresAt(LocalDateTime.now().plusHours(12)); + + ZiniaoShopIndexEntryDto allowedDto = new ZiniaoShopIndexEntryDto(); + allowedDto.setNormalizedShopName("allowed-shop"); + allowedDto.setStatus("ACTIVE"); + allowedDto.setApiKeyHash(sha256("allowed-key")); + allowedDto.setLastRefreshedAt(1000L); + ZiniaoMemoryStoreEntity allowedRow = new ZiniaoMemoryStoreEntity(); + allowedRow.setId(2L); + allowedRow.setCacheType("SHOP_INDEX_ENTRY"); + allowedRow.setCacheKey("s:allowed-shop"); + allowedRow.setPayloadJson(new ObjectMapper().writeValueAsString(allowedDto)); + allowedRow.setExpiresAt(LocalDateTime.now().plusHours(12)); + + when(ziniaoMemoryStoreService.listAliveEntitiesByType("SHOP_INDEX_ENTRY", 10000)) + .thenReturn(List.of(blockedRow, allowedRow)); + + service.refreshShopIndex(); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(ziniaoMemoryStoreService).updateStaleMarks(captor.capture()); + assertEquals(1, captor.getValue().size()); + ZiniaoMemoryStoreEntity updated = captor.getValue().get(0); + assertEquals(1L, updated.getId()); + var payload = new ObjectMapper().readTree(updated.getPayloadJson()); + assertEquals("IP_WHITELIST", payload.get("refreshBlockedReason").asText()); + assertEquals("ACTIVE", payload.get("status").asText()); + assertTrue(payload.get("lastRefreshBlockedAt").asLong() >= 1000L, + "lastRefreshBlockedAt 应不小于行内 lastRefreshedAt,否则查询侧豁免条件不成立"); + } + + @Test + void laterWhitelistClearRemovesBlockedMarksFromIndexRows() throws Exception { + ZiniaoApiKeyProvider.ApiKeyAccount key = new ZiniaoApiKeyProvider.ApiKeyAccount("key", "acct"); + when(ziniaoApiKeyProvider.listApiKeyAccounts()).thenReturn(List.of(key)); + when(ziniaoAuthService.resolveCompanyIdForIndex("key")).thenReturn(1L); + when(ziniaoAuthService.getOrLoadStaffForIndex("key", 1L)).thenReturn(List.of(staff(11L))); + when(ziniaoAuthService.getOrLoadUserStoresForIndex("key", 1L, 11L)) + .thenReturn(List.of(shop("s-1", "shop-1"))); + + when(ziniaoMemoryStoreService.listAliveEntitiesByType("SHOP_INDEX_ENTRY", 10000)).thenReturn(List.of()); + + service.refreshShopIndex(); + + verify(ziniaoMemoryStoreService, never()).updateStaleMarks(anyList()); + } + @Test void nonWhitelistCompanyFailureDoesNotOverwriteWhitelistStatus() { ZiniaoApiKeyProvider.ApiKeyAccount failed = new ZiniaoApiKeyProvider.ApiKeyAccount("failed-key", "failed-account"); @@ -327,6 +399,16 @@ class ZiniaoShopIndexServiceTest { return (ZiniaoShopIndexRefreshCursorDto) captor.getAllValues().getLast(); } + private String sha256(String value) throws Exception { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(value.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(); + for (byte b : hash) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } + private void stubIpWhitelistDetection() { when(ziniaoAuthService.isIpWhitelistError(any(BusinessException.class))) .thenAnswer(invocation -> invocation.getArgument(0).getMessage().contains("白名单")); diff --git a/backend/blueprints/admin_api.py b/backend/blueprints/admin_api.py index 775f673f..fc89455b 100644 --- a/backend/blueprints/admin_api.py +++ b/backend/blueprints/admin_api.py @@ -1359,7 +1359,7 @@ _SHOP_DATA_CRAWL_ADMIN_COLUMNS = f""" r.result_file_size, r.result_content_type, r.row_count, r.success AS result_success, r.error_message AS result_error, r.created_at AS result_created_at, - t.task_no, t.status AS task_status, t.request_json, t.result_json, + t.task_no, t.status AS task_status, t.request_json, t.error_message AS task_error, t.created_at, t.updated_at, t.finished_at, {_SHOP_DATA_CRAWL_LATEST_TIME_SQL} AS latest_file_updated_at, df.country_codes_json, COALESCE(df.row_count, r.row_count) AS row_count_display, @@ -3395,6 +3395,19 @@ def delete_invalid_asin_data(item_id): # ---------- 店铺管理 ---------- def _format_shop_manage_item(item): + latest_check = item.get('latestCheck') + if isinstance(latest_check, dict): + latest_check = { + 'id': latest_check.get('id'), + 'status': latest_check.get('status') or '', + 'detail': latest_check.get('detail') or '', + 'client_host': latest_check.get('clientHost') or '', + 'try_requested_at': (latest_check.get('tryRequestedAt') or '').replace('T', ' ')[:19], + 'check_started_at': (latest_check.get('checkStartedAt') or '').replace('T', ' ')[:19], + 'check_finished_at': (latest_check.get('checkFinishedAt') or '').replace('T', ' ')[:19], + } + else: + latest_check = None return { 'id': item.get('id'), 'group_id': item.get('groupId'), @@ -3404,6 +3417,7 @@ def _format_shop_manage_item(item): 'zn_username': item.get('znUsername') or '', 'account': item.get('account') or '', 'password': item.get('passwordMasked') or '', + 'latest_check': latest_check, 'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16], 'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16], } @@ -3483,6 +3497,36 @@ def list_shop_manages(): }) +@admin_api.route('/shop-manage//credential-check', methods=['POST']) +@login_required +def create_shop_credential_check(item_id): + role, current_row, denied = _ensure_backend_menu_access('shop-manage') + if denied: + return denied + + shop_name = (request.args.get('shop_name') or '').strip() + if not shop_name: + return jsonify({'success': False, 'error': '店铺名不能为空'}), 400 + + internal_token = _resolve_internal_token() + if not internal_token: + return jsonify({'success': False, 'error': '内部凭据服务未配置'}), 503 + result, error_response, status = _proxy_backend_java( + 'POST', + '/api/admin/shop-credential-checks', + json_data={'shopName': shop_name}, + headers={'X-Internal-Token': internal_token}, + ) + if error_response is not None: + return error_response, status + check = result.get('data') or {} + return jsonify({ + 'success': True, + 'msg': '检测任务已创建,客户端将在 1 分钟内执行', + 'check': {'id': check.get('id'), 'status': check.get('status') or 'PENDING'}, + }) + + @admin_api.route('/shop-manage//credential') @login_required def get_shop_manage_credential(item_id): diff --git a/backend/static/admin.js b/backend/static/admin.js index 7e6f4a00..f2d0f4ed 100644 --- a/backend/static/admin.js +++ b/backend/static/admin.js @@ -2777,7 +2777,7 @@ document.getElementById('editDedupeTotalDataModal').classList.remove('show'); }; - // ========== 不符合ASIN数据 ========== + // ========== 品牌数据库 ========== var invalidAsinDataPage = 1, invalidAsinDataPageSize = 15; function buildInvalidAsinDataQuery(page) { var q = 'page=' + (page || 1) + '&page_size=' + invalidAsinDataPageSize; @@ -3128,11 +3128,40 @@ function renderShopPasswordCell(item) { var maskedPassword = item.password || '******'; return '' + - '' + escapeHtml(maskedPassword) + '' + + '' + escapeHtml(maskedPassword) + '' + ''; } + function renderShopCheckBadge(check) { + if (!check) return ''; + var map = { + 'SUCCESS': ['ok', '密码正确'], + 'FAILED': ['bad', '密码错误'], + 'RUNNING': ['run', '检测中'], + 'PENDING': ['wait', '等待客户端'], + 'NO_NEED_LOGIN': ['warn', '已登录态'], + 'ERROR': ['bad', '检测异常'] + }; + var entry = map[check.status] || ['wait', check.status || '未知']; + var tipText = [check.status, check.detail, check.check_finished_at].filter(Boolean).join(' · '); + return '
' + escapeHtml(entry[1]) + '
'; + } + + var shopCheckPollTimer = null; + function startShopCheckPolling() { + if (shopCheckPollTimer) return; + var ticks = 0; + shopCheckPollTimer = setInterval(function () { + ticks += 1; + loadShopManage(shopManagePage); + if (ticks >= 9) { + clearInterval(shopCheckPollTimer); + shopCheckPollTimer = null; + } + }, 20000); + } + function renderShopTableText(value, fallback) { var text = String(value == null ? '' : value).trim(); var shown = text || fallback || '-'; @@ -3162,11 +3191,12 @@ '' + renderShopTableText(item.mall_name) + '' + '' + renderShopTableText(item.zn_username) + '' + '' + renderShopTableText(item.account) + '' + - '' + renderShopPasswordCell(item) + '' + + '' + renderShopPasswordCell(item) + renderShopCheckBadge(item.latest_check) + '' + '' + renderShopTableText(item.created_at) + '' + '' + renderShopTableText(item.updated_at) + '' + '' + ' ' + + ' ' + '' + ''; }).join(''); @@ -3186,6 +3216,7 @@ var revealed = btn.dataset.revealed === 'true'; if (revealed) { valueEl.textContent = btn.dataset.maskedPassword || '******'; + valueEl.title = btn.dataset.maskedPassword || '******'; btn.dataset.revealed = 'false'; btn.setAttribute('aria-label', '显示密码'); btn.setAttribute('aria-pressed', 'false'); @@ -3201,6 +3232,7 @@ .then(function (res) { if (!res.success) throw new Error(res.error || '读取密码失败'); valueEl.textContent = res.password || ''; + valueEl.title = res.password || ''; btn.dataset.revealed = 'true'; btn.setAttribute('aria-label', '隐藏密码'); btn.setAttribute('aria-pressed', 'true'); @@ -3245,6 +3277,29 @@ }); }; }); + document.querySelectorAll('[data-shop-credential-check]').forEach(function (btn) { + btn.onclick = function () { + var name = (btn.dataset.shopCheckName || '').replace(/"/g, '"'); + btn.disabled = true; + btn.textContent = '已提交...'; + fetch('/api/admin/shop-manage/' + encodeURIComponent(btn.dataset.shopCredentialCheck) + '/credential-check?shop_name=' + encodeURIComponent(name), { method: 'POST' }) + .then(function (r) { return r.json(); }) + .then(function (res) { + if (res.success) { + alert(res.msg || '检测任务已创建,客户端将在 1 分钟内执行'); + startShopCheckPolling(); + loadShopManage(shopManagePage); + } else { + alert(res.error || '发起检测失败'); + } + }) + .catch(function () { alert('发起检测失败'); }) + .finally(function () { + btn.disabled = false; + btn.textContent = '检测密码'; + }); + }; + }); } function getInvalidAsinDataLockedGroupId() { diff --git a/backend/web_source/admin.html b/backend/web_source/admin.html index c7ff6b4d..f767fc3a 100644 --- a/backend/web_source/admin.html +++ b/backend/web_source/admin.html @@ -1048,6 +1048,34 @@ pointer-events: none; } + .shop-check-badge { + display: inline-block; + margin-left: 8px; + padding: 1px 7px; + border-radius: 9px; + font-size: 11px; + line-height: 17px; + white-space: nowrap; + cursor: default; + } + + .shop-check-badge.ok { color: #067647; background: #e6f4ea; border: 1px solid #b7e0c3; } + .shop-check-badge.bad { color: #b42318; background: #fee4e2; border: 1px solid #fecdca; } + .shop-check-badge.run { color: #175cd3; background: #eaf2ff; border: 1px solid #b8d2ff; } + .shop-check-badge.wait { color: #667085; background: #f2f4f7; border: 1px solid #d0d5dd; } + .shop-check-badge.warn { color: #b54708; background: #fef0c7; border: 1px solid #fedf89; } + + .btn-check { + color: #5158d9; + border-color: #c7cbfa; + } + + .btn-check:hover:not(:disabled) { + color: #fff; + background: #5158d9; + border-color: #5158d9; + } + .dedupe-group-access { display: flex; align-items: center; @@ -3478,20 +3506,22 @@ #panel-dedupe-total-data .dedupe-table-scroll th:nth-child(6) { width: 16%; } #panel-dedupe-total-data .dedupe-table-scroll th:nth-child(7) { width: 9%; } - .shop-manage-table-scroll > table { min-width: 1220px; table-layout: fixed; } + .shop-manage-table-scroll > table { min-width: 1360px; table-layout: fixed; } #panel-shop-manage .shop-manage-table-scroll th:nth-child(1) { width: 58px; } #panel-shop-manage .shop-manage-table-scroll th:nth-child(2) { width: 116px; } #panel-shop-manage .shop-manage-table-scroll th:nth-child(3) { width: 126px; } - #panel-shop-manage .shop-manage-table-scroll th:nth-child(4) { width: 230px; } - #panel-shop-manage .shop-manage-table-scroll th:nth-child(5) { width: 150px; } - #panel-shop-manage .shop-manage-table-scroll th:nth-child(6) { width: 190px; } - #panel-shop-manage .shop-manage-table-scroll th:nth-child(7) { width: 108px; } + #panel-shop-manage .shop-manage-table-scroll th:nth-child(4) { width: 210px; } + #panel-shop-manage .shop-manage-table-scroll th:nth-child(5) { width: 140px; } + #panel-shop-manage .shop-manage-table-scroll th:nth-child(6) { width: 180px; } + #panel-shop-manage .shop-manage-table-scroll th:nth-child(7) { width: 152px; } #panel-shop-manage .shop-manage-table-scroll th:nth-child(8), #panel-shop-manage .shop-manage-table-scroll th:nth-child(9) { width: 132px; } - #panel-shop-manage .shop-manage-table-scroll th:nth-child(10) { width: 150px; } + #panel-shop-manage .shop-manage-table-scroll th:nth-child(10) { width: 200px; } #panel-shop-manage .shop-manage-table-scroll td { overflow: hidden; } .table-ellipsis { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .shop-password-cell { max-width: 100%; } .shop-password-value { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .shop-col-password { min-width: 0; } + .shop-col-actions { white-space: nowrap; } .category-table-scroll > table { min-width: 860px; } .category-table-scroll td { vertical-align: middle; } @@ -3887,7 +3917,7 @@ @@ -4302,7 +4332,7 @@
-

新增无效ASIN数据

+

新增品牌数据

@@ -4326,7 +4356,7 @@

-

无效ASIN数据列表

+

品牌数据列表

@@ -4970,7 +5000,7 @@